diff --git a/.azure/deployment-plan.md b/.azure/deployment-plan.md deleted file mode 100644 index e10859d..0000000 --- a/.azure/deployment-plan.md +++ /dev/null @@ -1,151 +0,0 @@ -# Deployment Plan: copilot-cli-agentops-azure - -Status: Public scaffold ready for user-owned Azure deployment. - -## Summary - -This repo packages a secure, metadata-first AgentOps control plane for GitHub Copilot CLI telemetry on Azure. - -The core loop is: - -1. Run Copilot CLI through the local AgentOps wrapper. -2. Send OpenTelemetry data to a localhost OpenTelemetry Collector. -3. Export locally for debug or to Azure Monitor/Application Insights. -4. Query telemetry through KQL and the `agentops` CLI. -5. Use Copilot agents, skills, hooks, and MCP samples to investigate telemetry and propose safe improvements. - -## Secure Defaults - -- Content capture is disabled. -- The collector binds to `127.0.0.1` only. -- Repository URLs are hashed before export. -- Azure and Grafana MCP samples are read-only. -- Patch workflows are proposal-only by default. -- The default `team` deployment profile caps Log Analytics ingestion and keeps 30-day retention. -- The App Insights connection string is retrieved at collector/actioner runtime, not emitted as a top-level azd output. -- Optional Entra group RBAC assignments and a monthly resource-group budget are off by default. -- Secrets are environment variables or Key Vault references, never committed. - -## Azure Inputs - -Set these values before running Azure scripts: - -```bash -export AZURE_SUBSCRIPTION_ID="" -export AZURE_RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" -export AZURE_LOCATION="${AZURE_LOCATION:-northeurope}" -export AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID="" -export AGENTOPS_GRAFANA_BASE_URL="https://.grafana.azure.com" -``` - -The Bicep deployment accepts: - -- `environmentName` -- `location` -- `baseName` -- `deploymentProfile` (`dev`, `team`, or `enterprise`; default `team`) -- `logRetentionDays` (`0` uses the profile default) -- `dailyIngestionCapGb` (`0` uses the profile default; `-1` disables the cap) -- `deployRbacAssignments` -- `observerPrincipalIds` -- `operatorPrincipalIds` -- `adminPrincipalIds` -- `deployBudget` -- `monthlyBudgetAmount` -- `budgetContactEmails` -- `deployActioner` -- `deployAlerts` -- `enableAlerts` - -## Resources - -The core stack creates: - -- Log Analytics Workspace -- Application Insights -- Azure Monitor Workspace -- Azure Managed Grafana -- Key Vault -- Optional Entra group RBAC assignments -- Optional resource-group monthly Azure Consumption budget -- Optional Function App placeholder for future alert actioner workflows -- Disabled proposal-only scheduled query rules when `deployAlerts=true` - -## Validation Plan - -Local validation: - -```bash -npm --prefix agentops-cli test -node agentops-cli/src/index.js doctor --local-only -node agentops-cli/src/index.js validate-enterprise -node scripts/build-grafana-dashboard-pack.js -docker compose -f collector/docker-compose.yaml config >/tmp/agentops-compose.yaml -az bicep build --file infra/bicep/main.bicep --stdout >/tmp/agentops-main-arm.json -``` - -Pilot review docs: - -- `docs/enterprise-pilot.md` -- `docs/threat-model.md` - -Azure validation: - -```bash -./scripts/azure-readiness.sh -AGENTOPS_DEPLOYMENT_PROFILE=team ./scripts/azure-what-if.sh -``` - -Only after reviewing what-if should provisioning run: - -```bash -azd provision -``` - -For an enterprise pilot that enables optional RBAC or budget modules, use: - -```bash -./scripts/azure-deploy-enterprise-pilot.sh -``` - -## Post-Deployment Smoke Tests - -Start the Azure Monitor collector: - -```bash -./scripts/collector-azuremonitor-up.sh -``` - -Send a privacy-safe synthetic OTLP trace: - -```bash -./scripts/otlp-smoke-trace.sh -``` - -Run a minimal Copilot CLI task through the wrapper: - -```bash -./copilot/copilot-observe -p "Reply with exactly: agentops telemetry smoke." -``` - -Query recent Copilot spans: - -```bash -az monitor log-analytics query \ - --workspace "$AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID" \ - --analytics-query "AppDependencies | where TimeGenerated > ago(2h) | where Properties has 'github.copilot' and Properties has 'github-copilot-cli' | project TimeGenerated, Name, AppRoleName, Properties | order by TimeGenerated desc | take 20" -``` - -Import dashboards after Grafana RBAC is configured: - -```bash -AZURE_RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" \ -GRAFANA_NAME="" \ -./scripts/grafana-import-dashboard.sh -``` - -## Open Decisions - -- Whether to keep the Azure Monitor collector running during daily Copilot CLI sessions or start it only for explicit smoke tests. -- When to enable the optional actioner Function. -- When tuned alert thresholds are stable enough to set `enableAlerts=true`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0c9950..e4de9ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: - ubuntu-latest - windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' @@ -107,9 +107,9 @@ jobs: env: AGENTOPS_COLLECTOR_HOME: ${{ github.workspace }}/.agentops-collector steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' diff --git a/.gitignore b/.gitignore index 66b92d0..9e90142 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ coverage/ .agentops/ .playwright-mcp/ .azure/* -!.azure/deployment-plan.md collector/*.local.out copilot-otel*.jsonl benchmarks/runs/ diff --git a/README.md b/README.md index 2775eaa..7f2e919 100644 --- a/README.md +++ b/README.md @@ -4,40 +4,119 @@ > Independent personal OSS project. Not an official Microsoft, GitHub, OpenAI, Azure, or Grafana product. -Privacy-first Datadog/Lapdog-style observability for GitHub Copilot CLI runs, Copilot SDK sessions, MCP tools, and code outcomes in Azure Monitor and Grafana. AgentOps records run/session metadata, tool names, failures, latency, token usage, estimated cost, privacy signals, evals, and GitHub outcomes without recording prompts, code, file contents, tool arguments, or tool results by default. +The checked-in dev deployment is a personal development/demo environment. Keep it metadata-only; it is not approved for Microsoft confidential, customer, or production data. `agentops validate-azure` defaults to this non-blocking `personal` posture. Use `--profile team` or `--profile internal` for readiness gates that fail when Log Analytics ingestion is uncapped or the resource group has no Azure Consumption budget. `--production` implies the stricter `internal` profile and retains the wider production security checks. + +Privacy-first observability for GitHub Copilot CLI runs, Copilot SDK sessions, MCP tools, and code outcomes using Azure Monitor. AgentOps records run/session metadata, tool names, failures, latency, token usage, estimated cost, privacy signals, evals, and GitHub outcomes without recording prompts, code, file contents, tool arguments, or tool results by default. Azure Monitor's native Application Insights Agents view is the primary investigation surface; Managed Grafana is optional for advanced operators. ```text GitHub Copilot CLI -> local OTLP endpoint on 127.0.0.1 -> local OpenTelemetry Collector privacy boundary - -> Azure Monitor / Application Insights / Log Analytics - -> Azure Managed Grafana dashboards + -> Azure Monitor / Application Insights Agents view + -> optional Log Analytics custom receipts and Managed Grafana dashboards ``` +See the [simplified Azure-native design](docs/simplified-azure-design.md) for +the minimum service footprint, first-value flow, and live-evidence states. + +If you are new to the project, start with the [junior quickstart](docs/junior-quickstart.md). +It gets you from zero to a local privacy-safe Copilot receipt, then shows the +short Azure-native path without making you copy OTLP URLs by hand. + +For the public release checklist, including packaging, privacy review, +rollback, and the boundary between preview and production, see +the [public release checklist](docs/public-release.md). + ## Quick Start -Prerequisites: +Prerequisites for local value: -- Azure CLI logged in. -- Azure Developer CLI (`azd`) for the Bicep deployment. +- Node.js. - GitHub Copilot CLI installed and authenticated. -- No Docker required: `./setup-agentops.sh` installs the tested local OpenTelemetry Collector binary. Docker is only an optional fallback. +- Azure CLI, `azd`, Docker, Grafana, and an Azure resource are optional. + +The native path keeps Copilot as the execution command. The local Collector is +the privacy boundary; no Copilot wrapper is required: ```bash -az login -azd provision ./setup-agentops.sh export PATH="$HOME/.local/bin:$PATH" -agentops configure import-azd -agentops setup -agentops collector smoke --privacy strict --poison --json -agentops collector start --mode auto --privacy strict -agentops smoke --real-copilot --wait 2m --poll 10s --open-browser -agentops latest --last 2h -agentops open latest --last 2h +agentops setup --json +eval "$(agentops init --local-only --yes --shell zsh)" +agentops smoke --local +copilot -p "Reply with exactly: AGENTOPS_READY." +agentops open latest --json ``` -`agentops setup` is read-only. It prints the current Azure/Grafana binding state, recommends `agentops init --full` as the guided first-run path, and keeps fallback commands for privacy smoke testing, running a safe no-edit Copilot smoke, opening the newest run, and verifying dashboards. `agentops smoke --real-copilot --open-browser` waits for the latest Copilot run to appear before opening the V2 Run Replay link. +`agentops setup` is read-only. `agentops init --local-only` applies only +AgentOps-owned local files and shell exports; `agentops smoke --local` starts +or health-checks the strict loopback Collector and reads back a metadata-only +native receipt. Prompts, responses, code, tool arguments, and tool results stay +off by default. + +Setup installs the `agentops` and `copilot-agentops` utility commands. It leaves +the existing plain `copilot` command unchanged. The compatibility command +`agentops copilot ...` remains available, but is not required for native OTel. +Opt in to transparent routing only when you want it: + +```bash +./setup-agentops.sh --shadow-copilot +``` + +Plain `copilot ...` is the native observed-session path after the local OTel +exports are applied. Use `agentops copilot ...` only for compatibility with the +older wrapper-based workflow, or opt in to transparent routing when required. + +Azure is a separate, gated pilot. Start with read-only discovery: + +```bash +az login +agentops setup --json +agentops validate-azure --last 24h +``` + +Do not run a cloud provisioning flow until the exact subscription, resource +group, region, and native OTLP onboarding path have been approved in +[the Azure-native design](docs/simplified-azure-design.md). Azure writes fail +closed unless the expected subscription is also present in an explicit public- +build allowlist: + +```bash +export AGENTOPS_AZURE_SUBSCRIPTION_ID="" +export AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS="" +az account show --query '{name:name,id:id}' -o table +``` + +The repository contains no owner subscription ID. Separate expected and +approved values make an accidental write fail before an Azure command runs. + +For the reviewed native Azure preview lane, see +[docs/azure-native-otlp-preview.md](docs/azure-native-otlp-preview.md). It uses +`agentops collector --mode azure-native` with the Application Insights OTLP +Connection Info endpoints and Entra authentication; it does not require a +Copilot wrapper. The readiness script is read-only and the Collector start is +explicitly gated by `AGENTOPS_APPROVE_NATIVE_OTLP=yes`. + +For an already-approved legacy cloud binding, the compatibility workflow is +still available: `agentops init --full` previews it, and +`agentops init --full --yes` applies the reviewed cloud stages. Existing +`agentops copilot ...` and `agentops open latest` remain supported while the +native pilot reaches parity. + +If you want a safe native Copilot value loop after local setup, use: + +```bash +copilot -p "Do not edit files. Reply with exactly: agentops smoke." +agentops open latest --json +``` + +The local receipt reports what native telemetry was observed. It does not infer +task success from a span ending or an `agentStop` hook. + +`agentops setup` is read-only. It prints separate local/cloud readiness, recommends +the native local init path, and keeps the older `init --full` cloud workflow as +an explicit advanced option. `agentops smoke --real-copilot --open-browser` +remains available for a cloud-bound environment. If `collector start --mode auto` cannot find a collector binary and Docker is not running, it fails with setup instructions. It does not silently run Copilot without the local privacy boundary. Install the binary any time with: @@ -112,9 +191,14 @@ To populate a local demo dataset without live Copilot traffic: ```bash agentops demo generate --runs 50 --with-failures --with-privacy-drops --with-github-outcomes --json agentops demo verify --runs 50 --json +# To persist verification artifacts under .agentops, add --write. +# agentops demo verify --runs 50 --write --json ``` -This writes metadata-only `AgentOps*_CL.jsonl` files under `.agentops/demo/latest`. +`demo generate` writes metadata-only `AgentOps*_CL.jsonl` files under +`.agentops/demo/latest`. `demo verify` is workspace-read-only by default and +uses temporary artifacts; add `--write` when persistent verification files are +needed. To audit the local control-room contract: @@ -143,7 +227,7 @@ If you generate dashboard screenshots with another authenticated browser harness To roll up a raw local span export into the same V2 table shape: ```bash -agentops run-summary generate --file tests/sample-otel/tool-failure.jsonl --json +agentops run-summary generate --file fixtures/sample-otel/tool-failure.ndjson.fixture --json ``` To check whether local V2 table files are ready for Azure Log Analytics custom-table ingestion: @@ -161,7 +245,7 @@ agentops azure-ingest plan --dir .agentops/demo/latest --allow-content --json agentops open latest --runs .agentops/demo/latest/AgentOpsRunSummary_CL.jsonl ``` -`agentops content status` shows whether transcript rows exist and whether ingestion is deliberately allowed. `agentops open` prints a Run Replay link plus a dedicated prompt/response viewer link. The viewer stays empty in strict mode and only shows `AgentOpsContent_CL` rows after explicit content-capture opt-in. +`agentops content status` shows whether transcript rows exist and whether ingestion is deliberately allowed. `agentops open` prints a Run Story link plus a dedicated prompt/response viewer link. The viewer stays empty in strict mode and only shows `AgentOpsContent_CL` rows after explicit content-capture opt-in. To observe a stdio MCP server without storing tool arguments/results: @@ -187,7 +271,7 @@ const client = createAgentOpsCopilotClient(CopilotClient, { captureContent: false }); -const session = await client.createSession(client.createAgentOpsSessionConfig()); +const session = await client.createAgentOpsSession(); ``` To generate deterministic eval and insight rows from V2 tables: @@ -237,9 +321,9 @@ agentops ask-context latest \ After installing the bundled skills, the memorable Copilot prompt is: ```text -Use agentops-setup to install AgentOps, run the guided init --full path, report the Run Replay link, and recommend one next action. +Use agentops-setup to install AgentOps, run the guided init --full path, report the Run Story link, and recommend one next action. -Use agentops-latest-run to find my latest AgentOps run, open the Run Replay link, explain it, and recommend one next action. +Use agentops-latest-run to find my latest AgentOps run, open the Run Story link, explain it, and recommend one next action. ``` To preview the Azure Managed Grafana import command: @@ -298,7 +382,11 @@ See [Privacy modes](docs/privacy-modes.md). ## Plugin And Hooks -`agentops install` installs local shims and the tested Collector binary. It also installs a plain `copilot` shim by default so normal Copilot CLI runs are observed when `~/.local/bin` is first on `PATH`. Plugin files are explicit and reversible: +`agentops install` installs the local AgentOps utility and tested Collector +binary. It leaves plain `copilot` unchanged by default; native observation uses +Copilot's OTel environment, while `agentops copilot ...` remains a compatibility +path. Explicit transparent routing is still available with `--shadow-copilot`. +Plugin files are explicit and reversible: ```bash agentops plugin install diff --git a/agentops-cli/README.md b/agentops-cli/README.md index aa53f1b..fe85d03 100644 --- a/agentops-cli/README.md +++ b/agentops-cli/README.md @@ -2,6 +2,39 @@ Small local utility for the Copilot CLI AgentOps for Azure scaffold. +AgentOps records run metadata, tool names, failures, latency, token usage, +estimated cost, privacy signals, and outcomes without recording prompts, code, +file contents, tool arguments, or tool results by default. Prompt/response rows +remain opt-in and isolated in `AgentOpsContent_CL`; check the current policy with +`agentops content status`. + +The shortest native first-value loop is: + +```bash +agentops setup --json +eval "$(agentops init --local-only --yes --shell zsh)" +agentops smoke --local +copilot -p "Reply with exactly: agentops smoke." +agentops open latest +``` + +The local init command applies only AgentOps-owned files and native Copilot OTel +environment exports. The local smoke starts or health-checks the strict +loopback Collector and reads back a metadata-only receipt. For native everyday +sessions, plain `copilot ...` is the execution command; `agentops copilot ...` +remains a compatibility path, and transparent routing is still opt-in with +`--shadow-copilot`. + +For a beginner-friendly end-to-end walkthrough, use the +[junior quickstart](https://github.com/c-mongan/copilot-cli-agentops-azure/blob/main/docs/junior-quickstart.md). The Azure-native +helper discovers the approved DCR and signal endpoints from Application +Insights, so operators do not have to transcribe long URLs. + +For an already-approved legacy cloud binding, `agentops init --full` remains a +preview and `agentops init --full --yes` applies the reviewed stages. The +compatibility command `agentops copilot ...` and `agentops open latest` remain +available during the native migration. + ## Commands ```bash @@ -19,12 +52,13 @@ node src/index.js otel-setup --shell powershell node src/index.js compat-check --last 2h node src/index.js init --dry-run node src/index.js init --full +node src/index.js init --full --yes node src/index.js init --import-dashboards node src/index.js init --run-smoke node src/index.js init --triage-latest node src/index.js scan node src/index.js primitives --last 7d -node src/index.js import-jsonl ../tests/sample-otel/tool-failure.jsonl +node src/index.js import-jsonl ../fixtures/sample-otel/tool-failure.ndjson.fixture node src/index.js validate-collector node src/index.js validate-azure node src/index.js smoke --dry-run @@ -62,7 +96,28 @@ node src/index.js saved-view add latest-risk --session `start` and `stop` are short aliases for `collector start` and `collector stop`. -`copilot` starts the collector if needed and runs the real Copilot CLI through the AgentOps shim. +`copilot` starts the collector if needed and runs the real Copilot CLI through +the AgentOps shim. After the run it prints an immediate metadata-only receipt +using the changed local Copilot event stream: actual Copilot session ID, model, +input/output tokens, AI credits, wall/API time, safe tool names/count, and code +change counts when present. Prompts, answers, tool arguments/results, paths and +file names are never copied into the receipt. Ordered wrapper lifecycle receipts +are fsynced into a bounded private queue. The receipt labels that evidence +`waiting for Azure` until the configured ingestion endpoint accepts the exact +event; native Copilot detail is labelled separately as best-effort collector +coverage. + +Inspect the queue at any time: + +```bash +agentops delivery status +agentops delivery drain # preview only +agentops delivery drain --yes # requires configured endpoint + DCR +``` + +`delivery drain` fails closed unless the exact approved subscription, Azure +Monitor ingestion hostname, and DCR identifier pass validation. Endpoint `2xx` +means accepted for ingestion, not yet visible in Log Analytics. `codex` starts the collector if needed, sets privacy-safe OTLP environment defaults, and runs the local Codex CLI. Add Azure Monitor MCP with `codex mcp add azure-mcp -- npx -y @azure/mcp@latest server start --read-only --namespace monitor`. @@ -71,18 +126,20 @@ Dashboard verification path: ```bash node src/index.js validate-azure --last 2h copilot plugin install c-mongan/copilot-cli-agentops-azure:plugin -node src/index.js copilot --agent agentops-orchestrator --allow-tool=bash --add-dir . --no-ask-user --no-remote -p "Do not edit files. Use read-only shell commands: pwd and ls docs | head." +node src/index.js copilot --agent agentops-orchestrator --allow-tool=bash --add-dir . --no-ask-user --no-remote --no-remote-export -p "Do not edit files. Use read-only shell commands: pwd and ls docs | head." node src/index.js custom emit --event agent.delegation.started --agent investigator --parent-agent agentops-orchestrator --delegation-id real-delegation --workflow investigation --step delegate --outcome started node src/index.js custom emit --event agent.policy.blocked --agent policy-reviewer --workflow safety-review --step pre-tool --outcome blocked --risk policy --attribute github.copilot.policy.decision=blocked node src/index.js open ``` -Open **Overview** first, then **Sessions**, **Traces / Spans**, and **Tools & MCP**. Empty Safety/Policy or Runtime Events panels are normal until matching policy, hook, skill, truncation, or content-capture signals exist. Use a real observed Copilot run when you want to seed those quieter pages. +`agentops open` prints the configured Azure Monitor Agents view first when one is available. Use the native view for the normal investigation flow; the Run Story link is the local/metadata fallback and the V2 Grafana pages are an optional advanced operator pack. Use a real observed Copilot run when you want to seed quieter pages. `configure` stores non-secret Azure/Grafana identifiers in `~/.agentops/config.json` so users do not need to export terminal environment variables for every shell. Environment variables still override saved config for CI and advanced workflows. `otel-setup` prints copyable VS Code Copilot Chat settings, Copilot CLI terminal environment variables, and a Copilot SDK TypeScript snippet that point native Copilot OTel at the AgentOps collector. This is the no-wrapper path: users can emit OTLP directly without installing `copilot-agentops`. +The optional native Azure preview uses `agentops collector validate --mode azure-native --privacy strict` and `agentops collector start --mode azure-native --privacy strict`. It merges the strict local privacy config with the Azure `otlp_http`/Entra `azure_auth` overlay; provide only the signal-specific endpoints copied from Application Insights OTLP Connection Info, then set `AGENTOPS_APPROVE_NATIVE_OTLP=yes`. See `docs/azure-native-otlp-preview.md` for the read-only readiness gate. + `compat-check` prints a Log Analytics query that checks whether recent Copilot/GenAI OTel has the fields dashboards and evals need: operation, session, model, tool, token usage, and cost or AIU signals. `validate-azure` runs read-only Azure checks for CLI login, resource group, Log Analytics query access, Application Insights, Grafana resource, datasource UID, and imported dashboard UIDs. diff --git a/agentops-cli/package.json b/agentops-cli/package.json index 200c292..7f44d2a 100644 --- a/agentops-cli/package.json +++ b/agentops-cli/package.json @@ -3,16 +3,28 @@ "version": "0.1.0", "description": "Local utilities for Copilot CLI AgentOps for Azure.", "files": [ + "LICENSE", "README.md", - ".azure", + "actioner", + "benchmark-judges", + "benchmark-runners", "azure.yaml", "collector", "copilot", "docs", + "examples", + "fixtures", "grafana", + "infra", + "kql", + "install-agentops.ps1", + "install-agentops.sh", + "packages", "plugin", "scripts", - "src" + "src", + "uninstall-agentops.ps1", + "uninstall-agentops.sh" ], "bin": { "agentops": "src/index.js" diff --git a/agentops-cli/src/commands/ask-context.js b/agentops-cli/src/commands/ask-context.js index 31959b4..3fad503 100644 --- a/agentops-cli/src/commands/ask-context.js +++ b/agentops-cli/src/commands/ask-context.js @@ -1,248 +1 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const legacy = require('../legacy'); -const { hasFlag, optionValue } = require('../lib/args'); -const { latestByTime } = require('../lib/explain/v2-explain'); - -function readJsonl(filePath) { - if (!filePath) return []; - return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} - -function hasV2AskArgs(args = []) { - return Boolean(optionValue(args, '--runs')); -} - -function filterByRun(rows = [], runId) { - return rows.filter(row => row.RunId === runId); -} - -function topRows(rows = [], count = 8) { - return rows.slice(0, count); -} - -function escapeKqlString(value) { - return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -function v2RunReplayUrl(run) { - const links = legacy.openLinksSummary({ session: { id: run.SessionId || run.RunId, grafana_url: null } }); - const base = links.v2_replay_url || ''; - const separator = base.includes('?') ? '&' : '?'; - return `${base}${separator}var-run_id=${encodeURIComponent(run.RunId)}&var-session_id=${encodeURIComponent(run.SessionId || '__all')}&var-trace_id=${encodeURIComponent(run.TraceId || '__all')}`; -} - -function investigationKql(run, last = '2h') { - const runId = escapeKqlString(run.RunId); - const sessionId = escapeKqlString(run.SessionId || run.RunId); - return [ - 'union isfuzzy=true AppDependencies, AppTraces, AppEvents', - `| where TimeGenerated > ago(${last})`, - `| where tostring(Properties) has_any ("${runId}", "${sessionId}")`, - '| extend Event=coalesce(tostring(Properties["agentops.event.name"]), tostring(Properties["github.copilot.event.name"]), Name)', - '| extend Tool=coalesce(tostring(Properties["gen_ai.tool.name"]), tostring(Properties["agentops.tool.name"]))', - '| extend Agent=coalesce(tostring(Properties["agentops.agent.name"]), tostring(Properties["gen_ai.agent.name"]))', - '| project TimeGenerated, Event, Name, OperationId, Id, ParentId, Agent, Tool, Success, DurationMs, Properties', - '| order by TimeGenerated asc', - '| take 200' - ].join('\n'); -} - -function latestRecommendation(rows = [], run = {}) { - const matches = rows.filter(row => { - return row.RunId === run.RunId || - (run.SessionId && row.SessionId === run.SessionId) || - (run.TraceId && row.TraceId === run.TraceId); - }); - matches.sort((left, right) => String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || ''))); - const row = matches[0] || null; - if (!row) return null; - return { - time: row.TimeGenerated || '', - action: row.Action || '', - severity: row.Severity || '', - observed_pattern: row.ObservedPattern || '', - next_action: row.NextAction || '', - validation: Array.isArray(row.Validation) ? row.Validation : [], - rollback_condition: row.RollbackCondition || '', - benchmark_run_id: row.BenchmarkRunId || '', - benchmark_decision: row.BenchmarkDecision || '', - dashboard_titles: Array.isArray(row.DashboardTitles) ? row.DashboardTitles : [] - }; -} - -function buildAskContext(options = {}) { - const runs = readJsonl(options.runsFile); - const run = options.runId && options.runId !== 'latest' - ? runs.find(row => row.RunId === options.runId) - : latestByTime(runs); - - if (!run) { - return { - ok: false, - run_id: options.runId || 'latest', - error: 'No V2 AgentOps run rows were available.' - }; - } - - const runId = run.RunId; - const events = filterByRun(readJsonl(options.eventsFile), runId); - const tools = filterByRun(readJsonl(options.toolsFile), runId); - const privacy = filterByRun(readJsonl(options.privacyFile), runId); - const github = filterByRun(readJsonl(options.githubFile), runId); - const evals = filterByRun(readJsonl(options.evalsFile), runId); - const insights = filterByRun(readJsonl(options.insightsFile), runId); - const recommendation = latestRecommendation(readJsonl(options.recommendationsFile), run); - const replayUrl = v2RunReplayUrl(run); - const last = legacy.validateKqlDuration(options.last || '2h'); - const kql = investigationKql(run, last); - - const failedTools = tools.filter(row => row.Status !== 'success' || row.Allowed === false); - const timeline = topRows(events, 20).map(row => ({ - time: row.TimeGenerated, - event: row.EventName, - status: row.Status, - tool: row.ToolName || '', - agent: row.AgentName || '', - skill: row.SkillName || '', - sub_agent: row.SubAgentName || '' - })); - - const prompt = [ - 'Use the telemetry-investigator or AgentOps triage skill.', - '', - `Investigate AgentOps run ${runId}.`, - `Run Replay: ${replayUrl}`, - `Time range: ${last}`, - `Session: ${run.SessionId || 'unknown'}`, - `Trace: ${run.TraceId || 'unknown'}`, - `Status: ${run.OutcomeStatus || 'unknown'}${run.OutcomeReason ? ` (${run.OutcomeReason})` : ''}`, - recommendation ? `Last recommendation: ${recommendation.action} (${recommendation.severity}) - ${recommendation.next_action}` : 'Last recommendation: none in this bundle', - recommendation?.benchmark_run_id ? `Benchmark run: ${recommendation.benchmark_run_id} (${recommendation.benchmark_decision || 'unknown'})` : 'Benchmark run: none in this bundle', - '', - 'Use only the metadata in this bundle and read-only Azure/Grafana MCP if available.', - 'Start with this KQL if Azure Monitor is available:', - kql, - '', - 'Return: what happened, why it matters, the most likely failure/cost/safety/context pattern, and one evidence-backed next action.', - 'Do not request or enable prompt, response, source code, file content, tool argument, tool result, URL, request body, response body, or secret capture.' - ].join('\n'); - - return { - ok: true, - run_id: runId, - session_id: run.SessionId || '', - trace_id: run.TraceId || '', - status: run.OutcomeStatus || 'unknown', - replay_url: replayUrl, - time_range: last, - kql_query: kql, - grafana_url: replayUrl, - last_recommendation: recommendation, - benchmark_run_id: recommendation?.benchmark_run_id || '', - run: { - TimeGenerated: run.TimeGenerated, - Surface: run.Surface, - RepoHash: run.RepoHash, - BranchHash: run.BranchHash, - TaskType: run.TaskType, - AgentName: run.AgentName, - SkillName: run.SkillName || '', - ParentAgentName: run.ParentAgentName || '', - SubAgentName: run.SubAgentName || '', - ModelActual: run.ModelActual, - DurationMs: run.DurationMs, - InputTokens: run.InputTokens, - OutputTokens: run.OutputTokens, - ReasoningTokens: run.ReasoningTokens, - CacheReadTokens: run.CacheReadTokens || 0, - ContextWindowPct: run.ContextWindowPct || 0, - TokensRemoved: run.TokensRemoved || 0, - PermissionWaitMs: run.PermissionWaitMs || 0, - EstimatedCostUsd: run.EstimatedCostUsd, - ToolCount: run.ToolCount, - ToolFailureCount: run.ToolFailureCount, - ToolDeniedCount: run.ToolDeniedCount, - TestsRan: run.TestsRan, - TestsPassed: run.TestsPassed, - PrOpened: run.PrOpened, - CiStatus: run.CiStatus, - EvalOverall: run.EvalOverall, - RiskScore: run.RiskScore, - PrivacyMode: run.PrivacyMode, - ContentCaptureMode: run.ContentCaptureMode - }, - evidence: { - timeline, - failed_tools: topRows(failedTools, 10), - privacy_signals: topRows(privacy, 10), - github_outcomes: topRows(github, 5), - evals: topRows(evals, 5), - insights: topRows(insights, 10), - recommendation: recommendation ? [recommendation] : [] - }, - counts: { - events: events.length, - tools: tools.length, - failed_tools: failedTools.length, - privacy_signals: privacy.length, - github_outcomes: github.length, - evals: evals.length, - insights: insights.length, - recommendations: recommendation ? 1 : 0 - }, - prompt - }; -} - -function renderAskContext(result) { - if (!result.ok) return `AgentOps ask context\n\n${result.error}\n`; - const lines = [ - 'AgentOps ask context', - '', - `Run: ${result.run_id}`, - `Status: ${result.status}`, - `Time range: ${result.time_range}`, - `Replay: ${result.replay_url}`, - `Evidence: ${result.counts.events} events, ${result.counts.failed_tools} failed/denied tools, ${result.counts.insights} insights, ${result.counts.recommendations} recommendation`, - '', - 'Prompt:', - result.prompt - ]; - return `${lines.join('\n')}\n`; -} - -function legacyAskContext(args = []) { - const sessionId = args[0] || 'latest'; - const last = optionValue(args, '--last', '24h'); - const result = legacy.askAgentOpsContext({ sessionId, last, json: hasFlag(args, '--json'), args: args.slice(1) }); - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(result, null, 2)}\n` : legacy.renderAskContext(result)); - process.exitCode = result.ok ? 0 : 1; -} - -function askContextCommand(args = []) { - if (!hasV2AskArgs(args)) return legacyAskContext(args); - const target = args[0] || 'latest'; - const result = buildAskContext({ - runId: target, - runsFile: path.resolve(optionValue(args, '--runs')), - eventsFile: optionValue(args, '--events') ? path.resolve(optionValue(args, '--events')) : null, - toolsFile: optionValue(args, '--tools') ? path.resolve(optionValue(args, '--tools')) : null, - privacyFile: optionValue(args, '--privacy') ? path.resolve(optionValue(args, '--privacy')) : null, - githubFile: optionValue(args, '--github') ? path.resolve(optionValue(args, '--github')) : null, - evalsFile: optionValue(args, '--evals') ? path.resolve(optionValue(args, '--evals')) : null, - insightsFile: optionValue(args, '--insights') ? path.resolve(optionValue(args, '--insights')) : null, - recommendationsFile: optionValue(args, '--recommendations') ? path.resolve(optionValue(args, '--recommendations')) : null, - last: optionValue(args, '--last', '2h') - }); - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(result, null, 2)}\n` : renderAskContext(result)); - process.exitCode = result.ok ? 0 : 1; -} - -module.exports = { - askContextCommand, - buildAskContext, - hasV2AskArgs, - renderAskContext -}; +module.exports = require('../lib/ask-context-command'); diff --git a/agentops-cli/src/commands/azure-ingest.js b/agentops-cli/src/commands/azure-ingest.js index 1d0264a..4539376 100644 --- a/agentops-cli/src/commands/azure-ingest.js +++ b/agentops-cli/src/commands/azure-ingest.js @@ -1,155 +1 @@ -const path = require('node:path'); -const fs = require('node:fs'); -const os = require('node:os'); -const childProcess = require('node:child_process'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { - buildAzureIngestPlan, - buildLogsIngestionUploadPlan, - buildSharedStorageUploadPlan, - renderAzureIngestPlan, - renderLogsIngestionUploadPlan, - renderSharedStorageUploadPlan -} = require('../lib/azure/v2-ingest-plan'); - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); -const logsIngestionResource = 'https://monitor.azure.com/'; - -function azureIngestCommand(args = []) { - const [subcommand = 'plan'] = args; - - if (subcommand === 'plan') { - const dir = optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'demo', 'latest')); - const plan = buildAzureIngestPlan({ dir, allowContent: hasFlag(args, '--allow-content') }); - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); - } else { - process.stdout.write(renderAzureIngestPlan(plan)); - } - if (!plan.ok) process.exitCode = 1; - return; - } - - if (subcommand === 'logs-upload') { - const dir = optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'demo', 'latest')); - const plan = buildLogsIngestionUploadPlan({ - dir, - endpoint: optionValue(args, '--endpoint', process.env.AGENTOPS_LOGS_INGESTION_ENDPOINT || ''), - dcrImmutableId: optionValue(args, '--dcr-immutable-id', process.env.AGENTOPS_DCR_IMMUTABLE_ID || ''), - allowContent: hasFlag(args, '--allow-content') - }); - const yes = hasFlag(args, '--yes'); - const result = yes ? runLogsIngestionUpload(plan) : plan; - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else { - process.stdout.write(yes ? renderLogsIngestionUploadResult(result) : renderLogsIngestionUploadPlan(result)); - } - if (!result.ok) process.exitCode = 1; - return; - } - - if (subcommand === 'upload-plan') { - const dir = optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'shared', 'latest')); - const plan = buildSharedStorageUploadPlan({ - dir, - account: optionValue(args, '--account'), - container: optionValue(args, '--container', 'agentops-shared'), - prefix: optionValue(args, '--prefix', 'agentops-shared') - }); - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); - } else { - process.stdout.write(renderSharedStorageUploadPlan(plan)); - } - if (!plan.ok) process.exitCode = 1; - return; - } - - throw new Error('azure-ingest supports: plan, logs-upload, upload-plan'); -} - -function jsonArrayUploadFile(jsonlFile, tempDir, table) { - const rows = fs.readFileSync(jsonlFile, 'utf8') - .split(/\r?\n/) - .filter(line => line.trim()) - .map(line => JSON.parse(line)); - const file = path.join(tempDir, `${table}.json`); - fs.writeFileSync(file, `${JSON.stringify(rows)}\n`); - return file; -} - -function runLogsIngestionUpload(plan, options = {}) { - if (!plan.ok) return { ...plan, ok: false, executed: false }; - const spawnSync = options.spawnSync || childProcess.spawnSync; - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-logs-upload-')); - const uploads = []; - - for (const upload of plan.uploads) { - const bodyFile = jsonArrayUploadFile(upload.file, tempDir, upload.table); - const args = [ - 'rest', - '--method', - 'post', - '--uri', - upload.uri, - '--resource', - logsIngestionResource, - '--headers', - 'Content-Type=application/json', - '--body', - `@${bodyFile}` - ]; - const result = spawnSync('az', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); - uploads.push({ - ...upload, - body_file: bodyFile, - status: result.status, - ok: !result.error && result.status === 0, - error: result.error ? result.error.message : '', - stderr: result.stderr ? String(result.stderr).slice(0, 2000) : '' - }); - } - - const ok = uploads.every(upload => upload.ok); - return { - ...plan, - ok, - executed: true, - temp_dir: tempDir, - uploads, - errors: ok ? plan.errors : [ - ...plan.errors, - ...uploads.filter(upload => !upload.ok).map(upload => `${upload.table}: az rest failed with status ${upload.status}${upload.error ? ` (${upload.error})` : ''}`) - ] - }; -} - -function renderLogsIngestionUploadResult(result) { - const lines = []; - lines.push('AgentOps Logs Ingestion upload'); - lines.push(''); - lines.push(`Status: ${result.ok ? 'uploaded' : 'failed'}`); - lines.push(`Directory: ${result.dir}`); - lines.push(''); - lines.push('Uploads:'); - for (const upload of result.uploads) { - lines.push(`- ${upload.table}: ${upload.rows} row(s), ${upload.ok ? 'ok' : `failed status ${upload.status}`}`); - } - if (result.errors.length > 0) { - lines.push(''); - lines.push('Errors:'); - for (const error of result.errors) lines.push(`- ${error}`); - } - return `${lines.join('\n')}\n`; -} - -module.exports = { - azureIngestCommand, - jsonArrayUploadFile, - runLogsIngestionUpload -}; +module.exports = require('../lib/azure-ingest-command'); diff --git a/agentops-cli/src/commands/collector.js b/agentops-cli/src/commands/collector.js index be771ba..72e0235 100644 --- a/agentops-cli/src/commands/collector.js +++ b/agentops-cli/src/commands/collector.js @@ -1,53 +1 @@ -const collector = require('../lib/collector-manager'); - -function renderCollector(result) { - const lines = ['AgentOps collector']; - lines.push(`Mode: ${result.effectiveMode || result.mode}`); - if (result.privacyMode) lines.push(`Privacy: ${result.privacyMode}`); - if (result.running !== undefined) lines.push(`Running: ${result.running ? 'yes' : 'no'}`); - if (result.endpoint) lines.push(`OTLP endpoint: ${result.endpoint}`); - if (result.healthUrl) lines.push(`Health: ${result.healthUrl}`); - if (result.error) lines.push(`Error: ${result.error}`); - if (result.warning) lines.push(`Warning: ${result.warning}`); - if (result.action === 'install-binary') { - lines.push(`Binary: ${result.path}`); - lines.push(`Version: ${result.version}`); - if (result.alreadyInstalled) lines.push('Already installed: yes'); - } - if (result.action === 'uninstall-binary') { - lines.push(`Removed binaries: ${result.removed?.length || 0}`); - if (result.collectorHome) lines.push(`Collector home: ${result.collectorHome}`); - } - if (result.details?.length) { - lines.push('', 'Details:'); - for (const detail of result.details) lines.push(`- ${detail}`); - } - if (result.poison) { - lines.push('', `Poison privacy check: ${result.poison.ok ? 'passed' : 'failed'}`); - if (result.poison.leaked?.length) lines.push(`Leaks: ${result.poison.leaked.join(', ')}`); - } - return `${lines.join('\n')}\n`; -} - -async function collectorCommand(args = []) { - const [action = 'status'] = args; - const options = collector.parseCollectorOptions(args); - let result; - - if (action === 'status') result = await collector.status(options); - else if (action === 'start') result = await collector.start(options); - else if (action === 'stop') result = collector.stop(options); - else if (action === 'validate') result = collector.validate(options); - else if (action === 'smoke') result = await collector.smoke(options); - else if (action === 'install-binary') result = await collector.installBinary(options); - else if (action === 'uninstall-binary') result = collector.uninstallBinary(options); - else throw new Error('collector requires start, stop, status, validate, smoke, install-binary, or uninstall-binary'); - - process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderCollector(result)); - process.exitCode = result.ok === false && action !== 'status' ? 1 : 0; -} - -module.exports = { - collectorCommand, - renderCollector -}; +module.exports = require('../lib/collector-command'); diff --git a/agentops-cli/src/commands/content.js b/agentops-cli/src/commands/content.js index 760e3e5..0c17ea5 100644 --- a/agentops-cli/src/commands/content.js +++ b/agentops-cli/src/commands/content.js @@ -1,148 +1 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { buildAzureIngestPlan } = require('../lib/azure/v2-ingest-plan'); -const { latestByTime } = require('../lib/explain/v2-explain'); -const { repoRoot } = require('../lib/paths'); -const { v2OpenLinksForRun } = require('./open'); - -function readJsonl(filePath) { - if (!filePath || !fs.existsSync(filePath)) return []; - return fs.readFileSync(filePath, 'utf8') - .split(/\r?\n/) - .filter(Boolean) - .map(line => JSON.parse(line)); -} - -function captureModeSummary(rows) { - const modes = new Map(); - for (const row of rows) { - const mode = row.CaptureMode || 'unknown'; - modes.set(mode, (modes.get(mode) || 0) + 1); - } - return Object.fromEntries([...modes.entries()].sort()); -} - -function buildContentStatus({ - dir = path.join(repoRoot, '.agentops', 'demo', 'latest'), - runsFile = '', - allowContent = false -} = {}) { - const absoluteDir = path.resolve(dir); - const contentFile = path.join(absoluteDir, 'AgentOpsContent_CL.jsonl'); - const runFile = runsFile || path.join(absoluteDir, 'AgentOpsRunSummary_CL.jsonl'); - const contentRows = readJsonl(contentFile); - const runs = readJsonl(runFile); - const latestRun = latestByTime(runs); - const plan = buildAzureIngestPlan({ dir: absoluteDir, allowContent }); - const openLinks = latestRun ? v2OpenLinksForRun(latestRun) : { ok: false, links: {} }; - const contentKinds = [...new Set(contentRows.map(row => row.ContentKind || 'unknown'))].sort(); - const redactionStates = [...new Set(contentRows.map(row => row.RedactionStatus || 'unknown'))].sort(); - const hasFullContent = contentRows.some(row => row.CaptureMode === 'full'); - - return { - ok: plan.content_capture.rows === 0 || allowContent, - dir: absoluteDir, - content_file: contentFile, - run_file: runFile, - content_rows: contentRows.length, - allowed_for_ingest: allowContent, - capture_modes: captureModeSummary(contentRows), - content_kinds: contentKinds, - redaction_states: redactionStates, - has_full_content: hasFullContent, - status: contentRows.length === 0 - ? 'strict metadata only' - : allowContent - ? 'explicit content opt-in acknowledged' - : 'content rows present but blocked until --allow-content', - safety_note: contentRows.length === 0 - ? 'Strict mode is not storing prompt or response text.' - : 'Prompt/response rows may contain sensitive text. Use a restricted workspace/dashboard and pass --allow-content only after review.', - latest_run_id: latestRun?.RunId || '', - transcript_viewer_url: openLinks.links?.content_viewer || '', - ingest_ready: plan.ok, - ingest_errors: plan.errors, - next: contentRows.length === 0 - ? [ - 'Keep AGENTOPS_CAPTURE_CONTENT=false for shared/default telemetry.', - 'Use agentops demo generate --with-content only for redacted demo transcript UX.', - 'For real prompt/response capture, use a restricted workspace and rerun agentops content opt-in for the review checklist.' - ] - : [ - `agentops content status --dir ${absoluteDir} --allow-content`, - `agentops azure-ingest plan --dir ${absoluteDir} --allow-content`, - 'Open the Prompt/response viewer link only in a restricted Grafana workspace.' - ] - }; -} - -function renderContentStatus(status) { - const lines = ['AgentOps content capture status', '']; - lines.push(`Status: ${status.status}`); - lines.push(`Content rows: ${status.content_rows}`); - lines.push(`Allowed for ingest: ${status.allowed_for_ingest ? 'yes' : 'no'}`); - lines.push(`Ingest ready: ${status.ingest_ready ? 'yes' : 'no'}`); - lines.push(`Capture modes: ${JSON.stringify(status.capture_modes)}`); - lines.push(`Content kinds: ${status.content_kinds.join(', ') || 'none'}`); - lines.push(`Safety: ${status.safety_note}`); - if (status.transcript_viewer_url) lines.push(`Prompt/response viewer: ${status.transcript_viewer_url}`); - if (status.ingest_errors.length > 0) { - lines.push(''); - lines.push('Ingest blockers:'); - for (const error of status.ingest_errors) lines.push(`- ${error}`); - } - lines.push(''); - lines.push('Next:'); - for (const step of status.next) lines.push(`- ${step}`); - return `${lines.join('\n')}\n`; -} - -function renderOptInGuide() { - return [ - 'AgentOps prompt/response opt-in checklist', - '', - 'Default: keep AGENTOPS_PRIVACY_MODE=strict and AGENTOPS_CAPTURE_CONTENT=false.', - '', - 'Use raw or redacted prompt/response capture only when all are true:', - '- the workspace is restricted to approved viewers', - '- the run does not include secrets, private source, customer data, or regulated data', - '- the team accepts that AgentOpsContent_CL can contain sensitive text', - '- ingestion is reviewed with agentops azure-ingest plan --allow-content', - '', - 'Safe demo path:', - '- agentops demo generate --with-content --out .agentops/demo/content-demo', - '- agentops content status --dir .agentops/demo/content-demo --allow-content', - '- agentops azure-ingest plan --dir .agentops/demo/content-demo --allow-content', - '', - 'Real capture remains explicit and environment-specific. Do not enable it in shared defaults.' - ].join('\n') + '\n'; -} - -function contentCommand(args = []) { - const [subcommand = 'status'] = args; - if (!['status', 'opt-in'].includes(subcommand)) throw new Error('content supports: status|opt-in'); - - if (subcommand === 'opt-in') { - const guide = { ok: true, default_capture: 'off', mode: 'explicit_opt_in', checklist: renderOptInGuide().trim().split(/\n/) }; - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(guide, null, 2)}\n` : renderOptInGuide()); - return; - } - - const status = buildContentStatus({ - dir: optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'demo', 'latest')), - runsFile: optionValue(args, '--runs', ''), - allowContent: hasFlag(args, '--allow-content') - }); - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(status, null, 2)}\n` : renderContentStatus(status)); - if (!status.ok) process.exitCode = 1; -} - -module.exports = { - buildContentStatus, - captureModeSummary, - contentCommand, - renderContentStatus, - renderOptInGuide -}; +module.exports = require('../lib/content-command'); diff --git a/agentops-cli/src/commands/copilot-session.js b/agentops-cli/src/commands/copilot-session.js index 101ba59..509b4d5 100644 --- a/agentops-cli/src/commands/copilot-session.js +++ b/agentops-cli/src/commands/copilot-session.js @@ -1,101 +1 @@ -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const { optionValue, parseJsonFlag } = require('../lib/args'); -const { - defaultSessionEventsPath, - enrichCopilotSessionEvents, - readCopilotSessionEvents -} = require('../lib/copilot/session-enricher'); -const { agentopsCustomImport, customEventId } = require('../legacy'); - -function parseCopilotSessionArgs(args = []) { - const [subcommand, sessionId] = args; - return { - subcommand, - sessionId, - file: optionValue(args, '--file'), - endpoint: optionValue(args, '--endpoint', 'http://127.0.0.1:4318'), - id: optionValue(args, '--id') || customEventId(), - dryRun: args.includes('--dry-run'), - json: parseJsonFlag(args) - }; -} - -async function buildCopilotSessionEnrichment(options = {}) { - if (options.subcommand !== 'enrich') throw new Error('copilot-session supports: enrich '); - if (!options.sessionId && !options.file) throw new Error('copilot-session enrich requires or --file '); - - const eventsFile = options.file || defaultSessionEventsPath(options.sessionId); - const sessionId = options.sessionId || path.basename(path.dirname(eventsFile)); - const rawEvents = readCopilotSessionEvents(eventsFile); - const rows = enrichCopilotSessionEvents(rawEvents, { sessionId }); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-copilot-session-')); - const outFile = path.join(tempDir, 'AgentOpsCopilotSessionEnrichment.jsonl'); - - try { - fs.writeFileSync(outFile, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`); - const result = await agentopsCustomImport(outFile, { - id: options.id, - endpoint: options.endpoint, - dryRun: options.dryRun, - last: '2h' - }); - return { - ...result, - ok: result.ok && rows.length > 0, - session_id: sessionId, - source_file: eventsFile, - enriched_rows: rows.length, - event_counts: rows.reduce((counts, row) => { - counts[row.event] = (counts[row.event] || 0) + 1; - return counts; - }, {}), - preview: rows.slice(0, 8).map(row => ({ - event: row.event, - agent: row.agent, - skill: row.attributes?.['agentops.skill.name'] || '', - mcp_server: row.attributes?.['agentops.mcp.server'] || '', - tool: row.attributes?.['gen_ai.tool.name'] || '', - outcome: row.outcome || '' - })) - }; - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -function renderCopilotSessionEnrichment(result = {}) { - const lines = [ - 'Copilot session enrichment', - `Session: ${result.session_id}`, - `Source: ${result.source_file}`, - `Rows: ${result.enriched_rows}`, - `Dry run: ${Boolean(result.dry_run)}`, - `OK: ${Boolean(result.ok)}` - ]; - if (result.event_counts) { - lines.push('', 'Events:'); - for (const [event, count] of Object.entries(result.event_counts)) lines.push(`- ${event}: ${count}`); - } - if (result.next?.length) { - lines.push('', 'Next:'); - for (const item of result.next) lines.push(`- ${item}`); - } - return `${lines.join('\n')}\n`; -} - -async function copilotSessionCommand(args = []) { - const options = parseCopilotSessionArgs(args); - const result = await buildCopilotSessionEnrichment(options); - process.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderCopilotSessionEnrichment(result)); - process.exitCode = result.ok ? 0 : 1; -} - -module.exports = { - buildCopilotSessionEnrichment, - copilotSessionCommand, - parseCopilotSessionArgs, - renderCopilotSessionEnrichment -}; +module.exports = require('../lib/copilot/session-command'); diff --git a/agentops-cli/src/commands/copilot.js b/agentops-cli/src/commands/copilot.js index 0330edd..ad73149 100644 --- a/agentops-cli/src/commands/copilot.js +++ b/agentops-cli/src/commands/copilot.js @@ -1,115 +1 @@ -const childProcess = require('node:child_process'); -const path = require('node:path'); - -const collector = require('../lib/collector-manager'); -const legacy = require('../legacy'); -const { optionValue, withoutFlags } = require('../lib/args'); -const { appendWrapperEvent, createWrapperEnvelope } = require('../lib/copilot/wrapper-envelope'); -const { resolveCopilotBinary } = require('../lib/copilot-resolver'); -const { copilotDir } = require('../lib/paths'); - -function removeAgentOpsCopilotFlags(args) { - return withoutFlags(args, ['--collector-mode', '--privacy', '--unsafe-no-collector']); -} - -function wrapperReplayUrl(envelope = createWrapperEnvelope(), links = legacy.openLinksSummary()) { - const base = String(links.v2_replay_url || '').split('?')[0]; - if (!base) return ''; - const params = new URLSearchParams({ - 'var-run_id': envelope.runId || '__all', - 'var-session_id': envelope.sessionId || '__all' - }); - return `${base}?${params.toString()}`; -} - -async function copilotCommand(args = []) { - const helpOnly = args.includes('--help') || args.includes('-h'); - const mode = optionValue(args, '--collector-mode', process.env.AGENTOPS_COLLECTOR_MODE || 'auto'); - const privacy = optionValue(args, '--privacy', process.env.AGENTOPS_PRIVACY_MODE || 'strict'); - const unsafeNoCollector = args.includes('--unsafe-no-collector') || process.env.AGENTOPS_ALLOW_NO_COLLECTOR === '1'; - const observedArgs = removeAgentOpsCopilotFlags(args); - const envelope = createWrapperEnvelope(); - let fallbackUnobserved = false; - const baseEvent = { - RunId: envelope.runId, - SessionId: envelope.sessionId, - Surface: 'cli', - PrivacyMode: privacy, - CollectorMode: mode - }; - - if (helpOnly) { - const resolved = resolveCopilotBinary(); - if (!resolved.ok) throw new Error(resolved.error); - const help = childProcess.spawnSync(resolved.path, observedArgs, { stdio: 'inherit', env: process.env }); - if (help.error) throw help.error; - process.exitCode = help.status === null ? 1 : help.status; - return; - } - - appendWrapperEvent({ - ...baseEvent, - EventName: 'agentops.run.start' - }); - - const currentStatus = await collector.status({ mode, privacy }); - if (!currentStatus.running && mode !== 'none') { - const started = await collector.start({ mode, privacy, unsafeNoCollector }); - if (!started.ok) { - appendWrapperEvent({ - ...baseEvent, - EventName: 'agentops.collector.start_failed', - Reason: started.error || 'collector start failed' - }); - if (process.env.AGENTOPS_ALLOW_UNOBSERVED_FALLBACK === '1') { - fallbackUnobserved = true; - const eventFile = appendWrapperEvent({ - ...baseEvent, - EventName: 'agentops.wrapper.fallback_unobserved', - Reason: started.error || 'collector start failed' - }); - process.stderr.write(`WARNING: AgentOps collector unavailable; running unobserved because AGENTOPS_ALLOW_UNOBSERVED_FALLBACK=1. ${started.error || ''}\n`); - process.stderr.write(`AgentOps wrapper fallback event: ${eventFile}\n`); - } else { - throw new Error(`AgentOps collector unavailable: ${started.error || 'unknown error'}`); - } - } - } - - if (mode === 'none' && !unsafeNoCollector) { - throw new Error('Collector mode none requires AGENTOPS_ALLOW_NO_COLLECTOR=1 or --unsafe-no-collector.'); - } - - const resolved = resolveCopilotBinary(); - if (!resolved.ok) throw new Error(resolved.error); - - const observeScript = path.join(copilotDir, 'copilot-observe'); - const env = { - ...process.env, - COPILOT_CLI_BIN: resolved.path, - AGENTOPS_PRIVACY_MODE: privacy, - AGENTOPS_COLLECTOR_MODE: mode, - AGENTOPS_WRAPPER_RUN_ID: envelope.runId, - AGENTOPS_WRAPPER_SESSION_ID: envelope.sessionId, - AGENTOPS_WRAPPER_FALLBACK_UNOBSERVED: fallbackUnobserved ? 'true' : 'false' - }; - const result = childProcess.spawnSync(observeScript, observedArgs, { stdio: 'inherit', env }); - appendWrapperEvent({ - ...baseEvent, - EventName: 'agentops.run.end', - ExitCode: result.status === null ? 1 : result.status, - Error: result.error ? result.error.message : '', - FallbackUnobserved: fallbackUnobserved - }); - if (result.error) throw result.error; - process.exitCode = result.status === null ? 1 : result.status; - if (process.exitCode === 0 && process.env.AGENTOPS_PRINT_RUN_LINK !== 'false') { - process.stderr.write(`AgentOps Run Replay: ${wrapperReplayUrl(envelope)}\n`); - } -} - -module.exports = { - copilotCommand, - removeAgentOpsCopilotFlags, - wrapperReplayUrl -}; +module.exports = require('../lib/copilot/command'); diff --git a/agentops-cli/src/commands/dashboard.js b/agentops-cli/src/commands/dashboard.js index 2658683..977ef61 100644 --- a/agentops-cli/src/commands/dashboard.js +++ b/agentops-cli/src/commands/dashboard.js @@ -1,767 +1 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { validateDashboardContentGuardrails } = require('../lib/dashboard-content-guardrails'); -const { repoRoot } = require('../lib/paths'); -const legacy = require('../legacy'); - -function dashboardJsonFiles() { - const roots = [ - path.join(repoRoot, 'grafana'), - path.join(repoRoot, 'grafana', 'dashboards', 'v2') - ]; - return roots.flatMap(root => { - if (!fs.existsSync(root)) return []; - return fs.readdirSync(root) - .filter(file => file.endsWith('.json')) - .map(file => path.join(root, file)); - }).sort(); -} - -function validateDashboards() { - const files = dashboardJsonFiles(); - const errors = []; - const requiredV2Variables = new Set([ - 'datasource', - 'workspace', - 'timeRange', - 'actioner_url', - 'run_id', - 'session_id', - 'trace_id', - 'surface', - 'repo_hash', - 'branch_hash', - 'model', - 'agent_name', - 'skill_name', - 'mcp_server', - 'sub_agent', - 'task_type', - 'tool_name', - 'tool_risk', - 'pattern_key', - 'privacy_mode', - 'outcome_status', - 'eval_bucket' - ]); - - for (const file of files) { - let dashboard; - try { - dashboard = JSON.parse(fs.readFileSync(file, 'utf8')); - } catch (error) { - errors.push(`${file}: invalid JSON: ${error.message}`); - continue; - } - if (!dashboard.uid) errors.push(`${file}: missing uid`); - if (!dashboard.title) errors.push(`${file}: missing title`); - if (!Array.isArray(dashboard.panels) || dashboard.panels.length === 0) errors.push(`${file}: missing panels`); - if (file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)) { - const variables = new Set((dashboard.templating?.list || []).map(item => item.name)); - for (const variable of requiredV2Variables) { - if (!variables.has(variable)) errors.push(`${file}: missing V2 variable ${variable}`); - } - if (!Array.isArray(dashboard.links) || dashboard.links.length < 5) errors.push(`${file}: missing V2 nav links`); - } - } - - return { - ok: errors.length === 0, - dashboards: files.length, - errors - }; -} - -function collectPanelLinks(panel, links = []) { - for (const link of panel.fieldConfig?.defaults?.links || []) links.push({ panel: panel.title, link }); - for (const override of panel.fieldConfig?.overrides || []) { - const field = override.matcher?.options || 'unknown-field'; - for (const property of override.properties || []) { - if (property.id !== 'links') continue; - for (const link of property.value || []) links.push({ panel: panel.title, field, link }); - } - } - for (const child of panel.panels || []) collectPanelLinks(child, links); - return links; -} - -function validateDashboardLinks() { - const files = dashboardJsonFiles().filter(file => file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)); - const dashboards = files.map(file => ({ file, body: JSON.parse(fs.readFileSync(file, 'utf8')) })); - const uidSet = new Set(dashboards.map(item => item.body.uid)); - const errors = []; - const expectedNav = [ - 'agentops-v2-home', - 'agentops-v2-runs-explorer', - 'agentops-v2-run-replay', - 'agentops-v2-models-cost-tokens', - 'agentops-v2-tools-mcp-risk', - 'agentops-v2-safety-privacy-policy', - 'agentops-v2-code-outcomes', - 'agentops-v2-evals-quality', - 'agentops-v2-insights-regressions', - 'agentops-v2-collector-health' - ]; - const requiredDataLinks = [ - { field: 'RunId', uid: 'agentops-v2-run-replay', variable: 'var-run_id', time: true }, - { field: 'SessionId', uid: 'agentops-v2-run-replay', variable: 'var-session_id', time: true }, - { field: 'TraceId', uid: 'agentops-v2-run-replay', variable: 'var-trace_id', time: true }, - { field: 'ToolName', uid: 'agentops-v2-tools-mcp-risk', variable: 'var-tool_name', time: true }, - { field: 'McpServer', uid: 'agentops-v2-tools-mcp-risk', variable: 'var-mcp_server', time: true }, - { field: 'ModelActual', uid: 'agentops-v2-models-cost-tokens', variable: 'var-model', time: true }, - { field: 'AgentName', uid: 'agentops-v2-runs-explorer', variable: 'var-agent_name', time: true }, - { field: 'SkillName', uid: 'agentops-v2-runs-explorer', variable: 'var-skill_name', time: true }, - { field: 'SubAgentName', uid: 'agentops-v2-run-replay', variable: 'var-sub_agent', time: true }, - { field: 'RepoHash', uid: 'agentops-v2-runs-explorer', variable: 'var-repo_hash', time: true }, - { field: 'PrNumberHash', uid: 'agentops-v2-code-outcomes', variable: 'var-repo_hash', time: true }, - { field: 'CiStatus', uid: 'agentops-v2-code-outcomes', variable: 'var-outcome_status', time: true }, - { field: 'EvalOverall', uid: 'agentops-v2-evals-quality', variable: 'var-run_id', time: true }, - { field: 'PatternKey', uid: 'agentops-v2-insights-regressions', variable: 'var-pattern_key', time: true }, - { field: 'OpenTranscript', uid: 'agentops-v2-run-replay', variable: 'viewPanel=26', time: true }, - { field: 'OpenReplay', uid: 'agentops-v2-run-replay', variable: 'var-run_id', time: true }, - { field: 'OpenTrace', uid: 'agentops-v2-run-replay', variable: 'var-trace_id', time: true }, - { field: 'OpenGithub', uid: 'agentops-v2-code-outcomes', variable: 'var-run_id', time: true }, - { field: 'OpenPattern', uid: 'agentops-v2-insights-regressions', variable: 'var-pattern_key', time: true } - ]; - - for (const { file, body } of dashboards) { - const navUids = new Set((body.links || []).map(link => link.uid).filter(Boolean)); - for (const uid of expectedNav) { - if (!navUids.has(uid)) errors.push(`${file}: missing nav link to ${uid}`); - } - for (const link of body.links || []) { - if (link.uid && !uidSet.has(link.uid)) errors.push(`${file}: nav target ${link.uid} does not exist`); - if (link.uid && link.url !== `/d/${link.uid}`) errors.push(`${file}: nav link ${link.uid} should use /d/${link.uid}`); - if (link.uid && link.keepTime !== true) errors.push(`${file}: nav link ${link.uid} must preserve the active time range`); - if (link.uid && link.includeVars !== true) errors.push(`${file}: nav link ${link.uid} must preserve active dashboard filters`); - } - - const panelLinks = (body.panels || []).flatMap(panel => collectPanelLinks(panel)); - for (const item of panelLinks) { - const match = String(item.link?.url || '').match(/\/d\/([^?]+)/); - if (match && !uidSet.has(match[1])) errors.push(`${file}: panel ${item.panel} links to missing dashboard ${match[1]}`); - } - for (const required of requiredDataLinks) { - const matching = panelLinks.filter(item => item.field === required.field); - if (matching.length === 0) { - errors.push(`${file}: missing data link for ${required.field}`); - continue; - } - if (!matching.some(item => String(item.link.url || '').includes(`/d/${required.uid}`))) { - errors.push(`${file}: ${required.field} data link does not target ${required.uid}`); - } - if (!matching.some(item => String(item.link.url || '').includes(required.variable))) { - errors.push(`${file}: ${required.field} data link does not set ${required.variable}`); - } - if (required.time && !matching.some(item => String(item.link.url || '').includes('__url_time_range'))) { - errors.push(`${file}: ${required.field} data link does not preserve time range`); - } - } - } - - return { - ok: errors.length === 0, - dashboards: dashboards.length, - checked_links: dashboards.reduce((total, item) => total + (item.body.links || []).length + (item.body.panels || []).flatMap(panel => collectPanelLinks(panel)).length, 0), - errors - }; -} - -function validateDashboardFilters() { - const dashboards = v2DashboardBodies(); - const errors = []; - const requiredChoices = { - surface: ['__all', 'cli', 'sdk', 'vscode_mcp', 'github_action', 'cloud_agent', 'custom'], - task_type: ['__all', 'explain', 'review', 'test', 'fix', 'refactor', 'docs', 'debug_ci', 'unknown'], - tool_risk: ['__all', 'read-only', 'write-file', 'shell', 'network', 'secret-access', 'browser-control', 'destructive', 'privileged'], - privacy_mode: ['__all', 'strict', 'compat', 'unsafe'], - outcome_status: ['__all', 'success', 'failed', 'cancelled', 'blocked', 'unknown'], - eval_bucket: ['__all', 'ok', 'review', 'poor'] - }; - const queryFilterContracts = { - 'agentops-v2-home': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], - 'agentops-v2-runs-explorer': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], - 'agentops-v2-run-replay': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], - 'agentops-v2-models-cost-tokens': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], - 'agentops-v2-tools-mcp-risk': ['run_id', 'trace_id', 'surface', 'agent_name', 'mcp_server', 'tool_name', 'tool_risk'], - 'agentops-v2-safety-privacy-policy': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], - 'agentops-v2-code-outcomes': ['run_id', 'repo_hash', 'branch_hash', 'outcome_status'], - 'agentops-v2-evals-quality': ['run_id', 'repo_hash', 'model', 'task_type', 'eval_bucket'], - 'agentops-v2-insights-regressions': ['run_id', 'repo_hash', 'model', 'task_type', 'tool_name', 'pattern_key', 'eval_bucket'], - 'agentops-v2-collector-health': ['privacy_mode'] - }; - - for (const { file, body } of dashboards) { - const variables = new Set((body.templating?.list || []).map(item => item.name)); - const queryText = (body.panels || []) - .flatMap(panel => [panel, ...(panel.panels || [])]) - .flatMap(panel => panel.targets || []) - .map(target => target.azureLogAnalytics?.query || target.query || '') - .join('\n'); - const expected = queryFilterContracts[body.uid] || []; - - for (const variable of expected) { - if (!variables.has(variable)) errors.push(`${file}: missing filter variable ${variable}`); - if (!queryText.includes(`$${variable}`) && !queryText.includes(`\${${variable}}`)) { - errors.push(`${file}: filter ${variable} is not wired into any panel query`); - } - } - for (const [name, values] of Object.entries(requiredChoices)) { - const variable = (body.templating?.list || []).find(item => item.name === name); - if (!variable) continue; - const choices = String(variable.query || '').split(',').map(value => value.trim()).filter(Boolean); - for (const value of values) { - if (!choices.includes(value)) errors.push(`${file}: filter ${name} missing dropdown value ${value}`); - } - } - if (expected.includes('eval_bucket') && !queryText.includes("iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')")) { - errors.push(`${file}: eval_bucket filter must accept ok as the user-facing alias for good`); - } - for (const link of body.links || []) { - if (link.uid && link.includeVars !== true) errors.push(`${file}: nav link ${link.uid} does not carry filters`); - if (link.uid && link.keepTime !== true) errors.push(`${file}: nav link ${link.uid} does not carry time range`); - } - } - - return { - ok: errors.length === 0, - dashboards: dashboards.length, - errors - }; -} - -const v2KqlSmokePanels = [ - { uid: 'agentops-v2-home', panel: 'Session Health', requireRows: true }, - { uid: 'agentops-v2-home', panel: 'Recommended next actions', requireRows: true }, - { uid: 'agentops-v2-home', panel: 'Saved investigations', requireRows: false }, - { uid: 'agentops-v2-runs-explorer', panel: 'Runs', requireRows: true }, - { uid: 'agentops-v2-run-replay', panel: 'Run summary', requireRows: true }, - { uid: 'agentops-v2-run-replay', panel: 'Agent, skill, and MCP lineage', requireRows: true }, - { uid: 'agentops-v2-run-replay', panel: 'Context and cache posture', requireRows: true }, - { uid: 'agentops-v2-run-replay', panel: 'Why this failed / next check', requireRows: true }, - { uid: 'agentops-v2-run-replay', panel: 'Ask AgentOps context', requireRows: true }, - { uid: 'agentops-v2-run-replay', panel: 'Transcript availability', requireRows: true }, - { uid: 'agentops-v2-run-replay', panel: 'Prompt and response viewer (explicit opt-in)', requireRows: false }, - { uid: 'agentops-v2-models-cost-tokens', panel: 'Model ROI', requireRows: true }, - { uid: 'agentops-v2-tools-mcp-risk', panel: 'Tool risk table', requireRows: true }, - { uid: 'agentops-v2-safety-privacy-policy', panel: 'Privacy drops by kind', requireRows: true }, - { uid: 'agentops-v2-safety-privacy-policy', panel: 'Alert handoff review', requireRows: false }, - { uid: 'agentops-v2-code-outcomes', panel: 'Runs and PR outcomes', requireRows: true }, - { uid: 'agentops-v2-code-outcomes', panel: 'Delivery timing', requireRows: true }, - { uid: 'agentops-v2-evals-quality', panel: 'Low-score runs', requireRows: true }, - { uid: 'agentops-v2-evals-quality', panel: 'Eval scorecard by repo, model, and task', requireRows: true }, - { uid: 'agentops-v2-evals-quality', panel: 'Eval regression follow-up', requireRows: false }, - { uid: 'agentops-v2-evals-quality', panel: 'Before/after run comparison', requireRows: false }, - { uid: 'agentops-v2-evals-quality', panel: 'Benchmark artifact diff review', requireRows: false }, - { uid: 'agentops-v2-evals-quality', panel: 'Benchmark artifact files', requireRows: false }, - { uid: 'agentops-v2-evals-quality', panel: 'Benchmark hidden check packs', requireRows: false }, - { uid: 'agentops-v2-evals-quality', panel: 'Benchmark policy review', requireRows: false }, - { uid: 'agentops-v2-evals-quality', panel: 'Benchmark semantic checks', requireRows: false }, - { uid: 'agentops-v2-evals-quality', panel: 'Benchmark promotion approvals', requireRows: false }, - { uid: 'agentops-v2-insights-regressions', panel: 'Latest insights', requireRows: true }, - { uid: 'agentops-v2-insights-regressions', panel: 'Recurring patterns', requireRows: false }, - { uid: 'agentops-v2-insights-regressions', panel: 'Eval regression queue', requireRows: false }, - { uid: 'agentops-v2-insights-regressions', panel: 'Recommendation artifacts', requireRows: false }, - { uid: 'agentops-v2-insights-regressions', panel: 'Config change annotations', requireRows: false }, - { uid: 'agentops-v2-collector-health', panel: 'Collector checks', requireRows: true }, - { uid: 'agentops-v2-collector-health', panel: 'Schema version coverage', requireRows: false }, - { uid: 'agentops-v2-collector-health', panel: 'Exporter failure review', requireRows: false } -]; - -function queryFromPanel(panel) { - return panel?.targets?.[0]?.azureLogAnalytics?.query || panel?.targets?.[0]?.query || ''; -} - -function substituteGrafanaMacros(query, { last = '24h' } = {}) { - const safeLast = legacy.validateKqlDuration(last); - const variableNames = [ - 'datasource', - 'workspace', - 'timeRange', - 'run_id', - 'session_id', - 'trace_id', - 'surface', - 'repo_hash', - 'branch_hash', - 'model', - 'agent_name', - 'skill_name', - 'mcp_server', - 'sub_agent', - 'task_type', - 'tool_name', - 'tool_risk', - 'pattern_key', - 'privacy_mode', - 'outcome_status', - 'eval_bucket' - ]; - let rendered = String(query || '') - .replaceAll('$__timeFrom()', `ago(${safeLast})`) - .replaceAll('$__timeTo()', 'now()') - .replaceAll('$__interval', '1h'); - for (const name of variableNames) { - rendered = rendered - .replaceAll(`$${name}`, '__all') - .replaceAll(`\${${name}}`, '__all'); - } - return `${rendered}\n| take 5`; -} - -function v2DashboardBodies() { - return dashboardJsonFiles() - .filter(file => file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)) - .map(file => ({ file, body: JSON.parse(fs.readFileSync(file, 'utf8')) })); -} - -function panelByTitle(dashboard, title) { - return (dashboard.body.panels || []).find(panel => panel.title === title); -} - -function orderedInText(text, terms) { - let cursor = -1; - for (const term of terms) { - const index = String(text || '').indexOf(term); - if (index <= cursor) return false; - cursor = index; - } - return true; -} - -function orderedAfter(text, marker, terms) { - const index = String(text || '').lastIndexOf(marker); - if (index === -1) return false; - return orderedInText(String(text).slice(index), terms); -} - -function validateDashboardUx() { - const dashboards = v2DashboardBodies(); - const byUid = new Map(dashboards.map(item => [item.body.uid, item])); - const errors = []; - const required = [ - 'agentops-v2-home', - 'agentops-v2-runs-explorer', - 'agentops-v2-run-replay', - 'agentops-v2-models-cost-tokens', - 'agentops-v2-tools-mcp-risk', - 'agentops-v2-safety-privacy-policy', - 'agentops-v2-code-outcomes', - 'agentops-v2-evals-quality', - 'agentops-v2-insights-regressions', - 'agentops-v2-collector-health' - ]; - for (const uid of required) { - if (!byUid.has(uid)) errors.push(`missing V2 dashboard ${uid}`); - } - let emptyStateDashboards = 0; - for (const dashboard of dashboards) { - const text = (dashboard.body.panels || []) - .filter(panel => panel.type === 'text') - .map(panel => panel.options?.content || '') - .join('\n'); - if (text.includes('agentops collector smoke --privacy strict --poison --json') && text.includes('agentops demo generate')) { - emptyStateDashboards += 1; - } else { - errors.push(`${dashboard.file}: missing dashboard-level empty-state commands`); - } - } - - const home = byUid.get('agentops-v2-home'); - const homeTitles = new Set((home?.body.panels || []).map(panel => panel.title)); - for (const title of ['Runs', 'Success rate', 'Failed runs', 'Policy blocks', 'Privacy drops', 'Estimated cost', 'Input tokens', 'Output tokens', 'p95 duration', 'Tests ran %', 'PRs opened', 'Collector health', 'Session Health', 'Saved investigations']) { - if (!homeTitles.has(title)) errors.push(`home missing top-strip panel ${title}`); - } - const homeText = (home?.body.panels || []) - .filter(panel => panel.type === 'text') - .map(panel => `${panel.title}\n${panel.options?.content || ''}`) - .join('\n'); - for (const snippet of ['Open latest run', 'agentops open latest --last 2h --json', 'Get recommendation', 'agentops recommend latest --last 2h', 'Ask AgentOps', 'agentops ask-context latest --last 2h --json', '--recommendations ', 'docs/copilot-mcp-agentops-prompts.md']) { - if (!homeText.includes(snippet)) errors.push(`home action strip missing ${snippet}`); - } - const savedViewsQuery = queryFromPanel(panelByTitle(home, 'Saved investigations')); - for (const field of ['AgentOpsSavedViews_CL', 'SavedViewId', 'Name', 'QueryHash', 'ChangeAnnotationCount', 'ChangeTargetRefs', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/saved-view/', 'OpenSavedView', 'OpenReplay']) { - if (!savedViewsQuery.includes(field)) errors.push(`saved investigations panel missing ${field}`); - } - const recommendedNextActionsQuery = queryFromPanel(panelByTitle(home, 'Recommended next actions')); - for (const field of ['AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/']) { - if (!recommendedNextActionsQuery.includes(field)) errors.push(`recommended next actions panel missing ${field}`); - } - const sessionHealthQuery = queryFromPanel(panelByTitle(home, 'Session Health')); - for (const field of ['LatestRecommendations', 'HealthStatus', 'RootAgent', 'RecommendedNextAction', 'ToolFailureCount', 'ToolDeniedCount', 'ContentCaptureSignal', 'ContextWindowPct', 'EvalOverall', 'OpenReplay']) { - if (!sessionHealthQuery.includes(field)) errors.push(`session health panel missing ${field}`); - } - - const runs = byUid.get('agentops-v2-runs-explorer'); - const runsQuery = queryFromPanel(panelByTitle(runs, 'Runs')); - for (const action of ['OpenReplay', 'OpenTrace', 'OpenGithub']) { - if (!runsQuery.includes(action)) errors.push(`runs explorer missing ${action} action cell`); - } - - const replay = byUid.get('agentops-v2-run-replay'); - const replayTitles = new Set((replay?.body.panels || []).map(panel => panel.title)); - for (const title of ['Run summary', 'Replay timeline', 'Agent, skill, and MCP lineage', 'Context and cache posture', 'Why this failed / next check', 'Latest recommendation', 'Ask AgentOps context', 'Transcript availability', 'Prompt and response viewer (explicit opt-in)', 'Policy, privacy, tests, and GitHub outcome']) { - if (!replayTitles.has(title)) errors.push(`run replay missing panel ${title}`); - } - const latestRecommendationQuery = queryFromPanel(panelByTitle(replay, 'Latest recommendation')); - for (const field of ['RecommendationId', 'Action', 'ObservedPattern', 'NextAction', 'RecommendationCommand', 'AskContextCommand', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/', 'OpenReplay', 'OpenPattern']) { - if (!latestRecommendationQuery.includes(field)) errors.push(`latest recommendation panel missing ${field}`); - } - const askQuery = queryFromPanel(panelByTitle(replay, 'Ask AgentOps context')); - for (const field of ['RunReplayUrl', 'InvestigationKql', 'AskContextCommand', 'BundleCommand', 'AskPrompt', 'TriageCommand', 'AskAgentOpsLaunch', '/ask-agentops', 'OpenReplay', 'Do not request or enable prompt']) { - if (!askQuery.includes(field)) errors.push(`ask agentops context panel missing ${field}`); - } - const transcriptQuery = queryFromPanel(panelByTitle(replay, 'Transcript availability')); - if (!orderedAfter(transcriptQuery, '| project Status', ['Status', 'SafetyNote', 'OpenTranscript', 'ContentRows', 'FullContentRows', 'RedactedContentRows'])) { - errors.push('transcript availability must put status, safety note, open action, and content counts first'); - } - const contentQuery = queryFromPanel(panelByTitle(replay, 'Prompt and response viewer (explicit opt-in)')); - if (!orderedAfter(contentQuery, '| project TimeGenerated', ['TimeGenerated', 'TurnIndex', 'Role', 'ContentKind', 'MessageText', 'CaptureMode', 'RedactionStatus', 'ViewerNote'])) { - errors.push('prompt/response viewer must read like a transcript before showing hashes and IDs'); - } - if (!contentQuery.includes('AgentOpsContent_CL')) errors.push('prompt/response viewer must use AgentOpsContent_CL only'); - - const tools = byUid.get('agentops-v2-tools-mcp-risk'); - const toolQuery = queryFromPanel(panelByTitle(tools, 'Tool risk table')); - for (const field of ['BadOutcomeCorrelation', 'McpServer', 'ToolRisk', 'DeniedRate']) { - if (!toolQuery.includes(field)) errors.push(`tool risk table missing ${field}`); - } - - const safety = byUid.get('agentops-v2-safety-privacy-policy'); - const safetyTitles = new Set((safety?.body.panels || []).map(panel => panel.title)); - if (!safetyTitles.has('Alert handoff review')) errors.push('safety dashboard missing Alert handoff review panel'); - const alertHandoffQuery = queryFromPanel(panelByTitle(safety, 'Alert handoff review')); - for (const field of ['AgentOpsAlertHandoffs_CL', 'HandoffId', 'AlertRule', 'SessionId', 'ConfigChangeCount', 'ChangeTargetRefs', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/alert-handoff/', 'OpenReplay']) { - if (!alertHandoffQuery.includes(field)) errors.push(`alert handoff review missing ${field}`); - } - - const code = byUid.get('agentops-v2-code-outcomes'); - const codeTitles = new Set((code?.body.panels || []).map(panel => panel.title)); - for (const title of ['Runs and PR outcomes', 'PR and CI outcomes', 'Delivery timing', 'Edited files but no tests']) { - if (!codeTitles.has(title)) errors.push(`code outcomes missing panel ${title}`); - } - const timingQuery = queryFromPanel(panelByTitle(code, 'Delivery timing')); - for (const field of ['TimeToPrMinutes', 'TimeToMergeMinutes', 'P95TimeToPrMinutes', 'P95TimeToMergeMinutes']) { - if (!timingQuery.includes(field)) errors.push(`delivery timing missing ${field}`); - } - - const evals = byUid.get('agentops-v2-evals-quality'); - const evalsTitles = new Set((evals?.body.panels || []).map(panel => panel.title)); - if (!evalsTitles.has('Eval scorecard by repo, model, and task')) errors.push('evals dashboard missing Eval scorecard by repo, model, and task panel'); - if (!evalsTitles.has('Eval regression follow-up')) errors.push('evals dashboard missing Eval regression follow-up panel'); - if (!evalsTitles.has('Before/after run comparison')) errors.push('evals dashboard missing Before/after run comparison panel'); - if (!evalsTitles.has('Benchmark artifact diff review')) errors.push('evals dashboard missing Benchmark artifact diff review panel'); - if (!evalsTitles.has('Benchmark artifact files')) errors.push('evals dashboard missing Benchmark artifact files panel'); - if (!evalsTitles.has('Benchmark artifact content diffs')) errors.push('evals dashboard missing Benchmark artifact content diffs panel'); - if (!evalsTitles.has('Benchmark hidden check packs')) errors.push('evals dashboard missing Benchmark hidden check packs panel'); - if (!evalsTitles.has('Benchmark policy review')) errors.push('evals dashboard missing Benchmark policy review panel'); - if (!evalsTitles.has('Benchmark semantic checks')) errors.push('evals dashboard missing Benchmark semantic checks panel'); - if (!evalsTitles.has('Benchmark promotion approvals')) errors.push('evals dashboard missing Benchmark promotion approvals panel'); - const evalScorecardQuery = queryFromPanel(panelByTitle(evals, 'Eval scorecard by repo, model, and task')); - for (const field of ['ScorecardStatus', 'PoorRuns', 'ReviewRuns', 'AvgTestDiscipline', 'AvgToolEfficiency', 'AvgSecurity', 'AvgReliability', 'AvgCodeOutcome']) { - if (!evalScorecardQuery.includes(field)) errors.push(`eval scorecard missing ${field}`); - } - const evalFollowUpQuery = queryFromPanel(panelByTitle(evals, 'Eval regression follow-up')); - for (const field of ['EvalBucket', 'ObservedPattern', 'NextAction', 'ChangeAnnotationCount', 'ChangeTargetRefs', 'OpenReplay', 'OpenPattern']) { - if (!evalFollowUpQuery.includes(field)) errors.push(`eval regression follow-up missing ${field}`); - } - const runComparisonQuery = queryFromPanel(panelByTitle(evals, 'Before/after run comparison')); - for (const field of ['BeforeRunId', 'AfterRunId', 'ComparisonStatus', 'EvalDelta', 'CostDelta', 'TokenDelta', 'ToolFailureDelta', 'RiskDelta', 'OpenReplay']) { - if (!runComparisonQuery.includes(field)) errors.push(`before/after run comparison missing ${field}`); - } - const artifactDiffQuery = queryFromPanel(panelByTitle(evals, 'Benchmark artifact diff review')); - for (const field of ['BenchmarkRunId', 'BenchmarkArtifactAdded', 'BenchmarkArtifactModified', 'BenchmarkArtifactDeleted', 'BenchmarkArtifactTotalChanged', 'ReviewAction', 'ChangeTargetRefs']) { - if (!artifactDiffQuery.includes(field)) errors.push(`benchmark artifact diff review missing ${field}`); - } - const artifactFilesQuery = queryFromPanel(panelByTitle(evals, 'Benchmark artifact files')); - for (const field of ['BenchmarkArtifactFiles', 'mv-expand', 'ArtifactTaskId', 'ArtifactChange', 'ArtifactPath']) { - if (!artifactFilesQuery.includes(field)) errors.push(`benchmark artifact files missing ${field}`); - } - const artifactContentDiffQuery = queryFromPanel(panelByTitle(evals, 'Benchmark artifact content diffs')); - for (const field of ['BenchmarkArtifactContentDiffs', 'mv-expand', 'ArtifactTaskId', 'ArtifactChange', 'ArtifactPath', 'DiffPreview']) { - if (!artifactContentDiffQuery.includes(field)) errors.push(`benchmark artifact content diffs missing ${field}`); - } - const hiddenCheckQuery = queryFromPanel(panelByTitle(evals, 'Benchmark hidden check packs')); - for (const field of ['BenchmarkHiddenCheckPacks', 'mv-expand', 'BenchmarkHiddenChecksPassed', 'BenchmarkHiddenChecksFailed', 'HiddenTaskId', 'HiddenPackId', 'HiddenCommandCount']) { - if (!hiddenCheckQuery.includes(field)) errors.push(`benchmark hidden check packs missing ${field}`); - } - const policyQuery = queryFromPanel(panelByTitle(evals, 'Benchmark policy review')); - for (const field of ['BenchmarkPolicyTasks', 'mv-expand', 'BenchmarkPolicyBlocks', 'BenchmarkPermissionProfiles', 'PolicyTaskId', 'PermissionProfile', 'OsSandboxMode', 'OsSandboxActive', 'BlockedRisks', 'ViolationRisks']) { - if (!policyQuery.includes(field)) errors.push(`benchmark policy review missing ${field}`); - } - const semanticQuery = queryFromPanel(panelByTitle(evals, 'Benchmark semantic checks')); - for (const field of ['BenchmarkSemanticChecks', 'mv-expand', 'BenchmarkSemanticCheckCount', 'BenchmarkSemanticAverageScore', 'SemanticTaskId', 'SemanticCheckId', 'SemanticAdapter', 'SemanticScore']) { - if (!semanticQuery.includes(field)) errors.push(`benchmark semantic checks missing ${field}`); - } - const approvalQuery = queryFromPanel(panelByTitle(evals, 'Benchmark promotion approvals')); - for (const field of ['BenchmarkRunId', 'BenchmarkApprovalStatus', 'BenchmarkApprovalCount', 'BenchmarkRequiredApprovals', 'BenchmarkApprovalTicket', 'ApprovalAction']) { - if (!approvalQuery.includes(field)) errors.push(`benchmark promotion approvals missing ${field}`); - } - - const insights = byUid.get('agentops-v2-insights-regressions'); - const insightsTitles = new Set((insights?.body.panels || []).map(panel => panel.title)); - if (!insightsTitles.has('Recurring patterns')) errors.push('insights dashboard missing Recurring patterns panel'); - if (!insightsTitles.has('Eval regression queue')) errors.push('insights dashboard missing Eval regression queue panel'); - if (!insightsTitles.has('Recommendation artifacts')) errors.push('insights dashboard missing Recommendation artifacts panel'); - if (!insightsTitles.has('Config change annotations')) errors.push('insights dashboard missing Config change annotations panel'); - const patternsQuery = queryFromPanel(panelByTitle(insights, 'Recurring patterns')); - for (const field of ['PatternId', 'PatternRuns', 'PatternDimension', 'PatternKey', 'OpenPattern', 'OpenReplay']) { - if (!patternsQuery.includes(field)) errors.push(`recurring patterns panel missing ${field}`); - } - const recommendationsQuery = queryFromPanel(panelByTitle(insights, 'Recommendation artifacts')); - for (const field of ['RecommendationId', 'Action', 'ObservedPattern', 'NextAction', 'BenchmarkRunId', 'BenchmarkDecision', 'BenchmarkArtifactTotalChanged', 'BenchmarkArtifactFiles', 'BenchmarkHiddenCheckPacks', 'BenchmarkPolicyTasks', 'BenchmarkSemanticChecks', 'BenchmarkApprovalStatus', 'ChangeAnnotationCount', 'ChangeAnnotations', 'ChangeTargetRefs', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/', 'OpenReplay', 'OpenPattern']) { - if (!recommendationsQuery.includes(field)) errors.push(`recommendation artifacts panel missing ${field}`); - } - const evalRegressionQueueQuery = queryFromPanel(panelByTitle(insights, 'Eval regression queue')); - for (const field of ['Source', 'EvalBucket', 'BaselineValue', 'CurrentValue', 'Summary', 'NextAction', 'OpenReplay', 'OpenPattern']) { - if (!evalRegressionQueueQuery.includes(field)) errors.push(`eval regression queue missing ${field}`); - } - const configAnnotationsQuery = queryFromPanel(panelByTitle(insights, 'Config change annotations')); - for (const field of ['agentops.config.changed', 'agentops.custom.annotation_type', 'ChangeComponent', 'ChangeTarget', 'ChangeType', 'ChangeId', 'Version']) { - if (!configAnnotationsQuery.includes(field)) errors.push(`config change annotations missing ${field}`); - } - - const collector = byUid.get('agentops-v2-collector-health'); - const collectorTitles = new Set((collector?.body.panels || []).map(panel => panel.title)); - if (!collectorTitles.has('Schema version coverage')) errors.push('collector health missing Schema version coverage panel'); - if (!collectorTitles.has('Exporter failure review')) errors.push('collector health missing Exporter failure review panel'); - const schemaCoverageQuery = queryFromPanel(panelByTitle(collector, 'Schema version coverage')); - for (const field of ['SchemaStatus', 'ExpectedSchemaVersion', 'MissingSchemaVersion', 'SchemaVersion', 'AgentOpsRunSummary_CL']) { - if (!schemaCoverageQuery.includes(field)) errors.push(`schema version coverage missing ${field}`); - } - const exporterFailureQuery = queryFromPanel(panelByTitle(collector, 'Exporter failure review')); - for (const field of ['ExportFailureReason', 'ExportFailureAction', 'ExportErrors', 'LastExportSuccess', 'agentops collector smoke --privacy strict --poison --json']) { - if (!exporterFailureQuery.includes(field)) errors.push(`exporter failure review missing ${field}`); - } - - return { - ok: errors.length === 0, - dashboards: dashboards.length, - contracts: { - home_top_strip: 12, - home_action_strip: true, - run_replay_panels: 9, - ask_agentops_context: true, - runs_actions: 3, - transcript_first_columns: ['Status', 'SafetyNote', 'OpenTranscript', 'ContentRows'], - code_outcome_timing: true, - recurring_patterns: true, - recommendation_artifacts: true, - artifact_diff_review: true, - artifact_file_review: true, - artifact_content_diff_review: true, - hidden_check_review: true, - policy_review: true, - semantic_review: true, - promotion_approvals: true, - robust_eval_center: true, - alert_handoff_review: true, - schema_version_coverage: true, - exporter_failure_review: true, - pattern_drilldowns: true, - run_centric_ui: true, - empty_state_dashboards: emptyStateDashboards - }, - errors - }; -} - -function dashboardKqlCheck(args = [], options = {}) { - const last = optionValue(args, '--last', '24h'); - const requireRows = hasFlag(args, '--require-rows'); - const runQuery = options.runQuery || ((query, queryOptions) => legacy.runAzureLogAnalyticsQuery(query, queryOptions)); - const dashboards = v2DashboardBodies(); - const byUid = new Map(dashboards.map(item => [item.body.uid, item])); - const checks = []; - - for (const smokePanel of v2KqlSmokePanels) { - const { uid, panel: panelTitle } = smokePanel; - const dashboard = byUid.get(uid); - if (!dashboard) { - checks.push({ uid, panel: panelTitle, ok: false, rows: 0, error: 'dashboard not found' }); - continue; - } - const panel = (dashboard.body.panels || []).find(item => item.title === panelTitle && queryFromPanel(item)); - const rawQuery = queryFromPanel(panel); - if (!rawQuery) { - checks.push({ uid, panel: panelTitle, ok: false, rows: 0, error: 'panel query not found' }); - continue; - } - const query = substituteGrafanaMacros(rawQuery, { last }); - const result = runQuery(query, { - spawnSync: options.spawnSync, - workspaceId: optionValue(args, '--workspace-id', options.workspaceId) - }); - const rows = Array.isArray(result.rows) ? result.rows.length : 0; - const rowsRequired = requireRows && smokePanel.requireRows !== false; - const ok = Boolean(result.ok) && (!rowsRequired || rows > 0); - checks.push({ - uid, - panel: panelTitle, - ok, - rows, - require_rows: rowsRequired, - error: ok ? '' : (result.error || (rowsRequired ? 'query returned no rows' : 'query failed')), - query - }); - } - - const errors = checks.filter(check => !check.ok).map(check => `${check.uid}/${check.panel}: ${check.error}`); - return { - ok: errors.length === 0, - last: legacy.validateKqlDuration(last), - require_rows: requireRows, - checks: checks.map(({ query, ...check }) => check), - errors - }; -} - -function dashboardVerify(args = [], options = {}) { - const includeLive = hasFlag(args, '--live') || hasFlag(args, '--kql'); - const checks = { - validate: validateDashboards(), - links: validateDashboardLinks(), - filters: validateDashboardFilters(), - ux: validateDashboardUx(), - content: validateDashboardContentGuardrails() - }; - if (includeLive) checks.kql = dashboardKqlCheck(args, options); - - const errors = Object.entries(checks) - .flatMap(([name, result]) => (result.errors || []).map(error => `${name}: ${error}`)); - return { - ok: errors.length === 0, - live: includeLive, - checks, - summary: { - dashboards: checks.validate.dashboards, - v2_dashboards: checks.links.dashboards, - checked_links: checks.links.checked_links, - filter_dashboards: checks.filters.dashboards, - ux_contracts: checks.ux.contracts, - kql_checks: checks.kql?.checks?.length || 0 - }, - errors, - next: errors.length === 0 - ? [ - includeLive ? 'agentops open' : 'agentops dashboard verify --live --last 24h', - 'agentops validate-azure --last 24h' - ] - : [ - 'agentops dashboard validate', - 'agentops dashboard links-check', - 'agentops dashboard filters-check', - 'agentops dashboard ux-check', - 'agentops dashboard kql-check --last 24h' - ] - }; -} - -function dashboardImportPlan(args = [], options = {}) { - const env = options.env || process.env; - const v2Only = !hasFlag(args, '--all'); - const folder = optionValue(args, '--folder', v2Only ? 'AgentOps for Azure' : 'AgentOps'); - const resourceGroup = optionValue(args, '--resource-group', env.AZURE_RESOURCE_GROUP || ''); - const grafanaName = optionValue(args, '--grafana-name', env.GRAFANA_NAME || env.AGENTOPS_GRAFANA_NAME || ''); - const script = path.join(repoRoot, 'scripts', 'grafana-import-dashboard.sh'); - const files = dashboardJsonFiles() - .filter(file => !v2Only || file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)); - const command = [ - `GRAFANA_FOLDER=${JSON.stringify(folder)}`, - v2Only ? 'AGENTOPS_V2_ONLY=true' : 'AGENTOPS_V2_ONLY=false AGENTOPS_INCLUDE_V2=true AGENTOPS_INCLUDE_LEGACY=true', - resourceGroup ? `AZURE_RESOURCE_GROUP=${JSON.stringify(resourceGroup)}` : 'AZURE_RESOURCE_GROUP=', - grafanaName ? `GRAFANA_NAME=${JSON.stringify(grafanaName)}` : 'GRAFANA_NAME=', - script - ].join(' '); - - return { - ok: files.length > 0, - dry_run: !hasFlag(args, '--yes'), - v2_only: v2Only, - folder, - script, - dashboards: files.length, - files, - requires: [ - 'az login', - 'Azure CLI amg extension', - 'Grafana Editor/Admin access', - 'Azure Monitor datasource UID configured' - ], - command, - errors: files.length > 0 ? [] : ['no dashboards found to import'] - }; -} - -function runDashboardImport(args = [], options = {}) { - const plan = dashboardImportPlan(args, options); - if (!plan.ok || plan.dry_run) return plan; - - const env = { - ...(options.env || process.env), - GRAFANA_FOLDER: plan.folder, - AGENTOPS_V2_ONLY: plan.v2_only ? 'true' : 'false', - AGENTOPS_INCLUDE_V2: 'true', - AGENTOPS_INCLUDE_LEGACY: plan.v2_only ? 'false' : 'true' - }; - const resourceGroup = optionValue(args, '--resource-group', env.AZURE_RESOURCE_GROUP || ''); - const grafanaName = optionValue(args, '--grafana-name', env.GRAFANA_NAME || env.AGENTOPS_GRAFANA_NAME || ''); - if (resourceGroup) env.AZURE_RESOURCE_GROUP = resourceGroup; - if (grafanaName) env.GRAFANA_NAME = grafanaName; - - const spawn = options.spawnSync || spawnSync; - const result = spawn(plan.script, [], { - cwd: repoRoot, - env, - encoding: 'utf8' - }); - - return { - ...plan, - dry_run: false, - ok: result.status === 0, - status: result.status, - stdout: result.stdout || '', - stderr: result.stderr || '', - errors: result.status === 0 ? [] : [result.stderr || result.stdout || `dashboard import exited ${result.status}`] - }; -} - -function dashboardCommand(args = []) { - const [subcommand = 'validate'] = args; - if (!['validate', 'links-check', 'filters-check', 'ux-check', 'content-check', 'kql-check', 'verify', 'import'].includes(subcommand)) throw new Error('dashboard supports: validate|links-check|filters-check|ux-check|content-check|kql-check|verify|import'); - const result = subcommand === 'links-check' - ? validateDashboardLinks() - : subcommand === 'filters-check' - ? validateDashboardFilters() - : subcommand === 'content-check' - ? validateDashboardContentGuardrails() - : subcommand === 'ux-check' - ? validateDashboardUx() - : subcommand === 'verify' - ? dashboardVerify(args.slice(1)) - : subcommand === 'kql-check' - ? dashboardKqlCheck(args.slice(1)) - : subcommand === 'import' - ? runDashboardImport(args.slice(1)) - : validateDashboards(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - if (!result.ok) process.exitCode = 1; -} - -module.exports = { - dashboardImportPlan, - dashboardCommand, - dashboardKqlCheck, - dashboardVerify, - runDashboardImport, - substituteGrafanaMacros, - validateDashboardContentGuardrails, - validateDashboardLinks, - validateDashboardFilters, - validateDashboardUx, - validateDashboards -}; +module.exports = require('../lib/dashboard-command'); diff --git a/agentops-cli/src/commands/delivery.js b/agentops-cli/src/commands/delivery.js new file mode 100644 index 0000000..60f81c4 --- /dev/null +++ b/agentops-cli/src/commands/delivery.js @@ -0,0 +1 @@ +module.exports = require('../lib/delivery-command'); diff --git a/agentops-cli/src/commands/demo.js b/agentops-cli/src/commands/demo.js index 7eff018..b99d5ff 100644 --- a/agentops-cli/src/commands/demo.js +++ b/agentops-cli/src/commands/demo.js @@ -1,174 +1 @@ -const path = require('node:path'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { validateDashboardLinks, validateDashboards } = require('./dashboard'); -const { buildAzureIngestPlan } = require('../lib/azure/v2-ingest-plan'); -const { generateDemoData, writeDemoData } = require('../lib/demo/agentops-demo-data'); -const { explainRun, latestByTime, renderV2Explanation } = require('../lib/explain/v2-explain'); -const { generateInsights, writeInsights } = require('../lib/insights/deterministic-insights'); -const { v2OpenLinksForRun } = require('./open'); -const { buildRecommendation, topInsightForRun, writeRecommendation } = require('./recommend'); - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function parseRuns(value) { - const runs = Number(value || 50); - if (!Number.isInteger(runs) || runs <= 0 || runs > 1000) { - throw new Error('--runs must be an integer between 1 and 1000'); - } - return runs; -} - -function flagPair(args, withFlag, withoutFlag, defaultValue = true) { - const withValue = hasFlag(args, withFlag); - const withoutValue = hasFlag(args, withoutFlag); - if (withValue && withoutValue) throw new Error(`Use either ${withFlag} or ${withoutFlag}, not both`); - if (withValue) return true; - if (withoutValue) return false; - return defaultValue; -} - -function demoOptionsFromArgs(args = []) { - return { - withFailures: flagPair(args, '--with-failures', '--without-failures', true), - withPrivacyDrops: flagPair(args, '--with-privacy-drops', '--without-privacy-drops', true), - withGithubOutcomes: flagPair(args, '--with-github-outcomes', '--without-github-outcomes', true), - withContent: hasFlag(args, '--with-content') - }; -} - -function demoCommand(args = []) { - const [subcommand = 'generate'] = args; - if (!['generate', 'verify'].includes(subcommand)) throw new Error('demo supports: generate|verify'); - if (subcommand === 'verify') return demoVerifyCommand(args.slice(1)); - - const runs = parseRuns(optionValue(args, '--runs', '50')); - const outDir = path.resolve(optionValue(args, '--out', path.join(repoRoot, '.agentops', 'demo', 'latest'))); - const demoOptions = demoOptionsFromArgs(args); - const result = generateDemoData({ - runs, - ...demoOptions - }); - const written = writeDemoData(result, outDir); - - const payload = { - ok: result.ok, - runs: result.runs, - out_dir: written.out_dir, - manifest: written.manifest, - table_counts: result.table_counts, - scenarios: result.scenarios, - scenario_names: result.scenario_names, - validation_errors: result.validation_errors, - content_capture: demoOptions.withContent ? 'redacted_demo_content' : 'off', - next: [ - 'agentops dashboard validate', - `ls ${written.out_dir}` - ] - }; - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else { - process.stdout.write(`Generated ${payload.runs} AgentOps demo runs.\n`); - if (demoOptions.withContent) process.stdout.write('Included redacted demo prompt/response rows in AgentOpsContent_CL.\n'); - process.stdout.write(`Output: ${payload.out_dir}\n`); - process.stdout.write(`Manifest: ${payload.manifest}\n`); - process.stdout.write('Next: agentops dashboard validate\n'); - } - - if (!result.ok) process.exitCode = 1; -} - -function demoVerifyCommand(args = []) { - const runs = parseRuns(optionValue(args, '--runs', '50')); - const outDir = path.resolve(optionValue(args, '--out', path.join(repoRoot, '.agentops', 'demo', 'latest'))); - const insightsOutDir = path.resolve(optionValue(args, '--insights-out', path.join(repoRoot, '.agentops', 'insights', 'latest'))); - const demo = generateDemoData({ - runs, - withFailures: true, - withPrivacyDrops: true, - withGithubOutcomes: true - }); - const writtenDemo = writeDemoData(demo, outDir); - const insights = generateInsights({ - runs: demo.tables.AgentOpsRunSummary_CL, - tools: demo.tables.AgentOpsToolCalls_CL, - privacy: demo.tables.AgentOpsPrivacy_CL, - github: demo.tables.AgentOpsGithubOutcomes_CL - }); - const writtenInsights = writeInsights(insights, insightsOutDir); - const latestRun = latestByTime(demo.tables.AgentOpsRunSummary_CL); - const explanation = explainRun(latestRun, insights.evals, insights.insights); - const openLinks = v2OpenLinksForRun(latestRun); - const recommendation = buildRecommendation({ - run: latestRun, - insight: topInsightForRun(insights.insights, latestRun?.RunId), - evaluation: insights.evals.find(row => row.RunId === latestRun?.RunId) || null - }); - const writtenRecommendation = writeRecommendation(recommendation, writtenDemo.out_dir); - demo.table_counts.AgentOpsRecommendations_CL = 1; - recommendation.artifact = { - table: 'AgentOpsRecommendations_CL', - file: writtenRecommendation.file, - manifest: writtenRecommendation.manifest, - privacy: 'metadata-only' - }; - const dashboard = validateDashboards(); - const links = validateDashboardLinks(); - const azureIngest = buildAzureIngestPlan({ dir: writtenDemo.out_dir }); - const payload = { - ok: demo.ok && insights.ok && explanation.ok && dashboard.ok && links.ok && azureIngest.ok, - demo: { - runs: demo.runs, - out_dir: writtenDemo.out_dir, - table_counts: demo.table_counts - }, - insights: { - out_dir: insightsOutDir, - eval_file: writtenInsights.evalFile, - insights_file: writtenInsights.insightsFile, - table_counts: insights.table_counts - }, - azure_ingest: azureIngest, - explanation: { - run_id: explanation.run?.RunId || null, - headline: explanation.headline, - detail: explanation.detail, - eval_overall: explanation.evaluation?.EvalOverall ?? null, - insight_count: explanation.insights.length - }, - open_links: openLinks, - recommendation, - dashboard, - links, - next: [ - `agentops replay latest --file ${writtenDemo.files.AgentOpsEvents_CL}`, - `agentops open latest --runs ${writtenDemo.files.AgentOpsRunSummary_CL}`, - `agentops recommend latest --runs ${writtenDemo.files.AgentOpsRunSummary_CL} --events ${writtenDemo.files.AgentOpsEvents_CL} --evals ${writtenInsights.evalFile} --insights ${writtenInsights.insightsFile}`, - `agentops azure-ingest plan --dir ${writtenDemo.out_dir}`, - `agentops explain latest --runs ${writtenDemo.files.AgentOpsRunSummary_CL} --evals ${writtenInsights.evalFile} --insights ${writtenInsights.insightsFile}` - ] - }; - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else { - process.stdout.write('AgentOps V2 demo verification\n\n'); - process.stdout.write(`Demo runs: ${payload.demo.runs}\n`); - process.stdout.write(`Eval rows: ${payload.insights.table_counts.AgentOpsEval_CL}\n`); - process.stdout.write(`Insight rows: ${payload.insights.table_counts.AgentOpsInsights_CL}\n`); - process.stdout.write(`Dashboard links: ${payload.links.checked_links}\n\n`); - process.stdout.write(renderV2Explanation(explanation)); - process.stdout.write(`Open Run Replay: ${openLinks.links?.replay || 'unavailable'}\n`); - process.stdout.write(`Recommended next action: ${recommendation.next_action}\n`); - } - if (!payload.ok) process.exitCode = 1; -} - -module.exports = { - demoOptionsFromArgs, - demoCommand, - demoVerifyCommand, - parseRuns -}; +module.exports = require('../lib/demo-command'); diff --git a/agentops-cli/src/commands/doctor.js b/agentops-cli/src/commands/doctor.js index be0f12f..1eb6927 100644 --- a/agentops-cli/src/commands/doctor.js +++ b/agentops-cli/src/commands/doctor.js @@ -1,118 +1 @@ -const fs = require('node:fs'); - -const legacy = require('../legacy'); -const collector = require('../lib/collector-manager'); -const { collectorReleaseContract } = require('../lib/collector-release'); -const { resolveCopilotBinary } = require('../lib/copilot-resolver'); -const { repoPath } = require('../lib/paths'); - -function check(name, ok, detail = null, severity = 'error') { - return { name, ok: Boolean(ok), detail, severity }; -} - -function readText(file) { - const fullPath = repoPath(file); - return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : ''; -} - -function hasPinnedCollectorImage(text) { - return /otel\/opentelemetry-collector-contrib:(?!latest\b)\d+\.\d+\.\d+/.test(text); -} - -function hasLocalhostPortBindings(text) { - return ['127.0.0.1:4318:4318', '127.0.0.1:4317:4317', '127.0.0.1:13133:13133'] - .every(binding => text.includes(binding)); -} - -function collectorConfigChecks() { - const localCompose = readText('collector/docker-compose.yaml'); - const azureCompose = readText('collector/docker-compose.azuremonitor.yaml'); - const azureConfig = readText('collector/otelcol.azuremonitor.yaml'); - const azureStrictConfig = readText('collector/otelcol.azuremonitor.strict.yaml'); - const azureConfigHasPrivacy = ['transform/content_capture_signal', 'attributes/privacy_safe', 'exporters:', 'azuremonitor'] - .every(snippet => azureConfig.includes(snippet)); - const azureStrictHasPrivacy = ['transform/privacy_strict', 'keep_keys(attributes', 'exporters:', 'azuremonitor'] - .every(snippet => azureStrictConfig.includes(snippet)); - const release = collectorReleaseContract(); - - return [ - check('collector-image-pinned', hasPinnedCollectorImage(localCompose) && hasPinnedCollectorImage(azureCompose), 'docker compose defaults use an explicit otel/opentelemetry-collector-contrib version'), - check('collector-release-cadence', release.ok, release.ok ? `${release.version}; review after ${release.review_after}` : release.missing.join(', '), 'warning'), - check('collector-azure-localhost-bindings', hasLocalhostPortBindings(azureCompose), 'Azure Monitor compose binds OTLP and health ports to 127.0.0.1'), - check('collector-azure-config-privacy-parity', azureConfigHasPrivacy && azureStrictHasPrivacy, 'Azure Monitor configs include content signal, privacy filtering, and azuremonitor exporter') - ]; -} - -async function doctorSummary(options = {}) { - const localOnly = Boolean(options.localOnly); - const base = legacy.doctor({ localOnly: true }).map(item => ({ - ...item, - severity: item.ok ? 'info' : 'error' - })); - const validateAzure = options.validateAzure || legacy.validateAzure; - const cloudSummary = localOnly ? null : validateAzure({ - last: options.last, - production: options.production, - spawnSync: options.spawnSync, - azAvailable: options.azAvailable, - expectedDashboards: options.expectedDashboards - }); - const cloudChecks = cloudSummary ? cloudSummary.checks - .filter(item => ['grafana-base-url', 'grafana-resource', 'grafana-datasource', 'grafana-dashboards'].includes(item.name)) - .map(item => ({ - ...item, - severity: 'warning' - })) : []; - const collectorStatus = await collector.status(options); - const copilot = resolveCopilotBinary(); - const configPath = process.env.AGENTOPS_CONFIG_PATH || repoPath('.agentops', 'config.json'); - const connectionStringStored = fs.existsSync(configPath) - && /APPLICATIONINSIGHTS_CONNECTION_STRING|InstrumentationKey=/i.test(fs.readFileSync(configPath, 'utf8')); - const checks = [ - ...base, - check('collector-mode-resolved', collectorStatus.effectiveMode !== 'auto' || collectorStatus.details.length > 0, collectorStatus.details.join(' '), 'warning'), - check('collector-localhost-bindings', collectorStatus.safeLocalhostBinding, collector.composeFile), - check('collector-health', collectorStatus.running, collectorStatus.health?.error || collectorStatus.health?.statusCode || 'not running', 'warning'), - check('collector-binary-available', collectorStatus.binary.ok, collectorStatus.binary.error, collectorStatus.effectiveMode === 'binary' ? 'error' : 'warning'), - ...collectorConfigChecks(), - check('copilot-binary-non-recursive', copilot.ok, copilot.error, localOnly ? 'warning' : 'error'), - check('plugin-reversible', fs.existsSync(repoPath('plugin', 'plugin.json')) && fs.existsSync(repoPath('plugin', 'hooks.json')), 'agentops plugin uninstall removes bundled files'), - check('connection-string-not-on-disk', !connectionStringStored, configPath), - check('experimental-hidden-from-quickstart', true, 'experimental commands live behind agentops experimental'), - ...cloudChecks - ]; - const ok = checks.every(item => item.ok || item.severity === 'warning'); - return { ok, checks, collector: collectorStatus, copilot, cloud: cloudSummary ? { ok: cloudSummary.ok, next: cloudSummary.next } : null }; -} - -function renderDoctor(summary) { - const lines = ['AgentOps doctor']; - for (const item of summary.checks) { - const status = item.ok ? 'ok' : item.severity === 'warning' ? 'warn' : 'failed'; - lines.push(`- ${item.name}: ${status}${item.detail ? ` (${item.detail})` : ''}`); - } - lines.push('', summary.ok ? 'Doctor passed with no blocking local issues.' : 'Doctor found blocking issues.'); - return `${lines.join('\n')}\n`; -} - -async function doctorCommand(args = []) { - const json = args.includes('--json'); - const summary = await doctorSummary({ - mode: process.env.AGENTOPS_COLLECTOR_MODE || 'auto', - localOnly: args.includes('--local-only'), - last: valueAfter(args, '--last') - }); - process.stdout.write(json ? `${JSON.stringify(summary, null, 2)}\n` : renderDoctor(summary)); - process.exitCode = summary.ok ? 0 : 1; -} - -function valueAfter(args, flag) { - const index = args.indexOf(flag); - return index >= 0 ? args[index + 1] : undefined; -} - -module.exports = { - doctorCommand, - doctorSummary, - renderDoctor -}; +module.exports = require('../lib/doctor-command'); diff --git a/agentops-cli/src/commands/e2e.js b/agentops-cli/src/commands/e2e.js index b74a32d..1de3654 100644 --- a/agentops-cli/src/commands/e2e.js +++ b/agentops-cli/src/commands/e2e.js @@ -1,653 +1 @@ -const fs = require('node:fs'); -const childProcess = require('node:child_process'); -const path = require('node:path'); -const { pathToFileURL } = require('node:url'); - -const legacy = require('../legacy'); -const collector = require('../lib/collector-manager'); -const { optionValue } = require('../lib/args'); -const { redactedEnvSummary } = require('../lib/privacy'); -const { repoRoot } = require('../lib/paths'); - -function timestamp() { - return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); -} - -function evidenceDir(name = timestamp()) { - return path.join(repoRoot, '.agentops', 'e2e', name); -} - -function latestEvidenceDir() { - return path.join(repoRoot, '.agentops', 'e2e', 'latest'); -} - -function writeJson(filePath, payload) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`); -} - -function redactText(text = '') { - return String(text) - .replace(/InstrumentationKey=[^;\s"]+/gi, 'InstrumentationKey=[REDACTED]') - .replace(/(Authorization=Bearer\s+)[^\s"]+/gi, '$1[REDACTED]') - .replace(/([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CONNECTION_STRING)[A-Z0-9_]*=)[^\s"]+/gi, '$1[REDACTED]'); -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function runAgentops(args, options = {}) { - const result = childProcess.spawnSync(process.execPath, [path.join(repoRoot, 'agentops-cli', 'src', 'index.js'), ...args], { - cwd: repoRoot, - encoding: 'utf8', - env: { ...process.env, ...(options.env || {}) }, - timeout: options.timeout || 120000 - }); - return { - command: ['agentops', ...args].join(' '), - status: result.status, - stdout: redactText(result.stdout || ''), - stderr: redactText(result.stderr || ''), - error: result.error ? result.error.message : null - }; -} - -function safeE2eEnv(extra = {}) { - return { - AGENTOPS_PRIVACY_MODE: 'strict', - AGENTOPS_CAPTURE_CONTENT: 'false', - AGENTOPS_DISABLE_CONTENT_CAPTURE_OVERRIDE: '1', - COPILOT_OTEL_CAPTURE_CONTENT: 'false', - ...extra - }; -} - -async function waitForLatestE2eSession(e2eId, last, options = {}) { - const timeoutMs = options.timeoutMs || 180000; - const intervalMs = options.intervalMs || 10000; - const deadline = Date.now() + timeoutMs; - let attempts = 0; - let latest = null; - let payload = null; - - while (Date.now() <= deadline) { - attempts += 1; - latest = runAgentops(['latest', '--last', last, '--json']); - try { - payload = JSON.parse(latest.stdout); - } catch { - payload = null; - } - - const ids = payload?.session?.e2e_ids || []; - if (latest.status === 0 && (payload?.session?.e2e_id === e2eId || ids.includes(e2eId))) { - return { latest, payload, attempts, matched: true }; - } - - await sleep(intervalMs); - } - - return { latest, payload, attempts, matched: false }; -} - -function htmlEscape(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function renderReportHtml(report) { - const status = report.ok ? 'PASS' : 'CHECK'; - return ` - - - - - AgentOps E2E Report - - - -
-

AgentOps E2E Report ${status}

-
-

Summary

-

Collector mode: ${htmlEscape(report.collector?.effectiveMode || report.collector?.mode || 'unknown')}

-

Privacy mode: ${htmlEscape(report.privacyMode)}

-

E2E marker: ${htmlEscape(report.e2eId || 'not available')}

-

Latest session: ${htmlEscape(report.latestSessionId || 'not available')}

-

Latest matched marker: ${htmlEscape(report.latestE2eMatched ? 'yes' : 'no')}

-
-
-

Privacy Poison Test

-
${htmlEscape(JSON.stringify(report.poison, null, 2))}
-
-
-

Grafana Links

- ${(report.grafanaLinks || []).map(link => `

${htmlEscape(link.label)}

`).join('\n') || '

No Grafana links available.

'} -
-
-

Evidence Files

- ${(report.evidenceFiles || []).map(file => `

${htmlEscape(path.basename(file))}

`).join('\n')} -
-
- - -`; -} - -function htmlLinks(html) { - const links = []; - const pattern = /]*href="([^"]+)"[^>]*>(.*?)<\/a>/gis; - let match; - while ((match = pattern.exec(String(html || '')))) { - links.push({ - href: match[1].replace(/&/g, '&'), - text: match[2].replace(/<[^>]+>/g, '').trim() - }); - } - return links; -} - -function checkReportHtml(html, options = {}) { - const text = String(html || '').replace(/<[^>]+>/g, ' '); - const links = htmlLinks(html); - const grafanaLinks = links.filter(link => /grafana\.azure\.com/i.test(link.href)); - const evidenceLinks = links.filter(link => /\.json($|[?#])/i.test(link.href)); - const secretPattern = /(SECRET_[A-Z_]+|InstrumentationKey=|CONNECTION_STRING=|PASSWORD=|TOKEN=|KEY=)/i; - const passVisible = /\bPASS\b/.test(text); - const allowCheckStatus = Boolean(options.allowCheckStatus); - return { - ok: (passVisible || allowCheckStatus) && !secretPattern.test(text) && grafanaLinks.length > 0 && evidenceLinks.length > 0, - passVisible, - secretLooking: secretPattern.test(text), - grafanaLinks: grafanaLinks.length, - evidenceLinks: evidenceLinks.length, - links - }; -} - -function reportPathFromArgs(args = []) { - return path.resolve(optionValue(args, ['--report', '--in'], path.join(latestEvidenceDir(), 'report.html'))); -} - -function screenshotSlug(label = '') { - return String(label) - .replace(/&/g, 'and') - .replace(/[^a-z0-9_-]+/gi, '-') - .replace(/^-|-$/g, '') - .toLowerCase() || 'grafana'; -} - -const V2_SCREENSHOT_NAMES = { - 'AgentOps V2 Home': 'agentops-v2-home-live.png', - 'V2 Runs Explorer': 'agentops-v2-runs-explorer-live.png', - 'V2 Run Replay': 'agentops-v2-run-replay-live.png' -}; - -function grafanaScreenshotTargets(links = [], options = {}) { - const v2Only = Boolean(options.v2Only); - return links - .filter(link => /grafana\.azure\.com/i.test(link.href || link.url || '')) - .map(link => ({ - label: link.text || link.label || 'Grafana', - url: link.href || link.url, - fileName: V2_SCREENSHOT_NAMES[link.text || link.label] || `${screenshotSlug(link.text || link.label)}.png`, - v2Tour: Boolean(V2_SCREENSHOT_NAMES[link.text || link.label]) - })) - .filter(target => !v2Only || target.v2Tour); -} - -function grafanaVisualOk(items = [], requireVisible = false) { - return items.every(item => requireVisible ? item.dashboardVisible : (item.dashboardVisible || item.authBlocked)); -} - -function browserProfileOptionsFromArgs(args = [], env = process.env) { - return { - browserExecutable: optionValue(args, '--browser-executable', env.AGENTOPS_BROWSER_EXECUTABLE || ''), - browserUserDataDir: optionValue(args, '--browser-user-data-dir', env.AGENTOPS_BROWSER_USER_DATA_DIR || ''), - storageState: optionValue(args, '--storage-state', env.AGENTOPS_BROWSER_STORAGE_STATE || ''), - headed: args.includes('--headed') || env.AGENTOPS_BROWSER_HEADED === '1' - }; -} - -function grafanaAuthRemediation(options = {}) { - const reportPath = options.reportPath || '.agentops/e2e/latest/report.html'; - const browserExecutable = options.browserExecutable || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; - const browserUserDataDir = options.browserUserDataDir || '$HOME/.agentops/browser/grafana-profile'; - const grafanaUrl = options.grafanaUrl || 'https://graf-copilotagentops-de-a4czh7g5aueyf4e0.neu.grafana.azure.com/d/agentops-v2-home'; - return { - reason: 'Azure Managed Grafana redirected to Microsoft sign-in.', - sign_in_once: [ - `mkdir -p ${path.dirname(browserUserDataDir)}`, - `"${browserExecutable}" --user-data-dir="${browserUserDataDir}" "${grafanaUrl}"` - ], - verify_after_sign_in: [ - 'AGENTOPS_PLAYWRIGHT_MODULE_DIR=/Users/conormongan/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules', - `agentops e2e browser-check --report ${reportPath} --playwright --grafana --grafana-v2-only --require-grafana-visible --browser-executable "${browserExecutable}" --browser-user-data-dir "${browserUserDataDir}" --json` - ], - note: 'The strict visual gate cannot pass until the supplied browser profile can open the V2 dashboards without Microsoft SSO.' - }; -} - -function e2eAuthProfile(args = []) { - const reportPath = reportPathFromArgs(args); - const profile = browserProfileOptionsFromArgs(args); - const grafanaUrl = optionValue(args, '--url', 'https://graf-copilotagentops-de-a4czh7g5aueyf4e0.neu.grafana.azure.com/d/agentops-v2-home'); - return { - ok: true, - reportPath, - browserProfile: { - browserExecutable: profile.browserExecutable || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - browserUserDataDir: profile.browserUserDataDir || '$HOME/.agentops/browser/grafana-profile', - storageState: profile.storageState || '', - headed: profile.headed - }, - remediation: grafanaAuthRemediation({ - reportPath, - browserExecutable: profile.browserExecutable || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - browserUserDataDir: profile.browserUserDataDir || '$HOME/.agentops/browser/grafana-profile', - grafanaUrl - }) - }; -} - -function renderAuthProfile(result) { - return [ - 'Grafana browser profile setup', - '', - 'Sign in once:', - ...result.remediation.sign_in_once.map(command => `- ${command}`), - '', - 'Verify after sign-in:', - ...result.remediation.verify_after_sign_in.map(command => `- ${command}`) - ].join('\n') + '\n'; -} - -async function playwrightBrowserCheck({ - reportPath, - outDir, - grafana = false, - grafanaV2Only = false, - docsScreenshotDir = null, - requireGrafanaVisible = false, - browserExecutable = process.env.AGENTOPS_BROWSER_EXECUTABLE || '', - browserUserDataDir = process.env.AGENTOPS_BROWSER_USER_DATA_DIR || '', - storageState = process.env.AGENTOPS_BROWSER_STORAGE_STATE || '', - headed = process.env.AGENTOPS_BROWSER_HEADED === '1' -}) { - let playwright; - try { - playwright = require('playwright'); - } catch (error) { - for (const dir of String(process.env.AGENTOPS_PLAYWRIGHT_MODULE_DIR || process.env.NODE_PATH || '').split(path.delimiter).filter(Boolean)) { - try { - playwright = require(path.join(dir, 'playwright')); - break; - } catch {} - } - if (!playwright) return { status: 'skipped', reason: `Playwright is not available: ${error.message}` }; - } - - const viewport = { width: 1440, height: 1000 }; - const launch = { headless: !headed }; - if (browserExecutable) launch.executablePath = browserExecutable; - let browser = null; - let context = null; - if (browserUserDataDir) { - context = await playwright.chromium.launchPersistentContext(path.resolve(browserUserDataDir), { - ...launch, - viewport - }); - } else { - browser = await playwright.chromium.launch(launch); - context = await browser.newContext({ - viewport, - ...(storageState ? { storageState: path.resolve(storageState) } : {}) - }); - } - const page = context.pages()[0] || await context.newPage(); - const url = pathToFileURL(reportPath).toString(); - await page.goto(url, { waitUntil: 'networkidle' }); - fs.mkdirSync(outDir, { recursive: true }); - const reportScreenshot = path.join(outDir, 'report.png'); - await page.screenshot({ path: reportScreenshot, fullPage: true }); - const text = await page.locator('body').innerText(); - const browserResult = { - status: 'checked', - reportScreenshot, - passVisible: /\bPASS\b/.test(text), - secretLooking: /(SECRET_[A-Z_]+|InstrumentationKey=|CONNECTION_STRING=|PASSWORD=|TOKEN=|KEY=)/i.test(text), - grafana: [], - browserProfile: { - persistent: Boolean(browserUserDataDir), - storageState: Boolean(storageState), - headed: Boolean(headed) - } - }; - - if (grafana) { - const links = await page.locator('a').evaluateAll(nodes => nodes.map(node => ({ - href: node.href, - text: node.textContent.trim() - }))); - for (const target of grafanaScreenshotTargets(links, { v2Only: grafanaV2Only })) { - const dashboard = await context.newPage(); - await dashboard.goto(target.url, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {}); - await dashboard.waitForTimeout(5000); - const body = await dashboard.locator('body').innerText({ timeout: 5000 }).catch(() => ''); - const screenshot = path.join(outDir, target.fileName); - await dashboard.screenshot({ path: screenshot, fullPage: true }).catch(() => {}); - let docsScreenshot = null; - if (docsScreenshotDir && target.v2Tour && fs.existsSync(screenshot) && !/Sign in|Can.t access your account|login.microsoftonline.com/i.test(body + dashboard.url())) { - fs.mkdirSync(docsScreenshotDir, { recursive: true }); - docsScreenshot = path.join(docsScreenshotDir, target.fileName); - fs.copyFileSync(screenshot, docsScreenshot); - } - browserResult.grafana.push({ - label: target.label, - url: target.url, - screenshot, - docsScreenshot, - v2Tour: target.v2Tour, - authBlocked: /Sign in|Can.t access your account|login.microsoftonline.com/i.test(body + dashboard.url()), - dashboardVisible: /AgentOps|Copilot|Sessions|Session Detail|No data/i.test(body) - }); - await dashboard.close(); - } - } - - await context.close(); - if (browser) await browser.close(); - browserResult.ok = browserResult.passVisible && !browserResult.secretLooking && - (!grafana || grafanaVisualOk(browserResult.grafana, requireGrafanaVisible)); - if (grafana) browserResult.requireGrafanaVisible = requireGrafanaVisible; - if (grafana && requireGrafanaVisible && browserResult.grafana.some(item => item.authBlocked)) { - const firstBlocked = browserResult.grafana.find(item => item.authBlocked); - browserResult.authRemediation = grafanaAuthRemediation({ - reportPath, - browserExecutable, - browserUserDataDir, - grafanaUrl: firstBlocked?.url - }); - } - return browserResult; -} - -function writeBrowserNotes(filePath, result) { - const lines = [ - '# Browser Validation Notes', - '', - `- Report: ${result.reportPath}`, - `- Static report check: ${result.static.ok ? 'pass' : 'fail'}`, - `- PASS visible: ${result.static.passVisible ? 'yes' : 'no'}`, - `- Secret-looking values: ${result.static.secretLooking ? 'yes' : 'no'}`, - `- Grafana links: ${result.static.grafanaLinks}`, - `- Evidence JSON links: ${result.static.evidenceLinks}`, - `- Playwright: ${result.playwright.status}` - ]; - if (result.playwright.reason) lines.push(`- Playwright reason: ${result.playwright.reason}`); - if (result.playwright.reportScreenshot) lines.push(`- Report screenshot: ${result.playwright.reportScreenshot}`); - if (result.playwright.browserProfile) { - lines.push(`- Browser profile: ${result.playwright.browserProfile.persistent ? 'persistent profile' : result.playwright.browserProfile.storageState ? 'storage state' : 'fresh context'}`); - } - if (result.playwright.grafana?.length) { - lines.push('', '## Grafana'); - for (const item of result.playwright.grafana) { - lines.push(`- ${item.label}: ${item.dashboardVisible ? 'visible' : item.authBlocked ? 'auth-blocked' : 'not verified'} (${item.url})`); - } - if (result.playwright.requireGrafanaVisible && result.playwright.grafana.some(item => !item.dashboardVisible)) { - lines.push('- Required visible dashboards: failed. Sign in with an authenticated Grafana browser profile and rerun.'); - } - if (result.playwright.authRemediation) { - lines.push('', '## Auth Remediation', '', result.playwright.authRemediation.reason, ''); - lines.push('Sign in once:'); - lines.push('```bash'); - for (const command of result.playwright.authRemediation.sign_in_once) lines.push(command); - lines.push('```', '', 'Verify after sign-in:'); - lines.push('```bash'); - for (const command of result.playwright.authRemediation.verify_after_sign_in) lines.push(command); - lines.push('```'); - } - } - fs.writeFileSync(filePath, `${lines.join('\n')}\n`); -} - -function grafanaLinksFromOpenSummary(summary = legacy.openLinksSummary()) { - return [ - { label: 'AgentOps V2 Home', url: summary.v2_home_url }, - { label: 'V2 Runs Explorer', url: summary.v2_runs_url }, - { label: 'V2 Run Replay', url: summary.v2_replay_url }, - { label: 'Overview', url: summary.main_dashboard_url }, - { label: 'Sessions', url: summary.sessions_dashboard_url }, - { label: 'Latest Session', url: summary.latest_session_url } - ].filter(link => link.url); -} - -async function e2eRun(args = []) { - const live = args.includes('--live'); - const last = optionValue(args, '--last', '2h'); - const dir = evidenceDir(); - const e2eId = `agentops-e2e-${path.basename(dir)}`; - const latestDir = latestEvidenceDir(); - fs.mkdirSync(dir, { recursive: true }); - fs.rmSync(latestDir, { recursive: true, force: true }); - fs.mkdirSync(path.dirname(latestDir), { recursive: true }); - fs.symlinkSync(dir, latestDir, 'dir'); - - const e2eEnv = safeE2eEnv(); - const doctor = runAgentops(['doctor', '--json'], { env: e2eEnv }); - const collectorStart = await collector.start({ mode: 'auto', privacy: 'strict' }); - const collectorStatus = await collector.status({ mode: 'auto', privacy: 'strict' }); - const poison = await collector.smoke({ privacy: 'strict', poison: true }); - const outputs = []; - writeJson(path.join(dir, 'doctor.json'), doctor); - writeJson(path.join(dir, 'collector-start.json'), collectorStart); - writeJson(path.join(dir, 'collector-status.json'), collectorStatus); - writeJson(path.join(dir, 'poison.json'), poison); - - const copilotArgs = [ - 'copilot', - '--no-ask-user', - '--no-remote', - '--add-dir', - '.', - "--allow-tool=shell(pwd)", - "--allow-tool=shell(ls:*)", - '-p', - 'AgentOps E2E test. Do not edit files. Run pwd and ls docs | head if available, then reply with exactly one short summary sentence containing AGENTOPS_E2E_OK.' - ]; - - let latest = null; - let replay = null; - let validateAzure = null; - let open = null; - let copilot = null; - let latestPayload = null; - let latestSessionId = null; - let latestE2eMatched = false; - let latestAttempts = 0; - - if (live) { - copilot = runAgentops(copilotArgs, { - timeout: 300000, - env: safeE2eEnv({ AGENTOPS_E2E_ID: e2eId }) - }); - writeJson(path.join(dir, 'copilot.json'), copilot); - outputs.push(copilot); - - const latestWait = await waitForLatestE2eSession(e2eId, last); - latest = latestWait.latest; - latestPayload = latestWait.payload; - latestE2eMatched = latestWait.matched; - latestAttempts = latestWait.attempts; - writeJson(path.join(dir, 'latest.json'), latest); - outputs.push(latest); - - latestSessionId = latestPayload?.session?.id || latestPayload?.session_id || null; - if (latestSessionId) { - replay = runAgentops(['replay', 'latest', '--last', last], { env: e2eEnv }); - writeJson(path.join(dir, 'replay.json'), replay); - outputs.push(replay); - } - - validateAzure = runAgentops(['validate-azure', '--last', last, '--json'], { env: e2eEnv }); - writeJson(path.join(dir, 'validate-azure.json'), validateAzure); - outputs.push(validateAzure); - - open = runAgentops(['open', '--last', last, '--json'], { env: e2eEnv }); - writeJson(path.join(dir, 'open.json'), open); - outputs.push(open); - } - - const summary = { - ok: poison.ok && (!live || (copilot?.status === 0 && latest?.status === 0 && latestE2eMatched)), - live, - e2eId, - evidenceDir: dir, - privacyMode: 'strict', - environment: redactedEnvSummary(safeE2eEnv({ AGENTOPS_E2E_ID: e2eId })), - doctor, - collectorStart, - collector: collectorStatus, - poison, - liveCopilot: live ? copilot : { status: 'skipped', reason: 'Pass --live to run Copilot.' }, - latest, - latestSessionId, - latestE2eMatched, - latestAttempts, - replay, - validateAzure, - open, - copilotCommand: copilotArgs.map(value => /SECRET|TOKEN|KEY|CONNECTION_STRING/i.test(value) ? '[REDACTED]' : value), - grafanaLinks: open?.stdout ? (() => { - try { - return grafanaLinksFromOpenSummary(JSON.parse(open.stdout)); - } catch { - return grafanaLinksFromOpenSummary(); - } - })() : grafanaLinksFromOpenSummary(), - evidenceFiles: fs.readdirSync(dir).map(file => path.join(dir, file)) - }; - writeJson(path.join(dir, 'summary.json'), summary); - if (args.includes('--browser-report')) { - fs.writeFileSync(path.join(dir, 'report.html'), renderReportHtml(summary)); - } - return summary; -} - -function e2eReport(args = []) { - const outIndex = args.indexOf('--out'); - const out = outIndex === -1 ? path.join(latestEvidenceDir(), 'report.html') : path.resolve(args[outIndex + 1]); - const dir = path.dirname(out); - fs.mkdirSync(dir, { recursive: true }); - const summaryPath = path.join(dir, 'summary.json'); - const summary = fs.existsSync(summaryPath) - ? JSON.parse(fs.readFileSync(summaryPath, 'utf8')) - : { - ok: false, - privacyMode: 'strict', - collector: null, - poison: null, - latestSessionId: null, - grafanaLinks: grafanaLinksFromOpenSummary(), - evidenceFiles: [] - }; - const report = { - ...summary, - grafanaLinks: summary.grafanaLinks || grafanaLinksFromOpenSummary(), - evidenceFiles: fs.existsSync(dir) ? fs.readdirSync(dir).map(file => path.join(dir, file)) : [] - }; - fs.writeFileSync(out, renderReportHtml(report)); - return { ok: true, out, report }; -} - -async function e2eBrowserCheck(args = []) { - const reportPath = reportPathFromArgs(args); - const out = path.resolve(optionValue(args, '--out', path.join(path.dirname(reportPath), 'browser-notes.md'))); - const screenshotDir = path.resolve(optionValue(args, '--screenshot-dir', path.join(path.dirname(out), 'screenshots'))); - const docsScreenshotDir = args.includes('--v2-docs-screenshots') - ? path.resolve(optionValue(args, '--v2-docs-screenshot-dir', path.join(repoRoot, 'docs', 'screenshots', 'v2'))) - : null; - const allowCheckStatus = args.includes('--allow-check-status'); - if (!fs.existsSync(reportPath)) throw new Error(`Report not found: ${reportPath}`); - const staticCheck = checkReportHtml(fs.readFileSync(reportPath, 'utf8'), { allowCheckStatus }); - const wantsPlaywright = args.includes('--playwright') || process.env.AGENTOPS_E2E_PLAYWRIGHT === '1'; - const playwright = wantsPlaywright - ? await playwrightBrowserCheck({ - reportPath, - outDir: screenshotDir, - grafana: args.includes('--grafana'), - grafanaV2Only: args.includes('--grafana-v2-only'), - docsScreenshotDir, - requireGrafanaVisible: args.includes('--require-grafana-visible'), - ...browserProfileOptionsFromArgs(args) - }) - : { status: 'skipped', reason: 'Pass --playwright or set AGENTOPS_E2E_PLAYWRIGHT=1 to capture browser screenshots.' }; - const result = { - ok: staticCheck.ok && (wantsPlaywright ? playwright.ok === true : playwright.ok !== false), - reportPath, - static: staticCheck, - playwright - }; - fs.mkdirSync(path.dirname(out), { recursive: true }); - writeBrowserNotes(out, result); - result.notes = out; - return result; -} - -async function e2eCommand(args = []) { - const [subcommand] = args; - if (subcommand === 'run') { - const result = await e2eRun(args.slice(1)); - process.stdout.write(args.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : `E2E evidence: ${result.evidenceDir}\n`); - process.exitCode = result.ok ? 0 : 1; - return; - } - if (subcommand === 'report') { - const result = e2eReport(args.slice(1)); - process.stdout.write(args.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : `E2E report: ${result.out}\n`); - return; - } - if (subcommand === 'browser-check') { - const result = await e2eBrowserCheck(args.slice(1)); - process.stdout.write(args.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : `E2E browser notes: ${result.notes}\n`); - process.exitCode = result.ok ? 0 : 1; - return; - } - if (subcommand === 'auth-profile') { - const result = e2eAuthProfile(args.slice(1)); - process.stdout.write(args.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : renderAuthProfile(result)); - return; - } - throw new Error('e2e requires run, report, browser-check, or auth-profile'); -} - -module.exports = { - checkReportHtml, - browserProfileOptionsFromArgs, - e2eCommand, - e2eBrowserCheck, - e2eAuthProfile, - e2eReport, - e2eRun, - grafanaAuthRemediation, - grafanaVisualOk, - grafanaScreenshotTargets, - grafanaLinksFromOpenSummary, - htmlLinks, - renderReportHtml, - renderAuthProfile, - safeE2eEnv -}; +module.exports = require('../lib/e2e-command'); diff --git a/agentops-cli/src/commands/explain.js b/agentops-cli/src/commands/explain.js index a28abe4..0be2cbb 100644 --- a/agentops-cli/src/commands/explain.js +++ b/agentops-cli/src/commands/explain.js @@ -1,42 +1 @@ -const path = require('node:path'); - -const legacy = require('../legacy'); -const { hasFlag, optionValue } = require('../lib/args'); -const { explainFromFiles, renderV2Explanation } = require('../lib/explain/v2-explain'); - -function hasV2Args(args) { - return Boolean(optionValue(args, '--runs') || optionValue(args, '--evals') || optionValue(args, '--insights')); -} - -function explainCommand(args = []) { - const target = args[0] || 'latest'; - if (target !== 'latest' && !target.startsWith('run_') && !target.startsWith('run-')) { - throw new Error('explain supports: latest or '); - } - - if (!hasV2Args(args)) { - if (target !== 'latest') throw new Error('legacy explain supports only latest unless --runs is supplied'); - const summary = legacy.latestSummaryFromArgs(args.slice(1)); - const explanation = legacy.explainLatest(summary); - process.stdout.write(hasFlag(args, '--json') - ? `${JSON.stringify(explanation, null, 2)}\n` - : legacy.renderExplanation(explanation)); - return; - } - - const explanation = explainFromFiles({ - runId: target, - runsFile: path.resolve(optionValue(args, '--runs')), - evalsFile: optionValue(args, '--evals') ? path.resolve(optionValue(args, '--evals')) : null, - insightsFile: optionValue(args, '--insights') ? path.resolve(optionValue(args, '--insights')) : null - }); - process.stdout.write(hasFlag(args, '--json') - ? `${JSON.stringify(explanation, null, 2)}\n` - : renderV2Explanation(explanation)); - if (!explanation.ok) process.exitCode = 1; -} - -module.exports = { - explainCommand, - hasV2Args -}; +module.exports = require('../lib/explain-command'); diff --git a/agentops-cli/src/commands/github-enrich.js b/agentops-cli/src/commands/github-enrich.js index fd17139..2bf49bd 100644 --- a/agentops-cli/src/commands/github-enrich.js +++ b/agentops-cli/src/commands/github-enrich.js @@ -1,39 +1 @@ -const path = require('node:path'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { enrichGithubOutcomes, writeGithubOutcomes } = require('../lib/github/outcome-enricher'); - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function githubEnrichCommand(args = []) { - const outDir = path.resolve(optionValue(args, '--out', path.join(repoRoot, '.agentops', 'github-outcomes', 'latest'))); - const limit = Number(optionValue(args, '--limit', '30')); - const runsFile = optionValue(args, '--runs') ? path.resolve(optionValue(args, '--runs')) : null; - if (!Number.isInteger(limit) || limit <= 0 || limit > 200) throw new Error('--limit must be an integer between 1 and 200'); - - const result = enrichGithubOutcomes({ limit, runsFile }); - if (result.ok) { - const written = writeGithubOutcomes(result.rows, outDir); - result.out_dir = written.out_dir; - result.manifest = written.manifest; - result.file = written.file; - result.next = [ - `agentops latest --file ${written.file}`, - 'agentops dashboard validate' - ]; - } - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else if (result.ok) { - process.stdout.write(`Generated ${result.rows.length} GitHub outcome row${result.rows.length === 1 ? '' : 's'}.\n`); - process.stdout.write(`Output: ${result.file}\n`); - } else { - process.stdout.write(`Could not enrich GitHub outcomes: ${result.error}\n`); - } - if (!result.ok) process.exitCode = 1; -} - -module.exports = { - githubEnrichCommand -}; +module.exports = require('../lib/github-enrich-command'); diff --git a/agentops-cli/src/commands/health.js b/agentops-cli/src/commands/health.js index 6da4b9c..17d16d2 100644 --- a/agentops-cli/src/commands/health.js +++ b/agentops-cli/src/commands/health.js @@ -1,113 +1 @@ -const path = require('node:path'); -const fs = require('node:fs'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { latestByTime } = require('../lib/explain/v2-explain'); -const { doctorSummary } = require('./doctor'); -const { statusSummary } = require('./status'); - -function readJsonl(filePath) { - if (!filePath) return []; - return fs.readFileSync(filePath, 'utf8') - .split(/\r?\n/) - .filter(Boolean) - .map(line => JSON.parse(line)); -} - -function summarizeChecks(checks = []) { - const blocking = checks.filter(check => !check.ok && check.severity !== 'warning').length; - const warnings = checks.filter(check => !check.ok && check.severity === 'warning').length; - return { - total: checks.length, - passed: checks.filter(check => check.ok).length, - warnings, - blocking - }; -} - -function runHealthFromRows(runs = []) { - const run = latestByTime(runs); - if (!run) return null; - const failed = run.OutcomeStatus && run.OutcomeStatus !== 'success'; - const needsValidation = Number(run.FilesEditedCount || 0) > 0 && !run.TestsRan; - const privacyDrop = Number(run.PrivacyDropCount || 0) > 0 || run.PrivacyMode === 'none'; - return { - run_id: run.RunId || '', - session_id: run.SessionId || '', - status: failed || needsValidation || privacyDrop ? 'needs-attention' : 'healthy', - outcome: run.OutcomeStatus || 'unknown', - reason: run.OutcomeReason || '', - tests_ran: Boolean(run.TestsRan), - privacy_mode: run.PrivacyMode || '', - next_action: failed - ? 'Open Run Replay and inspect the failed span, blocked tool, eval score, and GitHub outcome.' - : needsValidation - ? 'Run validation for the edited files before promoting this result.' - : privacyDrop - ? 'Review privacy drops and keep strict mode enabled for shared environments.' - : 'Keep strict privacy mode enabled and compare the next similar run for drift.' - }; -} - -async function healthSummary(options = {}) { - const [status, doctor] = await Promise.all([ - statusSummary(), - doctorSummary({ localOnly: true, mode: options.mode || 'auto' }) - ]); - const checkSummary = summarizeChecks(doctor.checks); - const runs = options.runsFile ? readJsonl(path.resolve(options.runsFile)) : []; - const latestRun = runHealthFromRows(runs); - const blocking = checkSummary.blocking > 0; - const warning = checkSummary.warnings > 0 || !status.collector.running || latestRun?.status === 'needs-attention'; - return { - ok: !blocking, - status: blocking ? 'blocking' : warning ? 'needs-attention' : 'healthy', - checks: checkSummary, - local: { - content_capture_off: status.content_capture_off, - collector_running: Boolean(status.collector.running), - collector_mode: status.collector.effectiveMode || status.collector.mode || '', - collector_privacy_mode: status.collector.privacyMode || '', - collector_localhost_only: Boolean(status.collector.safeLocalhostBinding), - copilot_ok: Boolean(status.copilot.ok), - copilot_source: status.copilot.source || '' - }, - latest_run: latestRun, - next_action: blocking - ? 'Run `agentops doctor` and fix blocking local readiness issues.' - : warning - ? 'Review warnings, then run `agentops smoke --real-copilot --wait 2m --poll 10s`.' - : 'Run `agentops smoke --real-copilot --wait 2m --poll 10s` and open the printed Run Replay link.' - }; -} - -function renderHealth(summary) { - const lines = [ - 'AgentOps health', - '', - `Status: ${summary.status}`, - `Checks: ${summary.checks.passed}/${summary.checks.total} passed, ${summary.checks.warnings} warning(s), ${summary.checks.blocking} blocking.`, - `Collector: ${summary.local.collector_running ? 'running' : 'not running'} (${summary.local.collector_mode || 'unknown'}, ${summary.local.collector_privacy_mode || 'unknown'}).`, - `Content capture: ${summary.local.content_capture_off ? 'off' : 'enabled or unknown'}.` - ]; - if (summary.latest_run) lines.push(`Latest run: ${summary.latest_run.run_id || 'unknown'} (${summary.latest_run.status}).`); - lines.push(`Next: ${summary.next_action}`); - return `${lines.join('\n')}\n`; -} - -async function healthCommand(args = []) { - const summary = await healthSummary({ - runsFile: optionValue(args, '--runs'), - mode: process.env.AGENTOPS_COLLECTOR_MODE || 'auto' - }); - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(summary, null, 2)}\n` : renderHealth(summary)); - process.exitCode = summary.ok ? 0 : 1; -} - -module.exports = { - healthCommand, - healthSummary, - renderHealth, - runHealthFromRows, - summarizeChecks -}; +module.exports = require('../lib/health-command'); diff --git a/agentops-cli/src/commands/insights.js b/agentops-cli/src/commands/insights.js index 3c855bc..4f1b83f 100644 --- a/agentops-cli/src/commands/insights.js +++ b/agentops-cli/src/commands/insights.js @@ -1,99 +1 @@ -const path = require('node:path'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { generateInsights, readJsonl, writeInsights } = require('../lib/insights/deterministic-insights'); - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function patternRows(rows = []) { - return rows - .filter(row => row.PatternId || row.PatternKey || String(row.InsightType || '').startsWith('recurring-')) - .sort((a, b) => Number(b.PatternRuns || 0) - Number(a.PatternRuns || 0) || String(b.TimeGenerated || '').localeCompare(String(a.TimeGenerated || ''))); -} - -function renderPatterns(rows = []) { - const patterns = patternRows(rows); - const lines = ['AgentOps recurring patterns', '']; - if (patterns.length === 0) { - lines.push('No recurring metadata-only patterns found.'); - lines.push('Next: run `agentops insights generate --runs ` after collecting more runs.'); - return `${lines.join('\n')}\n`; - } - for (const row of patterns.slice(0, 10)) { - lines.push(`- ${row.Severity || 'info'} ${row.InsightType}: ${row.PatternRuns || 0} run(s), ${row.PatternDimension || 'pattern'}`); - lines.push(` ${row.Summary || ''}`); - lines.push(` Next: ${row.SuggestedNextStep || 'Open Insights & Regressions.'}`); - lines.push(` PatternKey: ${row.PatternKey || ''}`); - } - return `${lines.join('\n')}\n`; -} - -function normalizeInsightsArgs(args = []) { - const [first] = args; - if (!first || first.startsWith('--')) { - return [hasFlag(args, '--runs') ? 'generate' : 'patterns', ...args]; - } - return args; -} - -function insightsCommand(args = []) { - const normalizedArgs = normalizeInsightsArgs(args); - const [subcommand = 'patterns'] = normalizedArgs; - if (!['generate', 'patterns'].includes(subcommand)) throw new Error('insights supports: generate|patterns'); - - if (subcommand === 'patterns') { - const insightsFile = optionValue(normalizedArgs, '--insights', path.join(repoRoot, '.agentops', 'insights', 'latest', 'AgentOpsInsights_CL.jsonl')); - const patterns = patternRows(readJsonl(path.resolve(insightsFile))); - const payload = { - ok: true, - insights_file: path.resolve(insightsFile), - patterns, - pattern_count: patterns.length, - next: [ - 'agentops open latest --runs .agentops/demo/latest/AgentOpsRunSummary_CL.jsonl', - 'Open the Insights & Regressions dashboard and click OpenPattern.' - ] - }; - process.stdout.write(hasFlag(normalizedArgs, '--json') ? `${JSON.stringify(payload, null, 2)}\n` : renderPatterns(patterns)); - return; - } - - const runsFile = optionValue(normalizedArgs, '--runs'); - if (!runsFile) throw new Error('insights generate requires --runs '); - - const outDir = path.resolve(optionValue(normalizedArgs, '--out', path.join(repoRoot, '.agentops', 'insights', 'latest'))); - const result = generateInsights({ - runs: readJsonl(path.resolve(runsFile)), - tools: readJsonl(optionValue(normalizedArgs, '--tools')), - privacy: readJsonl(optionValue(normalizedArgs, '--privacy')), - github: readJsonl(optionValue(normalizedArgs, '--github')), - evals: readJsonl(optionValue(normalizedArgs, '--baseline-evals')), - baselineTools: readJsonl(optionValue(normalizedArgs, '--baseline-tools')) - }); - const written = writeInsights(result, outDir); - const payload = { - ok: result.ok, - out_dir: outDir, - eval_file: written.evalFile, - insights_file: written.insightsFile, - table_counts: result.table_counts, - next: [ - `agentops replay latest --file ${optionValue(normalizedArgs, '--events', '.agentops/demo/latest/AgentOpsEvents_CL.jsonl')}`, - 'agentops dashboard validate' - ] - }; - - if (hasFlag(normalizedArgs, '--json')) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else { - process.stdout.write(`Generated ${payload.table_counts.AgentOpsEval_CL} eval row${payload.table_counts.AgentOpsEval_CL === 1 ? '' : 's'} and ${payload.table_counts.AgentOpsInsights_CL} insight row${payload.table_counts.AgentOpsInsights_CL === 1 ? '' : 's'}.\n`); - process.stdout.write(`Output: ${payload.out_dir}\n`); - } -} - -module.exports = { - insightsCommand, - normalizeInsightsArgs, - patternRows, - renderPatterns -}; +module.exports = require('../lib/insights-command'); diff --git a/agentops-cli/src/commands/mcp-proxy.js b/agentops-cli/src/commands/mcp-proxy.js index af4d20e..83c138d 100644 --- a/agentops-cli/src/commands/mcp-proxy.js +++ b/agentops-cli/src/commands/mcp-proxy.js @@ -1,30 +1 @@ -const path = require('node:path'); - -const { optionValue } = require('../lib/args'); -const { proxyStdio } = require('../lib/mcp/proxy-stdio'); - -function splitCommand(args) { - const separator = args.indexOf('--'); - if (separator === -1 || separator === args.length - 1) { - throw new Error('mcp-proxy requires -- [args...]'); - } - return args.slice(separator + 1); -} - -function mcpProxyCommand(args = []) { - const serverName = optionValue(args, '--server-name', 'unknown-mcp'); - const outFile = path.resolve(optionValue(args, '--out', path.join(process.cwd(), '.agentops', 'mcp-proxy', 'AgentOpsMcpCalls_CL.jsonl'))); - const commandAndArgs = splitCommand(args); - return proxyStdio({ - serverName, - outFile, - command: commandAndArgs[0], - args: commandAndArgs.slice(1), - sandboxed: args.includes('--sandboxed') - }); -} - -module.exports = { - mcpProxyCommand, - splitCommand -}; +module.exports = require('../lib/mcp-proxy-command'); diff --git a/agentops-cli/src/commands/open.js b/agentops-cli/src/commands/open.js index 25cba14..7cac175 100644 --- a/agentops-cli/src/commands/open.js +++ b/agentops-cli/src/commands/open.js @@ -1,117 +1 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const legacy = require('../legacy'); -const { hasFlag, optionValue } = require('../lib/args'); -const { latestByTime } = require('../lib/explain/v2-explain'); - -function readJsonl(filePath) { - if (!filePath) return []; - const text = fs.readFileSync(filePath, 'utf8'); - return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} - -function firstPositional(args = []) { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg.startsWith('--')) { - if (!arg.includes('=') && index + 1 < args.length && !args[index + 1].startsWith('--')) index += 1; - continue; - } - return arg; - } - return 'latest'; -} - -function withVars(baseUrl, vars = {}) { - const entries = Object.entries(vars).filter(([, value]) => value !== undefined && value !== null && value !== ''); - if (entries.length === 0) return baseUrl; - const separator = baseUrl.includes('?') ? '&' : '?'; - return `${baseUrl}${separator}${entries.map(([key, value]) => `var-${key}=${encodeURIComponent(value)}`).join('&')}`; -} - -function v2OpenLinksForRun(run, legacyLinks = legacy.openLinksSummary()) { - const runVars = run ? { - run_id: run.RunId || '__all', - session_id: run.SessionId || '__all', - trace_id: run.TraceId || '__all' - } : {}; - const modelVars = run?.ModelActual ? { model: run.ModelActual } : {}; - const repoVars = run?.RepoHash ? { repo_hash: run.RepoHash } : {}; - const agentVars = run?.AgentName ? { agent_name: run.AgentName } : {}; - - return { - ok: Boolean(run), - run_id: run?.RunId || '', - session_id: run?.SessionId || '', - trace_id: run?.TraceId || '', - status: run?.OutcomeStatus || '', - missing_latest_reason: run ? null : 'no V2 run row was found', - links: { - home: legacyLinks.v2_home_url, - runs: withVars(legacyLinks.v2_runs_url, { ...repoVars, ...agentVars }), - replay: withVars(legacyLinks.v2_replay_url, runVars), - content_viewer: withVars(`${legacyLinks.v2_replay_url}?viewPanel=26`, runVars), - models: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-models-cost-tokens`, modelVars), - tools: `${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-tools-mcp-risk`, - privacy: `${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-safety-privacy-policy`, - outcomes: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-code-outcomes`, repoVars), - evals: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-evals-quality`, runVars), - insights: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-insights-regressions`, runVars) - } - }; -} - -function openV2FromFiles(options = {}) { - const runs = readJsonl(options.runsFile); - const run = options.runId && options.runId !== 'latest' - ? runs.find(row => row.RunId === options.runId || row.SessionId === options.runId || row.TraceId === options.runId) - : latestByTime(runs); - return v2OpenLinksForRun(run, options.legacyLinks || legacy.openLinksSummary()); -} - -function renderOpenV2(result) { - const lines = ['AgentOps V2 links', '']; - if (!result.ok) { - lines.push(`Latest run: unknown. ${result.missing_latest_reason}.`); - return `${lines.join('\n')}\n`; - } - - lines.push(`Run: ${result.run_id}`); - lines.push(`Status: ${result.status || 'unknown'}`); - lines.push(`Home: ${result.links.home}`); - lines.push(`Runs Explorer: ${result.links.runs}`); - lines.push(`Run Replay: ${result.links.replay}`); - lines.push(`Prompt/response viewer (explicit opt-in): ${result.links.content_viewer}`); - lines.push(`Models: ${result.links.models}`); - lines.push(`Tools & MCP: ${result.links.tools}`); - lines.push(`Safety & Privacy: ${result.links.privacy}`); - lines.push(`Code Outcomes: ${result.links.outcomes}`); - lines.push(`Evals: ${result.links.evals}`); - lines.push(`Insights: ${result.links.insights}`); - return `${lines.join('\n')}\n`; -} - -function openCommand(args = []) { - if (!optionValue(args, '--runs')) { - const summary = legacy.latestSummaryFromArgs(args); - const links = legacy.openLinksSummary(summary); - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(links, null, 2)}\n` : legacy.renderOpenLinks(links)); - return; - } - - const result = openV2FromFiles({ - runId: firstPositional(args), - runsFile: path.resolve(optionValue(args, '--runs')) - }); - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(result, null, 2)}\n` : renderOpenV2(result)); - if (!result.ok) process.exitCode = 1; -} - -module.exports = { - firstPositional, - openCommand, - openV2FromFiles, - renderOpenV2, - v2OpenLinksForRun -}; +module.exports = require('../lib/open-command'); diff --git a/agentops-cli/src/commands/product.js b/agentops-cli/src/commands/product.js index 635fa3a..23d98c0 100644 --- a/agentops-cli/src/commands/product.js +++ b/agentops-cli/src/commands/product.js @@ -1,700 +1 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { dashboardVerify, validateDashboardLinks, validateDashboardUx, validateDashboards } = require('./dashboard'); -const { e2eBrowserCheck } = require('./e2e'); -const { repoRoot } = require('../lib/paths'); -const { validateWrapperContract } = require('../lib/copilot/wrapper-contract'); -const { validateCopilotOtelFixtureContract } = require('../lib/copilot/fixture-contract'); -const legacy = require('../legacy'); - -const requiredVisualDashboards = [ - 'agentops-v2-home', - 'agentops-v2-runs-explorer', - 'agentops-v2-run-replay', - 'agentops-v2-models-cost-tokens', - 'agentops-v2-tools-mcp-risk', - 'agentops-v2-safety-privacy-policy', - 'agentops-v2-code-outcomes', - 'agentops-v2-evals-quality', - 'agentops-v2-insights-regressions', - 'agentops-v2-collector-health' -]; - -function exists(relativePath) { - return fs.existsSync(path.join(repoRoot, relativePath)); -} - -function fileIncludes(relativePath, terms) { - if (!exists(relativePath)) return false; - const body = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); - return terms.every(term => body.includes(term)); -} - -function check(name, ok, evidence = [], missing = []) { - return { - name, - ok: Boolean(ok), - evidence, - missing - }; -} - -function requiredFilesCheck(name, files) { - const missing = files.filter(file => !exists(file)); - return check(name, missing.length === 0, files.filter(file => !missing.includes(file)), missing); -} - -function productAudit(options = {}) { - const live = Boolean(options.live); - const last = options.last || '24h'; - const requireRows = Boolean(options.requireRows); - const runDashboardVerify = options.dashboardVerify || dashboardVerify; - const runValidateAzure = options.validateAzure || legacy.validateAzure; - const checks = []; - - checks.push(requiredFilesCheck('agent-run-schema', [ - 'docs/agent-run-data-model.md', - 'docs/otel-genai-mcp-schema.md', - 'agentops-cli/src/lib/schema/agent-run-schema.js', - 'agentops-cli/src/lib/schema/agentops-attributes.js', - 'agentops-cli/src/lib/otel/genai-normalizer.js', - 'agentops-cli/src/lib/otel/mcp-normalizer.js' - ])); - - checks.push(requiredFilesCheck('strict-privacy-pipeline', [ - 'collector/processors/strict-allowlist.yaml', - 'collector/processors/content-signal.yaml', - 'collector/processors/genai-normalizer.yaml', - 'collector/processors/mcp-normalizer.yaml', - 'collector/processors/span-to-run-summary.yaml', - 'collector/release-cadence.json', - 'collector/tests/privacy-poison-fixtures/content-poison.json', - 'agentops-cli/src/commands/content.js', - 'agentops-cli/src/lib/privacy.js', - 'agentops-cli/src/lib/azure/v2-ingest-plan.js', - 'docs/privacy-threat-model-v2.md' - ])); - - checks.push(check( - 'privacy-defaults', - fileIncludes('README.md', ['without recording prompts', 'tool arguments', 'tool results by default']) - && fileIncludes('agentops-cli/src/lib/copilot/run-metadata.js', ['promptHash', 'commandHash']) - && fileIncludes('copilot/copilot-observe', ['capture_content_enabled="${AGENTOPS_CAPTURE_CONTENT:-false}"', 'COPILOT_OTEL_CAPTURE_CONTENT="false"']) - && fileIncludes('copilot/copilot-observe.ps1', ['$captureContentEnabled', 'COPILOT_OTEL_CAPTURE_CONTENT = "false"']), - [ - 'README.md', - 'agentops-cli/src/lib/copilot/run-metadata.js', - 'copilot/copilot-observe', - 'copilot/copilot-observe.ps1' - ], - [] - )); - - const wrapperContract = validateWrapperContract(repoRoot); - checks.push(check( - 'copilot-wrapper-sync-contract', - wrapperContract.ok, - wrapperContract.files, - wrapperContract.missing - )); - - checks.push(requiredFilesCheck('copilot-cli-surface', [ - 'agentops-cli/src/commands/copilot.js', - 'agentops-cli/src/lib/copilot/resolve-real-copilot.js', - 'agentops-cli/src/lib/copilot/run-metadata.js', - 'agentops-cli/src/lib/copilot/flag-contract.js', - 'agentops-cli/src/lib/copilot/fixture-contract.js', - 'agentops-cli/src/lib/copilot/session-parser.js', - 'agentops-cli/src/lib/copilot/tool-classifier.js', - 'agentops-cli/src/lib/copilot/run-summary.js', - 'docs/copilot-cli-instrumentation.md', - 'docs/copilot-cli-flag-contract.md' - ])); - - const copilotFixture = validateCopilotOtelFixtureContract(); - checks.push(check( - 'real-copilot-otel-fixture-contract', - copilotFixture.ok && fileIncludes('docs/telemetry-schema.md', ['copilot-cli-wrapper-snapshot.jsonl', 'contract fixture']), - [ - 'tests/sample-otel/copilot-cli-wrapper-snapshot.jsonl', - 'agentops-cli/src/lib/copilot/fixture-contract.js', - 'docs/telemetry-schema.md' - ], - copilotFixture.mismatches - )); - - checks.push(requiredFilesCheck('copilot-sdk-adapter', [ - 'packages/agentops-copilot-sdk/package.json', - 'packages/agentops-copilot-sdk/src/index.js', - 'packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js', - 'packages/agentops-copilot-sdk/src/hooks.js', - 'packages/agentops-copilot-sdk/src/otel.js', - 'packages/agentops-copilot-sdk/src/privacy.js', - 'packages/agentops-copilot-sdk/test/adapter.test.js', - 'docs/copilot-sdk-adapter.md' - ])); - - checks.push(requiredFilesCheck('mcp-observability-proxy', [ - 'agentops-cli/src/commands/mcp-proxy.js', - 'agentops-cli/src/lib/mcp/proxy-stdio.js', - 'agentops-cli/src/lib/mcp/proxy-http.js', - 'agentops-cli/src/lib/mcp/risk-classifier.js', - 'agentops-cli/src/lib/mcp/redactor.js', - 'agentops-cli/src/lib/mcp/trace-context.js', - 'docs/mcp-observability-proxy.md', - 'examples/mcp-proxy/demo-server.js' - ])); - - checks.push(requiredFilesCheck('github-outcomes', [ - 'agentops-cli/src/commands/github-enrich.js', - 'agentops-cli/src/lib/github/outcome-enricher.js', - 'agentops-cli/src/lib/github/pr-mapper.js', - 'agentops-cli/src/lib/github/actions-mapper.js', - 'agentops-cli/src/lib/github/revert-detector.js', - 'docs/github-outcome-enrichment.md' - ])); - - checks.push(requiredFilesCheck('evals-insights-recommendations', [ - 'agentops-cli/src/commands/explain.js', - 'agentops-cli/src/commands/insights.js', - 'agentops-cli/src/commands/recommend.js', - 'agentops-cli/src/commands/triage.js', - 'agentops-cli/src/lib/schema/recommendation-schema.js', - 'agentops-cli/src/lib/evals/test-discipline.js', - 'agentops-cli/src/lib/evals/tool-efficiency.js', - 'agentops-cli/src/lib/evals/security.js', - 'agentops-cli/src/lib/evals/reliability.js', - 'agentops-cli/src/lib/evals/code-outcome.js', - 'agentops-cli/src/lib/insights/outlier-detector.js', - 'agentops-cli/src/lib/insights/regression-detector.js', - 'docs/evals-and-insights.md' - ])); - - checks.push(requiredFilesCheck('grafana-v2-pack', [ - 'grafana/dashboards/v2/01-agentops-home.json', - 'grafana/dashboards/v2/02-runs-explorer.json', - 'grafana/dashboards/v2/03-run-replay.json', - 'grafana/dashboards/v2/04-models-cost-tokens.json', - 'grafana/dashboards/v2/05-tools-mcp-risk.json', - 'grafana/dashboards/v2/06-safety-privacy-policy.json', - 'grafana/dashboards/v2/07-code-outcomes.json', - 'grafana/dashboards/v2/08-evals-quality.json', - 'grafana/dashboards/v2/09-insights-regressions.json', - 'grafana/dashboards/v2/10-collector-health.json', - 'grafana/provisioning/dashboards/agentops-v2.yaml', - 'grafana/provisioning/datasources/azure-monitor.yaml', - 'docs/grafana-ux-spec.md', - 'docs/grafana-dashboard-tour-v2.md', - 'docs/grafana-query-library.md' - ])); - - checks.push(requiredFilesCheck('kql-library', [ - 'grafana/kql/run-summary.kql', - 'grafana/kql/runs-explorer.kql', - 'grafana/kql/run-replay.kql', - 'grafana/kql/tool-risk.kql', - 'grafana/kql/privacy-signals.kql', - 'grafana/kql/code-outcomes.kql', - 'grafana/kql/evals.kql', - 'grafana/kql/insights.kql', - 'grafana/kql/collector-health.kql', - 'grafana/kql/content-viewer.kql' - ])); - - const dashboard = validateDashboards(); - checks.push(check('dashboard-json-contract', dashboard.ok, [`${dashboard.dashboards} dashboard files parsed`], dashboard.errors)); - const links = validateDashboardLinks(); - checks.push(check('dashboard-drilldowns', links.ok, [`${links.checked_links} nav/data links checked`], links.errors)); - const ux = validateDashboardUx(); - checks.push(check('dashboard-operator-ux', ux.ok, ['Home, Runs, Replay, transcript, patterns, recommendations, and empty states checked'], ux.errors)); - checks.push(check( - 'run-centric-ui-contract', - ux.ok - && ux.contracts?.run_centric_ui === true - && fileIncludes('docs/agentops-architecture-product-audit.md', [ - 'Run-Centric UI', - 'Session explorer as first screen', - 'Trace waterfall through Run Replay', - 'Ask AgentOps panel' - ]), - [ - 'grafana/dashboards/v2/01-agentops-home.json', - 'grafana/dashboards/v2/02-runs-explorer.json', - 'grafana/dashboards/v2/03-run-replay.json', - 'docs/agentops-architecture-product-audit.md' - ], - ux.errors - )); - - checks.push(check( - 'robust-eval-center-contract', - ux.ok - && ux.contracts?.robust_eval_center === true - && fileIncludes('agentops-cli/src/legacy.js', [ - 'hiddenCheckPacks', - 'permissionProfiles', - 'macos-network-blocked', - 'container-network-blocked', - '--network', - 'none', - 'commandFileSeal', - 'semanticChecks', - 'llm-judge', - 'artifactDiff', - 'promotionApproval' - ]) - && fileIncludes('grafana/dashboards/v2/08-evals-quality.json', [ - 'BenchmarkArtifactContentDiffs', - 'BenchmarkApproval' - ]) - && fileIncludes('docs/agentops-architecture-product-audit.md', [ - 'Robust Eval Center', - 'Hidden check packs', - 'Rubric and semantic scoring', - 'Artifact diffing', - 'Promotion policy', - 'Container runtime network isolation' - ]), - [ - 'agentops-cli/src/legacy.js', - 'grafana/dashboards/v2/08-evals-quality.json', - 'docs/agentops-architecture-product-audit.md' - ], - ux.errors - )); - - checks.push(check( - 'hosted-llm-judge-deployment', - fileIncludes('benchmark-judges/hosted-judge/server.js', ['metadata-only-hosted-llm-judge', 'POST', '/score', 'OPENAI_API_KEY', 'AGENTOPS_JUDGE_TOKEN']) - && fileIncludes('benchmark-judges/hosted-judge/Dockerfile', ['node:22-alpine', 'server.js']) - && fileIncludes('infra/bicep/hosted-judge.bicep', ['Microsoft.App/containerApps', 'judge-token', 'openai-api-key', 'judgeEndpoint']) - && fileIncludes('agentops-cli/src/legacy.js', ['serviceArtifact', 'benchmark-judges/hosted-judge', 'infra/bicep/hosted-judge.bicep']) - && fileIncludes('docs/agentops-architecture-product-audit.md', ['deployable Azure Container Apps hosted judge', 'hosted-llm-judge-deployment']), - [ - 'benchmark-judges/hosted-judge/server.js', - 'benchmark-judges/hosted-judge/Dockerfile', - 'infra/bicep/hosted-judge.bicep', - 'agentops-cli/src/legacy.js', - 'docs/agentops-architecture-product-audit.md' - ], - [] - )); - - checks.push(check( - 'managed-benchmark-runner-image', - fileIncludes('benchmark-runners/copilot-sandbox/Dockerfile', [ - 'node:22-bookworm-slim', - 'AGENTOPS_BENCHMARK_RUNNER=managed-container-sandbox', - 'agentops-benchmark-entrypoint' - ]) - && fileIncludes('benchmark-runners/copilot-sandbox/docker-entrypoint.sh', [ - 'COPILOT_HOME', - 'command -v', - 'private derived image' - ]) - && fileIncludes('benchmark-runners/copilot-sandbox/README.md', [ - 'container-network-blocked', - '--network none', - 'private registry' - ]) - && fileIncludes('docs/agentops-architecture-product-audit.md', [ - 'managed benchmark runner base image', - 'managed-benchmark-runner-image' - ]), - [ - 'benchmark-runners/copilot-sandbox/Dockerfile', - 'benchmark-runners/copilot-sandbox/docker-entrypoint.sh', - 'benchmark-runners/copilot-sandbox/README.md', - 'docs/agentops-architecture-product-audit.md' - ], - [] - )); - - checks.push(check( - 'azure-ingest-privacy-plan', - fileIncludes('agentops-cli/src/lib/azure/v2-ingest-plan.js', ['--allow-content', 'AgentOpsContent_CL', 'schema_versioning', 'schema_migration_policy', 'logs-ingestion-upload-plan']) - && fileIncludes('agentops-cli/src/commands/azure-ingest.js', ['logs-upload', '--yes', 'az', 'rest']) - && fileIncludes('infra/bicep/v2-ingestion.bicep', ['AgentOpsRunSummary_CL', 'dataCollectionRules', 'streamDeclarations', 'logsIngestionEndpoint']) - && fileIncludes('infra/bicep/main.bicep', ['deployV2Ingestion', 'AGENTOPS_LOGS_INGESTION_ENDPOINT', 'AGENTOPS_DCR_IMMUTABLE_ID']) - && fileIncludes('docs/azure-v2-ingestion.md', ['AgentOpsContent_CL', '--allow-content', 'SchemaVersion', 'schema migration policy', 'azure-ingest logs-upload']), - ['agentops-cli/src/lib/azure/v2-ingest-plan.js', 'agentops-cli/src/commands/azure-ingest.js', 'infra/bicep/v2-ingestion.bicep', 'infra/bicep/main.bicep', 'docs/azure-v2-ingestion.md'], - [] - )); - - checks.push(check( - 'content-transcript-opt-in', - fileIncludes('docs/grafana-ux-spec.md', ['AgentOpsContent_CL', 'opt-in']) - && fileIncludes('README.md', ['agentops content status', 'AgentOpsContent_CL']) - && fileIncludes('grafana/kql/content-viewer.kql', ['AgentOpsContent_CL', 'MessageText']), - ['docs/grafana-ux-spec.md', 'README.md', 'grafana/kql/content-viewer.kql'], - [] - )); - - checks.push(check( - 'first-run-loop', - fileIncludes('README.md', ['agentops smoke --real-copilot', 'agentops open latest --last 2h']) - && fileIncludes('docs/release-checklist-v2.md', ['init --dry-run --provision-cloud', 'smoke --real-copilot']) - && fileIncludes('agentops-cli/src/legacy.js', ['First value: run the real smoke', 'Cloud provision failed at:']), - ['README.md', 'docs/release-checklist-v2.md', 'agentops-cli/src/legacy.js'], - [] - )); - - checks.push(check( - 'ask-agentops-response-flow', - fileIncludes('actioner/index.js', ['metadata-only-assistant-response', 'root_cause_candidates', 'rollback_condition', 'change_target_refs', 'expected_metric_movement', 'buildRecommendationReview', 'OperatorReview']) - && fileIncludes('actioner/README.md', ['first-party metadata-only response draft', 'ChangeTargetRefs', 'ExpectedMetricMovement', 'BeforeTelemetry', 'OperatorReview']) - && fileIncludes('docs/agentops-architecture-product-audit.md', ['first-party metadata-only response draft', 'ExpectedMetricMovement', 'OperatorReview']), - ['actioner/index.js', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], - [] - )); - - checks.push(check( - 'ask-agentops-live-response-flow', - fileIncludes('actioner/index.js', ['metadata-only-live-assistant-request', 'AGENTOPS_ASSISTANT_API_URL', 'live-assistant-run', 'fetch(liveBox.dataset.apiUrl']) - && fileIncludes('actioner/README.md', ['AGENTOPS_ASSISTANT_API_URL', 'inline live assistant form', 'metadata-only prompt and compact context']) - && fileIncludes('docs/agentops-architecture-product-audit.md', ['browser-native metadata-only live assistant response flow', 'AGENTOPS_ASSISTANT_API_URL']), - ['actioner/index.js', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], - [] - )); - - checks.push(check( - 'ask-agentops-shared-context', - fileIncludes('actioner/index.js', ['savedViewEvidenceFromPayload', 'alertHandoffEvidenceFromPayload', 'hydrateAskAgentOpsPayload', 'shared_context', 'recommendationBlob', 'savedViewBlob', 'alertHandoffBlob']) - && fileIncludes('actioner/AskAgentOpsShared/function.json', ['ask-agentops/shared', 'recommendation_blob_id', 'saved_view_blob_id', 'alert_handoff_blob_id']) - && fileIncludes('actioner/AskAgentOpsSharedRecommendation/function.json', ['ask-agentops/shared/recommendation/{recommendation_blob_id}', 'recommendationBlob']) - && fileIncludes('actioner/AskAgentOpsSharedSavedView/function.json', ['ask-agentops/shared/saved-view/{saved_view_blob_id}', 'savedViewBlob']) - && fileIncludes('actioner/AskAgentOpsSharedAlertHandoff/function.json', ['ask-agentops/shared/alert-handoff/{alert_handoff_blob_id}', 'alertHandoffBlob']) - && fileIncludes('grafana/dashboards/v2/01-agentops-home.json', ['AskAgentOpsSharedLaunch', '/ask-agentops/shared/saved-view/', '/ask-agentops/shared/recommendation/']) - && fileIncludes('grafana/dashboards/v2/03-run-replay.json', ['AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/']) - && fileIncludes('grafana/dashboards/v2/06-safety-privacy-policy.json', ['AgentOpsAlertHandoffs_CL', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/alert-handoff/']) - && fileIncludes('grafana/dashboards/v2/09-insights-regressions.json', ['AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/']) - && fileIncludes('actioner/README.md', ['saved_view', 'alert_handoff', '/api/ask-agentops/shared', 'Dashboard action cells use the GET routes']) - && fileIncludes('docs/agentops-architecture-product-audit.md', ['shared-storage hydrated recommendation', 'actioner/AskAgentOpsShared', 'shared Ask AgentOps action cells', 'alert handoff review rows']), - ['actioner/index.js', 'actioner/AskAgentOpsShared/function.json', 'actioner/AskAgentOpsSharedRecommendation/function.json', 'actioner/AskAgentOpsSharedSavedView/function.json', 'actioner/AskAgentOpsSharedAlertHandoff/function.json', 'grafana/dashboards/v2/01-agentops-home.json', 'grafana/dashboards/v2/03-run-replay.json', 'grafana/dashboards/v2/06-safety-privacy-policy.json', 'grafana/dashboards/v2/09-insights-regressions.json', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], - [] - )); - - checks.push(check( - 'recommendation-metric-movement', - fileIncludes('agentops-cli/src/commands/recommend.js', ['compareRecommendationAfterRun', 'AfterTelemetry', 'ObservedMetricMovement']) - && fileIncludes('docs/evals-and-insights.md', ['agentops recommend compare']) - && fileIncludes('docs/agentops-architecture-product-audit.md', ['AfterTelemetry']), - ['agentops-cli/src/commands/recommend.js', 'docs/evals-and-insights.md', 'docs/agentops-architecture-product-audit.md'], - [] - )); - - checks.push(check( - 'recommendation-action-plan', - fileIncludes('agentops-cli/src/commands/recommend.js', ['recommendationActionPlan', 'OperatorReview', 'benchmark_dry_run', 'compare_after_run']) - && fileIncludes('actioner/index.js', ['action_plan_command', 'agentops recommend action-plan']) - && fileIncludes('docs/evals-and-insights.md', ['agentops recommend action-plan']) - && fileIncludes('docs/agentops-architecture-product-audit.md', ['agentops recommend action-plan']), - ['agentops-cli/src/commands/recommend.js', 'actioner/index.js', 'docs/evals-and-insights.md', 'docs/agentops-architecture-product-audit.md'], - [] - )); - - checks.push(check( - 'agent-improvement-guarded-apply', - fileIncludes('actioner/index.js', ['buildGuardedRecommendationApply', 'metadata-only-guarded-apply', 'after-run metric movement', 'patch_handoff']) - && fileIncludes('actioner/README.md', ['guarded apply packet', 'after-run metric movement', 'patch handoff']) - && fileIncludes('docs/agentops-architecture-product-audit.md', ['guarded apply packet', 'metadata-only-guarded-apply']), - ['actioner/index.js', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], - [] - )); - - let liveDashboard = null; - let liveAzure = null; - if (live) { - const dashboardArgs = ['--live', '--last', last]; - if (requireRows) dashboardArgs.push('--require-rows'); - liveDashboard = runDashboardVerify(dashboardArgs, options.dashboardOptions || {}); - liveAzure = runValidateAzure({ last, importDashboards: false }); - checks.push(check( - 'live-grafana-dashboard-queries', - liveDashboard.ok, - [ - `${liveDashboard.summary?.kql_checks || 0} live KQL checks`, - `${liveDashboard.summary?.checked_links || 0} dashboard links checked` - ], - liveDashboard.errors || [] - )); - checks.push(check( - 'live-azure-resources', - liveAzure.ok, - (liveAzure.checks || []).filter(item => item.ok).map(item => item.name), - (liveAzure.checks || []).filter(item => !item.ok).map(item => item.name) - )); - } - - const failed = checks.filter(item => !item.ok); - return { - ok: failed.length === 0, - scope: live ? 'local-and-live-product-contract' : 'local-product-contract', - live_azure_verified: Boolean(liveAzure?.ok), - live_grafana_verified: Boolean(liveDashboard?.ok), - visual_grafana_verified: false, - summary: { - checks: checks.length, - passed: checks.length - failed.length, - failed: failed.length, - v2_dashboards: links.dashboards || 0, - checked_links: links.checked_links || 0, - live_kql_checks: liveDashboard?.summary?.kql_checks || 0 - }, - checks, - next: failed.length === 0 - ? (live - ? [ - 'agentops smoke --real-copilot --wait 2m --poll 10s --json', - 'agentops schema validate --json', - 'agentops validate-enterprise --json', - 'agentops collector smoke --privacy strict --poison --json', - 'npm --prefix packages/agentops-copilot-sdk test', - 'agentops e2e browser-check --report .agentops/e2e/latest/report.html --playwright --grafana --grafana-v2-only --require-grafana-visible --json', - 'npm --prefix agentops-cli test' - ] - : [ - 'agentops demo verify --runs 50 --json', - `agentops product audit --live --last ${last}${requireRows ? ' --require-rows' : ''} --json`, - 'agentops validate-azure --import-dashboards --last 24h --json', - 'agentops smoke --real-copilot --wait 2m --poll 10s --json' - ]) - : [ - 'agentops product audit --json', - 'agentops dashboard verify', - 'npm --prefix agentops-cli test' - ] - }; -} - -function visualAuditRecoveryCommands(reportPath) { - return [ - 'agentops e2e run --live --browser-report --last 2h --json', - `agentops e2e report --last 2h --out ${reportPath}`, - `agentops product audit --live --last 2h --require-rows --require-visual --report ${reportPath} --json` - ]; -} - -function validateVisualEvidence(evidencePath) { - const resolved = path.resolve(evidencePath || ''); - if (!evidencePath || !fs.existsSync(resolved)) { - return { - ok: false, - evidencePath: resolved, - dashboards: [], - visible: [], - missing: [`Visual evidence file not found: ${resolved}`] - }; - } - - let payload; - try { - payload = JSON.parse(fs.readFileSync(resolved, 'utf8')); - } catch (error) { - return { - ok: false, - evidencePath: resolved, - dashboards: [], - visible: [], - missing: [`Visual evidence file is not valid JSON: ${error.message}`] - }; - } - - const dashboards = Array.isArray(payload.dashboards) ? payload.dashboards : []; - const byUid = new Map(dashboards.map(item => [String(item.uid || ''), item])); - const missing = []; - const visible = []; - for (const uid of requiredVisualDashboards) { - const item = byUid.get(uid); - if (!item) { - missing.push(`${uid}: missing`); - continue; - } - const screenshotPath = item.screenshot ? path.resolve(path.dirname(resolved), item.screenshot) : ''; - const screenshotOk = screenshotPath && fs.existsSync(screenshotPath) && fs.statSync(screenshotPath).size > 1000; - if (item.authBlocked) missing.push(`${uid}: auth-blocked`); - if (!item.dashboardVisible) missing.push(`${uid}: not visible`); - if ((item.errors || []).length) missing.push(`${uid}: ${item.errors.join(', ')}`); - if (!screenshotOk) missing.push(`${uid}: screenshot missing or too small`); - if (!String(item.url || '').includes(`/d/${uid}`)) missing.push(`${uid}: URL does not match dashboard UID`); - if (!item.authBlocked && item.dashboardVisible && !(item.errors || []).length && screenshotOk) visible.push(uid); - } - - return { - ok: missing.length === 0, - evidencePath: resolved, - dashboards, - visible, - missing - }; -} - -async function productAuditWithVisual(options = {}) { - const result = productAudit(options); - if (!options.requireVisual) return result; - - if (options.visualEvidencePath) { - const evidence = validateVisualEvidence(options.visualEvidencePath); - const visualCheck = check( - 'visual-grafana-rendered-dashboards', - evidence.ok, - evidence.visible, - evidence.missing - ); - const checks = [...result.checks, visualCheck]; - const failed = checks.filter(item => !item.ok); - return { - ...result, - ok: failed.length === 0, - scope: options.live ? 'local-live-and-visual-product-contract' : 'local-and-visual-product-contract', - visual_grafana_verified: evidence.ok, - summary: { - ...result.summary, - checks: checks.length, - passed: checks.length - failed.length, - failed: failed.length, - visual_dashboards: evidence.dashboards.length, - visual_dashboards_visible: evidence.visible.length - }, - checks, - visual: { - ok: evidence.ok, - evidencePath: evidence.evidencePath, - status: 'evidence-file', - dashboards: evidence.dashboards - }, - next: evidence.ok - ? result.next - : [ - 'Regenerate authenticated Grafana visual evidence from a signed-in browser.', - ...visualAuditRecoveryCommands(options.reportPath || path.join(repoRoot, '.agentops', 'e2e', 'latest', 'report.html')) - ] - }; - } - - const runBrowserCheck = options.browserCheck || e2eBrowserCheck; - const reportPath = options.reportPath || path.join(repoRoot, '.agentops', 'e2e', 'latest', 'report.html'); - const browserArgs = [ - '--report', - reportPath, - '--playwright', - '--grafana', - '--grafana-v2-only', - '--require-grafana-visible' - ]; - for (const [flag, value] of [ - ['--browser-executable', options.browserExecutable], - ['--browser-user-data-dir', options.browserUserDataDir], - ['--storage-state', options.storageState] - ]) { - if (value) browserArgs.push(flag, value); - } - if (options.headed) browserArgs.push('--headed'); - - let visual; - try { - visual = await runBrowserCheck(browserArgs); - } catch (error) { - visual = { ok: false, error: error.message }; - } - const grafanaItems = visual.playwright?.grafana || []; - const authBlocked = grafanaItems.filter(item => item.authBlocked).map(item => item.label); - const visible = grafanaItems.filter(item => item.dashboardVisible).map(item => item.label); - const visualVerified = Boolean(visual.ok) && grafanaItems.length > 0 && visible.length === grafanaItems.length; - const visualCheck = check( - 'visual-grafana-rendered-dashboards', - visualVerified, - visible, - visualVerified ? [] : [ - visual.error || visual.playwright?.authRemediation?.reason || 'Grafana dashboards did not render in the browser profile', - grafanaItems.length === 0 ? 'No Grafana dashboards were rendered by the visual browser check.' : '', - ...authBlocked.map(label => `${label}: auth-blocked`) - ].filter(Boolean) - ); - - const checks = [...result.checks, visualCheck]; - const failed = checks.filter(item => !item.ok); - const recovery = visual.playwright?.authRemediation - ? [ - ...(visual.playwright.authRemediation.sign_in_once || []), - ...(visual.playwright.authRemediation.verify_after_sign_in || []) - ] - : visualAuditRecoveryCommands(reportPath); - - return { - ...result, - ok: failed.length === 0, - scope: options.live ? 'local-live-and-visual-product-contract' : 'local-and-visual-product-contract', - visual_grafana_verified: visualVerified, - summary: { - ...result.summary, - checks: checks.length, - passed: checks.length - failed.length, - failed: failed.length, - visual_dashboards: grafanaItems.length, - visual_dashboards_visible: visible.length - }, - checks, - visual, - next: visualVerified - ? result.next - : [ - ...recovery, - 'agentops product audit --live --last 2h --require-rows --require-visual --json' - ] - }; -} - -function renderProductAudit(result) { - const lines = [ - 'AgentOps product audit', - '', - `Result: ${result.ok ? 'pass' : 'needs work'}.`, - `Local checks: ${result.summary.passed}/${result.summary.checks} passed.`, - `Dashboards: ${result.summary.v2_dashboards}; links checked: ${result.summary.checked_links}.`, - `Live Azure verified: ${result.live_azure_verified ? 'yes' : 'not in this audit'}.`, - `Live Grafana verified: ${result.live_grafana_verified ? 'yes' : 'not in this audit'}.`, - `Visual Grafana verified: ${result.visual_grafana_verified ? 'yes' : result.summary.visual_dashboards ? 'no' : 'not in this audit'}.`, - '', - 'Checks:' - ]; - for (const item of result.checks) { - lines.push(`- ${item.ok ? 'PASS' : 'FAIL'} ${item.name}`); - if (!item.ok && item.missing.length) { - lines.push(` Missing: ${item.missing.slice(0, 5).join(', ')}${item.missing.length > 5 ? ', ...' : ''}`); - } - } - lines.push('', 'Next:'); - for (const command of result.next) lines.push(`- ${command}`); - return `${lines.join('\n')}\n`; -} - -async function productCommand(args = []) { - const [subcommand = 'audit'] = args; - if (subcommand !== 'audit') throw new Error('product supports: audit'); - const result = await productAuditWithVisual({ - live: hasFlag(args, '--live'), - requireRows: hasFlag(args, '--require-rows'), - requireVisual: hasFlag(args, '--require-visual'), - last: optionValue(args, '--last', '24h'), - reportPath: optionValue(args, '--report', path.join(repoRoot, '.agentops', 'e2e', 'latest', 'report.html')), - browserExecutable: optionValue(args, '--browser-executable', process.env.AGENTOPS_BROWSER_EXECUTABLE || ''), - browserUserDataDir: optionValue(args, '--browser-user-data-dir', process.env.AGENTOPS_BROWSER_USER_DATA_DIR || ''), - storageState: optionValue(args, '--storage-state', process.env.AGENTOPS_BROWSER_STORAGE_STATE || ''), - visualEvidencePath: optionValue(args, '--visual-evidence', ''), - headed: hasFlag(args, '--headed') || process.env.AGENTOPS_BROWSER_HEADED === '1' - }); - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(result, null, 2)}\n` : renderProductAudit(result)); - if (!result.ok) process.exitCode = 1; -} - -module.exports = { - productAudit, - productAuditWithVisual, - productCommand, - renderProductAudit, - validateVisualEvidence, - visualAuditRecoveryCommands -}; +module.exports = require('../lib/product-command'); diff --git a/agentops-cli/src/commands/recommend.js b/agentops-cli/src/commands/recommend.js index 1c59a56..e252bb9 100644 --- a/agentops-cli/src/commands/recommend.js +++ b/agentops-cli/src/commands/recommend.js @@ -1,1040 +1 @@ -const crypto = require('node:crypto'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const legacy = require('../legacy'); -const { hasFlag, optionValue } = require('../lib/args'); -const { latestByTime } = require('../lib/explain/v2-explain'); -const { AGENTOPS_SCHEMA_VERSION } = require('../lib/schema/agentops-attributes'); -const { validateRecommendationRow } = require('../lib/schema/recommendation-schema'); - -const severityRank = { - critical: 4, - high: 3, - medium: 2, - low: 1 -}; - -function readJsonl(filePath) { - if (!filePath) return []; - const text = fs.readFileSync(filePath, 'utf8'); - return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} - -function pickRun(runs, runId) { - if (runId && runId !== 'latest') return runs.find(row => row.RunId === runId) || null; - return latestByTime(runs); -} - -function firstPositional(args = []) { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg.startsWith('--')) { - if (!arg.includes('=') && index + 1 < args.length && !args[index + 1].startsWith('--')) index += 1; - continue; - } - return arg; - } - return 'latest'; -} - -function dashboardBaseUrl(links = legacy.openLinksSummary()) { - const home = links.v2_home_url || '/d/agentops-v2-home'; - return home.replace(/\/d\/agentops-v2-home.*$/, ''); -} - -function dashboardUrl(uid, vars = {}, links = legacy.openLinksSummary()) { - const base = `${dashboardBaseUrl(links)}/d/${uid}`; - const pairs = Object.entries(vars).filter(([, value]) => value !== undefined && value !== null && value !== ''); - if (pairs.length === 0) return base; - return `${base}?${pairs.map(([key, value]) => `var-${key}=${encodeURIComponent(value)}`).join('&')}`; -} - -function replayUrl(run, links) { - if (!run) return dashboardUrl('agentops-v2-runs-explorer', {}, links); - if (run.RunId) return dashboardUrl('agentops-v2-run-replay', { run_id: run.RunId }, links); - if (run.SessionId) return dashboardUrl('agentops-v2-run-replay', { session_id: run.SessionId }, links); - return dashboardUrl('agentops-v2-run-replay', {}, links); -} - -function topInsightForRun(insights, runId) { - return insights - .filter(row => row.RunId === runId) - .sort((left, right) => { - const bySeverity = (severityRank[right.Severity] || 0) - (severityRank[left.Severity] || 0); - if (bySeverity !== 0) return bySeverity; - return String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || '')); - })[0] || null; -} - -function matchingPatternInsight(insights, run = {}) { - const task = run.TaskType || ''; - const model = run.ModelActual || ''; - const repo = run.RepoHash || ''; - const agent = run.AgentName || 'agent'; - const privacy = run.PrivacyMode || 'strict'; - const outcome = run.OutcomeReason || (run.OutcomeStatus && run.OutcomeStatus !== 'success' ? 'failed' : ''); - const candidates = insights.filter(row => row.PatternKey || String(row.InsightType || '').startsWith('recurring-')); - return candidates - .filter(row => { - const key = String(row.PatternKey || ''); - return (task && key.includes(`|${task}|`)) - || (model && key.includes(`|${model}|`)) - || (repo && key.includes(`|${repo}|`)) - || (agent && key.includes(`|${agent}`)) - || (privacy && key.endsWith(`|${privacy}`)) - || (outcome && key.endsWith(`|${outcome}`)); - }) - .sort((left, right) => { - const byRuns = Number(right.PatternRuns || 0) - Number(left.PatternRuns || 0); - if (byRuns !== 0) return byRuns; - const bySeverity = (severityRank[right.Severity] || 0) - (severityRank[left.Severity] || 0); - if (bySeverity !== 0) return bySeverity; - return String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || '')); - })[0] || null; -} - -function linkedDashboardsForRecommendation(run, insight, links = legacy.openLinksSummary()) { - const dashboards = [{ title: 'Run Replay', url: replayUrl(run, links) }]; - if (insight?.ToolName || Number(run?.ToolFailureCount || 0) > 0 || Number(run?.ToolDeniedCount || 0) > 0) { - dashboards.push({ title: 'Tools & MCP Risk', url: dashboardUrl('agentops-v2-tools-mcp-risk', insight?.ToolName ? { tool_name: insight.ToolName } : {}, links) }); - } - if (Number(run?.EstimatedCostUsd || 0) > 0 || run?.ModelActual) { - dashboards.push({ title: 'Models, Cost & Tokens', url: dashboardUrl('agentops-v2-models-cost-tokens', run?.ModelActual ? { model: run.ModelActual } : {}, links) }); - } - if (Number(run?.ToolDeniedCount || 0) > 0 || insight?.InsightType === 'privacy-drop') { - dashboards.push({ title: 'Safety, Privacy & Policy', url: dashboardUrl('agentops-v2-safety-privacy-policy', {}, links) }); - } - if (run?.PrOpened || run?.CiStatus) { - dashboards.push({ title: 'Code Outcomes', url: dashboardUrl('agentops-v2-code-outcomes', run?.RepoHash ? { repo_hash: run.RepoHash } : {}, links) }); - } - dashboards.push({ - title: insight?.PatternKey ? 'Insights Pattern' : 'Insights & Regressions', - url: dashboardUrl('agentops-v2-insights-regressions', insight?.PatternKey ? { pattern_key: insight.PatternKey } : run?.RunId ? { run_id: run.RunId } : {}, links) - }); - return dashboards; -} - -function fileRefsForRecommendation(action, insight = {}, run = {}) { - insight = insight || {}; - run = run || {}; - const refs = new Set(); - if (action === 'run_validation') { - refs.add('tests_or_benchmark_suite'); - refs.add('agent_skill_validation_step'); - } - if (action === 'investigate_tool') { - refs.add('tool_policy_or_mcp_config'); - } - if (action === 'check_collector') { - refs.add('collector_config'); - } - if (action === 'review_policy') { - refs.add('agentops_policy_config'); - refs.add('mcp_server_config'); - } - if (action === 'reduce_context_or_cost' || action === 'reduce_context') { - refs.add('agent_instruction_or_skill_context_rules'); - } - if (action === 'fix_ci') { - refs.add('ci_workflow_or_test_command'); - } - if (action === 'compare_regression' || insight.ConfigHash || run.ConfigHash) { - refs.add('agent_instruction_config'); - refs.add('skill_definition'); - } - if (action === 'triage_recurring_pattern') { - refs.add('recurring_pattern_owner'); - } - return [...refs]; -} - -function stringValue(value) { - if (value === undefined || value === null) return ''; - if (typeof value === 'string') return value; - return String(value); -} - -function propertyValue(row = {}, key) { - const props = row.Properties && typeof row.Properties === 'object' - ? row.Properties - : {}; - return row[key] - ?? row[`agentops.custom.${key}`] - ?? row[`agentops.${key}`] - ?? props[key] - ?? props[`agentops.custom.${key}`] - ?? props[`agentops.${key}`] - ?? ''; -} - -function parseDetailsValue(details, key) { - const text = stringValue(details); - if (!text) return ''; - const pattern = new RegExp(`${key}[=: ]+([A-Za-z0-9_.@/-]+)`); - return pattern.exec(text)?.[1] || ''; -} - -function normalizeChangeAnnotation(row = {}) { - const props = row.Properties && typeof row.Properties === 'object' ? row.Properties : {}; - const eventName = stringValue(row.EventName || row.Event || row.event || props['agentops.event.name'] || props['event.name']); - const eventType = stringValue(row.EventType || row.Type || row.type); - const details = stringValue(row.Details || row.ResultCode || row.details || ''); - const annotationType = stringValue(propertyValue(row, 'annotation_type') || row.AnnotationType || parseDetailsValue(details, 'annotation_type')); - const isConfigAnnotation = eventName === 'agentops.config.changed' - || annotationType === 'config_change' - || eventType === 'annotation' - || details.includes('config_change'); - if (!isConfigAnnotation) return null; - - const component = stringValue( - row.ChangeComponent - || propertyValue(row, 'component') - || propertyValue(row, 'entity.type') - || row.EntityType - || parseDetailsValue(details, 'component') - ); - const target = stringValue( - row.ChangeTarget - || propertyValue(row, 'target') - || propertyValue(row, 'entity.id_hash') - || row.EntityIdHash - || parseDetailsValue(details, 'target') - ); - - return { - time_generated: stringValue(row.TimeGenerated || row.time || row.timestamp), - component, - target, - change_type: stringValue(row.ChangeType || propertyValue(row, 'change_type') || parseDetailsValue(details, 'change_type') || 'updated'), - change_id: stringValue(row.ChangeId || propertyValue(row, 'change_id') || parseDetailsValue(details, 'change_id')), - version: stringValue(row.Version || propertyValue(row, 'version') || parseDetailsValue(details, 'version')), - run_id: stringValue(row.RunId || propertyValue(row, 'run.id')), - session_id: stringValue(row.SessionId || propertyValue(row, 'session.id') || props['gen_ai.conversation.id']), - trace_id: stringValue(row.TraceId || propertyValue(row, 'trace.id')), - event_name: eventName || 'agentops.config.changed' - }; -} - -function annotationMatchesRun(annotation, run = {}) { - if (!annotation) return false; - if (annotation.run_id && run.RunId && annotation.run_id === run.RunId) return true; - if (annotation.session_id && run.SessionId && annotation.session_id === run.SessionId) return true; - if (annotation.trace_id && run.TraceId && annotation.trace_id === run.TraceId) return true; - return false; -} - -function changeAnnotationsForRun(events = [], run = {}) { - return events - .map(normalizeChangeAnnotation) - .filter(annotation => annotationMatchesRun(annotation, run)) - .slice(0, 10); -} - -function changeRef(annotation = {}) { - return [annotation.component, annotation.target].filter(Boolean).join(':'); -} - -function benchmarkArtifactFileRefs(report = null) { - const rows = []; - for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { - const diff = task.artifactDiff || {}; - for (const change of ['added', 'modified', 'deleted']) { - const files = Array.isArray(diff[change]) ? diff[change] : []; - for (const file of files) { - const artifactPath = String(file || '').replaceAll('\\', '/').trim(); - if (!artifactPath) continue; - rows.push({ - task_id: task.taskId || '', - change, - path: artifactPath - }); - } - } - } - return rows.slice(0, 200); -} - -function benchmarkArtifactContentDiffRefs(report = null) { - const rows = []; - for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { - const explicitDiffs = Array.isArray(task.artifactContentDiffs) ? task.artifactContentDiffs : []; - for (const diff of explicitDiffs) { - if (!diff || typeof diff !== 'object') continue; - const artifactPath = String(diff.path || diff.file || '').replaceAll('\\', '/').trim(); - if (!artifactPath) continue; - rows.push({ - task_id: task.taskId || '', - change: diff.change || diff.status || '', - path: artifactPath, - diff_preview: String(diff.diff || diff.preview || '').split(/\r?\n/).slice(0, 80).join('\n').slice(0, 12000) - }); - } - - const files = Array.isArray(task.artifactReview?.files) ? task.artifactReview.files : []; - for (const file of files) { - const artifactPath = String(file.path || file.file || '').replaceAll('\\', '/').trim(); - const diffLines = Array.isArray(file.diff) ? file.diff : []; - if (!artifactPath || diffLines.length === 0) continue; - rows.push({ - task_id: task.taskId || '', - change: file.change || file.status || '', - path: artifactPath, - diff_preview: diffLines.map(line => String(line).slice(0, 300)).slice(0, 80).join('\n') - }); - } - } - return rows.filter(row => row.diff_preview).slice(0, 50); -} - -function benchmarkHiddenCheckPackRefs(report = null) { - const rows = []; - for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { - const packs = Array.isArray(task.hiddenCheckPacks) ? task.hiddenCheckPacks : []; - for (const pack of packs) { - if (!pack || typeof pack !== 'object') continue; - rows.push({ - task_id: task.taskId || '', - id: pack.id || '', - title: pack.title || pack.id || '', - command_count: pack.commandCount ?? null - }); - } - } - return rows.slice(0, 200); -} - -function benchmarkPolicyRefs(report = null) { - const rows = []; - for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { - const violations = Array.isArray(task.toolPolicyViolations) ? task.toolPolicyViolations : []; - rows.push({ - task_id: task.taskId || '', - permission_profile: task.permissionProfile || '', - os_sandbox_mode: task.osSandbox?.mode || '', - os_sandbox_active: task.osSandboxRuntime?.active === undefined ? null : Boolean(task.osSandboxRuntime.active), - policy_blocks: task.policyBlocks ?? null, - blocked_risks: Array.isArray(task.toolPolicy?.blockedRisks) ? task.toolPolicy.blockedRisks : [], - violation_count: violations.length, - violation_risks: [...new Set(violations.map(violation => violation?.risk).filter(Boolean))].sort() - }); - } - return rows.slice(0, 200); -} - -function benchmarkSemanticCheckRefs(report = null) { - const rows = []; - for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { - const checks = Array.isArray(task.semanticChecks) ? task.semanticChecks : []; - for (const check of checks) { - if (!check || typeof check !== 'object') continue; - rows.push({ - task_id: task.taskId || '', - id: check.id || '', - adapter: check.adapter || '', - file: check.file || '', - ok: check.ok === undefined ? null : Boolean(check.ok), - score: check.score ?? null, - detail: check.detail || '' - }); - } - } - return rows.slice(0, 200); -} - -function benchmarkEvidenceFromReport(report = null) { - if (!report || typeof report !== 'object') return null; - const artifactDiff = report.artifactDiff || {}; - const approval = report.promotion?.approval || report.promotionApproval || {}; - const approvalSource = approval.source - ? String(approval.source).split(/[\\/]/).filter(Boolean).pop() || String(approval.source) - : ''; - return { - run_id: report.runId || '', - decision: report.ok === false ? 'missing' : (report.promotion?.decision || report.recommendation?.action || ''), - pass_rate_pct: report.passRatePct ?? null, - average_score: report.averageScore ?? null, - safety_violation_count: report.safetyViolationCount ?? null, - tool_failures: report.toolFailures ?? null, - total_tokens: report.totalTokens ?? null, - cost: report.cost ?? null, - artifact_diff: { - added: artifactDiff.added ?? null, - modified: artifactDiff.modified ?? null, - deleted: artifactDiff.deleted ?? null, - total_changed: artifactDiff.totalChanged ?? null - }, - artifact_files: benchmarkArtifactFileRefs(report), - artifact_content_diffs: benchmarkArtifactContentDiffRefs(report), - hidden_checks: { - passed: report.hiddenChecks?.passed ?? null, - failed: report.hiddenChecks?.failed ?? null, - packs: benchmarkHiddenCheckPackRefs(report) - }, - policy: { - blocks: report.policyBlocks ?? null, - permission_profiles: report.permissionProfiles || {}, - tasks: benchmarkPolicyRefs(report) - }, - semantic_checks: { - count: report.semanticChecks?.count ?? null, - average_score: report.semanticChecks?.averageScore ?? null, - checks: benchmarkSemanticCheckRefs(report) - }, - approval: { - status: approval.status || '', - approved_count: approval.status === 'approved' ? (approval.approvedBy || []).length : 0, - required_count: report.promotion?.gates?.requiredApprovals ?? report.promotionGates?.requiredApprovals ?? null, - approved_at: approval.approvedAt || '', - ticket: approval.ticket || '', - source: approvalSource - }, - validation: report.promotion?.validation || report.message || '', - rollback: report.promotion?.rollback || (report.ok === false ? 'run or attach benchmark evidence before promotion' : '') - }; -} - -function metricMovementForRecommendation(run = {}, insight = {}, evaluation = {}, benchmark = null) { - const before = telemetrySnapshot(run, evaluation); - const expected = []; - if (insight?.BaselineValue !== undefined || insight?.CurrentValue !== undefined) { - expected.push({ - metric: insight.InsightType || 'insight-value', - baseline_value: insight.BaselineValue ?? null, - current_value: insight.CurrentValue ?? null, - expected_direction: insight.InsightType === 'eval-regression' ? 'increase' : 'decrease', - source: 'insight' - }); - } - if (before.eval_overall !== null) { - expected.push({ - metric: 'EvalOverall', - baseline_value: before.eval_overall, - current_value: before.eval_overall, - expected_direction: 'increase', - source: 'eval' - }); - } - if (benchmark?.average_score !== undefined && benchmark?.average_score !== null) { - expected.push({ - metric: 'BenchmarkAverageScore', - baseline_value: benchmark.average_score, - current_value: benchmark.average_score, - expected_direction: 'increase', - source: 'benchmark' - }); - } - - return { - expected: { - status: expected.length ? 'ready' : 'needs-baseline', - metrics: expected - }, - before, - after: {}, - observed: { - status: 'awaiting-after-run', - compare_command: `agentops recommend compare --recommendation-id --after-runs --after-evals ` - } - }; -} - -function telemetrySnapshot(run = {}, evaluation = {}) { - return { - run_id: run.RunId || '', - eval_overall: evaluation?.EvalOverall ?? run.EvalOverall ?? null, - eval_bucket: evaluation?.EvalBucket || '', - estimated_cost_usd: run.EstimatedCostUsd ?? null, - input_tokens: run.InputTokens ?? null, - output_tokens: run.OutputTokens ?? null, - tool_failure_count: run.ToolFailureCount ?? null, - tool_denied_count: run.ToolDeniedCount ?? null, - risk_score: run.RiskScore ?? null, - outcome_status: run.OutcomeStatus || '' - }; -} - -function actionFromInsight(insight, run = {}) { - if (!insight) { - if (run.OutcomeStatus && run.OutcomeStatus !== 'success') return 'investigate_failed_run'; - if (Number(run.FilesEditedCount || 0) > 0 && !run.TestsRan) return 'run_validation'; - if (Number(run.ContextWindowPct || 0) >= 90 || Number(run.TokensRemoved || 0) > 0) return 'reduce_context'; - return 'keep_observing'; - } - - const type = insight.InsightType || ''; - if (type.startsWith('recurring-')) return 'triage_recurring_pattern'; - if (type.includes('test')) return 'run_validation'; - if (type.includes('tool')) return 'investigate_tool'; - if (type.includes('collector')) return 'check_collector'; - if (type.includes('policy') || type.includes('privacy')) return 'review_policy'; - if (type.includes('cost') || type.includes('context')) return 'reduce_context_or_cost'; - if (type.includes('ci')) return 'fix_ci'; - if (type.includes('eval') || type.includes('instruction') || type.includes('config')) return 'compare_regression'; - return 'investigate'; -} - -function buildRecommendation({ run, insight, evaluation, links, benchmarkReport, changeAnnotations = [] }) { - if (!run) { - return { - ok: false, - action: 'collect_data', - severity: 'medium', - observed_pattern: 'No AgentOps V2 run rows were available.', - next_action: 'Run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --with-github-outcomes --json` or collect a new Copilot run through the local collector.', - evidence: { dashboards: [{ title: 'AgentOps Home', url: dashboardUrl('agentops-v2-home', {}, links) }] }, - validation: ['Run `agentops dashboard kql-check --last 24h --json` after data is ingested.'], - rollback_condition: 'No rollback needed; this recommendation made no changes.' - }; - } - - const action = actionFromInsight(insight, run); - const healthy = action === 'keep_observing'; - const observedPattern = insight?.Summary - || (healthy ? 'No high-severity insight was found for this run.' : `Run status is ${run.OutcomeStatus || 'unknown'}.`); - const nextAction = insight?.SuggestedNextStep - || (healthy - ? 'Keep strict privacy mode enabled and compare the next similar run for cost, latency, eval, and outcome drift.' - : 'Open Run Replay and inspect the failed span, blocked tool, eval score, and GitHub outcome.'); - - const benchmark = benchmarkEvidenceFromReport(benchmarkReport); - const metricMovement = metricMovementForRecommendation(run, insight, evaluation, benchmark); - const annotationRefs = changeAnnotations.map(changeRef).filter(Boolean); - const validation = [ - `agentops explain ${run.RunId} --runs --evals --insights `, - 'agentops dashboard kql-check --last 24h --json' - ]; - if (changeAnnotations.length) validation.push(`agentops annotation config-change --component --target --run-id ${run.RunId}`); - if (benchmark?.run_id) validation.push(`agentops experimental benchmark report ${benchmark.run_id}`); - - return { - ok: true, - action, - severity: insight?.Severity || (healthy ? 'low' : 'medium'), - run_id: run.RunId, - session_id: run.SessionId || '', - trace_id: run.TraceId || '', - observed_pattern: observedPattern, - next_action: nextAction, - evidence: { - dashboards: linkedDashboardsForRecommendation(run, insight, links), - eval: evaluation ? { - overall: evaluation.EvalOverall, - bucket: evaluation.EvalBucket || '', - reason: evaluation.EvalReason || '' - } : null, - pattern: insight?.PatternKey ? { - id: insight.PatternId || '', - key: insight.PatternKey, - runs: insight.PatternRuns ?? null, - dimension: insight.PatternDimension || '' - } : null, - benchmark, - metric_movement: metricMovement, - change_annotations: changeAnnotations, - file_refs: [...new Set([...fileRefsForRecommendation(action, insight, run), ...annotationRefs])] - }, - validation, - rollback_condition: 'Rollback the agent, skill, MCP, model, or instruction change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.' - }; -} - -function recommendFromFiles(options = {}) { - const runs = readJsonl(options.runsFile); - const evals = readJsonl(options.evalsFile); - const insights = readJsonl(options.insightsFile); - const events = readJsonl(options.eventsFile); - const benchmarkReport = options.benchmarkReportFile - ? JSON.parse(fs.readFileSync(options.benchmarkReportFile, 'utf8')) - : options.benchmarkRunId - ? legacy.benchmarkReport(options.benchmarkRunId) - : null; - const run = pickRun(runs, options.runId); - const insight = run ? topInsightForRun(insights, run.RunId) || matchingPatternInsight(insights, run) : null; - const evaluation = run ? evals.find(row => row.RunId === run.RunId) || null : null; - const changeAnnotations = run ? changeAnnotationsForRun(events, run) : []; - return buildRecommendation({ run, insight, evaluation, links: options.links, benchmarkReport, changeAnnotations }); -} - -function stableId(value, prefix = 'rec') { - return `${prefix}_${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16)}`; -} - -function recommendationRow(recommendation, timeGenerated = new Date().toISOString()) { - const dashboards = recommendation.evidence?.dashboards || []; - const pattern = recommendation.evidence?.pattern || {}; - const evaluation = recommendation.evidence?.eval || {}; - const benchmark = recommendation.evidence?.benchmark || {}; - const metricMovement = recommendation.evidence?.metric_movement || {}; - const changeAnnotations = recommendation.evidence?.change_annotations || []; - return { - TimeGenerated: timeGenerated, - SchemaVersion: AGENTOPS_SCHEMA_VERSION, - RecommendationId: stableId([ - recommendation.run_id || 'none', - recommendation.action || 'none', - recommendation.severity || 'none', - recommendation.observed_pattern || '', - recommendation.next_action || '', - pattern.key || '' - ].join('|')), - RunId: recommendation.run_id || '', - SessionId: recommendation.session_id || '', - TraceId: recommendation.trace_id || '', - Action: recommendation.action || '', - Severity: recommendation.severity || '', - ObservedPattern: recommendation.observed_pattern || '', - NextAction: recommendation.next_action || '', - PatternId: pattern.id || '', - PatternKey: pattern.key || '', - PatternRuns: pattern.runs ?? null, - PatternDimension: pattern.dimension || '', - EvalOverall: evaluation.overall ?? null, - EvalBucket: evaluation.bucket || '', - BenchmarkRunId: benchmark.run_id || '', - BenchmarkDecision: benchmark.decision || '', - BenchmarkPassRatePct: benchmark.pass_rate_pct ?? null, - BenchmarkAverageScore: benchmark.average_score ?? null, - BenchmarkSafetyViolationCount: benchmark.safety_violation_count ?? null, - BenchmarkToolFailures: benchmark.tool_failures ?? null, - BenchmarkArtifactAdded: benchmark.artifact_diff?.added ?? null, - BenchmarkArtifactModified: benchmark.artifact_diff?.modified ?? null, - BenchmarkArtifactDeleted: benchmark.artifact_diff?.deleted ?? null, - BenchmarkArtifactTotalChanged: benchmark.artifact_diff?.total_changed ?? null, - BenchmarkArtifactFiles: benchmark.artifact_files || [], - BenchmarkArtifactContentDiffs: benchmark.artifact_content_diffs || [], - BenchmarkHiddenChecksPassed: benchmark.hidden_checks?.passed ?? null, - BenchmarkHiddenChecksFailed: benchmark.hidden_checks?.failed ?? null, - BenchmarkHiddenCheckPacks: benchmark.hidden_checks?.packs || [], - BenchmarkPolicyBlocks: benchmark.policy?.blocks ?? null, - BenchmarkPermissionProfiles: benchmark.policy?.permission_profiles || {}, - BenchmarkPolicyTasks: benchmark.policy?.tasks || [], - BenchmarkSemanticCheckCount: benchmark.semantic_checks?.count ?? null, - BenchmarkSemanticAverageScore: benchmark.semantic_checks?.average_score ?? null, - BenchmarkSemanticChecks: benchmark.semantic_checks?.checks || [], - BenchmarkApprovalStatus: benchmark.approval?.status || '', - BenchmarkApprovalCount: benchmark.approval?.approved_count ?? null, - BenchmarkRequiredApprovals: benchmark.approval?.required_count ?? null, - BenchmarkApprovalApprovedAt: benchmark.approval?.approved_at || '', - BenchmarkApprovalTicket: benchmark.approval?.ticket || '', - BenchmarkApprovalSource: benchmark.approval?.source || '', - ExpectedMetricMovement: metricMovement.expected || {}, - BeforeTelemetry: metricMovement.before || {}, - AfterTelemetry: metricMovement.after || {}, - ObservedMetricMovement: metricMovement.observed || {}, - ChangeAnnotations: changeAnnotations, - ChangeTargetRefs: recommendation.evidence?.file_refs || [], - DashboardTitles: dashboards.map(dashboard => dashboard.title), - DashboardCount: dashboards.length, - Validation: recommendation.validation || [], - RollbackCondition: recommendation.rollback_condition || '' - }; -} - -function writeRecommendation(recommendation, outDir) { - const absoluteDir = path.resolve(outDir); - fs.mkdirSync(absoluteDir, { recursive: true }); - const row = recommendationRow(recommendation); - const validation = validateRecommendationRow(row); - if (!validation.ok) throw new Error(`recommendation row failed schema validation: ${validation.errors.join('; ')}`); - const file = path.join(absoluteDir, 'AgentOpsRecommendations_CL.jsonl'); - fs.appendFileSync(file, `${JSON.stringify(row)}\n`); - const manifest = path.join(absoluteDir, 'recommendation-manifest.json'); - fs.writeFileSync(manifest, `${JSON.stringify({ - generated_at: row.TimeGenerated, - table: 'AgentOpsRecommendations_CL', - file, - rows_written: 1, - privacy: 'metadata-only; no prompts, responses, tool arguments, tool results, source code, or file contents' - }, null, 2)}\n`); - return { out_dir: absoluteDir, file, manifest, row }; -} - -function defaultRecommendationStorePath() { - return process.env.AGENTOPS_RECOMMENDATIONS_PATH || path.join(os.homedir(), '.agentops', 'recommendations.json'); -} - -function readRecommendationStore(filePath = defaultRecommendationStorePath()) { - if (!fs.existsSync(filePath)) return { recommendations: [] }; - const payload = JSON.parse(fs.readFileSync(filePath, 'utf8')); - return { - recommendations: Array.isArray(payload.recommendations) ? payload.recommendations : [] - }; -} - -function writeRecommendationStore(payload, filePath = defaultRecommendationStorePath()) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`); -} - -function saveRecommendation(recommendation, filePath = defaultRecommendationStorePath(), timeGenerated = new Date().toISOString()) { - const row = recommendationRow(recommendation, timeGenerated); - const validation = validateRecommendationRow(row); - if (!validation.ok) throw new Error(`recommendation row failed schema validation: ${validation.errors.join('; ')}`); - const payload = readRecommendationStore(filePath); - const next = payload.recommendations - .filter(item => item.RecommendationId !== row.RecommendationId) - .concat(row) - .sort((left, right) => String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || ''))); - writeRecommendationStore({ recommendations: next }, filePath); - return { path: filePath, saved: row, count: next.length }; -} - -function exportRecommendationStore({ storePath = defaultRecommendationStorePath(), outDir } = {}) { - const payload = readRecommendationStore(storePath); - const absoluteDir = path.resolve(outDir || path.join(path.dirname(storePath), 'recommendations-export')); - fs.mkdirSync(absoluteDir, { recursive: true }); - const rows = payload.recommendations; - const file = path.join(absoluteDir, 'AgentOpsRecommendations_CL.jsonl'); - fs.writeFileSync(file, `${rows.map(row => JSON.stringify(row)).join('\n')}${rows.length ? '\n' : ''}`); - const manifest = path.join(absoluteDir, 'recommendations-manifest.json'); - fs.writeFileSync(manifest, `${JSON.stringify({ - generated_at: new Date().toISOString(), - table: 'AgentOpsRecommendations_CL', - file, - rows_written: rows.length, - privacy: 'metadata-only; no prompts, responses, tool arguments, tool results, source code, or file contents' - }, null, 2)}\n`); - return { out_dir: absoluteDir, file, manifest, rows_written: rows.length, rows }; -} - -function metricValue(snapshot = {}, metric) { - const key = { - EvalOverall: 'eval_overall', - EstimatedCostUsd: 'estimated_cost_usd', - ToolFailureCount: 'tool_failure_count', - ToolDeniedCount: 'tool_denied_count', - RiskScore: 'risk_score' - }[metric] || metric; - return snapshot[key]; -} - -function movementResults(row = {}, after = {}) { - const before = row.BeforeTelemetry || {}; - const expected = Array.isArray(row.ExpectedMetricMovement?.metrics) ? row.ExpectedMetricMovement.metrics : []; - return expected - .map(metric => { - const name = metric.metric; - const beforeValue = metricValue(before, name) ?? metric.current_value ?? metric.baseline_value ?? null; - const afterValue = metricValue(after, name); - if (typeof beforeValue !== 'number' || typeof afterValue !== 'number') return null; - const direction = metric.expected_direction || 'decrease'; - const delta = Number((afterValue - beforeValue).toFixed(6)); - const passed = direction === 'increase' ? delta >= 0 : delta <= 0; - return { - metric: name, - expected_direction: direction, - before_value: beforeValue, - after_value: afterValue, - delta, - passed - }; - }) - .filter(Boolean); -} - -function observedMovementStatus(results = []) { - if (results.length === 0) return 'no-comparable-metrics'; - if (results.every(result => result.passed)) return 'improved'; - if (results.every(result => !result.passed)) return 'regressed'; - return 'mixed'; -} - -function reviewDecision(row = {}) { - return stringValue(row.OperatorReview?.decision || row.OperatorReview?.status).toLowerCase(); -} - -function safeToken(value, fallback = 'recommendation') { - return stringValue(value || fallback) - .trim() - .replace(/[^A-Za-z0-9_.-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 80) || fallback; -} - -function recommendationActionPlanForRow(row = {}, options = {}) { - const validation = validateRecommendationRow(row); - if (!validation.ok) throw new Error(`recommendation row failed schema validation: ${validation.errors.join('; ')}`); - - const decision = reviewDecision(row); - const movementStatus = stringValue(row.ObservedMetricMovement?.status); - const benchmarkDecision = stringValue(row.BenchmarkDecision); - const approved = decision === 'approve' || decision === 'approved'; - const blockedReasons = [ - approved ? '' : 'operator review approval is required', - movementStatus === 'regressed' ? 'observed metric movement regressed' : '', - benchmarkDecision === 'reject' ? 'benchmark decision rejected the recommendation' : '', - Array.isArray(row.Validation) && row.Validation.length > 0 ? '' : 'validation steps are required', - row.RollbackCondition ? '' : 'rollback condition is required' - ].filter(Boolean); - const hypothesis = safeToken(options.hypothesis || row.RecommendationId || row.RunId, 'recommendation'); - const benchmarkSuite = safeToken(options.benchmarkSuite || 'starter', 'starter'); - const branch = `agentops/${hypothesis}`; - const targetRefs = Array.isArray(row.ChangeTargetRefs) ? row.ChangeTargetRefs : []; - const patchPrompt = [ - `Implement approved AgentOps recommendation ${row.RecommendationId}.`, - `Action: ${row.Action}.`, - `Observed pattern: ${row.ObservedPattern}.`, - `Next action: ${row.NextAction}.`, - targetRefs.length ? `Change targets: ${targetRefs.join(', ')}.` : 'Infer the smallest safe target from the recommendation metadata.', - row.BenchmarkRunId ? `Benchmark evidence: ${row.BenchmarkRunId} (${benchmarkDecision || 'unknown'}).` : '', - movementStatus ? `Observed metric movement: ${movementStatus}.` : '', - 'Keep prompts, responses, tool arguments, tool results, source code, file contents, request bodies, response bodies, and secrets out of telemetry artifacts.', - `Validation: ${(row.Validation || []).join(' | ') || 'run the benchmark/report commands in this plan'}.`, - `Rollback: ${row.RollbackCondition}.` - ].filter(Boolean).join('\n'); - - return { - schema_version: 'agentops.recommendation-action-plan.v1', - mode: 'metadata-only-recommendation-action-plan', - status: blockedReasons.length ? 'needs-review' : 'ready', - recommendation_id: row.RecommendationId || null, - run_id: row.RunId || null, - operator_review: row.OperatorReview || {}, - blocked_reasons: blockedReasons, - guardrails: [ - 'Create a branch before editing files.', - 'Make only the minimal patch described by the approved recommendation metadata.', - 'Run benchmark and recommendation comparison before promoting the change.', - 'Reject or rollback if validation fails, metric movement regresses, cost rises unexpectedly, or privacy signals appear.' - ], - commands: { - create_branch: `git checkout -b ${branch}`, - patch_prompt: patchPrompt, - benchmark_dry_run: `agentops benchmark run ${benchmarkSuite} --variant ${hypothesis} --repeat 1 --hypothesis ${hypothesis} --dry-run`, - benchmark_run: `agentops benchmark run ${benchmarkSuite} --variant ${hypothesis} --repeat 1 --hypothesis ${hypothesis}`, - benchmark_report: 'agentops benchmark report ', - compare_after_run: `agentops recommend compare --recommendation-id ${row.RecommendationId || ''} --after-runs --after-evals ` - }, - evidence: { - change_target_refs: targetRefs, - validation: row.Validation || [], - rollback_condition: row.RollbackCondition || '', - expected_metric_movement: row.ExpectedMetricMovement || {}, - observed_metric_movement: row.ObservedMetricMovement || {}, - before_telemetry: row.BeforeTelemetry || {}, - after_telemetry: row.AfterTelemetry || {} - }, - next: blockedReasons.length - ? ['Resolve blocked reasons, then regenerate this action plan.'] - : [ - 'Create the branch.', - 'Apply the minimal patch using the patch prompt.', - 'Run the benchmark dry-run, benchmark run, benchmark report, and recommendation compare commands.', - 'Promote only if benchmark and after-run movement pass.' - ] - }; -} - -function recommendationActionPlan({ - storePath = defaultRecommendationStorePath(), - recommendationId, - benchmarkSuite, - hypothesis -} = {}) { - if (!recommendationId) throw new Error('recommend action-plan requires --recommendation-id '); - const payload = readRecommendationStore(storePath); - const row = payload.recommendations.find(item => item.RecommendationId === recommendationId); - if (!row) throw new Error(`recommendation not found: ${recommendationId}`); - return { - path: storePath, - action_plan: recommendationActionPlanForRow(row, { benchmarkSuite, hypothesis }) - }; -} - -function compareRecommendationAfterRun({ - storePath = defaultRecommendationStorePath(), - recommendationId, - afterRunsFile, - afterEvalsFile, - afterRunId, - comparedAt = new Date().toISOString() -} = {}) { - if (!recommendationId) throw new Error('recommend compare requires --recommendation-id '); - if (!afterRunsFile) throw new Error('recommend compare requires --after-runs '); - - const payload = readRecommendationStore(storePath); - const row = payload.recommendations.find(item => item.RecommendationId === recommendationId); - if (!row) throw new Error(`recommendation not found: ${recommendationId}`); - - const afterRuns = readJsonl(afterRunsFile); - const afterRun = afterRunId - ? afterRuns.find(item => item.RunId === afterRunId) - : latestByTime(afterRuns); - if (!afterRun) throw new Error('no after-run rows were available'); - - const afterEval = readJsonl(afterEvalsFile).find(item => item.RunId === afterRun.RunId) || null; - const after = telemetrySnapshot(afterRun, afterEval); - const results = movementResults(row, after); - const updated = { - ...row, - AfterTelemetry: after, - ObservedMetricMovement: { - status: observedMovementStatus(results), - compared_at: comparedAt, - after_run_id: after.RunId || after.run_id || afterRun.RunId || '', - results - } - }; - const validation = validateRecommendationRow(updated); - if (!validation.ok) throw new Error(`updated recommendation row failed schema validation: ${validation.errors.join('; ')}`); - - const next = payload.recommendations - .map(item => item.RecommendationId === recommendationId ? updated : item) - .sort((left, right) => String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || ''))); - writeRecommendationStore({ recommendations: next }, storePath); - return { path: storePath, updated, after_run: afterRun.RunId || '', status: updated.ObservedMetricMovement.status }; -} - -function recommendationStoreCommand(args = []) { - const subcommand = args[0]; - const storePath = optionValue(args, '--store') || defaultRecommendationStorePath(); - if (subcommand === 'list') { - const payload = readRecommendationStore(storePath); - return { - path: storePath, - recommendations: payload.recommendations.map(row => ({ - RecommendationId: row.RecommendationId, - TimeGenerated: row.TimeGenerated, - Severity: row.Severity, - Action: row.Action, - RunId: row.RunId, - NextAction: row.NextAction, - ObservedMetricMovementStatus: row.ObservedMetricMovement?.status || '', - ChangeTargetRefs: row.ChangeTargetRefs || [] - })) - }; - } - if (subcommand === 'export') { - return { - path: storePath, - export: exportRecommendationStore({ storePath, outDir: optionValue(args, '--out') }) - }; - } - if (subcommand === 'compare') { - return compareRecommendationAfterRun({ - storePath, - recommendationId: optionValue(args, '--recommendation-id'), - afterRunsFile: optionValue(args, '--after-runs'), - afterEvalsFile: optionValue(args, '--after-evals'), - afterRunId: optionValue(args, '--after-run-id') - }); - } - if (subcommand === 'action-plan') { - return recommendationActionPlan({ - storePath, - recommendationId: optionValue(args, '--recommendation-id'), - benchmarkSuite: optionValue(args, '--benchmark-suite'), - hypothesis: optionValue(args, '--hypothesis') - }); - } - throw new Error('recommend requires latest|, list, export, compare, or action-plan'); -} - -function renderRecommendationV2(recommendation) { - const lines = ['AgentOps recommendation', '']; - lines.push(`Action: ${recommendation.action}`); - lines.push(`Severity: ${recommendation.severity}`); - if (recommendation.run_id) lines.push(`Run: ${recommendation.run_id}`); - lines.push(`Observed pattern: ${recommendation.observed_pattern}`); - lines.push(`Next action: ${recommendation.next_action}`); - if (recommendation.evidence?.eval) { - const evaluation = recommendation.evidence.eval; - lines.push(`Eval: ${evaluation.overall} (${evaluation.bucket || 'unknown'})${evaluation.reason ? ` - ${evaluation.reason}` : ''}`); - } - if (recommendation.evidence?.pattern) { - const pattern = recommendation.evidence.pattern; - lines.push(`Pattern: ${pattern.key} (${pattern.runs ?? 'unknown'} run(s), ${pattern.dimension || 'unknown'})`); - } - if (recommendation.evidence?.benchmark) { - const benchmark = recommendation.evidence.benchmark; - lines.push(`Benchmark: ${benchmark.run_id || 'unknown'} (${benchmark.decision || 'unknown'}, score ${benchmark.average_score ?? 'unknown'}, pass ${benchmark.pass_rate_pct ?? 'unknown'}%)`); - } - if (recommendation.evidence?.change_annotations?.length) { - lines.push('Config changes:'); - for (const annotation of recommendation.evidence.change_annotations) { - lines.push(`- ${annotation.component || 'config'} ${annotation.target || 'unknown'} (${annotation.change_type || 'updated'}${annotation.version ? `, ${annotation.version}` : ''})`); - } - } - if (recommendation.evidence?.file_refs?.length) lines.push(`Change targets: ${recommendation.evidence.file_refs.join(', ')}`); - if (recommendation.evidence?.dashboards?.length) { - lines.push('Dashboards:'); - for (const dashboard of recommendation.evidence.dashboards) lines.push(`- ${dashboard.title}: ${dashboard.url}`); - } - lines.push('Validation:'); - for (const item of recommendation.validation || []) lines.push(`- ${item}`); - lines.push(`Rollback condition: ${recommendation.rollback_condition}`); - return `${lines.join('\n')}\n`; -} - -function recommendCommand(args = []) { - if (args[0] === 'list' || args[0] === 'export' || args[0] === 'compare' || args[0] === 'action-plan') { - process.stdout.write(`${JSON.stringify(recommendationStoreCommand(args), null, 2)}\n`); - return; - } - - const runId = firstPositional(args); - const runsFile = optionValue(args, '--runs'); - if (!runsFile) throw new Error('recommend requires --runs for V2 recommendations'); - - const recommendation = recommendFromFiles({ - runId, - runsFile, - evalsFile: optionValue(args, '--evals'), - eventsFile: optionValue(args, '--events'), - insightsFile: optionValue(args, '--insights'), - benchmarkReportFile: optionValue(args, '--benchmark-report'), - benchmarkRunId: optionValue(args, '--benchmark-run') - }); - const outDir = optionValue(args, '--out'); - const written = outDir ? writeRecommendation(recommendation, outDir) : null; - const saved = hasFlag(args, '--save') - ? saveRecommendation(recommendation, optionValue(args, '--store') || defaultRecommendationStorePath()) - : null; - if (written) recommendation.artifact = { - table: 'AgentOpsRecommendations_CL', - file: written.file, - manifest: written.manifest, - privacy: 'metadata-only' - }; - if (saved) recommendation.saved = { - store: saved.path, - recommendation_id: saved.saved.RecommendationId, - count: saved.count, - privacy: 'metadata-only' - }; - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(recommendation, null, 2)}\n`); - } else { - process.stdout.write(renderRecommendationV2(recommendation)); - if (written) process.stdout.write(`Artifact: ${written.file}\n`); - if (saved) process.stdout.write(`Saved: ${saved.path}\n`); - } -} - -module.exports = { - actionFromInsight, - buildRecommendation, - changeAnnotationsForRun, - normalizeChangeAnnotation, - dashboardUrl, - benchmarkEvidenceFromReport, - compareRecommendationAfterRun, - exportRecommendationStore, - firstPositional, - fileRefsForRecommendation, - recommendCommand, - recommendFromFiles, - recommendationActionPlan, - recommendationActionPlanForRow, - recommendationStoreCommand, - recommendationRow, - renderRecommendationV2, - saveRecommendation, - matchingPatternInsight, - writeRecommendation, - topInsightForRun -}; +module.exports = require('../lib/recommend-command'); diff --git a/agentops-cli/src/commands/run-summary.js b/agentops-cli/src/commands/run-summary.js index e6a44da..64e570e 100644 --- a/agentops-cli/src/commands/run-summary.js +++ b/agentops-cli/src/commands/run-summary.js @@ -1,48 +1 @@ -const path = require('node:path'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { readJsonlRows, rollupSpanRows, writeTables } = require('../lib/rollup/span-to-agentops-tables'); - -const repoRoot = path.resolve(__dirname, '..', '..', '..'); - -function runSummaryCommand(args = []) { - const [subcommand = 'generate'] = args; - if (subcommand !== 'generate') throw new Error('run-summary supports: generate'); - - const file = optionValue(args, ['--file', '--jsonl']); - if (!file) throw new Error('run-summary generate requires --file '); - - const input = path.resolve(file); - const outDir = path.resolve(optionValue(args, '--out', path.join(repoRoot, '.agentops', 'run-summary', 'latest'))); - const rows = readJsonlRows(input); - const result = rollupSpanRows(rows, { - surface: optionValue(args, '--surface', 'cli'), - repo: optionValue(args, '--repo', 'unknown-repo'), - branch: optionValue(args, '--branch', 'unknown-branch') - }); - const written = writeTables(result, outDir); - const payload = { - ok: result.ok, - input, - runs: result.runs, - out_dir: written.out_dir, - manifest: written.manifest, - table_counts: result.table_counts, - next: [ - `agentops latest --file ${written.files.AgentOpsRunSummary_CL}`, - `agentops replay latest --file ${written.files.AgentOpsEvents_CL}` - ] - }; - - if (hasFlag(args, '--json')) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else { - process.stdout.write(`Generated ${payload.runs} AgentOps run summar${payload.runs === 1 ? 'y' : 'ies'}.\n`); - process.stdout.write(`Output: ${payload.out_dir}\n`); - process.stdout.write(`Next: ${payload.next[0]}\n`); - } -} - -module.exports = { - runSummaryCommand -}; +module.exports = require('../lib/run-summary-command'); diff --git a/agentops-cli/src/commands/schema.js b/agentops-cli/src/commands/schema.js index a39f379..26d5087 100644 --- a/agentops-cli/src/commands/schema.js +++ b/agentops-cli/src/commands/schema.js @@ -1,27 +1 @@ -const fs = require('node:fs'); -const { schemaDocument, validateAgentRun } = require('../lib/schema/agent-run-schema'); - -function readInputFile(args) { - const index = args.indexOf('--file'); - if (index === -1) return null; - if (!args[index + 1]) throw new Error('--file requires a path'); - return JSON.parse(fs.readFileSync(args[index + 1], 'utf8')); -} - -function schemaCommand(args = []) { - const [subcommand = 'validate'] = args; - if (subcommand === 'print') { - process.stdout.write(`${JSON.stringify(schemaDocument(), null, 2)}\n`); - return; - } - if (subcommand !== 'validate') throw new Error('schema supports: validate|print'); - - const input = readInputFile(args) || { attributes: require('../lib/schema/agent-run-schema').exampleAgentRunAttributes() }; - const result = validateAgentRun(input); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - if (!result.ok) process.exitCode = 1; -} - -module.exports = { - schemaCommand -}; +module.exports = require('../lib/schema-command'); diff --git a/agentops-cli/src/commands/security.js b/agentops-cli/src/commands/security.js index f5c5f50..ddd2ae5 100644 --- a/agentops-cli/src/commands/security.js +++ b/agentops-cli/src/commands/security.js @@ -1,44 +1 @@ -const { securityAudit, securityPosture } = require('../lib/security-audit'); - -function renderSecurityAudit(audit) { - const lines = ['AgentOps security audit']; - for (const check of audit.checks) { - const status = check.severity === 'warning' ? 'warn' : check.ok ? 'ok' : 'failed'; - lines.push(`- ${check.name}: ${status}${check.detail ? ` (${check.detail})` : ''}`); - } - lines.push('', audit.ok ? 'Security audit passed with no blocking issues.' : 'Security audit found blocking issues.'); - if (audit.next) lines.push(audit.next); - return `${lines.join('\n')}\n`; -} - -function renderSecurityPosture(posture) { - const lines = ['AgentOps security posture']; - for (const control of posture.controls) { - lines.push(`- ${control.id} ${control.risk}: ${control.status} (${control.summary})`); - } - lines.push('', posture.ok ? 'Security posture has no evidence gaps.' : 'Security posture has evidence gaps.'); - if (posture.next) lines.push(posture.next); - return `${lines.join('\n')}\n`; -} - -async function securityCommand(args = []) { - const [subcommand = 'audit'] = args; - const json = args.includes('--json'); - const failOnWarning = args.includes('--fail-on-warning'); - if (subcommand === 'posture') { - const posture = securityPosture(); - process.stdout.write(json ? `${JSON.stringify(posture, null, 2)}\n` : renderSecurityPosture(posture)); - process.exitCode = posture.ok ? 0 : 1; - return; - } - if (subcommand !== 'audit') throw new Error('security supports: audit, posture'); - const audit = securityAudit(); - process.stdout.write(json ? `${JSON.stringify(audit, null, 2)}\n` : renderSecurityAudit(audit)); - process.exitCode = audit.ok && (!failOnWarning || audit.summary.warnings === 0) ? 0 : 1; -} - -module.exports = { - renderSecurityAudit, - renderSecurityPosture, - securityCommand -}; +module.exports = require('../lib/security-command'); diff --git a/agentops-cli/src/commands/status.js b/agentops-cli/src/commands/status.js index 123f10f..c90c720 100644 --- a/agentops-cli/src/commands/status.js +++ b/agentops-cli/src/commands/status.js @@ -1,52 +1 @@ -const legacy = require('../legacy'); -const collector = require('../lib/collector-manager'); -const { resolveCopilotBinary } = require('../lib/copilot-resolver'); - -function checkByName(checks, name) { - return checks.find(check => check.name === name); -} - -async function statusSummary() { - const checks = legacy.doctor({ localOnly: true }); - const summary = legacy.agentopsStatusSummary({ checks }); - const collectorStatus = await collector.status(); - const copilot = resolveCopilotBinary(); - return { - ...summary, - collector: collectorStatus, - copilot: { - ok: copilot.ok, - path: copilot.path, - source: copilot.source, - error: copilot.error, - candidates: copilot.candidates - }, - content_capture_off: Boolean(checkByName(checks, 'content-capture-disabled')?.ok) - }; -} - -function renderStatus(summary) { - return [ - 'AgentOps status', - '', - `Required files: ${summary.required_files.found} of ${summary.required_files.total} found.`, - `Content capture: ${summary.content_capture_off ? 'off' : 'enabled or unknown'}.`, - `Collector: ${summary.collector.running ? 'running' : 'not running'} (${summary.collector.effectiveMode || summary.collector.mode}, ${summary.collector.privacyMode}).`, - `Collector binding: ${summary.collector.safeLocalhostBinding ? 'localhost-only' : 'needs review'}.`, - `Copilot binary: ${summary.copilot.ok ? summary.copilot.path : summary.copilot.error}.`, - `Shim: agentops is ${summary.shim.agentops_cli}; copilot-agentops is ${summary.shim.agentops_command}; plain copilot is ${summary.shim.shadow}.` - ].join('\n') + '\n'; -} - -async function statusCommand(args = []) { - const json = args.includes('--json'); - const summary = await statusSummary(); - process.stdout.write(json ? `${JSON.stringify(summary, null, 2)}\n` : renderStatus(summary)); - process.exitCode = summary.ok ? 0 : 1; -} - -module.exports = { - renderStatus, - statusCommand, - statusSummary -}; +module.exports = require('../lib/status-command'); diff --git a/agentops-cli/src/commands/triage.js b/agentops-cli/src/commands/triage.js index 2bc8209..6295d1c 100644 --- a/agentops-cli/src/commands/triage.js +++ b/agentops-cli/src/commands/triage.js @@ -1,179 +1 @@ -const fs = require('node:fs'); -const path = require('node:path'); - -const { hasFlag, optionValue } = require('../lib/args'); -const { buildAskContext } = require('./ask-context'); -const { openV2FromFiles } = require('./open'); -const { recommendFromFiles, writeRecommendation } = require('./recommend'); - -function firstPositional(args = []) { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg.startsWith('--')) { - if (!arg.includes('=') && index + 1 < args.length && !args[index + 1].startsWith('--')) index += 1; - continue; - } - return arg; - } - return 'latest'; -} - -function resolveOption(args, name) { - const value = optionValue(args, name); - return value ? path.resolve(value) : null; -} - -function writeTriage(result, outDir) { - const absoluteDir = path.resolve(outDir); - fs.mkdirSync(absoluteDir, { recursive: true }); - const file = path.join(absoluteDir, 'agentops-triage.json'); - fs.writeFileSync(file, `${JSON.stringify(result, null, 2)}\n`); - return { file }; -} - -function buildTriage(options = {}) { - if (!options.runsFile) throw new Error('triage requires --runs '); - - const open = openV2FromFiles({ runId: options.runId, runsFile: options.runsFile }); - if (!open.ok) { - return { - ok: false, - run_id: options.runId || 'latest', - error: open.missing_latest_reason || 'no V2 run row was found' - }; - } - - const ask = buildAskContext({ - runId: open.run_id, - runsFile: options.runsFile, - eventsFile: options.eventsFile, - toolsFile: options.toolsFile, - privacyFile: options.privacyFile, - githubFile: options.githubFile, - evalsFile: options.evalsFile, - insightsFile: options.insightsFile - }); - const recommendation = recommendFromFiles({ - runId: open.run_id, - runsFile: options.runsFile, - eventsFile: options.eventsFile, - evalsFile: options.evalsFile, - insightsFile: options.insightsFile, - benchmarkReportFile: options.benchmarkReportFile, - benchmarkRunId: options.benchmarkRunId - }); - - return { - ok: true, - run_id: open.run_id, - session_id: open.session_id, - trace_id: open.trace_id, - status: open.status, - links: open.links, - evidence_counts: ask.counts || {}, - recommendation: { - action: recommendation.action, - severity: recommendation.severity, - observed_pattern: recommendation.observed_pattern, - next_action: recommendation.next_action, - pattern: recommendation.evidence?.pattern || null, - benchmark: recommendation.evidence?.benchmark || null, - change_annotations: recommendation.evidence?.change_annotations || [], - change_targets: recommendation.evidence?.file_refs || [], - dashboards: recommendation.evidence?.dashboards || [] - }, - ask_agentops: { - prompt: ask.prompt, - replay_url: ask.replay_url - }, - privacy: { - mode: ask.run?.PrivacyMode || 'strict', - content_capture_mode: ask.run?.ContentCaptureMode || 'off', - note: 'Metadata-only triage packet. Do not include prompts, responses, tool args, tool results, source code, file contents, URLs, request bodies, response bodies, or secrets unless content capture is explicitly approved.' - }, - next: [ - `agentops open ${open.run_id} --runs `, - `agentops ask-context ${open.run_id} --runs --events --tools --evals --insights `, - `agentops recommend ${open.run_id} --runs --events --evals --insights --out ` - ] - }; -} - -function renderTriage(result) { - if (!result.ok) return `AgentOps triage\n\n${result.error}\n`; - const lines = [ - 'AgentOps triage', - '', - `Run: ${result.run_id}`, - `Status: ${result.status || 'unknown'}`, - `Run Replay: ${result.links.replay}`, - `Ask AgentOps prompt: ready`, - `Recommendation: ${result.recommendation.action} (${result.recommendation.severity})`, - `Next action: ${result.recommendation.next_action}`, - `Evidence: ${result.evidence_counts.events || 0} events, ${result.evidence_counts.failed_tools || 0} failed/denied tools, ${result.evidence_counts.insights || 0} insights`, - `Privacy: ${result.privacy.mode}, content capture ${result.privacy.content_capture_mode}`, - '' - ]; - if (result.recommendation.pattern) lines.push(`Pattern: ${result.recommendation.pattern.key}`); - if (result.recommendation.benchmark) lines.push(`Benchmark: ${result.recommendation.benchmark.run_id} (${result.recommendation.benchmark.decision || 'unknown'})`); - if (result.recommendation.change_annotations?.length) lines.push(`Config changes: ${result.recommendation.change_annotations.map(annotation => [annotation.component, annotation.target].filter(Boolean).join(':')).filter(Boolean).join(', ')}`); - if (result.recommendation.change_targets.length) lines.push(`Change targets: ${result.recommendation.change_targets.join(', ')}`); - lines.push(''); - lines.push('Prompt:'); - lines.push(result.ask_agentops.prompt); - return `${lines.join('\n')}\n`; -} - -function triageCommand(args = []) { - const runId = firstPositional(args); - const runsFile = resolveOption(args, '--runs'); - const result = buildTriage({ - runId, - runsFile, - eventsFile: resolveOption(args, '--events'), - toolsFile: resolveOption(args, '--tools'), - privacyFile: resolveOption(args, '--privacy'), - githubFile: resolveOption(args, '--github'), - evalsFile: resolveOption(args, '--evals'), - insightsFile: resolveOption(args, '--insights'), - benchmarkReportFile: resolveOption(args, '--benchmark-report'), - benchmarkRunId: optionValue(args, '--benchmark-run') - }); - const outDir = optionValue(args, '--out'); - if (result.ok && outDir) { - const triageArtifact = writeTriage(result, outDir); - const recommendationArtifact = writeRecommendation({ - ok: true, - action: result.recommendation.action, - severity: result.recommendation.severity, - run_id: result.run_id, - session_id: result.session_id, - trace_id: result.trace_id, - observed_pattern: result.recommendation.observed_pattern, - next_action: result.recommendation.next_action, - evidence: { - dashboards: result.recommendation.dashboards, - pattern: result.recommendation.pattern, - benchmark: result.recommendation.benchmark, - change_annotations: result.recommendation.change_annotations, - file_refs: result.recommendation.change_targets - }, - validation: [], - rollback_condition: 'Rollback the agent, skill, MCP, model, or instruction change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.' - }, outDir); - result.artifacts = { - triage: triageArtifact.file, - recommendation: recommendationArtifact.file - }; - } - - process.stdout.write(hasFlag(args, '--json') ? `${JSON.stringify(result, null, 2)}\n` : renderTriage(result)); - if (!result.ok) process.exitCode = 1; -} - -module.exports = { - buildTriage, - renderTriage, - triageCommand, - writeTriage -}; +module.exports = require('../lib/triage-command'); diff --git a/agentops-cli/src/index.js b/agentops-cli/src/index.js index 82c9b93..55b5ed4 100755 --- a/agentops-cli/src/index.js +++ b/agentops-cli/src/index.js @@ -9,6 +9,7 @@ const { copilotCommand } = require('./commands/copilot'); const { copilotSessionCommand } = require('./commands/copilot-session'); const { dashboardCommand } = require('./commands/dashboard'); const { demoCommand } = require('./commands/demo'); +const { deliveryCommand } = require('./commands/delivery'); const { doctorCommand } = require('./commands/doctor'); const { e2eCommand } = require('./commands/e2e'); const { explainCommand } = require('./commands/explain'); @@ -24,187 +25,43 @@ const { schemaCommand } = require('./commands/schema'); const { securityCommand } = require('./commands/security'); const { statusCommand } = require('./commands/status'); const { triageCommand } = require('./commands/triage'); +const { createCliMain } = require('./lib/cli-dispatch'); +const { coreCommands, experimentalCommands, usage } = require('./lib/cli-surface'); -const coreCommands = [ - 'setup', - 'install', - 'uninstall', - 'status', - 'doctor', - 'configure', - 'collector', - 'azure-ingest', - 'annotation', - 'annotate', - 'ask-context', - 'content', - 'copilot', - 'copilot-session', - 'dashboard', - 'demo', - 'explain', - 'github-enrich', - 'health', - 'insights', - 'init', - 'latest', - 'mcp-proxy', - 'recommend', - 'replay', - 'open', - 'product', - 'validate-azure', - 'validate-enterprise', - 'plugin', - 'run-summary', - 'schema', - 'security', - 'smoke', - 'triage', - 'e2e' -]; - -const experimentalCommands = new Set([ - 'agents', - 'alert', - 'attribution', - 'attribution-smoke', - 'benchmark', - 'codex', - 'collector-health', - 'compat-check', - 'context', - 'custom', - 'enable-shadow', - 'fields', - 'import-jsonl', - 'incident', - 'lineage', - 'link', - 'live', - 'live-replay-smoke', - 'mcp', - 'otel-setup', - 'permission-friction', - 'policy', - 'primitives', - 'saved-view', - 'scan', - 'skills', - 'tail', - 'token-rollup-audit', - 'validate-collector', - 'workflows' -]); - -function usage() { - return `agentops - -Core commands: - setup [--json] - install [--no-shadow-copilot] [--no-collector] [--plugin] - uninstall [--keep-plugin] [--keep-collector] [--keep-binary] [--purge] - status [--json] - doctor [--local-only] [--last ] [--json] - configure show|set|import-azd [--json] - collector start|stop|status|validate|smoke|install-binary|uninstall-binary [--mode auto|docker|binary|none] [--privacy strict|compat] [--json] - azure-ingest plan [--dir ] [--allow-content] [--json] - azure-ingest upload-plan --dir --account [--container ] [--prefix ] [--json] - annotation config-change --component --target [--change-type ] [--change-id ] [--version ] [--run-id ] [--session ] [--trace-id ] [--dry-run] [--json] - ask-context latest| [--last ] [--runs ] [--events ] [--tools ] [--evals ] [--insights ] [--recommendations ] [--json] - content status|opt-in [--dir ] [--runs ] [--allow-content] [--json] - copilot [copilot-args...] - copilot-session enrich [--file ] [--dry-run] [--json] - schema validate|print [--file ] - security audit|posture [--json] [--fail-on-warning] - dashboard validate|links-check|filters-check|ux-check|content-check|kql-check|verify|import [--last ] [--live] [--yes] [--all] [--folder ] [--resource-group ] [--grafana-name ] - demo generate|verify [--runs ] [--out ] [--with-content] [--json] - github-enrich [--limit ] [--runs ] [--out ] [--json] - health [--runs ] [--json] - explain latest| [--runs ] [--evals ] [--insights ] [--json] - insights [generate|patterns] [--runs ] [--insights ] [--tools ] [--privacy ] [--github ] [--out ] [--json] - init [--dry-run] [--full] [--provision-cloud] [--import-dashboards] [--run-smoke] [--triage-latest] [--force-skills] [--no-skills] [--json] - recommend latest| [--runs ] [--events ] [--evals ] [--insights ] [--benchmark-run ] [--benchmark-report ] [--out ] [--save] [--store ] [--json] - recommend list|export [--store ] [--out ] - triage latest| [--runs ] [--events ] [--tools ] [--privacy ] [--github ] [--evals ] [--insights ] [--benchmark-run ] [--out ] [--json] - alert handoff --rule --session [--owner ] [--events ] [--output ] [--last ] - mcp-proxy --server-name [--out ] -- [args...] - latest [--file ] [--last ] [--json] - replay [--file ] [--last ] - open [latest|] [--runs ] [--file ] [--last ] [--json] - product audit [--live] [--last ] [--require-rows] [--require-visual] [--report ] [--json] - validate-azure [--last ] [--import-dashboards] [--production] [--remediation-plan] [--json] - validate-enterprise [--json] - plugin install|uninstall [--copilot-home ] [--force] [--dry-run] [--json] - run-summary generate --file [--out ] [--json] - smoke [--real-copilot] [--dry-run] [--wait ] [--poll ] [--json] - e2e run|report|browser-check|auth-profile [--json] - -Experimental: - agentops experimental [...] -`; -} - -function legacyWithMigration(command, args) { - process.stderr.write(`agentops ${command} is experimental now; use agentops experimental ${command} ${args.join(' ')}\n`); - return legacy.main([command, ...args]); -} - -async function main(argv) { - const [command, ...args] = argv; - - if (!command || command === '--help' || command === '-h') { - process.stdout.write(usage()); - return; - } - - if (command === 'experimental') { - const [experimentalCommand, ...experimentalArgs] = args; - if (!experimentalCommand) throw new Error('experimental requires a command'); - return legacy.main([experimentalCommand, ...experimentalArgs]); - } - - if (command === 'status') return statusCommand(args); - if (command === 'doctor') return doctorCommand(args); - if (command === 'collector' || command === 'start' || command === 'stop') { - const collectorArgs = command === 'start' || command === 'stop' ? [command, ...args] : args; - return collectorCommand(collectorArgs); - } - if (command === 'copilot') return copilotCommand(args); - if (command === 'copilot-session') return copilotSessionCommand(args); - if (command === 'azure-ingest') return azureIngestCommand(args); - if (command === 'ask-context') return askContextCommand(args); - if (command === 'content') return contentCommand(args); - if (command === 'schema') return schemaCommand(args); - if (command === 'security') return securityCommand(args); - if (command === 'dashboard') return dashboardCommand(args); - if (command === 'demo') return demoCommand(args); - if (command === 'e2e') return e2eCommand(args); - if (command === 'explain') return explainCommand(args); - if (command === 'github-enrich') return githubEnrichCommand(args); - if (command === 'health') return healthCommand(args); - if (command === 'insights') return insightsCommand(args); - if (command === 'mcp-proxy') return mcpProxyCommand(args); - if (command === 'recommend') { - if (args.includes('--runs') || args[0] === 'list' || args[0] === 'export') return recommendCommand(args); - return legacy.main([command, ...args]); - } - if (command === 'run-summary') return runSummaryCommand(args); - if (command === 'triage') return triageCommand(args); - if (command === 'latest' && args.includes('--json')) { - process.stdout.write(`${JSON.stringify(legacy.latestSummaryFromArgs(args), null, 2)}\n`); - return; - } - if (command === 'open') return openCommand(args); - if (command === 'product') return productCommand(args); - if (command === 'smoke') return legacy.main([command, ...args]); - - if (experimentalCommands.has(command)) return legacyWithMigration(command, args); - - if (coreCommands.includes(command)) return legacy.main([command, ...args]); +const commandHandlers = { + azureIngestCommand, + askContextCommand, + collectorCommand, + contentCommand, + copilotCommand, + copilotSessionCommand, + dashboardCommand, + demoCommand, + deliveryCommand, + doctorCommand, + e2eCommand, + explainCommand, + githubEnrichCommand, + healthCommand, + insightsCommand, + mcpProxyCommand, + openCommand, + productCommand, + recommendCommand, + runSummaryCommand, + schemaCommand, + securityCommand, + statusCommand, + triageCommand +}; - throw new Error(`Unknown command: ${command}`); -} +const main = createCliMain({ + commands: commandHandlers, + coreCommands, + experimentalCommands, + legacy, + usage +}); if (require.main === module) { main(process.argv.slice(2)).catch(error => { @@ -226,6 +83,7 @@ module.exports = { copilotSessionCommand, dashboardCommand, demoCommand, + deliveryCommand, doctorCommand, e2eCommand, explainCommand, diff --git a/agentops-cli/src/legacy.js b/agentops-cli/src/legacy.js index 82b119d..386d205 100755 --- a/agentops-cli/src/legacy.js +++ b/agentops-cli/src/legacy.js @@ -1,10223 +1,12 @@ #!/usr/bin/env node -const crypto = require('node:crypto'); -const childProcess = require('node:child_process'); -const fs = require('node:fs'); -const http = require('node:http'); -const https = require('node:https'); -const os = require('node:os'); -const path = require('node:path'); -const { createAlerts } = require('./alerts'); -const { createPrimitives } = require('./primitives'); -const { createRecommendations } = require('./recommendations'); -const { createSavedViews } = require('./saved-views'); -const { createTelemetry } = require('./telemetry'); -const { repoRoot } = require('./lib/paths'); -const { classifyToolName, extractAllowedTools } = require('./lib/copilot/tool-classifier'); -const { contentLikeKeys, safeAttributeKeys } = require('./lib/privacy'); - -const root = repoRoot; - -function usage() { - const commands = [ - 'setup [--json]', - 'status', - 'latest [--file ] [--last ]', - 'live|tail [--file ] [--last ] [--follow] [--interval ]', - 'replay [--file ] [--last ]', - 'explain latest [--file ] [--last ]', - 'recommend latest [--file ] [--last ]', - 'open [--file ] [--last ]', - 'workflows list|show [--json]', - 'plugin install|uninstall [--copilot-home ] [--force] [--json]', - 'agents list|path|install|uninstall [--copilot-home ] [--force] [--json]', - 'skills list|path|install|uninstall [--copilot-home ] [--force] [--json]', - 'doctor [--local-only]', - 'scan [--json]', - 'primitives [--last ] [--root ]', - 'import-jsonl ', - 'custom emit --event --agent [--parent-agent ] [--delegation-id ] [--workflow ] [--step ] [--outcome ] [--risk ] [--score ] [--tag ] [--custom key=value] [--attribute key=value] [--dry-run] [--json]', - 'custom import [--agent ] [--workflow ] [--dry-run] [--json]', - 'annotation config-change --component --target [--change-type ] [--change-id ] [--version ] [--run-id ] [--session ] [--trace-id ] [--dry-run] [--json]', - 'configure show|set|import-azd [--json]', - 'install [--shadow-copilot]', - 'otel-setup [--endpoint ] [--service-name ] [--shell bash|powershell|json]', - 'start|stop', - 'copilot [copilot-args...]', - 'codex [codex-args...]', - 'compat-check [--last ]', - 'validate-collector [endpoint]', - 'validate-azure [--last ] [--production] [--remediation-plan] [--json]', - 'init [--dry-run] [--full] [--provision-cloud] [--import-dashboards] [--run-smoke] [--triage-latest] [--force-skills] [--json]', - 'smoke [--dry-run] [--endpoint ] [--id ] [--last ] [--wait ] [--poll ] [--open-browser] [--no-verify] [--json]', - 'attribution-smoke [--dry-run] [--endpoint ] [--id ] [--last ] [--wait ] [--poll ] [--no-verify] [--json]', - 'live-replay-smoke [--dry-run] [--endpoint ] [--id ] [--last ] [--wait ] [--poll ] [--no-verify] [--json]', - 'validate-enterprise [--json]', - 'ask-context [--file ] [--last ] [--json]', - 'enable-shadow', - 'disable-shadow', - 'uninstall', - 'collector start|stop', - 'saved-view add --url [--query-file ] [--description ] [--tag ] [--events ]', - 'saved-view list|show|open|export [name] [--events ] [--out ]', - 'link session ', - 'link trace ', - 'fields [--last ]', - 'context [--last ]', - 'token-rollup-audit [--last ]', - 'collector-health [--last ]', - 'attribution [--last ]', - 'permission-friction [--last ]', - 'alert recommend [--last ]', - 'alert tune-plan [--last ] [--rule ] [--owner ]', - 'alert threshold-simulate --rule --threshold --owner [--last ]', - 'alert threshold-patch --rule --threshold --owner [--last ]', - 'alert policy [--owner ] [--service ] [--timezone ]', - 'alert resources [--resource-group ]', - 'alert history --rule [--last ]', - 'alert detail --rule --session [--last ]', - 'alert open --rule --session [--last ]', - 'alert review --rule --session [--owner ] [--last ]', - 'alert action-plan --rule --session [--last ]', - 'alert export --rule --session --output [--last ]', - 'alert handoff --rule --session [--owner ] [--events ] [--output ] [--last ]', - 'alert route-plan --rule --session [--owner ] [--events ] [--target ] [--output ]', - 'alert route-github --repo --rule --session --owner [--yes]', - 'alert route-azure-devops --org --project --rule --session --owner [--yes]', - 'alert action-group-plan --resource-group --name --short-name --owner [--email
] [--webhook ]', - 'alert route-action-group --resource-group --scheduled-query --action-group --rule --session --owner [--yes]', - 'incident timeline --artifact [--artifact ...] --output [--incident ]', - 'lineage [--last ]', - 'policy [--last ]', - 'mcp [--last ]', - 'benchmark list', - 'benchmark fixture-pack --id [--fixture ] [--title ] [--output <file>] [--sign-key-id <id> --sign-private-key <pem>]', - 'benchmark judge-provider [--json]', - 'benchmark run <suite> --variant <name> --repeat <n> [--hypothesis <id>] [--dry-run]', - 'benchmark approve <run-id> --by <name> [--ticket <id>] [--output <json>]', - 'benchmark artifacts <run-id> [--task <task-id>] [--include-content]', - 'benchmark report <run-id> [--azure] [--last <duration>] [--approval-file <json>] [--verify-external-review]', - 'benchmark compare <before-run-id> <after-run-id> [--azure] [--last <duration>] [--approval-file <json>] [--verify-external-review]' - ]; - return `agentops <command>\n\nCommands:\n ${commands.join('\n ')}\n`; -} - -const defaultConfigPath = process.env.AGENTOPS_CONFIG_PATH || path.join(os.homedir(), '.agentops', 'config.json'); -const agentopsConfig = readAgentOpsConfig({ quiet: true }).values; -const configuredWorkspaceId = process.env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID || process.env.LOG_ANALYTICS_WORKSPACE_ID || agentopsConfig.workspaceId || ''; -const workspaceId = configuredWorkspaceId || '00000000-0000-0000-0000-000000000000'; -const grafanaBaseUrl = (process.env.AGENTOPS_GRAFANA_BASE_URL || agentopsConfig.grafanaBaseUrl || 'https://your-grafana.grafana.azure.com').replace(/\/$/, ''); -const mainGrafanaDashboardUrl = `${grafanaBaseUrl}/d/copilot-agentops/copilot-cli-agentops`; -const sessionsGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-sessions/agentops-sessions`; -const v2HomeGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-v2-home`; -const v2RunsGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-v2-runs-explorer`; -const v2ReplayGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-v2-run-replay`; -const grafanaDatasourceUid = process.env.AGENTOPS_GRAFANA_DATASOURCE_UID || agentopsConfig.grafanaDatasourceUid || 'azure-monitor-oob'; -const azureSubscriptionId = process.env.AGENTOPS_AZURE_SUBSCRIPTION_ID || process.env.AZURE_SUBSCRIPTION_ID || agentopsConfig.subscriptionId || '00000000-0000-0000-0000-000000000000'; -const azureResourceGroup = process.env.AGENTOPS_AZURE_RESOURCE_GROUP || process.env.AZURE_RESOURCE_GROUP || agentopsConfig.resourceGroup || 'rg-agentops-dev'; -const logAnalyticsWorkspaceName = process.env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME || agentopsConfig.workspaceName || 'law-agentops-dev'; -const portalLogsUrl = process.env.AGENTOPS_AZURE_PORTAL_LOGS_URL || agentopsConfig.portalLogsUrl || `https://portal.azure.com/#@/resource/subscriptions/${azureSubscriptionId}/resourceGroups/${azureResourceGroup}/providers/Microsoft.OperationalInsights/workspaces/${logAnalyticsWorkspaceName}/logs`; -const agentServiceNames = '("github-copilot", "copilot-chat", "github-copilot-cli", "codex", "openai-codex", "openai-codex-cli")'; -const baseFilter = `(Properties has "github.copilot" or Properties has "gen_ai.operation.name" or Properties has "agentops." or AppRoleName in ${agentServiceNames} or tostring(Properties["service.name"]) in ${agentServiceNames} or tostring(Properties["agent.runtime"]) in ("codex", "openai-codex-cli"))`; -const copilotOtelFilter = baseFilter; -const customAttributePrefixes = ['agentops.', 'gen_ai.', 'github.copilot.', 'content.capture.', 'event.', 'error.']; -const copilotMetricNames = [ - 'gen_ai.client.operation.duration', - 'gen_ai.client.token.usage', - 'gen_ai.client.operation.time_to_first_chunk', - 'gen_ai.client.operation.time_per_output_chunk', - 'github.copilot.tool.call.count', - 'github.copilot.tool.call.duration', - 'github.copilot.agent.turn.count', - 'copilot_chat.tool.call.count', - 'copilot_chat.tool.call.duration', - 'copilot_chat.agent.invocation.duration', - 'copilot_chat.agent.turn.count', - 'copilot_chat.session.count', - 'copilot_chat.time_to_first_token', - 'copilot_chat.edit.acceptance.count', - 'copilot_chat.chat_edit.outcome.count', - 'copilot_chat.lines_of_code.count', - 'copilot_chat.edit.survival.four_gram', - 'copilot_chat.edit.survival.no_revert', - 'copilot_chat.user.action.count', - 'copilot_chat.user.feedback.count', - 'copilot_chat.agent.edit_response.count', - 'copilot_chat.agent.summarization.count', - 'copilot_chat.pull_request.count', - 'copilot_chat.cloud.session.count', - 'copilot_chat.cloud.pr_ready.count' -]; -const copilotEventNames = [ - 'gen_ai.client.inference.operation.details', - 'copilot_chat.session.start', - 'copilot_chat.tool.call', - 'copilot_chat.agent.turn', - 'copilot_chat.edit.feedback', - 'copilot_chat.edit.hunk.action', - 'copilot_chat.inline.done', - 'copilot_chat.edit.survival', - 'copilot_chat.user.feedback', - 'copilot_chat.cloud.session.invoke', - 'github.copilot.hook.start', - 'github.copilot.hook.end', - 'github.copilot.hook.error', - 'github.copilot.session.truncation', - 'github.copilot.session.compaction_start', - 'github.copilot.session.compaction_complete', - 'github.copilot.skill.invoked', - 'github.copilot.session.shutdown', - 'github.copilot.session.abort', - 'exception' -]; -const sessionFallbackPrefix = 'iff(isnotempty(tostring(Properties["gen_ai.agent.id"])), tostring(Properties["gen_ai.agent.id"]), iff(isnotempty(tostring(Properties["service.name"])), tostring(Properties["service.name"]), iff(isnotempty(AppRoleName), AppRoleName, "agent")))'; -const sessionFallbackTurn = 'iff(isnotempty(tostring(Properties["github.copilot.turn_count"])), tostring(Properties["github.copilot.turn_count"]), iff(isnotempty(OperationId), OperationId, "session"))'; -const sessionKey = `case(isnotempty(tostring(Properties["gen_ai.conversation.id"])), tostring(Properties["gen_ai.conversation.id"]), isnotempty(tostring(Properties["github.copilot.interaction_id"])), tostring(Properties["github.copilot.interaction_id"]), strcat(${sessionFallbackPrefix}, "_", ${sessionFallbackTurn}, "_", format_datetime(bin(TimeGenerated, 1h), "yyyyMMdd_HHmm")))`; -const directSessionKey = 'case(isnotempty(tostring(Properties["gen_ai.conversation.id"])), tostring(Properties["gen_ai.conversation.id"]), isnotempty(tostring(Properties["github.copilot.interaction_id"])), tostring(Properties["github.copilot.interaction_id"]), "")'; -const fallbackSessionKey = `strcat(${sessionFallbackPrefix}, "_", ${sessionFallbackTurn}, "_", format_datetime(bin(TimeGenerated, 1h), "yyyyMMdd_HHmm"))`; -const defaultInstallDir = process.env.AGENTOPS_BIN_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '', '.local', 'bin'); -const benchmarksDir = path.join(root, 'benchmarks'); -const benchmarkRunBaseDir = path.join(os.tmpdir(), 'agentops-benchmark-runs'); -const savedViewsPath = process.env.AGENTOPS_VIEWS_PATH || path.join(os.homedir(), '.agentops', 'views.json'); - -function encodeGrafanaValue(value) { - return encodeURIComponent(value); -} - -function grafanaUrlWithVars(baseUrl, vars = {}) { - const entries = Object.entries(vars).filter(([, value]) => value !== undefined && value !== null && value !== ''); - if (entries.length === 0) return baseUrl; - const separator = baseUrl.includes('?') ? '&' : '?'; - return `${baseUrl}${separator}${entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeGrafanaValue(String(value))}`).join('&')}`; -} - -function sessionQuery(conversation, last = '24h') { - const escaped = conversation.replace(/"/g, '\\"'); - return `let selected_session = "${escaped}";\nlet base = AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend direct_session=${directSessionKey}, fallback_session=${fallbackSessionKey};\nlet selected_operations = base\n| where direct_session == selected_session or fallback_session == selected_session\n| distinct OperationId;\nbase\n| extend linked_to_selected = OperationId in (selected_operations)\n| where direct_session == selected_session or fallback_session == selected_session or linked_to_selected\n| extend conversation=iff(linked_to_selected, selected_session, iff(isnotempty(direct_session), direct_session, fallback_session)), operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), tool=tostring(Properties["gen_ai.tool.name"]), error=tostring(Properties["error.type"])\n| project TimeGenerated, conversation, OperationId, ParentId, Id, Name, operation, model, tool, DurationMs, Success, ResultCode, error, Properties\n| order by TimeGenerated asc`; -} - -function traceQuery(operationId, last = '24h') { - return `AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| where OperationId == "${operationId.replace(/"/g, '\\"')}"\n| extend conversation=${sessionKey}, operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), tool=tostring(Properties["gen_ai.tool.name"]), error=tostring(Properties["error.type"])\n| project TimeGenerated, conversation, OperationId, ParentId, Id, Name, operation, model, tool, DurationMs, Success, ResultCode, error, Properties\n| order by TimeGenerated asc`; -} - -function fieldCatalogQuery(last = '7d') { - const contentKeys = contentLikeKeys.map(key => JSON.stringify(key)).join(', '); - const safeKeys = safeAttributeKeys.map(key => JSON.stringify(key)).join(', '); - return `let exact_content_keys = dynamic([${contentKeys}]);\nlet known_safe_keys = dynamic([${safeKeys}]);\nAppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend fields = bag_keys(Properties)\n| mv-expand field = fields to typeof(string)\n| extend value = tostring(Properties[field])\n| summarize observed=count(), example_values=make_set_if(value, isnotempty(value), 5) by field\n| extend content_risk = case(field in (exact_content_keys), "exact-content-key", field !in (known_safe_keys) and field matches regex "(?i)(prompt|completion|message|instruction|argument|result|body|secret|password|credential|cookie|url|filepath|file_path|path|token)", "sensitive-key-family", "")\n| order by content_risk desc, observed desc, field asc`; -} - -function contextPressureQuery(last = '7d') { - return `AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend conversation=${sessionKey}, operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), agent=tostring(Properties["gen_ai.agent.name"]), tool=tostring(Properties["gen_ai.tool.name"]), repo=tostring(Properties["agentops.repo.hash"]), error=tostring(Properties["error.type"]), InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), CacheRead=todouble(Properties["gen_ai.usage.cache_read.input_tokens"]), CacheWrite=todouble(Properties["gen_ai.usage.cache_creation.input_tokens"]), Credits=todouble(Properties["github.copilot.cost"]), AIU=todouble(Properties["github.copilot.aiu"])\n| summarize Started=min(TimeGenerated), Ended=max(TimeGenerated), Spans=count(), Runs=countif(operation == "invoke_agent"), Failures=countif(Success == false or isnotempty(error)), ChatSpans=countif(operation == "chat"), ChatInputTokens=sumif(InputTokens, operation == "chat"), ChatOutputTokens=sumif(OutputTokens, operation == "chat"), ChatCacheRead=sumif(CacheRead, operation == "chat"), ChatCacheWrite=sumif(CacheWrite, operation == "chat"), ChatCredits=sumif(Credits, operation == "chat"), ChatAIU=sumif(AIU, operation == "chat"), AgentInputTokens=maxif(InputTokens, operation == "invoke_agent"), AgentOutputTokens=maxif(OutputTokens, operation == "invoke_agent"), AgentCacheRead=maxif(CacheRead, operation == "invoke_agent"), AgentCacheWrite=maxif(CacheWrite, operation == "invoke_agent"), AgentCredits=maxif(Credits, operation == "invoke_agent"), AgentAIU=maxif(AIU, operation == "invoke_agent"), P95DurationMs=percentile(DurationMs, 95), Models=make_set(model, 5), Agents=make_set(agent, 5), Repos=make_set_if(repo, isnotempty(repo), 3), Tools=make_set_if(tool, isnotempty(tool), 10), Errors=make_set_if(error, isnotempty(error), 10) by Session=conversation\n| extend InputTokens=iff(ChatSpans > 0, ChatInputTokens, AgentInputTokens), OutputTokens=iff(ChatSpans > 0, ChatOutputTokens, AgentOutputTokens), CacheRead=iff(ChatSpans > 0, ChatCacheRead, AgentCacheRead), CacheWrite=iff(ChatSpans > 0, ChatCacheWrite, AgentCacheWrite), Credits=iff(ChatSpans > 0, ChatCredits, AgentCredits), AIU=iff(ChatSpans > 0, ChatAIU, AgentAIU)\n| extend FreshInput=iff(InputTokens - CacheRead - CacheWrite < 0, 0.0, InputTokens - CacheRead - CacheWrite), OutputYieldPct=iff(InputTokens > 0, round(100.0 * OutputTokens / InputTokens, 3), 0.0), CacheLeveragePct=iff(InputTokens > 0, round(100.0 * CacheRead / InputTokens, 1), 0.0), EstUsd=round(Credits * 0.01, 4), DurationSec=round(datetime_diff("millisecond", Ended, Started) / 1000.0, 2)\n| extend Pressure=case(InputTokens >= 100000 and OutputYieldPct < 0.1, "severe_low_yield", InputTokens >= 100000, "severe_context", InputTokens >= 30000 and OutputYieldPct < 0.1, "high_low_yield", InputTokens >= 30000, "high_context", FreshInput >= 30000 and CacheLeveragePct < 10, "low_cache_leverage", EstUsd >= 1.0, "expensive", "ok")\n| where Pressure != "ok"\n| project Started, Session, Pressure, InputTokens, OutputTokens, OutputYieldPct, CacheRead, CacheWrite, FreshInput, CacheLeveragePct, Credits, EstUsd, AIU, DurationSec, P95DurationMs, Runs, Spans, Failures, Models, Agents, Repos, Tools, Errors\n| order by InputTokens desc, EstUsd desc\n| take 100`; -} - -function tokenRollupAuditQuery(last = '7d') { - return `AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend conversation=${sessionKey}, operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), agent=tostring(Properties["gen_ai.agent.name"]), InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), CacheRead=todouble(Properties["gen_ai.usage.cache_read.input_tokens"]), CacheWrite=todouble(Properties["gen_ai.usage.cache_creation.input_tokens"]), Credits=todouble(Properties["github.copilot.cost"]), AIU=todouble(Properties["github.copilot.aiu"])\n| summarize Started=min(TimeGenerated), Ended=max(TimeGenerated), Spans=count(), ChatSpans=countif(operation == "chat"), AgentSpans=countif(operation == "invoke_agent"), AllSpanInputTokens=sum(InputTokens), AllSpanOutputTokens=sum(OutputTokens), ChatInputTokens=sumif(InputTokens, operation == "chat"), ChatOutputTokens=sumif(OutputTokens, operation == "chat"), AgentInputTokens=maxif(InputTokens, operation == "invoke_agent"), AgentOutputTokens=maxif(OutputTokens, operation == "invoke_agent"), ChatCredits=sumif(Credits, operation == "chat"), AgentCredits=maxif(Credits, operation == "invoke_agent"), ChatAIU=sumif(AIU, operation == "chat"), AgentAIU=maxif(AIU, operation == "invoke_agent"), Models=make_set(model, 5), Agents=make_set(agent, 5) by Session=conversation\n| extend RecommendedInputTokens=iff(ChatSpans > 0, ChatInputTokens, AgentInputTokens), RecommendedOutputTokens=iff(ChatSpans > 0, ChatOutputTokens, AgentOutputTokens), RecommendedCredits=iff(ChatSpans > 0, ChatCredits, AgentCredits), RecommendedAIU=iff(ChatSpans > 0, ChatAIU, AgentAIU)\n| extend TokenOvercountRatio=iff(RecommendedInputTokens > 0, round(AllSpanInputTokens / RecommendedInputTokens, 2), 0.0), RollupMode=iff(ChatSpans > 0, "chat_spans", "invoke_agent_fallback"), NeedsReview=AllSpanInputTokens > RecommendedInputTokens * 1.25\n| project Started, Ended, Session, RollupMode, NeedsReview, TokenOvercountRatio, AllSpanInputTokens, RecommendedInputTokens, AgentInputTokens, ChatInputTokens, AllSpanOutputTokens, RecommendedOutputTokens, AgentOutputTokens, ChatOutputTokens, RecommendedCredits, RecommendedAIU, Spans, ChatSpans, AgentSpans, Models, Agents\n| order by NeedsReview desc, TokenOvercountRatio desc, AllSpanInputTokens desc\n| take 100`; -} - -function collectorHealthQuery(last = '24h') { - const lookback = validateKqlDuration(last); - return `let lookback = ${lookback}; -let copilot = AppDependencies -| where TimeGenerated > ago(lookback) -| where ${copilotOtelFilter} -| where isempty(tostring(Properties["agentops.smoke_id"])) - and tostring(Properties["agentops.profile"]) !has "smoke" - and isempty(tostring(Properties["agentops.test.kind"])) -| summarize LastCopilotSpan=max(TimeGenerated), CopilotSpans=count(), AgentOpsSpans=countif(Properties has "agentops."), FailedSpans=countif(Success == false or tostring(Success) =~ "false" or isnotempty(tostring(Properties["error.type"]))); -let collectorLogs = AppTraces -| where TimeGenerated > ago(lookback) -| where Message has_any ("otelcol", "azuremonitor", "exporter", "dropped", "retry", "queue", "queued", "sending_queue", "refused", "timeout", "backpressure", "memory_limiter") -| summarize LastCollectorLog=max(TimeGenerated), - CollectorErrors=countif(SeverityLevel >= 3 or Message has_any ("error", "failed", "dropped", "refused", "timeout")), - CollectorWarnings=countif(SeverityLevel == 2 or Message has "warn"), - QueueSignals=countif(Message has_any ("queue", "queued", "sending_queue", "enqueue")), - DroppedSignals=countif(Message has_any ("dropped", "drop", "refused")), - RetrySignals=countif(Message has_any ("retry", "retried", "retrying")), - TimeoutSignals=countif(Message has_any ("timeout", "timed out")), - BackpressureSignals=countif(Message has_any ("backpressure", "memory_limiter", "queue is full", "sending queue", "refused")); -copilot -| extend joinKey=1 -| join kind=fullouter (collectorLogs | extend joinKey=1) on joinKey -| project LastCopilotSpan, CopilotSpans, AgentOpsSpans, FailedSpans, LastCollectorLog, CollectorErrors, CollectorWarnings, QueueSignals, DroppedSignals, RetrySignals, TimeoutSignals, BackpressureSignals -| extend Health=case(isnull(LastCopilotSpan), "no_copilot_spans", CollectorErrors > 0, "collector_errors", BackpressureSignals > 0 or DroppedSignals > 0, "collector_backpressure", "healthy")`; -} - -function otelCompatibilityQuery(last = '2h') { - const lookback = validateKqlDuration(last); - const metricNames = copilotMetricNames.map(name => `"${name}"`).join(', '); - const eventNames = copilotEventNames.map(name => `"${name}"`).join(', '); - return `let lookback = ${lookback}; -let expected_metrics = dynamic([${metricNames}]); -let expected_events = dynamic([${eventNames}]); -let span_summary = AppDependencies -| where TimeGenerated > ago(lookback) -| where ${copilotOtelFilter} -| extend operation=tostring(Properties["gen_ai.operation.name"]), - service=coalesce(AppRoleName, tostring(Properties["service.name"])), - agent=tostring(Properties["gen_ai.agent.name"]), - conversation=tostring(Properties["gen_ai.conversation.id"]), - interaction=tostring(Properties["github.copilot.interaction_id"]), - model=tostring(Properties["gen_ai.request.model"]), - tool=tostring(Properties["gen_ai.tool.name"]), - input_tokens=todouble(Properties["gen_ai.usage.input_tokens"]), - output_tokens=todouble(Properties["gen_ai.usage.output_tokens"]), - cost=todouble(Properties["github.copilot.cost"]), - aiu=todouble(Properties["github.copilot.aiu"]) -| summarize - Spans=count(), - Services=make_set_if(service, isnotempty(service), 10), - Operations=make_set_if(operation, isnotempty(operation), 10), - Agents=make_set_if(agent, isnotempty(agent), 10), - HasOperation=countif(isnotempty(operation)), - HasSession=countif(isnotempty(conversation) or isnotempty(interaction)), - HasModel=countif(isnotempty(model)), - HasTool=countif(isnotempty(tool)), - HasTokenUsage=countif(isnotnull(input_tokens) or isnotnull(output_tokens)), - HasCostOrAIU=countif(isnotnull(cost) or isnotnull(aiu)), - LastSpan=max(TimeGenerated) -| extend joinKey=1; -let metric_summary = union isfuzzy=true AppMetrics -| where TimeGenerated > ago(lookback) -| where Name in (expected_metrics) or tostring(Properties) has_any ("gen_ai", "github.copilot", "copilot_chat") -| summarize - Metrics=count(), - MetricNames=make_set(Name, 50), - HasGenAiMetrics=countif(Name startswith "gen_ai."), - HasCopilotCliMetrics=countif(Name startswith "github.copilot."), - HasVsCodeMetrics=countif(Name startswith "copilot_chat."), - LastMetric=max(TimeGenerated) -| extend joinKey=1; -let event_summary = union isfuzzy=true AppTraces, AppEvents -| where TimeGenerated > ago(lookback) -| extend event=coalesce(tostring(Properties["event.name"]), tostring(Properties["github.copilot.event.name"]), Name) -| where event in (expected_events) or tostring(Properties) has_any ("github.copilot", "copilot_chat", "gen_ai.client.inference") or Message has_any ("github.copilot", "copilot_chat", "gen_ai.client.inference") -| summarize - Events=count(), - EventNames=make_set_if(event, isnotempty(event), 50), - HasLifecycleEvents=countif(event has_any ("session", "hook", "skill", "exception")), - HasVsCodeEvents=countif(event startswith "copilot_chat."), - LastEvent=max(TimeGenerated) -| extend joinKey=1; -span_summary -| join kind=fullouter metric_summary on joinKey -| join kind=fullouter event_summary on joinKey -| extend Status=case(Spans == 0, "missing", - HasOperation == 0 or HasSession == 0, "partial", - HasModel == 0 or HasTokenUsage == 0, "partial", - "ready") -| extend Missing=pack_array( - iff(Spans == 0, "no Copilot/GenAI spans matched", ""), - iff(Spans > 0 and HasOperation == 0, "gen_ai.operation.name", ""), - iff(Spans > 0 and HasSession == 0, "gen_ai.conversation.id or github.copilot.interaction_id", ""), - iff(Spans > 0 and HasModel == 0, "gen_ai.request.model", ""), - iff(Spans > 0 and HasTokenUsage == 0, "gen_ai.usage.input_tokens/output_tokens", ""), - iff(Spans > 0 and HasCostOrAIU == 0, "github.copilot.cost or github.copilot.aiu", ""), - iff(coalesce(Metrics, 0) == 0, "no Copilot/GenAI metrics matched", ""), - iff(coalesce(Events, 0) == 0, "no Copilot/GenAI events matched", "") -) -| project Status, Spans=coalesce(Spans, 0), Metrics=coalesce(Metrics, 0), Events=coalesce(Events, 0), LastSpan, LastMetric, LastEvent, Services, Operations, Agents, MetricNames, EventNames, HasOperation, HasSession, HasModel, HasTool, HasTokenUsage, HasCostOrAIU, HasGenAiMetrics, HasCopilotCliMetrics, HasVsCodeMetrics, HasLifecycleEvents, HasVsCodeEvents, Missing`; -} - -function attributionUsageQuery(last = '7d') { - const lookback = validateKqlDuration(last); - return `let lookback = ${lookback}; -let dependency_rows = AppDependencies -| where TimeGenerated > ago(lookback) -| where ${copilotOtelFilter} -| extend conversation=${sessionKey}, - operation=tostring(Properties["gen_ai.operation.name"]), - agentops_agent=coalesce(tostring(Properties["agentops.agent.name"]), tostring(Properties["agentops.cli.agent"]), tostring(Properties["gen_ai.agent.name"])), - skill=coalesce(tostring(Properties["agentops.skill.name"]), tostring(Properties["github.copilot.skill.name"])), - tool=tostring(Properties["gen_ai.tool.name"]), - mcp_server=coalesce(tostring(Properties["agentops.mcp.server"]), tostring(Properties["agentops.mcp.config.servers"]), extract("^mcp__([^_]+)__", 1, tostring(Properties["gen_ai.tool.name"])), extract("^([^/]+)/", 1, tostring(Properties["gen_ai.tool.name"])), iff(tostring(Properties["gen_ai.tool.name"]) startswith "azure-mcp-", "azure-mcp", "")), - script=coalesce(tostring(Properties["agentops.script.name"]), tostring(Properties["agentops.hook.name"]), tostring(Properties["github.copilot.hook.name"]), tostring(Properties["github.copilot.hook.type"])), - model=tostring(Properties["gen_ai.request.model"]), - repo=tostring(Properties["agentops.repo.hash"]), - error=tostring(Properties["error.type"]), - InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), - OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), - AICredits=todouble(Properties["github.copilot.cost"]), - AIU=todouble(Properties["github.copilot.aiu"]) -| project TimeGenerated, conversation, operation, agentops_agent, skill, tool, mcp_server, script, model, repo, DurationMs, Success, error, InputTokens, OutputTokens, AICredits, AIU, Properties; -let event_rows = union isfuzzy=true AppTraces, AppEvents -| where TimeGenerated > ago(lookback) -| where tostring(Properties) has_any ("agentops.", "github.copilot", "copilot_chat", "codex") or Message has_any ("AgentOps", "github.copilot", "copilot_chat", "codex") -| extend conversation=${sessionKey}, - operation=coalesce(tostring(Properties["gen_ai.operation.name"]), tostring(Properties["event.name"]), Name), - agentops_agent=coalesce(tostring(Properties["agentops.agent.name"]), tostring(Properties["agentops.cli.agent"]), tostring(Properties["gen_ai.agent.name"])), - skill=coalesce(tostring(Properties["agentops.skill.name"]), tostring(Properties["github.copilot.skill.name"])), - tool=tostring(Properties["gen_ai.tool.name"]), - mcp_server=coalesce(tostring(Properties["agentops.mcp.server"]), tostring(Properties["agentops.mcp.config.servers"]), extract("^mcp__([^_]+)__", 1, tostring(Properties["gen_ai.tool.name"])), extract("^([^/]+)/", 1, tostring(Properties["gen_ai.tool.name"])), iff(tostring(Properties["gen_ai.tool.name"]) startswith "azure-mcp-", "azure-mcp", "")), - script=coalesce(tostring(Properties["agentops.script.name"]), tostring(Properties["agentops.hook.name"]), tostring(Properties["github.copilot.hook.name"]), tostring(Properties["github.copilot.hook.type"])), - model=tostring(Properties["gen_ai.request.model"]), - repo=tostring(Properties["agentops.repo.hash"]), - error=tostring(Properties["error.type"]) -| project TimeGenerated, conversation, operation, agentops_agent, skill, tool, mcp_server, script, model, repo, DurationMs=real(null), Success=bool(null), error, InputTokens=real(null), OutputTokens=real(null), AICredits=real(null), AIU=real(null), Properties; -union isfuzzy=true dependency_rows, event_rows -| extend AttributionKind=case(isnotempty(skill), "skill", isnotempty(mcp_server), "mcp", isnotempty(script), "script_or_hook", isnotempty(agentops_agent), "agent", "unattributed"), - AttributionName=case(isnotempty(skill), skill, isnotempty(mcp_server), mcp_server, isnotempty(script), script, isnotempty(agentops_agent), agentops_agent, "unattributed") -| summarize Started=min(TimeGenerated), LastSeen=max(TimeGenerated), Sessions=dcount(conversation), SpansOrEvents=count(), Failures=countif(Success == false or isnotempty(error)), ToolCalls=countif(operation == "execute_tool" or isnotempty(tool)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), AICredits=sum(AICredits), AIU=sum(AIU), Models=make_set_if(model, isnotempty(model), 5), Tools=make_set_if(tool, isnotempty(tool), 10), Errors=make_set_if(error, isnotempty(error), 10) by AttributionKind, AttributionName -| extend EstUsd=round(AICredits * 0.01, 4), FailurePct=iff(SpansOrEvents > 0, round(100.0 * Failures / SpansOrEvents, 1), 0.0) -| where AttributionKind != "unattributed" -| order by Sessions desc, Failures desc, AICredits desc`; -} - -function kqlFileQuery(fileName, last = '7d') { - const query = fs.readFileSync(path.join(root, 'kql', fileName), 'utf8'); - return query.replace(/let lookback = [^;]+;/, `let lookback = ${last};`); -} - -function buildLink(kind, id, options = {}) { - const last = options.last || '24h'; - if (kind === 'session') { - return { - kind, - conversation: id, - grafana_url: `${grafanaBaseUrl}/d/agentops-session-detail?var-conversation=${encodeGrafanaValue(id)}`, - azure_portal_url: portalLogsUrl, - workspace_id: workspaceId, - query: sessionQuery(id, last) - }; - } - - if (kind === 'trace') { - return { - kind, - operation_id: id, - grafana_url: `${grafanaBaseUrl}/d/agentops-traces-spans?var-conversation=__all`, - azure_portal_url: portalLogsUrl, - workspace_id: workspaceId, - query: traceQuery(id, last) - }; - } - - throw new Error(`Unknown link kind: ${kind}`); -} - -function parseLastArg(args, fallback = '7d') { - const index = args.indexOf('--last'); - if (index === -1) return fallback; - if (!args[index + 1]) throw new Error('--last requires a duration, for example 7d or 24h'); - return args[index + 1]; -} - -function validateKqlDuration(value) { - if (!/^[1-9][0-9]*(s|m|h|d)$/.test(value)) { - throw new Error('--last must be a duration like 30m, 24h, or 7d'); - } - return value; -} - -function durationToMs(value, fallbackMs) { - if (value === undefined || value === null || value === '') return fallbackMs; - if (typeof value === 'number') return value; - const match = String(value).match(/^([0-9]+)(ms|s|m|h)$/); - if (!match) throw new Error('duration must look like 500ms, 10s, 2m, or 1h'); - const amount = Number(match[1]); - const unit = match[2]; - if (unit === 'ms') return amount; - if (unit === 's') return amount * 1000; - if (unit === 'm') return amount * 60 * 1000; - return amount * 60 * 60 * 1000; -} - -function escapeKqlString(value) { - return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); -} - -function commandPlan(command, args = [], platform = process.platform) { - const isWindows = platform === 'win32'; - const scriptPath = script => path.join(root, 'scripts', script); - const compactEnv = values => Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== '')); - const cloudEnv = () => { - const cloud = configuredCloudValues(); - return compactEnv({ - AZURE_SUBSCRIPTION_ID: cloud.subscriptionId, - AZURE_RESOURCE_GROUP: cloud.resourceGroup, - APPLICATIONINSIGHTS_NAME: cloud.appInsightsName, - AGENTOPS_AZURE_SUBSCRIPTION_ID: cloud.subscriptionId, - AGENTOPS_AZURE_RESOURCE_GROUP: cloud.resourceGroup, - AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID: cloud.workspaceId, - AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME: cloud.workspaceName, - AGENTOPS_GRAFANA_BASE_URL: cloud.grafanaBaseUrl, - AGENTOPS_GRAFANA_NAME: cloud.grafanaName, - AGENTOPS_GRAFANA_DATASOURCE_UID: cloud.grafanaDatasourceUid, - AGENTOPS_APPLICATIONINSIGHTS_NAME: cloud.appInsightsName - }); - }; - - if (command === 'install') { - const shadow = !(args.includes('--no-shadow-copilot') || args.includes('--no-shadow')); - const passThrough = args.filter(arg => !['--shadow-copilot', '--shadow'].includes(arg)); - const psInstallArgs = []; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (['--shadow-copilot', '--shadow', '--no-shadow-copilot', '--no-shadow'].includes(arg)) continue; - if (arg === '--no-collector') psInstallArgs.push('-NoCollector'); - else if (arg === '--force-collector') psInstallArgs.push('-ForceCollector'); - else if (arg === '--plugin') psInstallArgs.push('-Plugin'); - else if (arg === '--collector-version') { - psInstallArgs.push('-CollectorVersion', args[index + 1]); - index += 1; - } else if (arg.startsWith('--collector-version=')) { - psInstallArgs.push('-CollectorVersion', arg.slice('--collector-version='.length)); - } else { - psInstallArgs.push(arg); - } - } - return isWindows - ? { - command: 'pwsh', - args: [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-File', - path.join(root, 'install-agentops.ps1'), - ...(shadow ? ['-ShadowCopilot'] : ['-NoShadowCopilot']), - ...psInstallArgs - ] - } - : { - command: path.join(root, 'install-agentops.sh'), - args: shadow ? passThrough : ['--no-shadow-copilot', ...passThrough.filter(arg => !['--no-shadow-copilot', '--no-shadow'].includes(arg))] - }; - } - - if (command === 'enable-shadow') { - return isWindows - ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('install-copilot-agentops-shim.ps1'), '-ShadowCopilot'] } - : { command: scriptPath('install-copilot-agentops-shim.sh'), args: ['--shadow-copilot'] }; - } - - if (command === 'disable-shadow') { - return isWindows - ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('uninstall-copilot-agentops-shim.ps1'), '-KeepAgentopsCommand'] } - : { command: scriptPath('uninstall-copilot-agentops-shim.sh'), args: ['--keep-agentops-command'] }; - } - - if (command === 'uninstall') { - const psUninstallArgs = args.map(arg => ({ - '--keep-plugin': '-KeepPlugin', - '--keep-collector': '-KeepCollector', - '--keep-binary': '-KeepBinary', - '--purge': '-Purge', - '--keep-agentops-command': '-KeepAgentopsCommand' - }[arg] || arg)); - return isWindows - ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', path.join(root, 'uninstall-agentops.ps1'), ...psUninstallArgs] } - : { command: path.join(root, 'uninstall-agentops.sh'), args }; - } - - if (command === 'copilot') { - return isWindows - ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('copilot-agentops.ps1'), ...args], env: cloudEnv() } - : { command: scriptPath('copilot-agentops'), args, env: cloudEnv() }; - } - - if (command === 'codex') { - return isWindows - ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('agentops-codex.ps1'), ...args], env: cloudEnv() } - : { command: scriptPath('agentops-codex'), args, env: cloudEnv() }; - } - - if (command === 'collector' || command === 'start' || command === 'stop') { - const action = command === 'start' ? 'start' : command === 'stop' ? 'stop' : args[0]; - if (action === 'start') { - return isWindows - ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('collector-azuremonitor-up.ps1')], env: cloudEnv() } - : { command: scriptPath('collector-azuremonitor-up.sh'), args: [], env: cloudEnv() }; - } - if (action === 'stop') { - return { command: 'docker', args: ['compose', '-f', path.join(root, 'collector', 'docker-compose.azuremonitor.yaml'), 'down'], env: cloudEnv() }; - } - throw new Error('collector requires start or stop'); - } - - throw new Error(`No command plan for: ${command}`); -} - -function runPlannedCommand(plan) { - let executable = plan.command; - if (executable === 'pwsh' && process.platform === 'win32' && commandCandidates('pwsh').length === 0) { - executable = 'powershell.exe'; - } - - const result = childProcess.spawnSync(executable, plan.args, { stdio: 'inherit', env: { ...process.env, ...(plan.env || {}) } }); - if (result.error) throw result.error; - process.exitCode = result.status === null ? 1 : result.status; -} - -function walk(dir, predicate, results = []) { - if (!fs.existsSync(dir)) return results; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) walk(fullPath, predicate, results); - if (entry.isFile() && predicate(fullPath)) results.push(fullPath); - } - return results; -} - -function parseFrontmatter(filePath) { - const text = fs.readFileSync(filePath, 'utf8'); - if (!text.startsWith('---')) return {}; - const end = text.indexOf('\n---', 3); - if (end === -1) return {}; - const yaml = text.slice(3, end).trim(); - const data = {}; - - for (const line of yaml.split(/\r?\n/)) { - const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); - if (!match) continue; - const value = match[2].trim().replace(/^['"]|['"]$/g, ''); - data[match[1]] = value; - } - - return data; -} - -function hashText(value) { - return crypto.createHash('sha256').update(value).digest('hex'); -} - -function repoHash() { - const gitConfig = path.join(root, '.git', 'config'); - if (!fs.existsSync(gitConfig)) return hashText('unknown'); - const text = fs.readFileSync(gitConfig, 'utf8'); - const match = text.match(/url = (.+)/); - return hashText(match ? match[1].trim() : 'unknown'); -} - -function commandCandidates(commandName) { - const pathValue = process.env.PATH || ''; - const pathExt = process.platform === 'win32' - ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) - : ['']; - const names = process.platform === 'win32' && !path.extname(commandName) - ? pathExt.map(ext => `${commandName}${ext.toLowerCase()}`).concat(pathExt.map(ext => `${commandName}${ext.toUpperCase()}`)) - : [commandName]; - const seen = new Set(); - const results = []; - - for (const dir of pathValue.split(path.delimiter).filter(Boolean)) { - for (const name of names) { - const candidate = path.join(dir, name); - const key = process.platform === 'win32' ? candidate.toLowerCase() : candidate; - if (seen.has(key)) continue; - seen.add(key); - if (fs.existsSync(candidate)) results.push(candidate); - } - } - - return results; -} - -function installedShimStatus(installDir = defaultInstallDir) { - const shadowName = process.platform === 'win32' ? 'copilot.cmd' : 'copilot'; - const agentopsName = process.platform === 'win32' ? 'copilot-agentops.cmd' : 'copilot-agentops'; - const agentopsCliName = process.platform === 'win32' ? 'agentops.cmd' : 'agentops'; - const shadowPath = path.join(installDir, shadowName); - const agentopsPath = path.join(installDir, agentopsName); - const agentopsCliPath = path.join(installDir, agentopsCliName); - const copilotCommands = commandCandidates('copilot'); - const installDirFull = path.resolve(installDir); - const firstCopilot = copilotCommands[0] || null; - const shadowInstalled = fs.existsSync(shadowPath); - const shadowFirst = firstCopilot ? path.resolve(firstCopilot).startsWith(installDirFull) : false; - const realCopilot = copilotCommands.find(candidate => !path.resolve(candidate).startsWith(installDirFull)) || null; - - return { - install_dir: installDir, - agentops_cli_installed: fs.existsSync(agentopsCliPath), - agentops_cli_path: agentopsCliPath, - copilot_agentops_installed: fs.existsSync(agentopsPath), - copilot_agentops_path: agentopsPath, - shadow_installed: shadowInstalled, - shadow_path: shadowPath, - plain_copilot_observed: shadowInstalled && shadowFirst, - first_copilot_on_path: firstCopilot, - real_copilot: realCopilot, - copilot_candidates: copilotCommands - }; -} - -function defaultCopilotHome() { - return process.env.AGENTOPS_COPILOT_HOME || process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot'); -} - -function listDefaultSkills(sourceDir = path.join(root, 'plugin', 'skills')) { - if (!fs.existsSync(sourceDir)) return []; - - return fs.readdirSync(sourceDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => { - const skillDir = path.join(sourceDir, entry.name); - const skillFile = path.join(skillDir, 'SKILL.md'); - if (!fs.existsSync(skillFile)) return null; - const frontmatter = parseFrontmatter(skillFile); - return { - name: frontmatter.name || entry.name, - directory: entry.name, - description: frontmatter.description || '', - source: skillFile - }; - }) - .filter(Boolean) - .sort((left, right) => left.name.localeCompare(right.name)); -} - -function listDefaultAgents(sourceDir = path.join(root, 'plugin', 'agents')) { - if (!fs.existsSync(sourceDir)) return []; - - return fs.readdirSync(sourceDir, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith('.agent.md')) - .map(entry => { - const agentFile = path.join(sourceDir, entry.name); - const frontmatter = parseFrontmatter(agentFile); - return { - name: frontmatter.name || entry.name.replace(/\.agent\.md$/, ''), - file: entry.name, - description: frontmatter.description || '', - source: agentFile - }; - }) - .sort((left, right) => left.name.localeCompare(right.name)); -} - -function skillInstallTarget(options = {}) { - const copilotHome = path.resolve(options.copilotHome || defaultCopilotHome()); - const targetDir = path.resolve(options.skillsDir || path.join(copilotHome, 'skills')); - return { copilotHome, targetDir }; -} - -function agentInstallTarget(options = {}) { - const copilotHome = path.resolve(options.copilotHome || defaultCopilotHome()); - const targetDir = path.resolve(options.agentsDir || path.join(copilotHome, 'agents')); - return { copilotHome, targetDir }; -} - -function installDefaultSkills(options = {}) { - const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'skills')); - const { copilotHome, targetDir } = skillInstallTarget(options); - const force = Boolean(options.force); - const dryRun = Boolean(options.dryRun); - const skills = listDefaultSkills(sourceDir); - const installedSkills = []; - const updated = []; - const skipped = []; - - if (!dryRun) fs.mkdirSync(targetDir, { recursive: true }); - - for (const skill of skills) { - const sourceSkillDir = path.dirname(skill.source); - const targetSkillDir = path.join(targetDir, skill.directory); - const targetExists = fs.existsSync(targetSkillDir); - - if (targetExists && !force) { - skipped.push({ name: skill.name, target: targetSkillDir, reason: 'exists' }); - continue; - } - - if (!dryRun) { - if (targetExists) fs.rmSync(targetSkillDir, { recursive: true, force: true }); - fs.cpSync(sourceSkillDir, targetSkillDir, { recursive: true }); - } - - const record = { name: skill.name, target: targetSkillDir }; - if (targetExists) updated.push(record); - else installedSkills.push(record); - } - - return { - copilotHome, - targetDir, - sourceDir, - force, - dryRun, - skills, - installed: installedSkills.length, - installedSkills, - updated, - skipped - }; -} - -function uninstallDefaultSkills(options = {}) { - const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'skills')); - const { copilotHome, targetDir } = skillInstallTarget(options); - const dryRun = Boolean(options.dryRun); - const skills = listDefaultSkills(sourceDir); - const removed = []; - const missing = []; - - for (const skill of skills) { - const targetSkillDir = path.join(targetDir, skill.directory); - if (!fs.existsSync(targetSkillDir)) { - missing.push({ name: skill.name, target: targetSkillDir }); - continue; - } - - if (!dryRun) fs.rmSync(targetSkillDir, { recursive: true, force: true }); - removed.push({ name: skill.name, target: targetSkillDir }); - } - - return { - copilotHome, - targetDir, - sourceDir, - dryRun, - skills, - removed, - missing - }; -} - -function installDefaultAgents(options = {}) { - const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'agents')); - const { copilotHome, targetDir } = agentInstallTarget(options); - const force = Boolean(options.force); - const dryRun = Boolean(options.dryRun); - const agents = listDefaultAgents(sourceDir); - const installedAgents = []; - const updated = []; - const skipped = []; - - if (!dryRun) fs.mkdirSync(targetDir, { recursive: true }); - - for (const agent of agents) { - const targetFile = path.join(targetDir, agent.file); - const targetExists = fs.existsSync(targetFile); - - if (targetExists && !force) { - skipped.push({ name: agent.name, target: targetFile, reason: 'exists' }); - continue; - } - - if (!dryRun) { - fs.copyFileSync(agent.source, targetFile); - } - - const record = { name: agent.name, target: targetFile }; - if (targetExists) updated.push(record); - else installedAgents.push(record); - } - - return { - copilotHome, - targetDir, - sourceDir, - force, - dryRun, - agents, - installed: installedAgents.length, - installedAgents, - updated, - skipped - }; -} - -function uninstallDefaultAgents(options = {}) { - const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'agents')); - const { copilotHome, targetDir } = agentInstallTarget(options); - const dryRun = Boolean(options.dryRun); - const agents = listDefaultAgents(sourceDir); - const removed = []; - const missing = []; - - for (const agent of agents) { - const targetFile = path.join(targetDir, agent.file); - if (!fs.existsSync(targetFile)) { - missing.push({ name: agent.name, target: targetFile }); - continue; - } - - if (!dryRun) fs.rmSync(targetFile, { force: true }); - removed.push({ name: agent.name, target: targetFile }); - } - - return { - copilotHome, - targetDir, - sourceDir, - dryRun, - agents, - removed, - missing - }; -} - -function plural(count, singular, pluralValue = `${singular}s`) { - return `${count} ${count === 1 ? singular : pluralValue}`; -} - -function renderSkillsInstall(result) { - const lines = [ - `Installed AgentOps skills into ${result.targetDir}.`, - `${plural(result.installed, 'new skill')}; ${plural(result.updated.length, 'updated skill')}; skipped ${plural(result.skipped.length, 'existing skill')}.` - ]; - - if (result.skills.length > 0) { - lines.push('', 'Available skills:'); - for (const skill of result.skills) lines.push(`- ${skill.name}`); - const starterSkill = result.skills.find(skill => skill.name === 'agentops-live-triage') || result.skills[0]; - lines.push('', `Ask Copilot: Use ${starterSkill.name} to inspect the latest AgentOps run.`); - } - - if (result.skipped.length > 0) { - lines.push('Run `agentops skills install --force` to refresh skipped skills from this repo.'); - } - - return `${lines.join('\n')}\n`; -} - -function renderSkillsUninstall(result) { - const lines = [ - `Removed AgentOps skills from ${result.targetDir}.`, - `${plural(result.removed.length, 'skill')} removed; ${plural(result.missing.length, 'skill')} already absent.` - ]; - return `${lines.join('\n')}\n`; -} - -function renderAgentsInstall(result) { - const lines = [ - `Installed AgentOps agents into ${result.targetDir}.`, - `${plural(result.installed, 'new agent')}; ${plural(result.updated.length, 'updated agent')}; skipped ${plural(result.skipped.length, 'existing agent')}.` - ]; - - if (result.agents.length > 0) { - lines.push('', 'Available agents:'); - for (const agent of result.agents) lines.push(`- ${agent.name}`); - const starterAgent = result.agents.find(agent => agent.name === 'agentops-orchestrator') || result.agents[0]; - lines.push('', `Ask Copilot: Use ${starterAgent.name} to route my AgentOps question.`); - } - - if (result.skipped.length > 0) { - lines.push('Run `agentops agents install --force` to refresh skipped agents from this repo.'); - } - - return `${lines.join('\n')}\n`; -} - -function renderAgentsUninstall(result) { - const lines = [ - `Removed AgentOps agents from ${result.targetDir}.`, - `${plural(result.removed.length, 'agent')} removed; ${plural(result.missing.length, 'agent')} already absent.` - ]; - return `${lines.join('\n')}\n`; -} - -function installPlugin(options = {}) { - return { - agents: installDefaultAgents(options), - skills: installDefaultSkills(options) - }; -} - -function uninstallPlugin(options = {}) { - return { - agents: uninstallDefaultAgents(options), - skills: uninstallDefaultSkills(options) - }; -} - -function renderPluginInstall(result) { - const lines = [ - 'Installed AgentOps Copilot plugin files.', - `Agents: ${plural(result.agents.installed, 'new agent')}; ${plural(result.agents.updated.length, 'updated agent')}; skipped ${plural(result.agents.skipped.length, 'existing agent')}.`, - `Skills: ${plural(result.skills.installed, 'new skill')}; ${plural(result.skills.updated.length, 'updated skill')}; skipped ${plural(result.skills.skipped.length, 'existing skill')}.`, - '', - 'Ask Copilot: Use agentops-orchestrator to run the first read-only AgentOps check.', - 'Remove later with `agentops plugin uninstall`.' - ]; - return `${lines.join('\n')}\n`; -} - -function renderPluginUninstall(result) { - const lines = [ - 'Removed AgentOps Copilot plugin files.', - `Agents: ${plural(result.agents.removed.length, 'agent')} removed; ${plural(result.agents.missing.length, 'agent')} already absent.`, - `Skills: ${plural(result.skills.removed.length, 'skill')} removed; ${plural(result.skills.missing.length, 'skill')} already absent.` - ]; - return `${lines.join('\n')}\n`; -} - -function parseSkillsArgs(args) { - const subcommand = args[0] || 'install'; - const rest = args.slice(1); - return { - subcommand, - copilotHome: optionValue(rest, ['--copilot-home', '--home']), - force: rest.includes('--force'), - dryRun: rest.includes('--dry-run'), - json: rest.includes('--json') - }; -} - -function agentopsWorkflows() { - const cli = 'node agentops-cli/src/index.js'; - return [ - { - name: 'setup', - skill: 'agentops-setup', - description: 'Install the local Collector binary, local shim, and safe defaults.', - prompt: 'Use agentops-setup to check my AgentOps install and tell me the next command to run.', - commands: [ - `${cli} setup`, - `${cli} init --full`, - `${cli} validate-enterprise`, - 'az login', - 'azd provision', - `${cli} install`, - './setup-agentops.sh', - './setup-agentops.ps1', - `${cli} configure show`, - `${cli} configure import-azd`, - `${cli} init --dry-run`, - `${cli} validate-azure`, - `${cli} collector smoke --privacy strict --poison`, - `${cli} smoke --real-copilot --wait 2m --poll 10s --open-browser`, - `${cli} plugin install`, - `${cli} status`, - `${cli} doctor --local-only` - ] - }, - { - name: 'orchestrate', - skill: 'agentops-orchestrator', - description: 'Route setup, triage, attribution, dashboard, benchmark, and operations questions to the right AgentOps skill.', - prompt: 'Use agentops-orchestrator to figure out which AgentOps workflow I need and run the first read-only check.', - commands: [ - `${cli} workflows list`, - `${cli} workflows show setup`, - `${cli} workflows show latest-run`, - `${cli} workflows show attribution`, - `${cli} workflows show dashboard`, - `${cli} workflows show science-mode`, - `${cli} workflows show operations` - ] - }, - { - name: 'latest-run', - skill: 'agentops-latest-run', - description: 'Find, open, and inspect the latest observed Copilot CLI run.', - prompt: 'Use agentops-latest-run to find my latest AgentOps run, open the Run Replay link, explain it, and recommend one next action.', - commands: [ - 'copilot -p "Reply with exactly: agentops smoke."', - `${cli} ask-context latest --last 2h`, - `${cli} open latest --last 2h`, - `${cli} latest --last 7d`, - `${cli} explain latest --last 7d`, - `${cli} recommend latest --last 7d`, - `${cli} live --last 2h`, - `${cli} replay latest --last 7d` - ] - }, - { - name: 'attribution', - skill: 'agentops-attribution', - description: 'Filter telemetry by custom agent, skill, MCP server/tool, script, or hook.', - prompt: 'Use agentops-attribution to show usage, failures, cost, and tools for my custom agents, skills, MCP servers, and hooks.', - commands: [ - `${cli} attribution --last 7d`, - `${cli} primitives --last 7d`, - `${cli} mcp --last 7d`, - `${cli} lineage --last 24h`, - `${cli} link session <conversation-id>` - ] - }, - { - name: 'dashboard', - skill: 'agentops-dashboard-ops', - description: 'Open, rebuild, import, and deep-link Grafana dashboards.', - prompt: 'Use agentops-dashboard-ops to open the AgentOps dashboard and create a link for this session.', - commands: [ - `${cli} open`, - `${cli} link session <conversation>`, - `${cli} link trace <operationId>`, - 'node scripts/build-grafana-dashboard-pack.js', - 'AZURE_RESOURCE_GROUP=rg-agentops-dev GRAFANA_NAME=graf-agentops-dev ./scripts/grafana-import-dashboard.sh' - ] - }, - { - name: 'science-mode', - skill: 'agentops-benchmark-gate', - description: 'Run repeatable benchmark checks before keeping agent changes.', - prompt: 'Use agentops-benchmark-gate to compare my baseline and candidate benchmark runs.', - commands: [ - `${cli} benchmark list`, - `${cli} benchmark fixture-pack benchmarks/starter/fixtures/tiny-repo --id tiny-repo-sealed --fixture fixtures/tiny-repo --output benchmarks/starter/fixture-packs/tiny-repo.json`, - `${cli} benchmark fixture-pack benchmarks/starter/fixtures/tiny-repo --id tiny-repo-sealed --fixture fixtures/tiny-repo --sign-key-id eval-fixtures-v1 --sign-private-key keys/eval-fixtures-v1.pem --output benchmarks/starter/fixture-packs/tiny-repo.json`, - `${cli} benchmark judge-provider`, - `${cli} benchmark run starter --variant baseline --repeat 1 --hypothesis safer-tool-policy --dry-run`, - `${cli} benchmark run starter --variant baseline --repeat 1 --hypothesis safer-tool-policy`, - `${cli} benchmark approve <run-id> --by alice@example.com --ticket CHG-123 --output approvals/<run-id>.json`, - `${cli} benchmark artifacts <run-id> --task create-note --include-content`, - `${cli} benchmark report <run-id>`, - `${cli} benchmark compare <baseline-run-id> <variant-run-id> --azure --last 24h` - ] - }, - { - name: 'judge-provider', - skill: 'agentops-benchmark-gate', - description: 'Wire a hosted LLM judge CLI into benchmark semantic checks without storing prompts or secrets.', - prompt: 'Use agentops-benchmark-gate to configure a hosted llm-judge provider for my benchmark suite.', - commands: [ - `${cli} benchmark judge-provider`, - `${cli} benchmark judge-provider --json`, - 'AGENTOPS_JUDGE_ENDPOINT=https://judge.example.com AGENTOPS_JUDGE_TOKEN=... benchmark-judges/hosted-judge.sh notes/hello.txt note-quality' - ] - }, - { - name: 'offline-test', - skill: 'agentops-live-triage', - description: 'Use local JSONL fixtures when Azure telemetry is not available.', - prompt: 'Use agentops-live-triage with the sample JSONL fixture to explain a local tool failure.', - commands: [ - `${cli} latest --file tests/sample-otel/tool-failure.jsonl`, - `${cli} explain latest --file tests/sample-otel/tool-failure.jsonl`, - `${cli} recommend latest --file tests/sample-otel/tool-failure.jsonl`, - `${cli} live --file tests/sample-otel/tool-failure.jsonl`, - `${cli} replay latest --file tests/sample-otel/tool-failure.jsonl` - ] - }, - { - name: 'analyst-mode', - skill: 'agentops-evidence-prompts', - description: 'Generate read-only KQL, links, saved views, and investigation prompts.', - prompt: 'Use agentops-evidence-prompts to investigate the last 24 hours and propose one safe improvement.', - commands: [ - `${cli} fields --last 7d`, - `${cli} context --last 7d`, - `${cli} token-rollup-audit --last 14d`, - `${cli} collector-health --last 24h`, - `${cli} policy --last 7d`, - `${cli} mcp --last 7d`, - `${cli} lineage --last 24h`, - `${cli} permission-friction --last 7d`, - `${cli} alert recommend --last 14d`, - `${cli} ask-context latest --last 24h`, - `${cli} saved-view add latest-risk --session <conversation-id> --tag risk`, - `${cli} saved-view list` - ] - }, - { - name: 'primitive-inventory', - skill: 'agentops-primitive-inventory', - description: 'Show which agents, skills, hooks, MCP tools, and other primitives are configured or observed.', - prompt: 'Use agentops-primitive-inventory to inventory this repo and explain any missing runtime signals.', - commands: [ - `${cli} primitives --last 7d`, - `${cli} primitives --root /path/to/awesome-copilot --last 7d` - ] - }, - { - name: 'operations', - skill: 'agentops-operations', - description: 'Check health, stop collector, disable shadowing, or uninstall safely.', - prompt: 'Use agentops-operations to check health and choose the safest cleanup command.', - commands: [ - `${cli} status`, - `${cli} validate-collector`, - `${cli} collector-health --last 24h`, - `${cli} disable-shadow`, - `${cli} collector stop`, - `${cli} plugin uninstall`, - `${cli} uninstall` - ] - } - ]; -} - -function parseWorkflowsArgs(args) { - return { - subcommand: args[0] || 'list', - name: args[1], - json: args.includes('--json') - }; -} - -function renderWorkflow(workflow) { - const lines = [ - `${workflow.name}: ${workflow.description}`, - `Skill: ${workflow.skill}`, - `Ask Copilot: ${workflow.prompt}`, - '', - 'Commands:' - ]; - for (const command of workflow.commands) lines.push(`- ${command}`); - return `${lines.join('\n')}\n`; -} - -function renderWorkflowsList(workflows = agentopsWorkflows()) { - const lines = ['AgentOps workflows', '']; - for (const workflow of workflows) { - lines.push(`- ${workflow.name}: ${workflow.description}`); - lines.push(` Skill: ${workflow.skill}`); - lines.push(` Ask: ${workflow.prompt}`); - } - lines.push('', 'Run `agentops workflows show <name>` to print the commands for one workflow.'); - return `${lines.join('\n')}\n`; -} - -function optionValue(args, names) { - for (const name of names) { - const index = args.indexOf(name); - if (index !== -1) { - if (!args[index + 1]) throw new Error(`${name} requires a value`); - return args[index + 1]; - } - } - return null; -} - -function checkByName(checks, name) { - return checks.find(check => check.name === name); -} - -function agentopsStatusSummary({ checks = doctor({ localOnly: true }) } = {}) { - const required = checks.filter(check => check.name.startsWith('exists:')); - const missing = required.filter(check => !check.ok).map(check => check.name.slice('exists:'.length)); - const contentCapture = checkByName(checks, 'content-capture-disabled'); - const httpLocal = checkByName(checks, 'collector-http-localhost'); - const grpcLocal = checkByName(checks, 'collector-grpc-localhost'); - const agentopsCli = checkByName(checks, 'agentops-command'); - const agentopsShim = checkByName(checks, 'copilot-agentops-command'); - const shadowShim = checkByName(checks, 'plain-copilot-shadow'); - - return { - ok: checks.every(check => check.ok), - required_files: { - found: required.length - missing.length, - total: required.length, - missing - }, - content_capture_off: Boolean(contentCapture?.ok), - collector_localhost: Boolean(httpLocal?.ok && grpcLocal?.ok), - shim: { - agentops_cli: agentopsCli?.status || 'unknown', - agentops_command: agentopsShim?.status || 'unknown', - shadow: shadowShim?.status || 'unknown', - first_copilot_on_path: shadowShim?.first_copilot_on_path || null, - real_copilot: shadowShim?.real_copilot || null - } - }; -} - -function renderStatus(summary = agentopsStatusSummary()) { - const lines = [ - 'AgentOps status', - '', - `Required files: ${summary.required_files.found} of ${summary.required_files.total} found.` - ]; - - if (summary.required_files.missing.length > 0) { - lines.push(`Missing files: ${summary.required_files.missing.join(', ')}.`); - } - - lines.push(summary.content_capture_off - ? 'Content capture: off. Prompts/code were not recorded.' - : 'Content capture: on. Turn it off before sharing telemetry.'); - lines.push(summary.collector_localhost - ? 'Collector config: localhost for HTTP and gRPC.' - : 'Collector config: not confirmed as localhost.'); - - const agentopsCli = summary.shim.agentops_cli === 'installed' ? 'installed' : 'not installed'; - const agentopsCommand = summary.shim.agentops_command === 'installed' ? 'installed' : 'not installed'; - const shadow = summary.shim.shadow === 'observed' - ? 'plain copilot is routed through AgentOps' - : summary.shim.shadow === 'installed_not_first_on_path' - ? 'installed, but not first on PATH' - : summary.shim.shadow === 'not_installed' - ? 'plain copilot shadow is not installed' - : summary.shim.shadow.replace(/_/g, ' '); - lines.push(`Shim: agentops is ${agentopsCli}; copilot-agentops is ${agentopsCommand}; ${shadow}.`); - - return `${lines.join('\n')}\n`; -} - -function setupToolStatus(name, options = {}) { - if (name === 'node') { - return { name, ok: true, path: process.execPath, version: process.version }; - } - - const availability = options.commandAvailability || {}; - if (Object.prototype.hasOwnProperty.call(availability, name)) { - return { - name, - ok: Boolean(availability[name]), - path: options.commandPaths?.[name] || null - }; - } - - const candidates = commandCandidates(name); - return { name, ok: candidates.length > 0, path: candidates[0] || null }; -} - -function azdEnvironmentStatus(options = {}, azdAvailable = true) { - if (!azdAvailable) { - return { checked: false, ok: false, values: {}, detail: 'azd is not available on PATH.' }; - } - - if (Object.prototype.hasOwnProperty.call(options, 'azdValues')) { - const values = configFromEnvValues(parseEnvAssignments(options.azdValues)); - return { - checked: true, - ok: Object.keys(values).length > 0, - values, - detail: Object.keys(values).length > 0 - ? 'azd environment contains AgentOps outputs.' - : 'azd environment does not contain AgentOps outputs yet.' - }; - } - - const spawnSync = options.spawnSync || childProcess.spawnSync; - const result = spawnSync('azd', ['env', 'get-values'], { - encoding: 'utf8', - maxBuffer: 1024 * 1024 - }); - - if (result.error) { - return { checked: true, ok: false, values: {}, detail: result.error.message }; - } - if (result.status !== 0) { - const rawDetail = (result.stderr || result.stdout || `azd exited with status ${result.status}`).trim(); - const detail = /out of date/i.test(rawDetail) && !/error|failed|not found/i.test(rawDetail) - ? 'azd env get-values did not return AgentOps outputs. Run azd provision or select the right azd environment.' - : rawDetail; - return { - checked: true, - ok: false, - values: {}, - detail - }; - } - - const values = configFromEnvValues(parseEnvAssignments(result.stdout)); - return { - checked: true, - ok: Object.keys(values).length > 0, - values, - detail: Object.keys(values).length > 0 - ? 'azd environment contains AgentOps outputs.' - : 'azd environment does not contain AgentOps outputs yet.' - }; -} - -function parseSetupArgs(args) { - return { - json: args.includes('--json') - }; -} - -function agentopsSetupGuide(options = {}) { - const tools = ['node', 'az', 'azd', 'docker', 'copilot'] - .map(name => setupToolStatus(name, options)); - const toolByName = Object.fromEntries(tools.map(tool => [tool.name, tool])); - const shim = installedShimStatus(options.installDir || defaultInstallDir); - const cloud = configuredCloudValues(options); - const workspaceConfigured = isConfiguredValue(cloud.workspaceId, /^0{8}-0{4}-0{4}-0{4}-0{12}$/); - const grafanaConfigured = isConfiguredValue(cloud.grafanaBaseUrl, /your-grafana|<your-grafana>|^$/); - const cloudConfigured = workspaceConfigured && grafanaConfigured; - const azd = azdEnvironmentStatus(options, toolByName.azd.ok); - const dashboardCloud = cloudConfigured ? cloud : { ...cloud, ...azd.values }; - - const phases = [ - { - name: '1. Provision Azure once', - status: cloudConfigured ? 'done' : (azd.ok ? 'ready-to-import' : 'needed'), - commands: cloudConfigured ? ['agentops configure show'] : ['az login', 'azd provision'], - verify: 'agentops configure import-azd' - }, - { - name: '2. Install local wrapper', - status: shim.agentops_cli_installed && shim.copilot_agentops_installed ? 'done' : 'needed', - commands: [ - './setup-agentops.sh', - 'export PATH="$HOME/.local/bin:$PATH"' - ], - verify: 'agentops status' - }, - { - name: '3. Bind local CLI to Azure outputs', - status: cloudConfigured ? 'done' : (azd.ok ? 'needed' : 'blocked'), - commands: cloudConfigured - ? ['agentops configure show'] - : azd.ok - ? ['agentops configure import-azd'] - : ['agentops configure set --resource-group <resource-group> --workspace-id <workspace-id> --grafana-url https://<your-grafana>.grafana.azure.com --grafana-name <grafana-resource-name> --app-insights-name <app-insights-name>'], - verify: 'agentops configure show' - }, - { - name: '4. Validate and smoke test', - status: cloudConfigured ? 'ready' : 'blocked', - commands: [ - 'agentops validate-enterprise', - 'agentops validate-azure', - 'agentops collector smoke --privacy strict --poison' - ], - verify: 'agentops latest --last 2h' - }, - { - name: '5. Observe a real run', - status: cloudConfigured ? 'ready' : 'blocked', - commands: [ - 'copilot -p "Reply with exactly: agentops smoke."', - 'agentops latest --last 2h', - 'agentops open' - ], - verify: 'Open the newest row in the Sessions dashboard.' - } - ]; - - const firstRun = { - name: 'First-run loop', - ready: cloudConfigured && shim.agentops_cli_installed && shim.copilot_agentops_installed, - read_only: true, - setup_command: 'agentops setup', - guided_command: 'agentops init --full', - bind_command: cloudConfigured - ? 'agentops configure show' - : azd.ok - ? 'agentops configure import-azd' - : 'az login && azd provision && agentops configure import-azd', - privacy_smoke_command: 'agentops collector smoke --privacy strict --poison --json', - smoke_command: 'agentops smoke --real-copilot --wait 2m --poll 10s --open-browser', - run_command: realCopilotSmokeCommand(), - latest_command: 'agentops latest --last 2h', - replay_command: 'agentops replay latest --last 2h', - open_command: 'agentops open latest --last 2h', - dashboard_import_command: grafanaDashboardImportCommand(dashboardCloud), - dashboard_verify_command: 'agentops dashboard verify --live --last 24h --json', - content_command: 'agentops content status --json', - privacy_note: 'Prompts and responses stay off by default. Use agentops content opt-in only when you intentionally want transcript rows.' - }; - - const next = []; - const missingTools = tools.filter(tool => !tool.ok).map(tool => tool.name); - if (missingTools.length > 0) { - next.push(`Install missing tools: ${missingTools.join(', ')}.`); - } - if (!workspaceConfigured || !grafanaConfigured) { - if (azd.ok) { - next.push('agentops configure import-azd'); - } else { - next.push('az login'); - next.push('azd provision'); - next.push('agentops configure import-azd'); - } - } - if (!shim.agentops_cli_installed || !shim.copilot_agentops_installed) { - next.push('./setup-agentops.sh'); - } - if (!shim.plain_copilot_observed) { - next.push('export PATH="$HOME/.local/bin:$PATH"'); - } - next.push('agentops init --full'); - next.push('agentops validate-enterprise'); - next.push('agentops validate-azure'); - next.push('agentops collector smoke --privacy strict --poison'); - next.push('copilot -p "Reply with exactly: agentops smoke."'); - next.push('agentops latest --last 2h'); - next.push('agentops open'); - - return { - ok: tools.every(tool => tool.ok) && - shim.agentops_cli_installed && - shim.copilot_agentops_installed && - cloudConfigured, - mode: 'guide', - mutates: false, - tools, - azd, - shim, - first_run: firstRun, - cloud: { - resource_group: cloud.resourceGroup, - workspace_id_configured: workspaceConfigured, - workspace_name: cloud.workspaceName || null, - grafana_url_configured: grafanaConfigured, - grafana_name: cloud.grafanaName || null, - app_insights_name: cloud.appInsightsName || null - }, - phases, - next - }; -} - -function renderSetupGuide(result) { - const lines = [ - 'AgentOps setup guide', - '', - 'This command is read-only. It does not create Azure resources or change local files.', - '', - 'Detected tools:' - ]; - - for (const tool of result.tools) { - const detail = tool.path ? ` (${tool.path})` : ''; - const version = tool.version ? ` ${tool.version}` : ''; - lines.push(`- ${tool.name}: ${tool.ok ? 'found' : 'missing'}${version}${detail}`); - } - - lines.push('', `azd environment: ${result.azd.ok ? 'AgentOps outputs found' : result.azd.detail}`); - lines.push(`Local shim: agentops=${result.shim.agentops_cli_installed ? 'installed' : 'missing'}, copilot-agentops=${result.shim.copilot_agentops_installed ? 'installed' : 'missing'}, plain copilot=${result.shim.plain_copilot_observed ? 'observed' : 'not observed'}.`); - lines.push(`Cloud config: workspace=${result.cloud.workspace_id_configured ? 'set' : 'missing'}, grafana=${result.cloud.grafana_url_configured ? 'set' : 'missing'}.`); - - lines.push('', 'One-minute first run:'); - lines.push(`1. Guided path: ${result.first_run.guided_command}`); - lines.push(`2. Setup/bind fallback: ${result.first_run.bind_command}`); - lines.push(`3. Privacy smoke fallback: ${result.first_run.privacy_smoke_command}`); - lines.push(`4. Real smoke fallback: ${result.first_run.smoke_command}`); - lines.push(`5. See it: the smoke opens Run Replay, or run ${result.first_run.latest_command} && ${result.first_run.open_command}`); - lines.push(`6. Dashboards: ${result.first_run.dashboard_import_command} && ${result.first_run.dashboard_verify_command}`); - lines.push(`Privacy: ${result.first_run.privacy_note}`); - - lines.push('', 'Fastest path:'); - for (const phase of result.phases) { - lines.push('', `${phase.name} (${phase.status})`); - for (const command of phase.commands) lines.push(` ${command}`); - lines.push(` verify: ${phase.verify}`); - } - - lines.push('', 'Run next:'); - for (const command of result.next) lines.push(`- ${command}`); - return `${lines.join('\n')}\n`; -} - -function scan() { - const agents = walk(path.join(root, 'plugin', 'agents'), file => file.endsWith('.agent.md')).map(file => ({ - path: path.relative(root, file), - definition_hash: hashText(fs.readFileSync(file, 'utf8')), - ...parseFrontmatter(file) - })); - - const skills = walk(path.join(root, 'plugin', 'skills'), file => path.basename(file) === 'SKILL.md').map(file => ({ - path: path.relative(root, file), - definition_hash: hashText(fs.readFileSync(file, 'utf8')), - ...parseFrontmatter(file) - })); - - const hookPath = path.join(root, 'plugin', 'hooks.json'); - const hooks = fs.existsSync(hookPath) ? JSON.parse(fs.readFileSync(hookPath, 'utf8')) : null; - - const mcpPath = path.join(root, 'plugin', '.mcp.json'); - const mcp = fs.existsSync(mcpPath) ? JSON.parse(fs.readFileSync(mcpPath, 'utf8')) : null; - - return { - repo_hash: repoHash(), - timestamp: new Date().toISOString(), - agents, - skills, - hooks, - mcp_servers: mcp ? Object.keys(mcp.mcpServers || mcp.servers || {}) : [] - }; -} - -function doctor({ localOnly }) { - const checks = []; - const requiredFiles = [ - 'copilot/copilot-observe', - 'copilot/copilot-observe.ps1', - 'collector/otelcol.local.yaml', - 'collector/docker-compose.yaml', - 'plugin/plugin.json', - 'plugin/hooks.json', - 'scripts/copilot-agentops', - 'scripts/copilot-agentops.ps1', - 'scripts/install-copilot-agentops-shim.sh', - 'scripts/install-copilot-agentops-shim.ps1', - 'scripts/uninstall-copilot-agentops-shim.sh', - 'scripts/uninstall-copilot-agentops-shim.ps1', - 'azure.yaml', - '.azure/deployment-plan.md' - ]; - - for (const file of requiredFiles) { - checks.push({ name: `exists:${file}`, ok: fs.existsSync(path.join(root, file)) }); - } - - const contentCapture = process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT === 'true'; - checks.push({ name: 'content-capture-disabled', ok: !contentCapture }); - - const localConfig = fs.readFileSync(path.join(root, 'collector', 'otelcol.local.yaml'), 'utf8'); - checks.push({ name: 'collector-http-localhost', ok: localConfig.includes('endpoint: 127.0.0.1:4318') }); - checks.push({ name: 'collector-grpc-localhost', ok: localConfig.includes('endpoint: 127.0.0.1:4317') }); - - const scanResult = scan(); - checks.push({ name: 'agents-present', ok: scanResult.agents.length >= 1 }); - checks.push({ name: 'skills-present', ok: scanResult.skills.length >= 1 }); - - const shim = installedShimStatus(); - checks.push({ - name: 'agentops-command', - ok: true, - status: shim.agentops_cli_installed ? 'installed' : 'not_installed', - path: shim.agentops_cli_path - }); - checks.push({ - name: 'copilot-agentops-command', - ok: true, - status: shim.copilot_agentops_installed ? 'installed' : 'not_installed', - path: shim.copilot_agentops_path - }); - checks.push({ - name: 'plain-copilot-shadow', - ok: true, - status: shim.plain_copilot_observed ? 'observed' : (shim.shadow_installed ? 'installed_not_first_on_path' : 'not_installed'), - first_copilot_on_path: shim.first_copilot_on_path, - real_copilot: shim.real_copilot, - shadow_path: shim.shadow_path - }); - - if (!localOnly) { - checks.push({ name: 'azure-validation', ok: false, note: 'Run azure-validate before deployment.' }); - } - - return checks; -} - -function normalizeAgentOpsConfig(raw = {}) { - return { - subscriptionId: raw.subscriptionId || raw.azureSubscriptionId || raw.AZURE_SUBSCRIPTION_ID || raw.AGENTOPS_AZURE_SUBSCRIPTION_ID || '', - resourceGroup: raw.resourceGroup || raw.azureResourceGroup || raw.AZURE_RESOURCE_GROUP || raw.AGENTOPS_AZURE_RESOURCE_GROUP || '', - workspaceId: raw.workspaceId || raw.logAnalyticsWorkspaceId || raw.LOG_ANALYTICS_WORKSPACE_ID || raw.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID || '', - workspaceName: raw.workspaceName || raw.logAnalyticsWorkspaceName || raw.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME || '', - grafanaBaseUrl: (raw.grafanaBaseUrl || raw.grafanaUrl || raw.AGENTOPS_GRAFANA_BASE_URL || '').replace(/\/$/, ''), - grafanaName: raw.grafanaName || raw.GRAFANA_NAME || raw.AGENTOPS_GRAFANA_NAME || '', - grafanaDatasourceUid: raw.grafanaDatasourceUid || raw.datasourceUid || raw.AGENTOPS_GRAFANA_DATASOURCE_UID || '', - appInsightsName: raw.appInsightsName || raw.applicationInsightsName || raw.APPLICATIONINSIGHTS_NAME || raw.AGENTOPS_APPLICATIONINSIGHTS_NAME || '', - portalLogsUrl: raw.portalLogsUrl || raw.AGENTOPS_AZURE_PORTAL_LOGS_URL || '' - }; -} - -function compactConfig(config) { - return Object.fromEntries(Object.entries(normalizeAgentOpsConfig(config)).filter(([, value]) => value !== undefined && value !== null && value !== '')); -} - -function readAgentOpsConfig(options = {}) { - const configPath = options.configPath || defaultConfigPath; - if (!fs.existsSync(configPath)) { - return { path: configPath, exists: false, values: {} }; - } - - try { - return { - path: configPath, - exists: true, - values: compactConfig(JSON.parse(fs.readFileSync(configPath, 'utf8'))) - }; - } catch (error) { - if (options.quiet) return { path: configPath, exists: true, values: {}, error: error.message }; - throw new Error(`Could not read AgentOps config at ${configPath}: ${error.message}`); - } -} - -function writeAgentOpsConfig(values, options = {}) { - const configPath = options.configPath || defaultConfigPath; - const existing = readAgentOpsConfig({ configPath, quiet: true }).values; - const next = compactConfig({ ...existing, ...values }); - if (!options.dryRun) { - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`); - } - return { path: configPath, exists: true, values: next, dryRun: Boolean(options.dryRun) }; -} - -function parseEnvAssignments(text) { - const values = {}; - for (const line of String(text || '').split(/\r?\n/)) { - const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); - if (!match) continue; - let value = match[2].trim(); - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - values[match[1]] = value.replace(/\\"/g, '"'); - } - return values; -} - -function configFromEnvValues(values = {}) { - return compactConfig({ - subscriptionId: values.AGENTOPS_AZURE_SUBSCRIPTION_ID || values.AZURE_SUBSCRIPTION_ID, - resourceGroup: values.AGENTOPS_AZURE_RESOURCE_GROUP || values.AZURE_RESOURCE_GROUP, - workspaceId: values.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID || values.LOG_ANALYTICS_WORKSPACE_ID, - workspaceName: values.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME || values.LOG_ANALYTICS_WORKSPACE_NAME, - grafanaBaseUrl: values.AGENTOPS_GRAFANA_BASE_URL || values.GRAFANA_ENDPOINT, - grafanaName: values.AGENTOPS_GRAFANA_NAME || values.GRAFANA_NAME, - grafanaDatasourceUid: values.AGENTOPS_GRAFANA_DATASOURCE_UID, - appInsightsName: values.AGENTOPS_APPLICATIONINSIGHTS_NAME || values.APPLICATIONINSIGHTS_NAME, - portalLogsUrl: values.AGENTOPS_AZURE_PORTAL_LOGS_URL - }); -} - -function parseConfigureSetArgs(args) { - const map = { - '--subscription-id': 'subscriptionId', - '--resource-group': 'resourceGroup', - '--workspace-id': 'workspaceId', - '--workspace-name': 'workspaceName', - '--grafana-url': 'grafanaBaseUrl', - '--grafana-name': 'grafanaName', - '--datasource-uid': 'grafanaDatasourceUid', - '--app-insights-name': 'appInsightsName', - '--portal-logs-url': 'portalLogsUrl' - }; - const values = {}; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--json' || arg === '--dry-run') continue; - const key = map[arg]; - if (!key) throw new Error(`Unknown configure set option: ${arg}`); - if (!args[index + 1]) throw new Error(`${arg} requires a value`); - values[key] = args[index + 1]; - index += 1; - } - return compactConfig(values); -} - -function parseConfigureArgs(args) { - const subcommandIndex = args.findIndex(arg => !arg.startsWith('--')); - const subcommand = subcommandIndex === -1 ? 'show' : args[subcommandIndex]; - const subcommandArgs = subcommandIndex === -1 ? args : args.slice(subcommandIndex + 1); - return { - subcommand, - json: args.includes('--json'), - dryRun: args.includes('--dry-run'), - values: subcommand === 'set' ? parseConfigureSetArgs(subcommandArgs) : {} - }; -} - -function parseOtelSetupArgs(args = []) { - const options = { - endpoint: 'http://127.0.0.1:4318', - serviceName: 'github-copilot', - shell: 'bash', - captureContent: false - }; - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--endpoint') { - if (!args[index + 1]) throw new Error('--endpoint requires a URL'); - options.endpoint = args[index + 1]; - index += 1; - } else if (arg === '--service-name') { - if (!args[index + 1]) throw new Error('--service-name requires a value'); - options.serviceName = args[index + 1]; - index += 1; - } else if (arg === '--shell') { - if (!args[index + 1]) throw new Error('--shell requires bash, powershell, or json'); - options.shell = args[index + 1]; - index += 1; - } else if (arg === '--capture-content') { - options.captureContent = true; - } else { - throw new Error(`Unknown otel-setup option: ${arg}`); - } - } - if (!['bash', 'powershell', 'json'].includes(options.shell)) { - throw new Error('--shell must be bash, powershell, or json'); - } - return options; -} - -function shellQuote(value) { - return `'${String(value).replace(/'/g, "'\\''")}'`; -} - -function buildOtelSetup(options = {}) { - const endpoint = options.endpoint || 'http://127.0.0.1:4318'; - const serviceName = options.serviceName || 'github-copilot'; - const captureContent = Boolean(options.captureContent); - const resourceAttributes = [ - 'agent.framework=github-copilot', - `agent.runtime=${serviceName}`, - 'agentops.profile=bring-your-own-otel' - ].join(','); - const env = { - OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, - OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf', - OTEL_SERVICE_NAME: serviceName, - OTEL_RESOURCE_ATTRIBUTES: resourceAttributes, - COPILOT_OTEL_ENABLED: 'true', - COPILOT_OTEL_ENDPOINT: endpoint, - COPILOT_OTEL_EXPORTER_TYPE: 'otlp-http', - COPILOT_OTEL_PROTOCOL: 'http', - COPILOT_OTEL_CAPTURE_CONTENT: captureContent ? 'true' : 'false', - OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: captureContent ? 'true' : 'false' - }; - const vscode = { - 'github.copilot.chat.otel.enabled': true, - 'github.copilot.chat.otel.exporterType': 'otlp-http', - 'github.copilot.chat.otel.otlpEndpoint': endpoint, - 'github.copilot.chat.otel.captureContent': captureContent, - 'github.copilot.chat.otel.maxAttributeSizeChars': 0, - 'github.copilot.chat.otel.dbSpanExporter.enabled': false - }; - const fileExport = { - vscode: { - 'github.copilot.chat.otel.enabled': true, - 'github.copilot.chat.otel.exporterType': 'file', - 'github.copilot.chat.otel.outfile': './copilot-otel.jsonl', - 'github.copilot.chat.otel.captureContent': captureContent - }, - env: { - COPILOT_OTEL_ENABLED: 'true', - COPILOT_OTEL_EXPORTER_TYPE: 'file', - COPILOT_OTEL_FILE_EXPORTER_PATH: './copilot-otel.jsonl', - OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: captureContent ? 'true' : 'false', - COPILOT_OTEL_CAPTURE_CONTENT: captureContent ? 'true' : 'false' - } - }; - const sdkTypescript = `import { CopilotClient } from "@github/copilot-sdk"; - -const client = new CopilotClient({ - telemetry: { - otlpEndpoint: "${endpoint}", - exporterType: "otlp-http", - sourceName: "github.copilot", - captureContent: ${captureContent} - } -});`; - - return { endpoint, serviceName, captureContent, env, vscode, fileExport, sdkTypescript }; -} - -function renderOtelSetup(setup, options = {}) { - if (options.shell === 'json') return `${JSON.stringify(setup, null, 2)}\n`; - const lines = [ - 'AgentOps bring-your-own-OTel setup', - '', - 'VS Code settings.json:', - JSON.stringify(setup.vscode, null, 2), - '', - 'Copilot CLI terminal environment:' - ]; - - if (options.shell === 'powershell') { - for (const [key, value] of Object.entries(setup.env)) { - lines.push(`$env:${key} = "${String(value).replace(/"/g, '`"')}"`); - } - } else { - for (const [key, value] of Object.entries(setup.env)) { - lines.push(`export ${key}=${shellQuote(value)}`); - } - } - - lines.push( - '', - 'Copilot SDK TypeScript:', - setup.sdkTypescript, - '', - 'Optional JSONL file export for offline review:', - JSON.stringify(setup.fileExport, null, 2), - '', - 'Then point Copilot at the AgentOps collector and run:', - './scripts/collector-azuremonitor-up.sh', - 'agentops collector start', - 'agentops compat-check --last 2h', - '', - 'No installed CLI is required for ingestion; use kql/22-otel-compatibility.kql directly in Log Analytics if you want a pure manual check.' - ); - - return `${lines.join('\n')}\n`; -} - -function agentopsConfigure(options = {}) { - const configPath = options.configPath || defaultConfigPath; - const subcommand = options.subcommand || 'show'; - if (subcommand === 'show') { - return { action: 'show', ...readAgentOpsConfig({ configPath }) }; - } - if (subcommand === 'set') { - if (Object.keys(options.values || {}).length === 0) throw new Error('configure set requires at least one value'); - return { action: 'set', ...writeAgentOpsConfig(options.values, { configPath, dryRun: options.dryRun }) }; - } - if (subcommand === 'import-azd') { - const spawnSync = options.spawnSync || childProcess.spawnSync; - const result = spawnSync('azd', ['env', 'get-values'], { - encoding: 'utf8', - maxBuffer: 1024 * 1024 - }); - if (result.error) { - return { action: 'import-azd', path: configPath, ok: false, error: result.error.message }; - } - if (result.status !== 0) { - return { action: 'import-azd', path: configPath, ok: false, error: (result.stderr || result.stdout || `azd exited with status ${result.status}`).trim() }; - } - const values = configFromEnvValues(parseEnvAssignments(result.stdout)); - if (Object.keys(values).length === 0) { - return { action: 'import-azd', path: configPath, ok: false, error: 'azd env get-values did not include AgentOps configuration values' }; - } - return { action: 'import-azd', ok: true, ...writeAgentOpsConfig(values, { configPath, dryRun: options.dryRun }) }; - } - throw new Error('configure requires show, set, or import-azd'); -} - -function renderConfigure(result) { - const lines = ['AgentOps config', '', `Path: ${result.path}`]; - if (result.error) lines.push(`Status: ${result.error}`); - if (result.dryRun) lines.push('Mode: dry-run'); - const values = result.values || {}; - const labels = [ - ['subscriptionId', 'Subscription'], - ['resourceGroup', 'Resource group'], - ['workspaceId', 'Workspace ID'], - ['workspaceName', 'Workspace name'], - ['grafanaBaseUrl', 'Grafana URL'], - ['grafanaName', 'Grafana resource'], - ['grafanaDatasourceUid', 'Grafana datasource UID'], - ['appInsightsName', 'Application Insights'], - ['portalLogsUrl', 'Portal logs URL'] - ]; - for (const [key, label] of labels) { - lines.push(`${label}: ${values[key] || 'not set'}`); - } - lines.push('', 'Next:'); - lines.push('- agentops validate-azure'); - lines.push('- agentops collector smoke --privacy strict --poison'); - return `${lines.join('\n')}\n`; -} - -function configuredCloudValues(options = {}) { - const env = options.env || process.env; - const config = options.config || readAgentOpsConfig({ configPath: options.configPath, quiet: true }).values; - const optionValueOr = (key, ...values) => { - if (Object.prototype.hasOwnProperty.call(options, key)) return options[key]; - return values.find(value => value !== undefined && value !== null && value !== '') || ''; - }; - const configuredGrafanaBaseUrl = optionValueOr('grafanaBaseUrl', env.AGENTOPS_GRAFANA_BASE_URL, config.grafanaBaseUrl); - return { - subscriptionId: optionValueOr('subscriptionId', env.AGENTOPS_AZURE_SUBSCRIPTION_ID, env.AZURE_SUBSCRIPTION_ID, config.subscriptionId), - resourceGroup: optionValueOr('resourceGroup', env.AGENTOPS_AZURE_RESOURCE_GROUP, env.AZURE_RESOURCE_GROUP, config.resourceGroup, azureResourceGroup), - workspaceId: optionValueOr('workspaceId', env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID, env.LOG_ANALYTICS_WORKSPACE_ID, config.workspaceId), - workspaceName: optionValueOr('workspaceName', env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME, config.workspaceName, logAnalyticsWorkspaceName), - grafanaBaseUrl: configuredGrafanaBaseUrl.replace(/\/$/, ''), - grafanaName: optionValueOr('grafanaName', env.AGENTOPS_GRAFANA_NAME, env.GRAFANA_NAME, config.grafanaName), - grafanaDatasourceUid: optionValueOr('grafanaDatasourceUid', env.AGENTOPS_GRAFANA_DATASOURCE_UID, config.grafanaDatasourceUid, grafanaDatasourceUid), - appInsightsName: optionValueOr('appInsightsName', env.APPLICATIONINSIGHTS_NAME, env.AGENTOPS_APPLICATIONINSIGHTS_NAME, config.appInsightsName, 'appi-agentops-dev') - }; -} - -function listGrafanaDashboardFiles(options = {}) { - const dirs = [options.grafanaDir || path.join(root, 'grafana')]; - if (options.includeV2 !== false) dirs.push(path.join(root, 'grafana', 'dashboards', 'v2')); - return dirs.flatMap(dir => { - if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir) - .filter(file => file.endsWith('.json')) - .map(file => path.join(dir, file)); - }) - .map(fullPath => { - const dashboard = readJson(fullPath); - return { - file: path.relative(root, fullPath), - uid: dashboard.uid || path.basename(file, '.json'), - title: dashboard.title || path.basename(file, '.json') - }; - }) - .sort((left, right) => left.uid.localeCompare(right.uid)); -} - -function flattenGrafanaList(payload) { - if (Array.isArray(payload)) return payload; - if (Array.isArray(payload?.value)) return payload.value; - if (Array.isArray(payload?.items)) return payload.items; - if (Array.isArray(payload?.dashboards)) return payload.dashboards; - if (Array.isArray(payload?.dataSources)) return payload.dataSources; - if (Array.isArray(payload?.datasources)) return payload.datasources; - return []; -} - -function grafanaItemUid(item) { - return item?.uid || item?.dashboard?.uid || item?.model?.uid || item?.slug || item?.name || item?.title || ''; -} - -function grafanaDashboardImportCommand(cloud) { - const args = [ - 'agentops dashboard import --yes', - cloud.resourceGroup ? `--resource-group ${cloud.resourceGroup}` : null, - cloud.grafanaName ? `--grafana-name ${cloud.grafanaName}` : null - ].filter(Boolean); - return args.join(' '); -} - -function runGrafanaDashboardImportRemediation(cloud, options = {}) { - const args = [ - path.join(root, 'agentops-cli', 'src', 'index.js'), - 'dashboard', - 'import', - '--yes', - ...(cloud.resourceGroup ? ['--resource-group', cloud.resourceGroup] : []), - ...(cloud.grafanaName ? ['--grafana-name', cloud.grafanaName] : []) - ]; - const spawnSync = options.spawnDashboardImport || options.spawnSync || childProcess.spawnSync; - const result = spawnSync(process.execPath, args, { - encoding: 'utf8', - maxBuffer: 20 * 1024 * 1024 - }); - return { - ok: result.status === 0, - command: grafanaDashboardImportCommand(cloud), - status: result.status, - stdout: result.stdout || '', - stderr: result.stderr || '', - error: result.error?.message || null - }; -} - -function isConfiguredValue(value, placeholderPattern) { - return Boolean(value) && !placeholderPattern.test(value); -} - -function parseInitArgs(args) { - const full = args.includes('--full'); - return { - dryRun: args.includes('--dry-run'), - full, - forceSkills: args.includes('--force-skills') || args.includes('--force'), - json: args.includes('--json'), - importDashboards: full || args.includes('--import-dashboards'), - noSkills: args.includes('--no-skills') || args.includes('--no-plugin'), - provisionCloud: full || args.includes('--provision-cloud'), - runSmoke: full || args.includes('--run-smoke'), - triageLatest: full || args.includes('--triage-latest'), - copilotHome: optionValue(args, ['--copilot-home', '--home']) - }; -} - -function runInitCloudProvision(options = {}) { - const spawnSync = options.spawnSync || childProcess.spawnSync; - const command = options.azdCommand || 'azd'; - const provisionArgs = options.azdProvisionArgs || ['provision']; - const commandText = `${command} ${provisionArgs.join(' ')}`; - if (options.dryRun) { - return { - requested: true, - dry_run: true, - ok: true, - command: commandText, - import_result: { dryRun: true, action: 'import-azd' }, - failing_stage: null, - next: [] - }; - } - - const provision = spawnSync(command, provisionArgs, { - encoding: 'utf8', - maxBuffer: 20 * 1024 * 1024 - }); - const provisionOk = provision.status === 0 && !provision.error; - const importResult = provisionOk - ? agentopsConfigure({ - subcommand: 'import-azd', - configPath: options.configPath, - dryRun: false, - spawnSync - }) - : null; - const importOk = importResult?.ok === true; - const failingStage = !provisionOk ? 'azd provision' : importOk ? null : 'agentops configure import-azd'; - const next = !provisionOk - ? [ - 'az login', - 'azd env list', - commandText, - 'agentops init --dry-run --provision-cloud' - ] - : importOk - ? [] - : [ - 'azd env get-values', - 'agentops configure import-azd', - 'agentops configure set --workspace-id "<workspace-id>"', - 'agentops configure set --grafana-url "https://<your-grafana>.grafana.azure.com"' - ]; - - return { - requested: true, - dry_run: false, - ok: provisionOk && importOk, - command: commandText, - failing_stage: failingStage, - provision: { - ok: provisionOk, - status: provision.status, - stdout: provision.stdout || '', - stderr: provision.stderr || '', - error: provision.error?.message || null - }, - import_result: importResult, - next - }; -} - -function runInitDashboardImport(options = {}) { - const command = 'agentops validate-azure --import-dashboards --last 24h'; - if (!options.importDashboards) { - return { - requested: false, - dry_run: Boolean(options.dryRun), - ok: null, - command - }; - } - - if (options.dryRun) { - return { - requested: true, - dry_run: true, - ok: true, - command, - validation: null, - next: [] - }; - } - - const validate = options.validateAzure || validateAzure; - const validation = validate({ - ...options, - last: '24h', - importDashboards: true, - production: false, - remediationPlan: false - }); - - return { - requested: true, - dry_run: false, - ok: validation.ok === true, - command, - validation, - next: validation.ok === true - ? ['node agentops-cli/src/index.js collector smoke --privacy strict --poison --json'] - : (Array.isArray(validation.next) && validation.next.length ? validation.next : [command]) - }; -} - -function runInitRealSmoke(options = {}) { - const args = ['smoke', '--real-copilot', '--wait', '2m', '--poll', '10s', '--open-browser', '--json']; - const command = 'agentops smoke --real-copilot --wait 2m --poll 10s --open-browser --json'; - if (!options.runSmoke) { - return { - requested: false, - dry_run: Boolean(options.dryRun), - ok: null, - command - }; - } - - if (options.dryRun) { - return { - requested: true, - dry_run: true, - ok: true, - command, - status: null, - next: [] - }; - } - - const spawnSync = options.spawnSync || childProcess.spawnSync; - const result = spawnSync(process.execPath, [path.join(__dirname, 'index.js'), ...args], { - cwd: options.cwd || process.cwd(), - env: { ...process.env, ...(options.env || {}) }, - encoding: 'utf8', - timeout: durationToMs(options.smokeTimeoutMs ?? options.timeout, 180000), - maxBuffer: 20 * 1024 * 1024 - }); - const status = result.status === null || result.status === undefined ? 1 : result.status; - let smoke = null; - try { - smoke = result.stdout ? JSON.parse(result.stdout) : null; - } catch { - smoke = null; - } - - const ok = status === 0 && !result.error && smoke?.ok !== false; - return { - requested: true, - dry_run: false, - ok, - command, - status, - stdout: result.stdout || '', - stderr: result.stderr || '', - error: result.error?.message || null, - smoke, - next: ok - ? ['node agentops-cli/src/index.js open latest --last 2h'] - : [command, 'node agentops-cli/src/index.js latest --last 2h', 'node agentops-cli/src/index.js open latest --last 2h'] - }; -} - -function runInitLatestTriage(options = {}) { - const args = ['triage', 'latest', '--out', '.agentops/triage/latest', '--json']; - const command = 'agentops triage latest --out .agentops/triage/latest --json'; - if (!options.triageLatest) { - return { - requested: false, - dry_run: Boolean(options.dryRun), - ok: null, - command - }; - } - - if (options.dryRun) { - return { - requested: true, - dry_run: true, - ok: true, - command, - status: null, - next: [] - }; - } - - const spawnSync = options.spawnSync || childProcess.spawnSync; - const result = spawnSync(process.execPath, [path.join(__dirname, 'index.js'), ...args], { - cwd: options.cwd || process.cwd(), - env: { ...process.env, ...(options.env || {}) }, - encoding: 'utf8', - maxBuffer: 20 * 1024 * 1024 - }); - const status = result.status === null || result.status === undefined ? 1 : result.status; - let triage = null; - try { - triage = result.stdout ? JSON.parse(result.stdout) : null; - } catch { - triage = null; - } - - const ok = status === 0 && !result.error && triage?.ok !== false; - return { - requested: true, - dry_run: false, - ok, - command, - status, - stdout: result.stdout || '', - stderr: result.stderr || '', - error: result.error?.message || null, - triage, - next: ok - ? [] - : [command, 'node agentops-cli/src/index.js latest --last 2h', 'node agentops-cli/src/index.js open latest --last 2h'] - }; -} - -function buildInitSummary(result) { - const stages = [ - ['cloud_provision', result.cloud_provision], - ['dashboard_import', result.dashboard_import], - ['real_smoke', result.real_smoke], - ['triage_latest', result.triage_latest] - ] - .filter(([, stage]) => stage?.requested) - .map(([name, stage]) => ({ - name, - status: stage.ok === true ? 'ready' : 'needs_review', - command: stage.command - })); - const nextAction = result.next?.[0] || 'node agentops-cli/src/index.js open latest --last 2h'; - const ready = result.ok === true; - return { - status: ready ? 'ready' : 'needs_action', - next_action: ready ? 'node agentops-cli/src/index.js open latest --last 2h' : nextAction, - detail: ready - ? 'AgentOps first-run path is ready; open the latest run or continue with normal Copilot work.' - : `Run next: ${nextAction}`, - stages - }; -} - -function agentopsInit(options = {}) { - const checks = doctor({ localOnly: true }); - const status = agentopsStatusSummary({ checks }); - const cloud = configuredCloudValues(options); - const shim = installedShimStatus(options.installDir || defaultInstallDir); - const skills = options.noSkills - ? null - : installDefaultSkills({ - copilotHome: options.copilotHome, - force: options.forceSkills, - dryRun: options.dryRun - }); - const agents = options.noSkills - ? null - : installDefaultAgents({ - copilotHome: options.copilotHome, - force: options.forceSkills, - dryRun: options.dryRun - }); - const workspaceConfigured = isConfiguredValue(cloud.workspaceId, /^0{8}-0{4}-0{4}-0{4}-0{12}$/); - const grafanaConfigured = isConfiguredValue(cloud.grafanaBaseUrl, /your-grafana|<your-grafana>|^$/); - const azdTool = setupToolStatus('azd', options); - const azd = azdEnvironmentStatus(options, azdTool.ok); - const cloudProvision = options.provisionCloud - ? runInitCloudProvision(options) - : { - requested: false, - dry_run: Boolean(options.dryRun), - ok: null, - command: 'agentops init --provision-cloud' - }; - const dashboardImport = runInitDashboardImport(options); - const realSmoke = runInitRealSmoke(options); - const latestTriage = runInitLatestTriage(options); - const next = []; - - if (!shim.agentops_cli_installed) { - next.push('./install-agentops.sh'); - } - if (!shim.plain_copilot_observed) { - next.push('node agentops-cli/src/index.js enable-shadow'); - } - if (!workspaceConfigured || !grafanaConfigured) { - if (azd.ok) { - next.push('agentops configure import-azd'); - } else if (!options.provisionCloud) { - next.push('agentops init --provision-cloud'); - } else { - if (!workspaceConfigured) { - next.push('agentops configure set --workspace-id "<workspace-id>"'); - } - if (!grafanaConfigured) { - next.push('agentops configure set --grafana-url "https://<your-grafana>.grafana.azure.com"'); - } - } - } - if (dashboardImport.requested && dashboardImport.ok !== true) { - for (const command of dashboardImport.next || []) next.push(command); - } else if (!dashboardImport.requested) { - next.push('node agentops-cli/src/index.js validate-azure --import-dashboards --last 24h'); - } - next.push('node agentops-cli/src/index.js collector smoke --privacy strict --poison --json'); - if (realSmoke.requested && realSmoke.ok !== true) { - for (const command of realSmoke.next || []) next.push(command); - } else if (!realSmoke.requested) { - next.push('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s'); - next.push('node agentops-cli/src/index.js latest --last 2h'); - next.push('node agentops-cli/src/index.js open latest --last 2h'); - } else { - next.push('node agentops-cli/src/index.js open latest --last 2h'); - } - if (latestTriage.requested && latestTriage.ok !== true) { - for (const command of latestTriage.next || []) next.push(command); - } else if (!latestTriage.requested) { - next.push('node agentops-cli/src/index.js triage latest --out .agentops/triage/latest --json'); - } - next.push('node agentops-cli/src/index.js plugin uninstall'); - - const initOk = status.ok && Boolean(shim.agentops_cli_installed) && Boolean(shim.copilot_agentops_installed) && workspaceConfigured && grafanaConfigured && (!dashboardImport.requested || dashboardImport.ok === true) && (!realSmoke.requested || realSmoke.ok === true) && (!latestTriage.requested || latestTriage.ok === true); - const result = { - ok: initOk, - mode: options.dryRun ? 'dry-run' : 'local-init', - local_status: status, - azd, - cloud_provision: cloudProvision, - dashboard_import: dashboardImport, - real_smoke: realSmoke, - triage_latest: latestTriage, - skills, - agents, - shim, - cloud: { - resource_group: cloud.resourceGroup, - workspace_id_configured: workspaceConfigured, - grafana_url_configured: grafanaConfigured, - grafana_name_configured: Boolean(cloud.grafanaName), - app_insights_name: cloud.appInsightsName - }, - next - }; - result.summary = buildInitSummary(result); - return result; -} - -function renderInit(result) { - const lines = [ - 'AgentOps init', - '', - `Mode: ${result.mode}.`, - `Local files: ${result.local_status.required_files.found} of ${result.local_status.required_files.total} found.`, - result.local_status.content_capture_off - ? 'Content capture: off.' - : 'Content capture: on; turn it off before sharing telemetry.', - result.local_status.collector_localhost - ? 'Collector config: localhost confirmed.' - : 'Collector config: localhost not confirmed.', - `Shim: agentops=${result.shim.agentops_cli_installed ? 'installed' : 'missing'}, copilot-agentops=${result.shim.copilot_agentops_installed ? 'installed' : 'missing'}, plain copilot=${result.shim.plain_copilot_observed ? 'observed' : 'not observed'}.`, - `Cloud config: workspace=${result.cloud.workspace_id_configured ? 'set' : 'missing'}, grafana=${result.cloud.grafana_url_configured ? 'set' : 'missing'}.`, - `azd environment: ${result.azd.ok ? 'AgentOps outputs found.' : result.azd.detail}` - ]; - - if (result.cloud_provision.requested) { - lines.push(`Cloud provision: ${result.cloud_provision.ok ? 'ready' : 'needs review'} (${result.cloud_provision.command}).`); - if (!result.cloud_provision.ok && result.cloud_provision.failing_stage) { - lines.push(`Cloud provision failed at: ${result.cloud_provision.failing_stage}.`); - } - if (!result.cloud_provision.ok && result.cloud_provision.next?.length) { - lines.push('Cloud provision next:'); - for (const command of result.cloud_provision.next) lines.push(`- ${command}`); - } - } - if (result.dashboard_import?.requested) { - lines.push(`Dashboard import: ${result.dashboard_import.ok ? 'ready' : 'needs review'} (${result.dashboard_import.command}).`); - if (!result.dashboard_import.ok && result.dashboard_import.next?.length) { - lines.push('Dashboard import next:'); - for (const command of result.dashboard_import.next) lines.push(`- ${command}`); - } - } - if (result.real_smoke?.requested) { - lines.push(`Real smoke: ${result.real_smoke.ok ? 'ready' : 'needs review'} (${result.real_smoke.command}).`); - if (!result.real_smoke.ok && result.real_smoke.next?.length) { - lines.push('Real smoke next:'); - for (const command of result.real_smoke.next) lines.push(`- ${command}`); - } - } - if (result.triage_latest?.requested) { - lines.push(`Latest triage: ${result.triage_latest.ok ? 'ready' : 'needs review'} (${result.triage_latest.command}).`); - if (!result.triage_latest.ok && result.triage_latest.next?.length) { - lines.push('Latest triage next:'); - for (const command of result.triage_latest.next) lines.push(`- ${command}`); - } - } - - if (result.skills) { - lines.push(`Skills: ${plural(result.skills.installed, 'new skill')}; ${plural(result.skills.updated.length, 'updated skill')}; skipped ${plural(result.skills.skipped.length, 'existing skill')}.`); - } - if (result.agents) { - lines.push(`Agents: ${plural(result.agents.installed, 'new agent')}; ${plural(result.agents.updated.length, 'updated agent')}; skipped ${plural(result.agents.skipped.length, 'existing agent')}.`); - } - - if (result.summary) { - const stages = result.summary.stages?.length - ? ` Stages: ${result.summary.stages.map(stage => `${stage.name}=${stage.status}`).join(', ')}.` - : ''; - lines.push(`Summary: ${result.summary.status}. ${result.summary.detail}${stages}`); - } - - lines.push('', 'Next commands:'); - for (const command of result.next) lines.push(`- ${command}`); - lines.push('', 'First value: run the real smoke, then open the V2 Run Replay link it prints.'); - lines.push('', 'Plugin files are reversible: run `agentops plugin uninstall` to remove only the bundled AgentOps agents and skills from Copilot home.'); - return `${lines.join('\n')}\n`; -} - -function smokeId(now = new Date()) { - const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); - return `agentops-smoke-${stamp}-${crypto.randomBytes(3).toString('hex')}`; -} - -function attributionSmokeId(now = new Date()) { - const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); - return `agentops-attribution-smoke-${stamp}-${crypto.randomBytes(3).toString('hex')}`; -} - -function liveReplaySmokeId(now = new Date()) { - const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); - return `agentops-live-replay-smoke-${stamp}-${crypto.randomBytes(3).toString('hex')}`; -} - -function liveReplayGrafanaUrl(id, last = '2h') { - return `${grafanaBaseUrl}/d/agentops-live-replay/agentops-live-replay?from=now-${encodeGrafanaValue(validateKqlDuration(last))}&to=now&timezone=browser&refresh=30s&var-conversation=${encodeGrafanaValue(id)}&var-agentops_agent=__all&var-mcp_server=__all&var-tool=__all`; -} - -function smokeAzureQuery(id, last = '2h') { - const lookback = validateKqlDuration(last); - const escapedId = escapeKqlString(id); - return `AppDependencies\n| where TimeGenerated > ago(${lookback})\n| where Properties has "${escapedId}" or Name has "${escapedId}"\n| project TimeGenerated, Name, OperationId, Id, Success, ResultCode, Properties\n| order by TimeGenerated desc\n| take 20`; -} - -function otlpSmokeTracePayload(id, nowMs = Date.now()) { - const traceId = crypto.randomBytes(16).toString('hex'); - const spanId = crypto.randomBytes(8).toString('hex'); - const start = BigInt(nowMs) * 1000000n; - const end = start + 100000000n; - const attr = (key, stringValue) => ({ key, value: { stringValue } }); - - return { - resourceSpans: [ - { - resource: { - attributes: [ - attr('service.name', 'github-copilot-cli'), - attr('service.namespace', 'copilot-agentops'), - attr('agent.framework', 'github-copilot'), - attr('agent.runtime', 'github-copilot-cli'), - attr('agentops.profile', 'safe-default'), - attr('agentops.smoke_id', id) - ] - }, - scopeSpans: [ - { - scope: { name: 'agentops.smoke', version: '0.1.0' }, - spans: [ - { - traceId, - spanId, - name: `agentops.smoke.${id}`, - kind: 1, - startTimeUnixNano: start.toString(), - endTimeUnixNano: end.toString(), - attributes: [ - attr('agentops.smoke_id', id), - attr('gen_ai.operation.name', 'smoke_test'), - { key: 'content.capture.enabled', value: { boolValue: false } } - ], - status: { code: 1 } - } - ] - } - ] - } - ] - }; -} - -function otlpAttributionSmokeTracePayload(id, nowMs = Date.now()) { - const traceId = crypto.randomBytes(16).toString('hex'); - const start = BigInt(nowMs) * 1000000n; - const attr = (key, stringValue) => ({ key, value: { stringValue } }); - const boolAttr = (key, boolValue) => ({ key, value: { boolValue } }); - const intAttr = (key, intValue) => ({ key, value: { intValue: String(intValue) } }); - const span = (name, offsetMs, durationMs, attributes) => { - const spanStart = start + BigInt(offsetMs) * 1000000n; - const spanEnd = spanStart + BigInt(durationMs) * 1000000n; - return { - traceId, - spanId: crypto.randomBytes(8).toString('hex'), - name, - kind: 1, - startTimeUnixNano: spanStart.toString(), - endTimeUnixNano: spanEnd.toString(), - attributes: [ - attr('agentops.smoke_id', id), - attr('agentops.test.kind', 'attribution'), - attr('gen_ai.conversation.id', id), - boolAttr('content.capture.enabled', false), - ...attributes - ], - status: { code: 1 } - }; - }; - - return { - resourceSpans: [ - { - resource: { - attributes: [ - attr('service.name', 'github-copilot-cli'), - attr('service.namespace', 'copilot-agentops'), - attr('agent.framework', 'github-copilot'), - attr('agent.runtime', 'github-copilot-cli'), - attr('agentops.profile', 'attribution-smoke'), - attr('agentops.smoke_id', id) - ] - }, - scopeSpans: [ - { - scope: { name: 'agentops.attribution-smoke', version: '0.1.0' }, - spans: [ - span(`agentops.attribution.${id}.agent`, 0, 100, [ - attr('gen_ai.operation.name', 'invoke_agent'), - attr('gen_ai.agent.name', 'agentops-kitchen-sink-smoke'), - attr('agentops.agent.name', 'agentops-kitchen-sink-smoke'), - attr('agentops.agent.file', 'agentops-kitchen-sink-smoke.agent.md'), - intAttr('gen_ai.usage.input_tokens', 1200), - intAttr('gen_ai.usage.output_tokens', 80), - attr('github.copilot.cost', '0.2') - ]), - span(`agentops.attribution.${id}.skill`, 110, 40, [ - attr('gen_ai.operation.name', 'skill.invoke'), - attr('agentops.skill.name', 'agentops-attribution'), - attr('agentops.skill.file', 'agentops-attribution/SKILL.md') - ]), - span(`agentops.attribution.${id}.mcp`, 160, 60, [ - attr('gen_ai.operation.name', 'execute_tool'), - attr('gen_ai.tool.name', 'azure-mcp/monitor_query'), - attr('agentops.mcp.server', 'azure-mcp'), - attr('agentops.mcp.tool', 'monitor_query') - ]), - span(`agentops.attribution.${id}.script`, 230, 30, [ - attr('gen_ai.operation.name', 'hook.execute'), - attr('agentops.script.name', 'pre-tool-policy'), - attr('agentops.script.file', 'plugin/scripts/pre-tool-policy.js'), - attr('agentops.hook.name', 'preToolUse') - ]) - ] - } - ] - } - ] - }; -} - -function otlpLiveReplaySmokeTracePayload(id, nowMs = Date.now()) { - const traceId = crypto.randomBytes(16).toString('hex'); - const orchestratorSpanId = crypto.randomBytes(8).toString('hex'); - const delegationSpanId = crypto.randomBytes(8).toString('hex'); - const subagentSpanId = crypto.randomBytes(8).toString('hex'); - const start = BigInt(nowMs) * 1000000n; - const attr = (key, stringValue) => ({ key, value: { stringValue } }); - const boolAttr = (key, boolValue) => ({ key, value: { boolValue } }); - const intAttr = (key, intValue) => ({ key, value: { intValue: String(intValue) } }); - const span = (spanId, name, offsetMs, durationMs, attributes, parentSpanId = undefined) => { - const spanStart = start + BigInt(offsetMs) * 1000000n; - const spanEnd = spanStart + BigInt(durationMs) * 1000000n; - return { - traceId, - spanId, - ...(parentSpanId ? { parentSpanId } : {}), - name, - kind: 1, - startTimeUnixNano: spanStart.toString(), - endTimeUnixNano: spanEnd.toString(), - attributes: [ - attr('agentops.smoke_id', id), - attr('agentops.test.kind', 'live-replay'), - attr('gen_ai.conversation.id', id), - boolAttr('content.capture.enabled', false), - ...attributes - ], - status: { code: 1 } - }; - }; - - return { - resourceSpans: [ - { - resource: { - attributes: [ - attr('service.name', 'github-copilot-cli'), - attr('service.namespace', 'copilot-agentops'), - attr('agent.framework', 'github-copilot'), - attr('agent.runtime', 'github-copilot-cli'), - attr('agentops.profile', 'live-replay-smoke'), - attr('agentops.smoke_id', id) - ] - }, - scopeSpans: [ - { - scope: { name: 'agentops.live-replay-smoke', version: '0.1.0' }, - spans: [ - span(orchestratorSpanId, `agentops.live_replay.${id}.orchestrator`, 0, 380, [ - attr('gen_ai.operation.name', 'invoke_agent'), - attr('gen_ai.agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.agent.file', 'agentops-orchestrator.agent.md'), - intAttr('gen_ai.usage.input_tokens', 900), - intAttr('gen_ai.usage.output_tokens', 120), - attr('github.copilot.cost', '0.12') - ]), - span(delegationSpanId, `agentops.live_replay.${id}.delegation.started`, 70, 40, [ - attr('gen_ai.operation.name', 'agent.delegation.started'), - attr('agentops.event.name', 'agent.delegation.started'), - attr('agentops.agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.delegation.id', `${id}-delegation-1`), - attr('agentops.workflow.name', 'live-replay-e2e'), - attr('agentops.step.name', 'delegate-investigation') - ], orchestratorSpanId), - span(subagentSpanId, `agentops.live_replay.${id}.subagent`, 120, 180, [ - attr('gen_ai.operation.name', 'invoke_agent'), - attr('gen_ai.agent.name', 'agentops-investigator-smoke'), - attr('agentops.agent.name', 'agentops-investigator-smoke'), - attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.delegation.id', `${id}-delegation-1`), - attr('agentops.workflow.name', 'live-replay-e2e'), - intAttr('gen_ai.usage.input_tokens', 400), - intAttr('gen_ai.usage.output_tokens', 60), - attr('github.copilot.cost', '0.08') - ], delegationSpanId), - span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.skill`, 160, 35, [ - attr('gen_ai.operation.name', 'skill.invoke'), - attr('agentops.agent.name', 'agentops-investigator-smoke'), - attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.delegation.id', `${id}-delegation-1`), - attr('agentops.skill.name', 'agentops-live-triage'), - attr('agentops.skill.file', 'agentops-live-triage/SKILL.md') - ], subagentSpanId), - span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.mcp`, 210, 70, [ - attr('gen_ai.operation.name', 'execute_tool'), - attr('agentops.agent.name', 'agentops-investigator-smoke'), - attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.delegation.id', `${id}-delegation-1`), - attr('gen_ai.tool.name', 'azure-mcp/monitor_query'), - attr('agentops.mcp.server', 'azure-mcp'), - attr('agentops.mcp.tool', 'monitor_query') - ], subagentSpanId), - span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.script`, 300, 30, [ - attr('gen_ai.operation.name', 'hook.execute'), - attr('agentops.agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.script.name', 'pre-tool-policy'), - attr('agentops.script.file', 'plugin/scripts/pre-tool-policy.js'), - attr('agentops.hook.name', 'preToolUse') - ], orchestratorSpanId), - span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.delegation.completed`, 340, 30, [ - attr('gen_ai.operation.name', 'agent.delegation.completed'), - attr('agentops.event.name', 'agent.delegation.completed'), - attr('agentops.agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), - attr('agentops.delegation.id', `${id}-delegation-1`), - attr('agentops.workflow.name', 'live-replay-e2e'), - attr('agentops.outcome', 'completed') - ], delegationSpanId) - ] - } - ] - } - ] - }; -} - -function postJson(url, payload, options = {}) { - return new Promise(resolve => { - const parsed = new URL(url); - const body = JSON.stringify(payload); - const client = parsed.protocol === 'https:' ? https : http; - const req = client.request(parsed, { - method: 'POST', - timeout: options.timeoutMs || 2500, - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body) - } - }, res => { - let responseBody = ''; - res.setEncoding('utf8'); - res.on('data', chunk => responseBody += chunk); - res.on('end', () => resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - statusCode: res.statusCode, - body: responseBody - })); - }); - req.on('timeout', () => { - req.destroy(); - resolve({ ok: false, error: 'timeout' }); - }); - req.on('error', error => resolve({ ok: false, error: error.message })); - req.end(body); - }); -} - -function parseSmokeArgs(args) { - return { - dryRun: args.includes('--dry-run'), - endpoint: optionValue(args, ['--endpoint']), - id: optionValue(args, ['--id']), - last: parseLastArg(args, '2h'), - realCopilot: args.includes('--real-copilot') || args.includes('--copilot'), - openBrowser: args.includes('--open-browser'), - copilotTimeoutMs: durationToMs(optionValue(args, ['--timeout']), 120000), - verify: !args.includes('--no-verify'), - waitMs: durationToMs(optionValue(args, ['--wait']), 60000), - pollMs: durationToMs(optionValue(args, ['--poll']), 10000), - json: args.includes('--json') - }; -} - -function realCopilotSmokeArgs() { - return [ - '--no-ask-user', - '--no-remote', - '--add-dir', - '.', - "--allow-tool=shell(pwd)", - "--allow-tool=shell(ls:*)", - '-p', - 'Do not edit files. Run pwd and ls docs | head, then summarize.' - ]; -} - -function commandShellQuote(value) { - const text = String(value); - if (/^[A-Za-z0-9_./:=+-]+$/.test(text)) return text; - return `'${text.replace(/'/g, `'\\''`)}'`; -} - -function realCopilotSmokeCommand() { - return `copilot ${realCopilotSmokeArgs().map(commandShellQuote).join(' ')}`; -} - -function runRealCopilotSmoke(options = {}) { - const spawnSync = options.spawnSync || childProcess.spawnSync; - const command = options.copilotCommand || 'copilot'; - const args = realCopilotSmokeArgs(); - const started = Date.now(); - const result = spawnSync(command, args, { - cwd: options.cwd || process.cwd(), - env: { - ...process.env, - AGENTOPS_PRIVACY_MODE: 'strict', - AGENTOPS_CAPTURE_CONTENT: 'false', - COPILOT_OTEL_CAPTURE_CONTENT: 'false', - OTEL_EXPORTER_OTLP_ENDPOINT: options.endpoint || 'http://127.0.0.1:4318' - }, - encoding: 'utf8', - timeout: durationToMs(options.copilotTimeoutMs ?? options.timeout, 120000), - maxBuffer: 1024 * 1024 - }); - const status = result.status === null || result.status === undefined ? 1 : result.status; - return { - ok: status === 0 && !result.error, - status, - signal: result.signal || null, - error: result.error?.message || null, - duration_ms: Date.now() - started, - command: realCopilotSmokeCommand(), - cwd: options.cwd || process.cwd() - }; -} - -function openUrlInBrowser(url, options = {}) { - if (!url) return { ok: false, reason: 'missing-url' }; - if (options.openUrl) return options.openUrl(url); - const spawnSync = options.spawnSync || childProcess.spawnSync; - const platform = options.platform || process.platform; - const command = platform === 'darwin' ? 'open' : (platform === 'win32' ? 'cmd' : 'xdg-open'); - const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]; - const result = spawnSync(command, args, { - encoding: 'utf8', - timeout: durationToMs(options.openTimeoutMs, 5000), - maxBuffer: 1024 * 1024 - }); - const status = result.status === null || result.status === undefined ? 1 : result.status; - return { - ok: status === 0 && !result.error, - command: [command, ...args].join(' '), - status, - error: result.error?.message || null, - url - }; -} - -async function waitForLatestRunSummary(options = {}) { - const last = validateKqlDuration(options.last || '2h'); - const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); - const pollMs = Math.max(1, durationToMs(options.pollMs ?? options.poll, 10000)); - const latestFn = options.latestSummary || (() => latestSummaryFromArgs(['--last', last], last)); - const sleepFn = options.sleep || sleep; - const started = Date.now(); - const attempts = []; - - while (true) { - let summary; - try { - const value = latestFn({ last }); - summary = typeof value?.then === 'function' ? await value : value; - } catch (error) { - summary = { session: null, error: error.message }; - } - const visible = Boolean(summary?.session?.grafana_url); - attempts.push({ - visible, - session_id: summary?.session?.id || null, - error: summary?.error || null - }); - if (visible) { - return { - ok: true, - status: 'found', - summary, - attempts, - elapsed_ms: Date.now() - started - }; - } - - const elapsed = Date.now() - started; - if (waitMs === 0 || elapsed >= waitMs) break; - await sleepFn(Math.min(pollMs, waitMs - elapsed)); - } - - return { - ok: false, - status: attempts.some(attempt => !attempt.error) ? 'not_found' : 'query_failed', - summary: null, - attempts, - elapsed_ms: Date.now() - started - }; -} - -async function verifySmokeInAzure(id, options = {}) { - const last = validateKqlDuration(options.last || '2h'); - const query = smokeAzureQuery(id, last); - const workspace = options.workspaceId || workspaceId; - const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); - const pollMs = Math.max(1, durationToMs(options.pollMs ?? options.poll, 10000)); - const sleepFn = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))); - const queryFn = options.runQuery || ((analyticsQuery, queryOptions) => runAzureLogAnalyticsQuery(analyticsQuery, queryOptions)); - const started = Date.now(); - const attempts = []; - - while (true) { - let resolved; - try { - const queryResult = queryFn(query, { - workspaceId: workspace, - spawnSync: options.spawnSync - }); - resolved = typeof queryResult?.then === 'function' ? await queryResult : queryResult; - } catch (error) { - resolved = { ok: false, rows: [], error: error.message }; - } - const rows = Array.isArray(resolved?.rows) ? resolved.rows : []; - const attempt = { - ok: Boolean(resolved?.ok), - rows: rows.length, - error: resolved?.error || null - }; - attempts.push(attempt); - - if (attempt.ok && rows.length > 0) { - return { - ok: true, - status: 'found', - workspace_id: workspace, - query, - rows: rows.length, - attempts, - elapsed_ms: Date.now() - started - }; - } - - const elapsed = Date.now() - started; - if (waitMs === 0 || elapsed >= waitMs) break; - await sleepFn(Math.min(pollMs, waitMs - elapsed)); - } - - return { - ok: false, - status: attempts.some(attempt => attempt.ok) ? 'not_found' : 'query_failed', - workspace_id: workspace, - query, - rows: 0, - attempts, - elapsed_ms: Date.now() - started - }; -} - -async function agentopsSmoke(options = {}) { - const endpoint = (options.endpoint || 'http://127.0.0.1:4318').replace(/\/$/, ''); - const id = options.id || smokeId(options.now); - const last = validateKqlDuration(options.last || '2h'); - const query = smokeAzureQuery(id, last); - const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); - const pollMs = durationToMs(options.pollMs ?? options.poll, 10000); - const realCopilot = Boolean(options.realCopilot); - const verify = options.verify !== false; - const result = { - smoke_kind: 'collector', - smoke_id: id, - endpoint, - dry_run: Boolean(options.dryRun), - real_copilot: realCopilot, - verify, - wait_ms: waitMs, - poll_ms: pollMs, - workspace_id: options.workspaceId || workspaceId, - azure_query: query, - payload_preview: { - service: 'github-copilot-cli', - operation: 'smoke_test', - content_capture_enabled: false - } - }; - - if (options.dryRun) { - const next = [ - `POST ${endpoint}/v1/traces`, - 'node agentops-cli/src/index.js validate-azure', - verify - ? `node agentops-cli/src/index.js smoke --id ${id} --wait ${Math.ceil(waitMs / 1000)}s --poll ${Math.ceil(pollMs / 1000)}s${realCopilot ? ' --real-copilot' : ''}${options.openBrowser ? ' --open-browser' : ''}` - : `az monitor log-analytics query --workspace "${result.workspace_id}" --analytics-query "<azure_query>"` - ]; - if (realCopilot) { - next.push(realCopilotSmokeCommand()); - next.push(options.openBrowser - ? 'The successful real-Copilot smoke opens Run Replay after latest-run visibility is verified.' - : `node agentops-cli/src/index.js open latest --last ${last}`); - } - return { - ...result, - ok: true, - copilot_command: realCopilot ? realCopilotSmokeCommand() : null, - next - }; - } - - const post = options.postJson || postJson; - const response = await post(`${endpoint}/v1/traces`, otlpSmokeTracePayload(id, options.nowMs), options); - let copilotRun = null; - let verification = null; - let latestVisibility = null; - let links = null; - let browserOpen = null; - if (response.ok && realCopilot) { - copilotRun = runRealCopilotSmoke({ ...options, endpoint }); - if (copilotRun.ok) { - latestVisibility = await waitForLatestRunSummary({ - ...options, - last, - waitMs, - pollMs - }); - if (latestVisibility.ok) links = openLinksSummary(latestVisibility.summary); - if (options.openBrowser && links?.v2_replay_url) { - browserOpen = openUrlInBrowser(links.v2_replay_url, options); - } - } - } - if (response.ok && verify) { - verification = await verifySmokeInAzure(id, { - ...options, - last, - workspaceId: result.workspace_id, - waitMs, - pollMs - }); - } - const ok = response.ok && (!realCopilot || copilotRun?.ok === true) && (!verify || verification?.ok === true); - return { - ...result, - ok, - collector_response: response, - copilot_run: copilotRun, - latest_visibility: latestVisibility, - verification, - links, - browser_open: browserOpen, - next: response.ok - ? (verification?.ok - ? [ - `Verified ${verification.rows} smoke row${verification.rows === 1 ? '' : 's'} in Log Analytics.`, - realCopilot && links?.v2_replay_url - ? `${browserOpen?.ok ? 'Opened' : 'Open'} Run Replay: ${links.v2_replay_url}` - : 'node agentops-cli/src/index.js latest --last 2h', - realCopilot && links?.v2_replay_url ? 'node agentops-cli/src/index.js triage latest --out .agentops/triage/latest --json' : 'node agentops-cli/src/index.js latest --last 2h' - ] - : [ - verify - ? `Smoke was sent, but Log Analytics did not return ${id} before the wait expired.` - : `Run this Azure query after ingestion latency settles: ${query}`, - realCopilot && links?.v2_replay_url ? `Open Run Replay: ${links.v2_replay_url}` : 'node agentops-cli/src/index.js validate-azure' - ]) - : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] - }; -} - -async function agentopsAttributionSmoke(options = {}) { - const endpoint = (options.endpoint || 'http://127.0.0.1:4318').replace(/\/$/, ''); - const id = options.id || attributionSmokeId(options.now); - const last = validateKqlDuration(options.last || '2h'); - const query = smokeAzureQuery(id, last); - const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); - const pollMs = durationToMs(options.pollMs ?? options.poll, 10000); - const verify = options.verify !== false; - const result = { - smoke_kind: 'attribution', - smoke_id: id, - endpoint, - dry_run: Boolean(options.dryRun), - verify, - wait_ms: waitMs, - poll_ms: pollMs, - workspace_id: options.workspaceId || workspaceId, - azure_query: query, - payload_preview: { - service: 'github-copilot-cli', - operation: 'attribution_smoke', - agent: 'agentops-kitchen-sink-smoke', - skill: 'agentops-attribution', - mcp_server: 'azure-mcp', - script: 'pre-tool-policy', - content_capture_enabled: false - } - }; - - if (options.dryRun) { - return { - ...result, - ok: true, - next: [ - `POST ${endpoint}/v1/traces`, - 'node agentops-cli/src/index.js attribution --last 2h', - verify - ? `node agentops-cli/src/index.js attribution-smoke --id ${id} --wait ${Math.ceil(waitMs / 1000)}s --poll ${Math.ceil(pollMs / 1000)}s` - : `az monitor log-analytics query --workspace "${result.workspace_id}" --analytics-query "<azure_query>"` - ] - }; - } - - const post = options.postJson || postJson; - const response = await post(`${endpoint}/v1/traces`, otlpAttributionSmokeTracePayload(id, options.nowMs), options); - let verification = null; - if (response.ok && verify) { - verification = await verifySmokeInAzure(id, { - ...options, - last, - workspaceId: result.workspace_id, - waitMs, - pollMs - }); - } - const ok = response.ok && (!verify || verification?.ok === true); - return { - ...result, - ok, - collector_response: response, - verification, - next: response.ok - ? (verification?.ok - ? [ - `Verified ${verification.rows} attribution smoke row${verification.rows === 1 ? '' : 's'} in Log Analytics.`, - 'node agentops-cli/src/index.js attribution --last 2h', - 'node agentops-cli/src/index.js mcp --last 2h', - 'node agentops-cli/src/index.js lineage --last 2h' - ] - : [ - verify - ? `Attribution smoke was sent, but Log Analytics did not return ${id} before the wait expired.` - : `Run this Azure query after ingestion latency settles: ${query}`, - 'node agentops-cli/src/index.js validate-azure' - ]) - : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] - }; -} - -async function agentopsLiveReplaySmoke(options = {}) { - const endpoint = (options.endpoint || 'http://127.0.0.1:4318').replace(/\/$/, ''); - const id = options.id || liveReplaySmokeId(options.now); - const last = validateKqlDuration(options.last || '2h'); - const query = smokeAzureQuery(id, last); - const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); - const pollMs = durationToMs(options.pollMs ?? options.poll, 10000); - const verify = options.verify !== false; - const grafanaUrl = liveReplayGrafanaUrl(id, last); - const result = { - smoke_kind: 'live-replay', - smoke_id: id, - endpoint, - dry_run: Boolean(options.dryRun), - verify, - wait_ms: waitMs, - poll_ms: pollMs, - workspace_id: options.workspaceId || workspaceId, - azure_query: query, - grafana_url: grafanaUrl, - payload_preview: { - service: 'github-copilot-cli', - operation: 'live_replay_smoke', - agent: 'agentops-orchestrator-smoke', - subagent: 'agentops-investigator-smoke', - delegation_id: `${id}-delegation-1`, - skill: 'agentops-live-triage', - mcp_server: 'azure-mcp', - script: 'pre-tool-policy', - content_capture_enabled: false - } - }; - - if (options.dryRun) { - return { - ...result, - ok: true, - next: [ - `POST ${endpoint}/v1/traces`, - grafanaUrl, - verify - ? `node agentops-cli/src/index.js live-replay-smoke --id ${id} --wait ${Math.ceil(waitMs / 1000)}s --poll ${Math.ceil(pollMs / 1000)}s` - : `az monitor log-analytics query --workspace "${result.workspace_id}" --analytics-query "<azure_query>"` - ] - }; - } - - const post = options.postJson || postJson; - const response = await post(`${endpoint}/v1/traces`, otlpLiveReplaySmokeTracePayload(id, options.nowMs), options); - let verification = null; - if (response.ok && verify) { - verification = await verifySmokeInAzure(id, { - ...options, - last, - workspaceId: result.workspace_id, - waitMs, - pollMs - }); - } - const ok = response.ok && (!verify || verification?.ok === true); - return { - ...result, - ok, - collector_response: response, - verification, - next: response.ok - ? (verification?.ok - ? [ - `Verified ${verification.rows} live replay smoke rows in Log Analytics.`, - grafanaUrl, - 'node agentops-cli/src/index.js lineage --last 2h' - ] - : [ - verify - ? `Live replay smoke was sent, but Log Analytics did not return ${id} before the wait expired.` - : `Run this Azure query after ingestion latency settles: ${query}`, - grafanaUrl - ]) - : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] - }; -} - -function renderSmoke(result) { - const lines = [ - result.smoke_kind === 'live-replay' - ? 'AgentOps live replay smoke' - : (result.smoke_kind === 'attribution' - ? 'AgentOps attribution smoke' - : 'AgentOps smoke'), - '', - `Smoke id: ${result.smoke_id}`, - `Endpoint: ${result.endpoint}`, - `Mode: ${result.dry_run ? 'dry-run' : 'sent'}` - ]; - - if (result.collector_response) { - lines.push(result.collector_response.ok - ? `Collector response: ${result.collector_response.statusCode || 'ok'}.` - : `Collector response: failed (${result.collector_response.error || result.collector_response.statusCode || 'unknown'}).`); - } - - if (result.copilot_run) { - lines.push(result.copilot_run.ok - ? `Real Copilot smoke: completed in ${result.copilot_run.duration_ms}ms.` - : `Real Copilot smoke: failed (${result.copilot_run.error || result.copilot_run.signal || `exit ${result.copilot_run.status}`}).`); - } else if (result.real_copilot && result.dry_run) { - lines.push(`Real Copilot smoke: planned (${result.copilot_command}).`); - } - - if (result.latest_visibility) { - lines.push(result.latest_visibility.ok - ? `Latest Copilot run: visible after ${result.latest_visibility.attempts.length} attempt${result.latest_visibility.attempts.length === 1 ? '' : 's'}.` - : `Latest Copilot run: ${result.latest_visibility.status.replace(/_/g, ' ')} after ${result.latest_visibility.attempts.length} attempt${result.latest_visibility.attempts.length === 1 ? '' : 's'}.`); - } - - if (result.verification) { - lines.push(result.verification.ok - ? `Azure verification: found ${result.verification.rows} row${result.verification.rows === 1 ? '' : 's'} after ${result.verification.attempts.length} attempt${result.verification.attempts.length === 1 ? '' : 's'}.` - : `Azure verification: ${result.verification.status.replace(/_/g, ' ')} after ${result.verification.attempts.length} attempt${result.verification.attempts.length === 1 ? '' : 's'}.`); - } else if (!result.dry_run && result.verify === false) { - lines.push('Azure verification: skipped.'); - } - - if (result.grafana_url) { - lines.push(`Grafana Live Replay: ${result.grafana_url}`); - } - if (result.links?.v2_replay_url) { - lines.push(`V2 Run Replay: ${result.links.v2_replay_url}`); - } - if (result.browser_open) { - lines.push(result.browser_open.ok - ? `Browser open: opened Run Replay.` - : `Browser open: failed (${result.browser_open.error || result.browser_open.status || result.browser_open.reason || 'unknown'}).`); - } - lines.push('', 'Azure verification query:', result.azure_query, '', 'Next:'); - for (const item of result.next || []) lines.push(`- ${item}`); - return `${lines.join('\n')}\n`; -} - -function azAvailable(options = {}) { - if (options.azAvailable !== undefined) return Boolean(options.azAvailable); - if (options.spawnSync) return true; - return commandCandidates('az').length > 0; -} - -function runAz(args, options = {}) { - const spawnSync = options.spawnSync || childProcess.spawnSync; - return spawnSync('az', args, { - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024 - }); -} - -function parseJsonOutput(result) { - try { - return JSON.parse(result.stdout || '{}'); - } catch { - return null; - } -} - -function checkResult(name, ok, extra = {}) { - return { name, ok: Boolean(ok), ...extra }; -} - -function azErrorDetail(result, fallback) { - return (result.stderr || result.stdout || fallback || `az exited with status ${result.status}`).trim(); -} - -function pathValue(source, keys, fallback = null) { - let value = source; - for (const key of keys) { - if (value === undefined || value === null) return fallback; - value = value[key]; - } - return value === undefined ? fallback : value; -} - -function asArray(value) { - return Array.isArray(value) ? value : []; -} - -function boolish(value) { - if (typeof value === 'boolean') return value; - if (typeof value === 'string') return value.toLowerCase() === 'true'; - return Boolean(value); -} - -const azureRoleIds = { - logAnalyticsDataReader: '3b03c2da-16b3-4a49-8834-0f8130efdd3b', - monitoringReader: '43d0d8ad-25c7-4714-9337-8ba259a9fe05', - grafanaViewer: '60921a7e-fef1-4a43-9b16-a26c52ad4769', - grafanaEditor: 'a79a5197-3a5c-4973-a920-486035ffd60f', - grafanaAdmin: '22926164-76b3-42b3-bc55-97df8dab3e41', - contributor: 'b24988ac-6180-42a0-ab88-20f7382dd24c', - owner: '8e3af657-a8ff-443c-a75c-2fe8c4bcb635', - userAccessAdministrator: ['f1a07417', 'd97a', '45cb', '824c', '7a7467783830b'].join('-') -}; - -function roleDefinitionIdSuffix(value) { - const id = String(value || '').toLowerCase(); - const parts = id.split('/'); - return parts[parts.length - 1] || id; -} - -function roleAssignmentSummary(assignments, allowedRoleIds) { - const allowed = new Set(allowedRoleIds.map(role => role.toLowerCase())); - const rows = asArray(assignments); - const matching = rows.filter(row => allowed.has(roleDefinitionIdSuffix(row.roleDefinitionId))); - const groupAssignments = matching.filter(row => String(row.principalType || '').toLowerCase() === 'group'); - const broadAssignments = rows.filter(row => [ - azureRoleIds.owner, - azureRoleIds.contributor, - azureRoleIds.userAccessAdministrator - ].includes(roleDefinitionIdSuffix(row.roleDefinitionId))); - return { - assignments: rows.length, - matching: matching.length, - group_assignments: groupAssignments.length, - broad_assignments: broadAssignments.length, - principal_types: Array.from(new Set(rows.map(row => row.principalType).filter(Boolean))).sort(), - role_names: Array.from(new Set(rows.map(row => row.roleDefinitionName).filter(Boolean))).sort() - }; -} - -function agentOpsScheduledQueryRules(rules) { - return asArray(rules).filter(rule => { - const name = String(rule.name || '').toLowerCase(); - const displayName = String(pathValue(rule, ['properties', 'displayName'], '')).toLowerCase(); - return name.startsWith('sqr-') || displayName.includes('copilot agentops'); - }); -} - -function logAnalyticsTablesFromResult(value) { - const parsed = asArray(value?.value || value); - return parsed.map(table => ({ - name: table?.name || pathValue(table, ['properties', 'name'], ''), - retention_days: [table?.retentionInDays, pathValue(table, ['properties', 'retentionInDays'], NaN)] - .map(Number) - .find(Number.isFinite), - total_retention_days: Number(table?.totalRetentionInDays ?? pathValue(table, ['properties', 'totalRetentionInDays'], NaN)) - })); -} - -function agentOpsContentTables(tables) { - return logAnalyticsTablesFromResult(tables).filter(table => String(table.name || '').toLowerCase() === 'agentopscontent_cl'); -} - -function azureBudgetsFromResult(value) { - return asArray(value?.value || value).map(budget => ({ - name: budget?.name || '', - amount: Number(budget?.amount ?? pathValue(budget, ['properties', 'amount'], NaN)), - category: budget?.category || pathValue(budget, ['properties', 'category'], ''), - time_grain: budget?.timeGrain || pathValue(budget, ['properties', 'timeGrain'], '') - })); -} - -function privateEndpointConnectionsFromResource(resource) { - return asArray(pathValue(resource, ['properties', 'privateEndpointConnections'], [])); -} - -function approvedPrivateEndpointConnections(resource) { - return privateEndpointConnectionsFromResource(resource).filter(connection => { - const status = String( - pathValue(connection, ['properties', 'privateLinkServiceConnectionState', 'status'], '') || - pathValue(connection, ['privateLinkServiceConnectionState', 'status'], '') - ).toLowerCase(); - return status === 'approved'; - }); -} - -function actionGroupReceiverSummary(actionGroup) { - const receiverKeys = [ - 'emailReceivers', - 'smsReceivers', - 'webhookReceivers', - 'azureAppPushReceivers', - 'itsmReceivers', - 'automationRunbookReceivers', - 'voiceReceivers', - 'logicAppReceivers', - 'azureFunctionReceivers', - 'armRoleReceivers', - 'eventHubReceivers' - ]; - const properties = actionGroup?.properties || actionGroup || {}; - const counts = Object.fromEntries(receiverKeys.map(key => [key, asArray(properties[key]).length])); - const total = Object.values(counts).reduce((sum, count) => sum + count, 0); - return { receiver_count: total, receiver_types: counts }; -} - -function azureProductionRemediationPlan(result, options = {}) { - const checks = Object.fromEntries((result.checks || []).map(check => [check.name, check])); - const config = result.config || {}; - const resourceGroup = config.resource_group || '<resource-group>'; - const workspaceName = config.workspace_name || '<workspace-name>'; - const grafanaName = config.grafana_name || '<grafana-name>'; - const desiredQuotaGb = Number(options.dailyQuotaGb || 5); - const actionGroups = options.actionGroupResourceIds || '["/subscriptions/<sub>/resourceGroups/<rg>/providers/microsoft.insights/actionGroups/<name>"]'; - const actions = []; - - if (checks['log-analytics-posture'] && !checks['log-analytics-posture'].ok) { - actions.push({ - name: 'set-log-analytics-daily-cap', - risk: 'low', - reason: 'Production mode expects a finite Log Analytics daily ingestion cap.', - review: 'Confirm expected telemetry volume before applying the cap.', - commands: [ - `az monitor log-analytics workspace update --resource-group ${commandShellQuote(resourceGroup)} --workspace-name ${commandShellQuote(workspaceName)} --quota ${desiredQuotaGb}`, - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - if (checks['grafana-production-posture'] && !checks['grafana-production-posture'].ok) { - actions.push({ - name: 'harden-managed-grafana-network-and-availability', - risk: 'medium', - reason: 'Production mode expects Managed Grafana private access and zone redundancy.', - review: 'Verify private connectivity, DNS, operator access, and regional zone-redundancy support before disabling public access.', - commands: [ - `AGENTOPS_GRAFANA_PUBLIC_NETWORK_ACCESS=Disabled AGENTOPS_GRAFANA_ZONE_REDUNDANCY=Enabled ./scripts/azure-what-if.sh`, - `az grafana update --resource-group ${commandShellQuote(resourceGroup)} --name ${commandShellQuote(grafanaName)} --public-network-access Disabled --zone-redundancy Enabled`, - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - if (checks['alert-routing-posture'] && !checks['alert-routing-posture'].ok) { - const ruleNames = asArray(checks['alert-routing-posture'].rule_names).length - ? asArray(checks['alert-routing-posture'].rule_names) - : ['sqr-<agentops-alert-name>']; - actions.push({ - name: 'route-agentops-alerts-to-action-groups', - risk: 'medium', - reason: 'Production mode expects enabled AgentOps scheduled query alerts routed to Azure Monitor action groups.', - review: 'Create or approve action groups first, then tune thresholds against real traffic before enabling notifications.', - commands: [ - `export AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS=${commandShellQuote(String(actionGroups))}`, - 'AGENTOPS_DEPLOY_ALERTS=true AGENTOPS_ENABLE_ALERTS=true ./scripts/azure-what-if.sh', - ...ruleNames.map(name => `az monitor scheduled-query update --resource-group ${commandShellQuote(resourceGroup)} --name ${commandShellQuote(name)} --disabled false --action-groups "$AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS"`), - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - if (checks['access-rbac-posture'] && !checks['access-rbac-posture'].ok) { - actions.push({ - name: 'review-agentops-rbac-assignments', - risk: 'medium', - reason: 'Production mode expects least-privilege RBAC on Log Analytics and Managed Grafana scopes.', - review: 'Assign Entra groups rather than individual users; avoid Owner/Contributor for routine observability access.', - commands: [ - 'AGENTOPS_DEPLOY_RBAC_ASSIGNMENTS=true ./scripts/azure-what-if.sh', - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - if (checks['content-capture-table-posture'] && !checks['content-capture-table-posture'].ok) { - actions.push({ - name: 'harden-optional-content-capture-storage', - risk: 'high', - reason: 'Optional prompt/response transcript storage must have short retention and least-privilege workspace access.', - review: 'Confirm content capture is intentionally enabled, then restrict access and lower retention before production.', - commands: [ - `az monitor log-analytics workspace table update --resource-group ${commandShellQuote(resourceGroup)} --workspace-name ${commandShellQuote(workspaceName)} --name AgentOpsContent_CL --retention-time 30`, - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - if (checks['azure-budget-posture'] && !checks['azure-budget-posture'].ok) { - actions.push({ - name: 'configure-agentops-budget', - risk: 'medium', - reason: 'Production mode expects an Azure Consumption budget so runaway token/tool loops have a spend guardrail.', - review: 'Confirm the monthly amount and approved notification contacts before deploying the budget.', - commands: [ - 'AGENTOPS_DEPLOY_BUDGET=true ./scripts/azure-what-if.sh', - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - if (checks['grafana-private-access-posture'] && !checks['grafana-private-access-posture'].ok) { - actions.push({ - name: 'verify-managed-grafana-private-access', - risk: 'medium', - reason: 'Production mode expects public Grafana access disabled with an approved private endpoint path.', - review: 'Test private DNS and operator access before disabling or depending on private access.', - commands: [ - `az grafana show --resource-group ${commandShellQuote(resourceGroup)} --name ${commandShellQuote(grafanaName)} --query properties.privateEndpointConnections`, - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - if (checks['action-group-destination-posture'] && !checks['action-group-destination-posture'].ok) { - actions.push({ - name: 'verify-alert-action-group-destinations', - risk: 'medium', - reason: 'Production mode expects routed AgentOps alerts to target enabled action groups with at least one receiver.', - review: 'Review notification destinations, rate limits, and escalation ownership before enabling alerts.', - commands: [ - 'az monitor action-group list --resource-group <resource-group> --query "[].{name:name,enabled:enabled}"', - `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` - ] - }); - } - - return { - ok: actions.length === 0, - mode: 'proposal-only', - actions, - note: actions.length === 0 - ? 'No production posture remediation is currently required.' - : 'Review these commands before running them. The planner does not mutate Azure.' - }; -} - -function validateAzure(options = {}) { - const last = validateKqlDuration(options.last || '2h'); - const cloud = configuredCloudValues(options); - const checks = []; - const next = []; - const hasAz = azAvailable(options); - const production = Boolean(options.production); - - checks.push(checkResult('az-cli', hasAz, hasAz ? {} : { detail: 'Azure CLI was not found on PATH.' })); - - let account = null; - if (hasAz) { - const accountResult = runAz(['account', 'show', '-o', 'json'], options); - account = accountResult.status === 0 ? parseJsonOutput(accountResult) : null; - checks.push(checkResult('azure-account', accountResult.status === 0, { - detail: accountResult.status === 0 ? account?.name || account?.id || 'logged in' : (accountResult.stderr || accountResult.stdout || 'az account show failed').trim() - })); - if (cloud.subscriptionId && account?.id && account.id !== cloud.subscriptionId) { - checks.push(checkResult('azure-subscription', false, { - expected: cloud.subscriptionId, - actual: account.id, - detail: 'Active Azure subscription does not match AGENTOPS_AZURE_SUBSCRIPTION_ID/AZURE_SUBSCRIPTION_ID.' - })); - next.push(`az account set --subscription "${cloud.subscriptionId}"`); - } else if (cloud.subscriptionId) { - checks.push(checkResult('azure-subscription', true, { expected: cloud.subscriptionId, actual: account?.id || null })); - } - } - - if (!cloud.resourceGroup) { - checks.push(checkResult('resource-group-configured', false, { detail: 'Set AZURE_RESOURCE_GROUP or AGENTOPS_AZURE_RESOURCE_GROUP.' })); - next.push('agentops configure set --resource-group rg-agentops-dev'); - } else if (hasAz) { - const groupResult = runAz(['group', 'exists', '--name', cloud.resourceGroup, '-o', 'tsv'], options); - const exists = groupResult.status === 0 && String(groupResult.stdout || '').trim() === 'true'; - checks.push(checkResult('resource-group', exists, { resource_group: cloud.resourceGroup })); - if (!exists) next.push('Run ./scripts/azure-readiness.sh and review the target resource group.'); - } - - if (production && hasAz && cloud.resourceGroup) { - const budgetResult = runAz(['consumption', 'budget', 'list', '--resource-group', cloud.resourceGroup, '-o', 'json'], options); - const budgets = budgetResult.status === 0 ? azureBudgetsFromResult(parseJsonOutput(budgetResult)) : []; - const validBudgets = budgets.filter(budget => Number.isFinite(budget.amount) && budget.amount > 0); - checks.push(checkResult('azure-budget-posture', budgetResult.status === 0 && validBudgets.length > 0, { - budgets: budgets.length, - budget_names: budgets.map(budget => budget.name).filter(Boolean), - valid_budgets: validBudgets.length, - production, - detail: budgetResult.status !== 0 - ? azErrorDetail(budgetResult, 'could not list Azure Consumption budgets') - : validBudgets.length > 0 - ? 'Azure budget guardrail observed' - : 'production mode expects an Azure budget for AgentOps spend guardrails' - })); - if (budgetResult.status !== 0) next.push('Verify Azure Consumption budget read permissions for the resource group.'); - else if (validBudgets.length === 0) next.push('Configure an Azure Consumption budget before production AgentOps rollout.'); - } - - const workspaceConfigured = isConfiguredValue(cloud.workspaceId, /^0{8}-0{4}-0{4}-0{4}-0{12}$/); - checks.push(checkResult('log-analytics-workspace-id', workspaceConfigured, { - workspace_id: workspaceConfigured ? cloud.workspaceId : null, - detail: workspaceConfigured ? 'configured' : 'Set AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID or LOG_ANALYTICS_WORKSPACE_ID.' - })); - if (!workspaceConfigured) next.push('agentops configure set --workspace-id "<workspace-id>"'); - - if (hasAz && workspaceConfigured) { - const query = `AppDependencies | where TimeGenerated > ago(${last}) | where ${baseFilter} | summarize Rows=count()`; - const queryResult = runAzureLogAnalyticsQuery(query, { - spawnSync: options.spawnSync, - workspaceId: cloud.workspaceId - }); - const rowCount = queryResult.rows?.[0]?.Rows ?? queryResult.rows?.[0]?.rows ?? null; - checks.push(checkResult('log-analytics-query', queryResult.ok, { - rows: rowCount, - detail: queryResult.ok ? 'query succeeded' : queryResult.error - })); - } - - if (hasAz && cloud.resourceGroup && cloud.workspaceName) { - let workspaceRbacOkForContent = null; - const workspaceResult = runAz([ - 'monitor', - 'log-analytics', - 'workspace', - 'show', - '--resource-group', - cloud.resourceGroup, - '--workspace-name', - cloud.workspaceName, - '-o', - 'json' - ], options); - const workspace = workspaceResult.status === 0 ? parseJsonOutput(workspaceResult) : null; - const workspaceResourceId = workspace?.id || null; - const retentionDays = Number(workspace?.retentionInDays ?? 0); - const dailyQuotaGb = Number(pathValue(workspace, ['workspaceCapping', 'dailyQuotaGb'], NaN)); - const resourceScopedAccess = boolish(pathValue(workspace, ['features', 'enableLogAccessUsingOnlyResourcePermissions'], false)); - const logAnalyticsPostureOk = workspaceResult.status === 0 && - (!production || (retentionDays > 0 && dailyQuotaGb !== -1 && resourceScopedAccess)); - checks.push(checkResult('log-analytics-posture', logAnalyticsPostureOk, { - workspace: cloud.workspaceName, - retention_days: Number.isFinite(retentionDays) ? retentionDays : null, - daily_quota_gb: Number.isFinite(dailyQuotaGb) ? dailyQuotaGb : null, - resource_scoped_access: resourceScopedAccess, - issues: [ - retentionDays <= 0 ? 'retention' : null, - dailyQuotaGb === -1 ? 'daily_cap' : null, - !resourceScopedAccess ? 'resource_scoped_access' : null - ].filter(Boolean), - production, - detail: workspaceResult.status !== 0 - ? azErrorDetail(workspaceResult, 'could not read Log Analytics workspace posture') - : production - ? (logAnalyticsPostureOk - ? 'retention, daily cap, and resource-scoped access configured' - : 'production mode expects retention, daily ingestion cap, and resource-scoped access') - : 'Log Analytics posture observed' - })); - if (workspaceResult.status !== 0) next.push('Set AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME to the deployed workspace name.'); - else if (production && (retentionDays <= 0 || dailyQuotaGb === -1 || !resourceScopedAccess)) { - next.push('Review Log Analytics retention, daily cap, and resource-scoped access before production.'); - } - - if (workspaceResult.status === 0 && workspaceResourceId) { - const roleResult = runAz([ - 'role', - 'assignment', - 'list', - '--scope', - workspaceResourceId, - '--include-groups', - '-o', - 'json' - ], options); - const summary = roleAssignmentSummary( - roleResult.status === 0 ? parseJsonOutput(roleResult) : [], - [azureRoleIds.logAnalyticsDataReader, azureRoleIds.monitoringReader] - ); - const workspaceRbacOk = roleResult.status === 0 && - (!production || (summary.group_assignments > 0 && summary.broad_assignments === 0)); - workspaceRbacOkForContent = workspaceRbacOk; - checks.push(checkResult('log-analytics-rbac-posture', workspaceRbacOk, { - scope: workspaceResourceId, - ...summary, - production, - detail: roleResult.status !== 0 - ? azErrorDetail(roleResult, 'could not list Log Analytics RBAC assignments') - : production - ? (workspaceRbacOk - ? 'least-privilege group RBAC observed on Log Analytics' - : 'production mode expects group-based reader RBAC and no broad Owner/Contributor assignments on Log Analytics') - : 'Log Analytics RBAC posture observed' - })); - if (roleResult.status !== 0) next.push('Verify Azure RBAC read permissions for the Log Analytics workspace.'); - else if (production && !workspaceRbacOk) next.push('Review Log Analytics RBAC: assign observer groups and remove routine broad roles before production.'); - } - - if (production && workspaceResult.status === 0) { - const tableResult = runAz([ - 'monitor', - 'log-analytics', - 'workspace', - 'table', - 'list', - '--resource-group', - cloud.resourceGroup, - '--workspace-name', - cloud.workspaceName, - '-o', - 'json' - ], options); - const contentTables = tableResult.status === 0 ? agentOpsContentTables(parseJsonOutput(tableResult)) : []; - const retentionCandidates = contentTables - .map(table => table.retention_days) - .filter(Number.isFinite); - const contentRetentionDays = retentionCandidates.length > 0 - ? Math.min(...retentionCandidates) - : retentionDays; - const hasContentTable = contentTables.length > 0; - const shortRetention = Number.isFinite(contentRetentionDays) && contentRetentionDays > 0 && contentRetentionDays <= 30; - const contentPostureOk = tableResult.status === 0 && - (!hasContentTable || (shortRetention && resourceScopedAccess && workspaceRbacOkForContent === true)); - checks.push(checkResult('content-capture-table-posture', contentPostureOk, { - table: 'AgentOpsContent_CL', - observed: hasContentTable, - retention_days: Number.isFinite(contentRetentionDays) ? contentRetentionDays : null, - max_retention_days: 30, - resource_scoped_access: resourceScopedAccess, - log_analytics_rbac_ok: workspaceRbacOkForContent, - issues: hasContentTable ? [ - !shortRetention ? 'short_retention' : null, - !resourceScopedAccess ? 'resource_scoped_access' : null, - workspaceRbacOkForContent !== true ? 'workspace_rbac' : null - ].filter(Boolean) : [], - production, - detail: tableResult.status !== 0 - ? azErrorDetail(tableResult, 'could not list Log Analytics tables') - : hasContentTable - ? (contentPostureOk - ? 'optional content table uses short retention and least-privilege workspace access' - : 'production mode expects optional content rows to use <=30 day retention and least-privilege workspace access') - : 'no optional content capture table observed' - })); - if (tableResult.status !== 0) next.push('Install/update Azure CLI monitor extension or verify Log Analytics table read permissions.'); - else if (hasContentTable && !contentPostureOk) { - next.push('Harden optional content capture: keep AgentOpsContent_CL retention <=30 days and restrict Log Analytics access before production.'); - } - } - } - - if (hasAz && cloud.resourceGroup && cloud.appInsightsName) { - const appResult = runAz([ - 'monitor', - 'app-insights', - 'component', - 'show', - '--resource-group', - cloud.resourceGroup, - '--app', - cloud.appInsightsName, - '-o', - 'json' - ], options); - checks.push(checkResult('application-insights', appResult.status === 0, { - app: cloud.appInsightsName, - detail: appResult.status === 0 ? 'found' : (appResult.stderr || appResult.stdout || 'not found').trim() - })); - if (appResult.status !== 0) next.push('Set APPLICATIONINSIGHTS_NAME to the deployed App Insights component name.'); - } - - const grafanaConfigured = isConfiguredValue(cloud.grafanaBaseUrl, /your-grafana|<your-grafana>|^$/); - checks.push(checkResult('grafana-base-url', grafanaConfigured, { - url: grafanaConfigured ? cloud.grafanaBaseUrl : null, - detail: grafanaConfigured ? 'configured' : 'Set AGENTOPS_GRAFANA_BASE_URL.' - })); - if (!grafanaConfigured) next.push('agentops configure set --grafana-url "https://<your-grafana>.grafana.azure.com"'); - - if (hasAz && cloud.grafanaName && cloud.resourceGroup) { - const grafanaResult = runAz(['grafana', 'show', '-n', cloud.grafanaName, '-g', cloud.resourceGroup, '-o', 'json'], options); - const grafanaFound = grafanaResult.status === 0; - const grafanaResource = grafanaFound ? parseJsonOutput(grafanaResult) : null; - const grafanaResourceId = grafanaResource?.id || null; - checks.push(checkResult('grafana-resource', grafanaFound, { - grafana: cloud.grafanaName, - detail: grafanaFound ? 'found' : azErrorDetail(grafanaResult, 'not found') - })); - if (!grafanaFound) { - next.push('Set GRAFANA_NAME or AGENTOPS_GRAFANA_NAME to the deployed Azure Managed Grafana resource name.'); - } else { - const identityType = String(pathValue(grafanaResource, ['identity', 'type'], '')); - const apiKey = String(pathValue(grafanaResource, ['properties', 'apiKey'], '')); - const publicNetworkAccess = String(pathValue(grafanaResource, ['properties', 'publicNetworkAccess'], 'unknown')); - const zoneRedundancy = String(pathValue(grafanaResource, ['properties', 'zoneRedundancy'], 'unknown')); - const grafanaPostureOk = identityType.includes('SystemAssigned') && - apiKey === 'Disabled' && - (!production || publicNetworkAccess === 'Disabled') && - (!production || zoneRedundancy === 'Enabled'); - checks.push(checkResult('grafana-production-posture', grafanaPostureOk, { - identity_type: identityType || null, - api_key: apiKey || null, - public_network_access: publicNetworkAccess, - zone_redundancy: zoneRedundancy, - issues: [ - !identityType.includes('SystemAssigned') ? 'managed_identity' : null, - apiKey !== 'Disabled' ? 'api_keys' : null, - publicNetworkAccess !== 'Disabled' ? 'public_network_access' : null, - zoneRedundancy !== 'Enabled' ? 'zone_redundancy' : null - ].filter(Boolean), - production, - detail: grafanaPostureOk - ? (production ? 'managed identity and Grafana hardening posture verified' : 'pilot Grafana posture observed') - : production - ? 'production mode expects managed identity, API keys disabled, private access, and zone redundancy' - : 'pilot posture observed; use --production to enforce private access and zone redundancy' - })); - if (!grafanaPostureOk) { - next.push(production - ? 'Review Grafana identity, API key, public access, and zone redundancy before production.' - : 'Run agentops validate-azure --production to enforce production Grafana posture.'); - } - - if (production) { - const approvedPrivateConnections = approvedPrivateEndpointConnections(grafanaResource); - const privateAccessOk = publicNetworkAccess === 'Disabled' && approvedPrivateConnections.length > 0; - checks.push(checkResult('grafana-private-access-posture', privateAccessOk, { - public_network_access: publicNetworkAccess, - private_endpoint_connections: privateEndpointConnectionsFromResource(grafanaResource).length, - approved_private_endpoint_connections: approvedPrivateConnections.length, - production, - detail: privateAccessOk - ? 'public access disabled and approved private endpoint connection observed' - : 'production mode expects disabled public access plus an approved private endpoint connection' - })); - if (!privateAccessOk) next.push('Verify Managed Grafana private endpoint connectivity before production.'); - } - - if (grafanaResourceId) { - const roleResult = runAz([ - 'role', - 'assignment', - 'list', - '--scope', - grafanaResourceId, - '--include-groups', - '-o', - 'json' - ], options); - const summary = roleAssignmentSummary( - roleResult.status === 0 ? parseJsonOutput(roleResult) : [], - [azureRoleIds.grafanaViewer, azureRoleIds.grafanaEditor, azureRoleIds.grafanaAdmin] - ); - const grafanaRbacOk = roleResult.status === 0 && - (!production || (summary.group_assignments > 0 && summary.broad_assignments === 0)); - checks.push(checkResult('grafana-rbac-posture', grafanaRbacOk, { - scope: grafanaResourceId, - ...summary, - production, - detail: roleResult.status !== 0 - ? azErrorDetail(roleResult, 'could not list Grafana RBAC assignments') - : production - ? (grafanaRbacOk - ? 'least-privilege group RBAC observed on Managed Grafana' - : 'production mode expects group-based Grafana RBAC and no broad Owner/Contributor assignments on Managed Grafana') - : 'Grafana RBAC posture observed' - })); - if (roleResult.status !== 0) next.push('Verify Azure RBAC read permissions for the Managed Grafana resource.'); - else if (production && !grafanaRbacOk) next.push('Review Managed Grafana RBAC: assign observer/operator groups and remove routine broad roles before production.'); - } - - const dataSourceResult = runAz(['grafana', 'data-source', 'list', '-n', cloud.grafanaName, '-g', cloud.resourceGroup, '-o', 'json'], options); - const dataSources = flattenGrafanaList(dataSourceResult.status === 0 ? parseJsonOutput(dataSourceResult) : null); - const datasourceFound = dataSources.some(item => grafanaItemUid(item) === cloud.grafanaDatasourceUid || item?.name === cloud.grafanaDatasourceUid); - checks.push(checkResult('grafana-datasource', dataSourceResult.status === 0 && datasourceFound, { - expected_uid: cloud.grafanaDatasourceUid, - observed: dataSources.map(grafanaItemUid).filter(Boolean).slice(0, 10), - detail: dataSourceResult.status !== 0 - ? (dataSourceResult.stderr || dataSourceResult.stdout || 'could not list datasources').trim() - : datasourceFound - ? 'found' - : 'datasource UID not found' - })); - if (dataSourceResult.status !== 0 || !datasourceFound) { - next.push('Set AGENTOPS_GRAFANA_DATASOURCE_UID to the Azure Monitor datasource UID used by the dashboards.'); - } - - const expectedDashboards = options.expectedDashboards || listGrafanaDashboardFiles(options); - const dashboardResult = runAz(['grafana', 'dashboard', 'list', '-n', cloud.grafanaName, '-g', cloud.resourceGroup, '-o', 'json'], options); - const dashboards = flattenGrafanaList(dashboardResult.status === 0 ? parseJsonOutput(dashboardResult) : null); - const observedUids = new Set(dashboards.map(grafanaItemUid).filter(Boolean)); - const missingDashboards = expectedDashboards.filter(dashboard => !observedUids.has(dashboard.uid)); - checks.push(checkResult('grafana-dashboards', dashboardResult.status === 0 && missingDashboards.length === 0, { - expected: expectedDashboards.length, - missing: missingDashboards.map(dashboard => dashboard.uid), - detail: dashboardResult.status !== 0 - ? (dashboardResult.stderr || dashboardResult.stdout || 'could not list dashboards').trim() - : missingDashboards.length === 0 - ? 'all expected dashboards found' - : `${missingDashboards.length} expected dashboard${missingDashboards.length === 1 ? '' : 's'} missing` - })); - if (dashboardResult.status !== 0 || missingDashboards.length > 0) { - next.push(grafanaDashboardImportCommand(cloud)); - if (options.importDashboards) { - const remediation = runGrafanaDashboardImportRemediation(cloud, options); - checks.push(checkResult('grafana-dashboard-import', remediation.ok, { - command: remediation.command, - detail: remediation.ok - ? 'import completed' - : (remediation.stderr || remediation.stdout || remediation.error || `dashboard import exited ${remediation.status}`).trim(), - remediation - })); - if (remediation.ok) next.push('agentops validate-azure --last 24h'); - } - } - } - } else if (!cloud.grafanaName) { - checks.push({ name: 'grafana-resource', ok: true, skipped: true, detail: 'Set GRAFANA_NAME or AGENTOPS_GRAFANA_NAME to validate the resource directly.' }); - checks.push({ name: 'grafana-production-posture', ok: true, skipped: true, detail: 'Skipped because Grafana resource name is not configured.' }); - checks.push({ name: 'grafana-datasource', ok: true, skipped: true, detail: 'Skipped because Grafana resource name is not configured.' }); - checks.push({ name: 'grafana-dashboards', ok: true, skipped: true, detail: 'Skipped because Grafana resource name is not configured.' }); - } - - if (hasAz && cloud.resourceGroup) { - const alertResult = runAz(['monitor', 'scheduled-query', 'list', '--resource-group', cloud.resourceGroup, '-o', 'json'], options); - const rules = alertResult.status === 0 ? agentOpsScheduledQueryRules(parseJsonOutput(alertResult)) : []; - const enabledRules = rules.filter(rule => boolish(pathValue(rule, ['properties', 'enabled'], false))); - const routedRules = rules.filter(rule => asArray(pathValue(rule, ['properties', 'actions', 'actionGroups'], [])).length > 0); - const unroutedRules = rules.filter(rule => asArray(pathValue(rule, ['properties', 'actions', 'actionGroups'], [])).length === 0); - const actionGroupIds = Array.from(new Set(routedRules.flatMap(rule => asArray(pathValue(rule, ['properties', 'actions', 'actionGroups'], []))).filter(Boolean))); - const alertPostureOk = alertResult.status === 0 && (!production || (enabledRules.length > 0 && enabledRules.length === routedRules.length)); - checks.push(checkResult('alert-routing-posture', alertPostureOk, { - rules: rules.length, - rule_names: rules.map(rule => rule.name).filter(Boolean), - enabled_rules: enabledRules.length, - enabled_rule_names: enabledRules.map(rule => rule.name).filter(Boolean), - routed_rules: routedRules.length, - action_groups: actionGroupIds.length, - unrouted_rule_names: unroutedRules.map(rule => rule.name).filter(Boolean), - production, - detail: alertResult.status !== 0 - ? azErrorDetail(alertResult, 'could not list scheduled query rules') - : production - ? 'production mode expects enabled AgentOps alerts routed to action groups' - : 'scheduled query alert posture observed' - })); - if (alertResult.status !== 0) next.push('Install/update Azure CLI monitor extension or verify scheduled query rule read permissions.'); - else if (production && (enabledRules.length === 0 || enabledRules.length !== routedRules.length)) { - next.push('Configure AgentOps scheduled query alerts with approved Azure Monitor action groups before production.'); - } - - if (production && alertResult.status === 0) { - const actionGroupChecks = actionGroupIds.map(id => { - const groupResult = runAz(['monitor', 'action-group', 'show', '--ids', id, '-o', 'json'], options); - const actionGroup = groupResult.status === 0 ? parseJsonOutput(groupResult) : null; - const receiverSummary = actionGroupReceiverSummary(actionGroup); - const enabled = boolish(actionGroup?.enabled ?? pathValue(actionGroup, ['properties', 'enabled'], true)); - return { - id, - ok: groupResult.status === 0 && enabled && receiverSummary.receiver_count > 0, - status: groupResult.status, - enabled, - receiver_count: receiverSummary.receiver_count, - receiver_types: receiverSummary.receiver_types, - detail: groupResult.status === 0 ? 'found' : azErrorDetail(groupResult, 'could not read action group') - }; - }); - const actionGroupDestinationOk = actionGroupIds.length > 0 && actionGroupChecks.every(item => item.ok); - checks.push(checkResult('action-group-destination-posture', actionGroupDestinationOk, { - action_groups: actionGroupIds.length, - checked: actionGroupChecks.length, - invalid: actionGroupChecks.filter(item => !item.ok).map(item => item.id), - receivers: actionGroupChecks.reduce((sum, item) => sum + item.receiver_count, 0), - production, - detail: actionGroupDestinationOk - ? 'routed action groups exist and have notification receivers' - : 'production mode expects routed action groups to exist, be enabled, and have at least one receiver' - })); - if (!actionGroupDestinationOk) next.push('Review Azure Monitor action group destinations before production alerts are enabled.'); - } - } - - const workspaceRbacCheck = checks.find(check => check.name === 'log-analytics-rbac-posture'); - const grafanaRbacCheck = checks.find(check => check.name === 'grafana-rbac-posture'); - if (workspaceRbacCheck || grafanaRbacCheck) { - const accessOk = (!workspaceRbacCheck || workspaceRbacCheck.ok) && (!grafanaRbacCheck || grafanaRbacCheck.ok); - checks.push(checkResult('access-rbac-posture', accessOk, { - production, - log_analytics_ok: workspaceRbacCheck ? workspaceRbacCheck.ok : null, - grafana_ok: grafanaRbacCheck ? grafanaRbacCheck.ok : null, - detail: accessOk - ? 'Azure access RBAC posture observed' - : 'production mode expects least-privilege group RBAC for Log Analytics and Managed Grafana' - })); - } else if (production) { - checks.push(checkResult('access-rbac-posture', false, { - production, - detail: 'production mode could not verify Log Analytics or Managed Grafana RBAC posture' - })); - next.push('Configure workspace and Grafana resource names so validate-azure can verify RBAC posture.'); - } - - if (next.length === 0) { - next.push('node agentops-cli/src/index.js collector smoke --privacy strict --poison'); - next.push('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s --open-browser'); - next.push('copilot -p "Reply with exactly: agentops smoke."'); - next.push('node agentops-cli/src/index.js latest --last 2h'); - } - - const result = { - ok: checks.every(check => check.ok), - last, - config: { - resource_group: cloud.resourceGroup, - workspace_id: workspaceConfigured ? cloud.workspaceId : null, - workspace_name: cloud.workspaceName, - grafana_base_url: grafanaConfigured ? cloud.grafanaBaseUrl : null, - grafana_name: cloud.grafanaName || null, - grafana_datasource_uid: cloud.grafanaDatasourceUid, - app_insights_name: cloud.appInsightsName, - production - }, - checks, - next - }; - if (options.remediationPlan) { - result.remediation_plan = azureProductionRemediationPlan(result, options); - } - return result; -} - -function renderValidateAzure(result) { - const lines = ['AgentOps Azure validation', '']; - for (const check of result.checks) { - const status = check.ok ? 'ok' : 'failed'; - const skipped = check.skipped ? ' skipped' : ''; - lines.push(`- ${check.name}: ${status}${skipped}${check.detail ? ` (${check.detail})` : ''}`); - if (check.name === 'grafana-dashboards' && !check.ok && Array.isArray(check.missing) && check.missing.length > 0) { - lines.push(` missing: ${check.missing.join(', ')}`); - lines.push(' fix: agentops validate-azure --import-dashboards --last 24h'); - } - } - lines.push('', result.ok ? 'Azure validation passed.' : 'Azure validation is incomplete.'); - if (result.remediation_plan) { - lines.push('', 'Remediation plan:', result.remediation_plan.note); - for (const action of result.remediation_plan.actions || []) { - lines.push(`- ${action.name} (${action.risk}): ${action.reason}`); - lines.push(` review: ${action.review}`); - for (const command of action.commands || []) lines.push(` command: ${command}`); - } - } - lines.push('Next:'); - for (const item of result.next) lines.push(`- ${item}`); - return `${lines.join('\n')}\n`; -} - -function repoFileText(relativePath, options = {}) { - const base = options.root || root; - const fullPath = path.join(base, relativePath); - return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : ''; -} - -function enterpriseCheck(name, ok, severity, detail) { - return checkResult(name, ok, { severity, detail }); -} - -function validateEnterprise(options = {}) { - const env = options.env || process.env; - const config = options.config || readAgentOpsConfig({ configPath: options.configPath, quiet: true }).values; - const mainBicep = repoFileText('infra/bicep/main.bicep', options); - const logAnalyticsBicep = repoFileText('infra/bicep/log-analytics.bicep', options); - const grafanaBicep = repoFileText('infra/bicep/grafana.bicep', options); - const keyVaultBicep = repoFileText('infra/bicep/key-vault.bicep', options); - const appInsightsBicep = repoFileText('infra/bicep/app-insights.bicep', options); - const alertsBicep = repoFileText('infra/bicep/alerts.bicep', options); - const rbacBicep = repoFileText('infra/bicep/rbac.bicep', options); - const budgetBicep = repoFileText('infra/bicep/budget.bicep', options); - const datasourceProvisioning = repoFileText('grafana/provisioning/datasources/azure-monitor.yaml', options); - const azureCollector = repoFileText('collector/otelcol.azuremonitor.yaml', options); - const azureCompose = repoFileText('collector/docker-compose.azuremonitor.yaml', options); - const azureWhatIf = repoFileText('scripts/azure-what-if.sh', options); - const enterpriseDeploy = repoFileText('scripts/azure-deploy-enterprise-pilot.sh', options); - const readme = repoFileText('README.md', options); - const enterprisePilot = repoFileText('docs/enterprise-pilot.md', options); - const azureProdHardening = repoFileText('docs/azure-production-hardening.md', options); - const threatModel = repoFileText('docs/threat-model.md', options); - - const checks = [ - enterpriseCheck( - 'deployment-profiles', - /param deploymentProfile string/.test(mainBicep) && /'dev'/.test(mainBicep) && /'team'/.test(mainBicep) && /'enterprise'/.test(mainBicep), - 'high', - 'Bicep exposes dev/team/enterprise profiles.' - ), - enterpriseCheck( - 'daily-ingestion-cap', - /dailyIngestionCapGb/.test(mainBicep) && /workspaceCapping/.test(logAnalyticsBicep) && /dailyQuotaGb/.test(logAnalyticsBicep), - 'critical', - 'Log Analytics has a default daily ingestion cap as a spike guardrail.' - ), - enterpriseCheck( - 'retention-parameter', - /logRetentionDays/.test(mainBicep) && /retentionInDays/.test(logAnalyticsBicep), - 'high', - 'Retention is explicit and profile-driven.' - ), - enterpriseCheck( - 'metadata-only-tags', - /telemetryContent: 'metadata-only'/.test(mainBicep), - 'medium', - 'Azure resources are tagged as metadata-only telemetry.' - ), - enterpriseCheck( - 'actioner-disabled-default', - /param deployActioner bool = false/.test(mainBicep), - 'critical', - 'Actioning workflows are opt-in, not enabled by default.' - ), - enterpriseCheck( - 'alerts-disabled-default', - /param deployAlerts bool = false/.test(mainBicep) && /param enableAlerts bool = false/.test(mainBicep), - 'high', - 'Alerts are opt-in until thresholds and action groups are tuned.' - ), - enterpriseCheck( - 'rbac-disabled-default', - /param deployRbacAssignments bool = false/.test(mainBicep), - 'high', - 'RBAC assignment automation is opt-in because it mutates access control.' - ), - enterpriseCheck( - 'budget-disabled-default', - /param deployBudget bool = false/.test(mainBicep) && /budgetContactEmails/.test(mainBicep), - 'high', - 'Budget creation is opt-in and requires explicit contact emails.' - ), - enterpriseCheck( - 'collector-localhost-published', - /127\.0\.0\.1:4318:4318/.test(azureCompose) && /127\.0\.0\.1:4317:4317/.test(azureCompose), - 'critical', - 'Docker publishes OTLP only on localhost.' - ), - enterpriseCheck( - 'collector-content-scrub', - [ - 'gen_ai.input.messages', - 'gen_ai.output.messages', - 'gen_ai.prompt', - 'gen_ai.completion', - 'gen_ai.tool.call.arguments', - 'gen_ai.tool.call.result', - 'http.request.body.content', - 'http.response.body.content' - ].every(key => azureCollector.includes(key)) && /action: delete/.test(azureCollector), - 'critical', - 'Collector deletes prompt, response, tool payload, URL, and body content before Azure export.' - ), - enterpriseCheck( - 'content-capture-env-off', - String(env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT || '').toLowerCase() !== 'true' && - String(env.COPILOT_OTEL_CAPTURE_CONTENT || '').toLowerCase() !== 'true', - 'critical', - 'Content capture is not enabled in this environment.' - ), - enterpriseCheck( - 'grafana-api-keys-disabled', - /apiKey: 'Disabled'/.test(grafanaBicep), - 'high', - 'Azure Managed Grafana API keys are disabled.' - ), - enterpriseCheck( - 'grafana-managed-identity', - /identity:\s*{\s*type: 'SystemAssigned'/s.test(grafanaBicep) && /azureAuthType: msi/.test(datasourceProvisioning), - 'high', - 'Managed Grafana and the Azure Monitor datasource use managed identity auth.' - ), - enterpriseCheck( - 'grafana-network-posture-params', - /param grafanaPublicNetworkAccess string/.test(mainBicep) && - /param grafanaZoneRedundancy string/.test(mainBicep) && - /publicNetworkAccess: publicNetworkAccess/.test(grafanaBicep) && - /zoneRedundancy: zoneRedundancy/.test(grafanaBicep), - 'medium', - 'Grafana public access and zone redundancy are explicit deployment choices.' - ), - enterpriseCheck( - 'alert-action-groups-parameter', - /param alertActionGroupResourceIds array/.test(mainBicep) && - /param actionGroupResourceIds array/.test(alertsBicep) && - /actionGroups: actionGroupResourceIds/.test(alertsBicep), - 'high', - 'Alert routing uses explicit Azure Monitor action group resource IDs.' - ), - enterpriseCheck( - 'key-vault-rbac-purge-protection', - /enableRbacAuthorization: true/.test(keyVaultBicep) && /enablePurgeProtection: true/.test(keyVaultBicep), - 'high', - 'Key Vault uses RBAC authorization and purge protection.' - ), - enterpriseCheck( - 'log-access-resource-permissions', - /enableLogAccessUsingOnlyResourcePermissions: true/.test(logAnalyticsBicep), - 'high', - 'Log Analytics access follows resource permissions.' - ), - enterpriseCheck( - 'app-insights-workspace-based', - /WorkspaceResourceId/.test(appInsightsBicep) && /IngestionMode: 'LogAnalytics'/.test(appInsightsBicep), - 'high', - 'Application Insights is workspace-based for central query and retention control.' - ), - enterpriseCheck( - 'least-privilege-rbac-module', - /Microsoft\.Authorization\/roleAssignments@2022-04-01/.test(rbacBicep) && - /principalType: 'Group'/.test(rbacBicep) && - /3b03c2da-16b3-4a49-8834-0f8130efdd3b/.test(rbacBicep) && - /60921a7e-fef1-4a43-9b16-a26c52ad4769/.test(rbacBicep), - 'high', - 'Optional RBAC module assigns least-privilege roles to Entra security groups.' - ), - enterpriseCheck( - 'budget-module', - /Microsoft\.Consumption\/budgets@/.test(budgetBicep) && - /Actual_GreaterThan_80_Percent/.test(budgetBicep) && - /Actual_GreaterThan_100_Percent/.test(budgetBicep), - 'high', - 'Optional budget module alerts owners at 80 percent and 100 percent.' - ), - enterpriseCheck( - 'what-if-enterprise-params', - /AGENTOPS_DEPLOY_RBAC_ASSIGNMENTS/.test(azureWhatIf) && - /AGENTOPS_DEPLOY_BUDGET/.test(azureWhatIf) && - /AGENTOPS_BUDGET_CONTACT_EMAILS/.test(azureWhatIf) && - /AGENTOPS_DEPLOY_ALERTS/.test(azureWhatIf) && - /AGENTOPS_ENABLE_ALERTS/.test(azureWhatIf) && - /AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS/.test(azureWhatIf) && - /AGENTOPS_GRAFANA_PUBLIC_NETWORK_ACCESS/.test(azureWhatIf), - 'medium', - 'what-if supports RBAC, budget, alert routing, and Grafana network posture parameters.' - ), - enterpriseCheck( - 'enterprise-deploy-script', - /az deployment group create/.test(enterpriseDeploy) && - /AGENTOPS_DEPLOY_RBAC_ASSIGNMENTS/.test(enterpriseDeploy) && - /AGENTOPS_DEPLOY_BUDGET/.test(enterpriseDeploy) && - /AGENTOPS_DEPLOY_ALERTS/.test(enterpriseDeploy) && - /AGENTOPS_ENABLE_ALERTS/.test(enterpriseDeploy) && - /AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS/.test(enterpriseDeploy) && - /AGENTOPS_GRAFANA_PUBLIC_NETWORK_ACCESS/.test(enterpriseDeploy), - 'medium', - 'Enterprise pilot script deploys the same RBAC, budget, alert, and Grafana posture parameters reviewed by what-if.' - ), - enterpriseCheck( - 'connection-string-not-configured', - !Object.keys(config).some(key => /connection|string|instrumentation/i.test(key)), - 'critical', - 'Local AgentOps config stores names/IDs, not connection strings.' - ), - enterpriseCheck( - 'azd-no-connection-string-output', - !/output APPLICATIONINSIGHTS_CONNECTION_STRING/.test(mainBicep), - 'critical', - 'azd outputs do not persist the Application Insights connection string.' - ), - enterpriseCheck( - 'azd-outputs-importable', - /output APPLICATIONINSIGHTS_NAME/.test(mainBicep) && - /output LOG_ANALYTICS_DAILY_QUOTA_GB/.test(mainBicep) && - /output GRAFANA_ENDPOINT/.test(mainBicep), - 'medium', - 'azd outputs include names, endpoints, and cost guardrail values.' - ), - enterpriseCheck( - 'enterprise-docs', - /Enterprise-safe, cost-bounded setup/.test(readme), - 'medium', - 'README documents the enterprise-safe path.' - ), - enterpriseCheck( - 'pilot-review-docs', - /Data Classification/.test(enterprisePilot) && - /Review Checklist/.test(enterprisePilot) && - /Rollback/.test(enterprisePilot), - 'medium', - 'Enterprise pilot guide documents data classification, review, and rollback.' - ), - enterpriseCheck( - 'azure-production-hardening-docs', - /Managed Grafana Access/i.test(azureProdHardening) && - /Log Analytics Posture/i.test(azureProdHardening) && - /Alert Routing/i.test(azureProdHardening) && - /Private Access/i.test(azureProdHardening), - 'medium', - 'Azure production hardening doc covers Grafana access, Log Analytics, alert routing, and private access.' - ), - enterpriseCheck( - 'threat-model', - /Trust Boundaries/.test(threatModel) && - /Threats And Mitigations/.test(threatModel) && - /Residual Risk/.test(threatModel), - 'medium', - 'Threat model documents boundaries, mitigations, and residual risk.' - ) - ]; - - const blocking = checks.filter(check => !check.ok && check.severity !== 'warning'); - const warnings = checks.filter(check => !check.ok && check.severity === 'warning'); - const score = Math.max(0, 100 - blocking.length * 8 - warnings.length * 3); - const next = []; - - if (blocking.length === 0) { - next.push('Run agentops setup, then agentops validate-azure.'); - next.push('Run ./scripts/azure-what-if.sh and review retention/cap values before azd provision.'); - next.push('Run agentops collector smoke --privacy strict --poison after provisioning.'); - } else { - next.push('Fix failed critical/high checks before enterprise rollout.'); - } - - return { - ok: blocking.length === 0, - score, - checks, - failed: blocking.map(check => check.name), - warnings: warnings.map(check => check.name), - next - }; -} - -function renderValidateEnterprise(result) { - const lines = ['AgentOps enterprise validation', '', `Score: ${result.score}/100.`]; - for (const check of result.checks) { - const status = check.ok ? 'ok' : 'failed'; - lines.push(`- ${check.name}: ${status} [${check.severity}]${check.detail ? ` (${check.detail})` : ''}`); - } - lines.push('', result.ok ? 'Enterprise guardrails passed.' : 'Enterprise guardrails are incomplete.'); - lines.push('Next:'); - for (const item of result.next) lines.push(`- ${item}`); - return `${lines.join('\n')}\n`; -} - -function askAgentOpsContext(options = {}) { - const last = validateKqlDuration(options.last || '24h'); - const target = options.sessionId || 'latest'; - let session = null; - let link = null; - let dataMissing = []; - - if (target === 'latest') { - const summary = options.summary || latestSummaryFromArgs(options.args || [], last); - session = summary.session; - dataMissing = summary.data_missing || []; - if (session?.id && session.id !== 'unknown-session') { - link = buildLink('session', session.id, { last }); - } - } else { - session = { id: target }; - link = buildLink('session', target, { last }); - } - - const sessionId = session?.id || 'unknown-session'; - const prompt = [ - 'Use the telemetry-investigator agent with read-only Azure MCP and Grafana MCP.', - '', - `Investigate AgentOps session ${sessionId} over the last ${last}.`, - `Grafana session URL: ${link?.grafana_url || sessionsGrafanaDashboardUrl}`, - `Log Analytics workspace: ${workspaceId}`, - '', - 'Start from the KQL query in this context bundle. Return only evidence-backed findings.', - 'For each recommendation include: evidence query or dashboard link, observed pattern, proposed file(s), expected metric movement, validation benchmark or query, and rollback condition.', - 'Do not edit files yet. Do not request prompt, response, tool argument, tool result, secret, URL content, or file-content capture.' - ].join('\n'); - - return { - ok: Boolean(link), - session: sessionId, - last, - dashboard: link?.grafana_url || sessionsGrafanaDashboardUrl, - azure_portal_url: link?.azure_portal_url || portalLogsUrl, - workspace_id: workspaceId, - query: link?.query || latestSessionAzureQuery(last), - mcp_configs: [ - 'copilot/mcp.azure-monitor.sample.json', - 'copilot/mcp.grafana.sample.json' - ], - prompt, - data_missing: dataMissing - }; -} - -function parseAskContextArgs(args) { - const sessionId = args[0] || 'latest'; - return { - sessionId, - last: parseLastArg(args.slice(1), '24h'), - json: args.includes('--json'), - args: args.slice(1) - }; -} - -function renderAskContext(context) { - const lines = [ - 'AgentOps ask context', - '', - `Session: ${context.session}`, - `Dashboard: ${context.dashboard}`, - `Workspace: ${context.workspace_id}`, - `MCP configs: ${context.mcp_configs.join(', ')}`, - '' - ]; - - if (context.data_missing.length > 0) { - lines.push(`Data missing: ${context.data_missing.join(', ')}.`, ''); - } - - lines.push('KQL:', context.query, '', 'Prompt:', context.prompt); - return `${lines.join('\n')}\n`; -} - -function importJsonl(filePath) { - const text = fs.readFileSync(filePath, 'utf8'); - const rows = text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); - const operations = new Map(); - - for (const row of rows) { - const operation = row.name || row.operation || row.attributes?.['gen_ai.operation.name'] || 'unknown'; - operations.set(operation, (operations.get(operation) || 0) + 1); - } - - return { - file: filePath, - rows: rows.length, - operations: Object.fromEntries(operations) - }; -} - -function otlpAttr(key, value) { - if (typeof value === 'boolean') return { key, value: { boolValue: value } }; - if (typeof value === 'number') return { key, value: { doubleValue: value } }; - return { key, value: { stringValue: String(value) } }; -} - -function optionValues(args, name) { - const values = []; - for (let index = 0; index < args.length; index += 1) { - if (args[index] === name) { - if (!args[index + 1]) throw new Error(`${name} requires a value`); - values.push(args[index + 1]); - index += 1; - } - } - return values; -} - -function parseKeyValues(values, prefix) { - const attrs = {}; - for (const value of values) { - const separator = value.indexOf('='); - if (separator <= 0) throw new Error(`Expected ${prefix} value as key=value`); - const key = value.slice(0, separator).trim(); - if (!/^[A-Za-z0-9_.-]+$/.test(key)) throw new Error(`Invalid ${prefix} key: ${key}`); - attrs[`${prefix}.${key}`] = value.slice(separator + 1); - } - return attrs; -} - -function parseTelemetryAttributes(values) { - const attrs = {}; - for (const value of values) { - const separator = value.indexOf('='); - if (separator <= 0) throw new Error('Expected attribute value as key=value'); - const key = value.slice(0, separator).trim(); - if (!/^[A-Za-z0-9_.-]+$/.test(key)) throw new Error(`Invalid attribute key: ${key}`); - if (!customAttributePrefixes.some(prefix => key.startsWith(prefix))) { - throw new Error(`Unsupported attribute key: ${key}`); - } - attrs[key] = value.slice(separator + 1); - } - return attrs; -} - -function customAttributeKey(key) { - return key.startsWith('agentops.custom.') ? key : `agentops.custom.${key}`; -} - -function parseCustomArgs(args) { - const [subcommand, ...rest] = args; - const scoreText = optionValue(rest, ['--score']); - const score = scoreText === null ? null : Number(scoreText); - if (scoreText !== null && !Number.isFinite(score)) throw new Error('--score must be a number'); - - return { - subcommand, - file: subcommand === 'import' ? rest[0] : null, - event: optionValue(rest, ['--event', '--name']), - agent: optionValue(rest, ['--agent']), - parentAgent: optionValue(rest, ['--parent-agent']), - delegationId: optionValue(rest, ['--delegation-id']), - workflow: optionValue(rest, ['--workflow']), - step: optionValue(rest, ['--step']), - outcome: optionValue(rest, ['--outcome']), - risk: optionValue(rest, ['--risk']), - score, - entityType: optionValue(rest, ['--entity-type']), - entityIdHash: optionValue(rest, ['--entity-id-hash']), - session: optionValue(rest, ['--session']), - endpoint: optionValue(rest, ['--endpoint']), - runtime: optionValue(rest, ['--runtime']) || process.env.AGENTOPS_RUNTIME || 'github-copilot-cli', - framework: optionValue(rest, ['--framework']) || process.env.AGENTOPS_FRAMEWORK || 'github-copilot', - tags: optionValues(rest, '--tag'), - custom: parseKeyValues(optionValues(rest, '--custom'), 'agentops.custom'), - attributes: parseTelemetryAttributes([ - ...optionValues(rest, '--attribute'), - ...optionValues(rest, '--attr') - ]), - dryRun: rest.includes('--dry-run'), - verify: !rest.includes('--no-verify'), - last: parseLastArg(rest, '2h'), - waitMs: durationToMs(optionValue(rest, ['--wait']), 60000), - pollMs: durationToMs(optionValue(rest, ['--poll']), 10000), - json: rest.includes('--json') - }; -} - -function parseAnnotationArgs(args) { - const [subcommand, ...rest] = args; - return { - subcommand, - component: optionValue(rest, ['--component']), - target: optionValue(rest, ['--target', '--name']), - changeType: optionValue(rest, ['--change-type', '--type']) || 'updated', - changeId: optionValue(rest, ['--change-id']), - version: optionValue(rest, ['--version']), - runId: optionValue(rest, ['--run-id']), - session: optionValue(rest, ['--session', '--session-id']), - traceId: optionValue(rest, ['--trace-id']), - agent: optionValue(rest, ['--agent']) || 'agentops', - risk: optionValue(rest, ['--risk']), - endpoint: optionValue(rest, ['--endpoint']), - runtime: optionValue(rest, ['--runtime']) || process.env.AGENTOPS_RUNTIME || 'github-copilot-cli', - framework: optionValue(rest, ['--framework']) || process.env.AGENTOPS_FRAMEWORK || 'github-copilot', - dryRun: rest.includes('--dry-run'), - verify: !rest.includes('--no-verify'), - last: parseLastArg(rest, '2h'), - waitMs: durationToMs(optionValue(rest, ['--wait']), 60000), - pollMs: durationToMs(optionValue(rest, ['--poll']), 10000), - json: rest.includes('--json') - }; -} - -function customEventId(now = new Date()) { - const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); - return `agentops-custom-${stamp}-${crypto.randomBytes(3).toString('hex')}`; -} - -function normalizeCustomEvent(row = {}, defaults = {}, index = 0) { - const attrs = rowAttributes(row); - const custom = { - ...(row.custom || {}), - ...(row.metrics || {}) - }; - const attributes = { - ...(row.attributes || {}), - ...(row.attrs || {}) - }; - const event = row.event || row.event_name || row.name || attrs['agentops.event.name'] || attrs['event.name'] || defaults.event || 'agent.event'; - const agent = row.agent || row.agent_name || attrs['agentops.agent.name'] || attrs['gen_ai.agent.name'] || defaults.agent || 'custom-agent'; - const parentAgent = row.parentAgent || row.parent_agent || attrs['agentops.parent_agent.name'] || defaults.parentAgent || null; - const delegationId = row.delegationId || row.delegation_id || attrs['agentops.delegation.id'] || defaults.delegationId || null; - const workflow = row.workflow || row.workflow_name || attrs['agentops.workflow.name'] || defaults.workflow || null; - const step = row.step || row.step_name || attrs['agentops.step.name'] || null; - const session = row.session || row.session_id || row.conversation_id || attrs['gen_ai.conversation.id'] || defaults.session || null; - - return { - event, - agent, - parentAgent, - delegationId, - workflow, - step, - session, - outcome: row.outcome || attrs['agentops.outcome'] || null, - risk: row.risk || attrs['agentops.risk'] || null, - score: row.score === undefined || row.score === null ? null : Number(row.score), - entityType: row.entityType || row.entity_type || attrs['agentops.entity.type'] || null, - entityIdHash: row.entityIdHash || row.entity_id_hash || attrs['agentops.entity.id_hash'] || null, - tags: Array.isArray(row.tags) ? row.tags : [], - custom: { - ...Object.fromEntries(Object.entries(custom).map(([key, value]) => [customAttributeKey(key), value])), - 'agentops.custom.row_index': index - }, - attributes: parseTelemetryAttributes( - Object.entries(attributes).map(([key, value]) => `${key}=${value}`) - ) - }; -} - -function customEventAttributes(event, defaults = {}) { - if (!event.event) throw new Error('custom event requires --event'); - if (!event.agent) throw new Error('custom event requires --agent'); - - const attrs = { - 'agentops.custom_event_id': defaults.id, - 'agentops.schema.version': '1', - 'agentops.event.kind': 'agent.event', - 'agentops.event.name': event.event, - 'gen_ai.operation.name': event.event, - 'gen_ai.agent.name': event.agent, - 'agentops.agent.name': event.agent, - 'gen_ai.conversation.id': event.session || defaults.session || defaults.id, - 'content.capture.enabled': false, - ...event.custom, - ...event.attributes - }; - if (event.workflow) attrs['agentops.workflow.name'] = event.workflow; - if (event.parentAgent) attrs['agentops.parent_agent.name'] = event.parentAgent; - if (event.delegationId) attrs['agentops.delegation.id'] = event.delegationId; - if (event.step) attrs['agentops.step.name'] = event.step; - if (event.outcome) attrs['agentops.outcome'] = event.outcome; - if (event.risk) attrs['agentops.risk'] = event.risk; - if (event.score !== null && event.score !== undefined && Number.isFinite(event.score)) attrs['agentops.score'] = event.score; - if (event.entityType) attrs['agentops.entity.type'] = event.entityType; - if (event.entityIdHash) attrs['agentops.entity.id_hash'] = event.entityIdHash; - if (event.tags?.length) attrs['agentops.tags'] = event.tags.join(','); - return Object.entries(attrs).map(([key, value]) => otlpAttr(key, value)); -} - -function otlpCustomEventPayload(events, options = {}) { - const id = options.id || customEventId(options.now); - const traceId = crypto.randomBytes(16).toString('hex'); - const start = BigInt(options.nowMs || Date.now()) * 1000000n; - const normalized = events.map((event, index) => normalizeCustomEvent(event, { ...options, id }, index)); - const spans = normalized.map((event, index) => { - const spanStart = start + BigInt(index * 10) * 1000000n; - return { - traceId, - spanId: crypto.randomBytes(8).toString('hex'), - name: `agentops.custom.${event.event}`, - kind: 1, - startTimeUnixNano: spanStart.toString(), - endTimeUnixNano: (spanStart + 10000000n).toString(), - attributes: customEventAttributes(event, { ...options, id }), - status: { code: event.outcome === 'failed' ? 2 : 1 } - }; - }); - - return { - id, - normalized, - payload: { - resourceSpans: [ - { - resource: { - attributes: [ - otlpAttr('service.name', options.serviceName || 'github-copilot-cli'), - otlpAttr('service.namespace', 'copilot-agentops'), - otlpAttr('agent.framework', options.framework || 'github-copilot'), - otlpAttr('agent.runtime', options.runtime || 'github-copilot-cli'), - otlpAttr('agentops.profile', 'custom-event'), - otlpAttr('agentops.custom_event_id', id) - ] - }, - scopeSpans: [ - { - scope: { name: 'agentops.custom-event', version: '0.1.0' }, - spans - } - ] - } - ] - } - }; -} - -function customAzureQuery(id, last = '2h') { - const lookback = validateKqlDuration(last); - const escapedId = escapeKqlString(id); - return `AppDependencies\n| where TimeGenerated > ago(${lookback})\n| where Properties has "${escapedId}"\n| extend Event=tostring(Properties["agentops.event.name"]), Agent=tostring(Properties["agentops.agent.name"]), Workflow=tostring(Properties["agentops.workflow.name"]), Step=tostring(Properties["agentops.step.name"])\n| project TimeGenerated, Name, Event, Agent, Workflow, Step, OperationId, Success, Properties\n| order by TimeGenerated desc\n| take 50`; -} - -async function agentopsCustomEmit(options = {}) { - const endpoint = (options.endpoint || 'http://127.0.0.1:4318').replace(/\/$/, ''); - const id = options.id || customEventId(options.now); - const event = { - event: options.event, - agent: options.agent, - parentAgent: options.parentAgent, - delegationId: options.delegationId, - workflow: options.workflow, - step: options.step, - session: options.session, - outcome: options.outcome, - risk: options.risk, - score: options.score, - entityType: options.entityType, - entityIdHash: options.entityIdHash, - tags: options.tags || [], - custom: options.custom || {}, - attributes: options.attributes || {} - }; - const { normalized, payload } = otlpCustomEventPayload([event], { ...options, id }); - const result = { - ok: true, - custom_event_id: id, - endpoint, - dry_run: Boolean(options.dryRun), - verify: options.verify !== false, - workspace_id: options.workspaceId || workspaceId, - azure_query: customAzureQuery(id, options.last || '2h'), - events: normalized, - payload_preview: { - event: normalized[0].event, - agent: normalized[0].agent, - workflow: normalized[0].workflow, - step: normalized[0].step, - content_capture_enabled: false - } - }; - if (options.dryRun) return result; - - const response = await (options.postJson || postJson)(`${endpoint}/v1/traces`, payload, options); - return { - ...result, - ok: response.ok, - collector_response: response, - next: response.ok - ? ['node agentops-cli/src/index.js attribution --last 2h', 'Open Grafana: AgentOps Attribution or Runtime Events.'] - : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] - }; -} - -async function agentopsAnnotationConfigChange(options = {}) { - if (!options.component) throw new Error('annotation config-change requires --component'); - if (!options.target) throw new Error('annotation config-change requires --target'); - - const custom = { - 'agentops.custom.annotation_type': 'config_change', - 'agentops.custom.component': options.component, - 'agentops.custom.target': options.target, - 'agentops.custom.change_type': options.changeType || 'updated' - }; - if (options.changeId) custom['agentops.custom.change_id'] = options.changeId; - if (options.version) custom['agentops.custom.version'] = options.version; - - const attributes = { - ...(options.runId ? { 'agentops.run.id': options.runId } : {}), - ...(options.traceId ? { 'agentops.trace.id': options.traceId } : {}) - }; - - return agentopsCustomEmit({ - ...options, - event: 'agentops.config.changed', - workflow: 'config-change', - step: options.component, - outcome: 'changed', - entityType: options.component, - entityIdHash: options.target, - tags: ['annotation', 'config-change'], - custom, - attributes - }); -} - -async function agentopsCustomImport(filePath, options = {}) { - const rows = readJsonlRows(filePath); - const endpoint = (options.endpoint || 'http://127.0.0.1:4318').replace(/\/$/, ''); - const id = options.id || customEventId(options.now); - const events = rows.map((row, index) => normalizeCustomEvent(row, { ...options, id }, index)); - const { payload } = otlpCustomEventPayload(events, { ...options, id }); - const result = { - ok: true, - file: filePath, - rows: rows.length, - custom_event_id: id, - endpoint, - dry_run: Boolean(options.dryRun), - workspace_id: options.workspaceId || workspaceId, - azure_query: customAzureQuery(id, options.last || '2h'), - events: events.slice(0, 5) - }; - if (options.dryRun) return result; - - const response = await (options.postJson || postJson)(`${endpoint}/v1/traces`, payload, options); - return { - ...result, - ok: response.ok, - collector_response: response, - next: response.ok - ? ['node agentops-cli/src/index.js attribution --last 2h', 'Open Grafana: AgentOps Attribution or Runtime Events.'] - : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] - }; -} - -function renderCustom(result) { - const lines = [ - 'AgentOps custom telemetry', - '', - `Custom event id: ${result.custom_event_id}`, - `Endpoint: ${result.endpoint}`, - `Mode: ${result.dry_run ? 'dry-run' : 'sent'}`, - `Events: ${result.events.length}` - ]; - for (const event of result.events.slice(0, 5)) { - lines.push(`- ${event.event} agent=${event.agent}${event.workflow ? ` workflow=${event.workflow}` : ''}${event.step ? ` step=${event.step}` : ''}`); - } - if (result.collector_response) { - lines.push(result.collector_response.ok - ? `Collector response: ${result.collector_response.statusCode || 'ok'}.` - : `Collector response: failed (${result.collector_response.error || result.collector_response.statusCode || 'unknown'}).`); - } - lines.push('', 'Azure query:', result.azure_query); - if (result.next?.length) { - lines.push('', 'Next:'); - for (const item of result.next) lines.push(`- ${item}`); - } - return `${lines.join('\n')}\n`; -} - -function readJsonlRows(filePath) { - const text = fs.readFileSync(filePath, 'utf8'); - return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} - -function rowAttributes(row) { - const attrs = row.attributes || row.Properties || row.properties || {}; - if (typeof attrs !== 'string') return attrs; - - try { - return JSON.parse(attrs); - } catch { - return {}; - } -} - -function attributeValue(attrs, keys) { - for (const key of keys) { - if (attrs[key] !== undefined && attrs[key] !== null && attrs[key] !== '') return attrs[key]; - } - return null; -} - -function numberAttribute(attrs, keys) { - const value = attributeValue(attrs, keys); - if (value === null) return 0; - const number = Number(value); - return Number.isFinite(number) ? number : 0; -} - -function booleanAttribute(attrs, keys) { - const value = attributeValue(attrs, keys); - if (typeof value === 'boolean') return value; - if (typeof value === 'string') return value.toLowerCase() === 'true'; - return Boolean(value); -} - -function operationFromRow(row, attrs) { - return row.operation - || row.EventName - || row.SpanName - || row.name - || attributeValue(attrs, ['gen_ai.operation.name', 'operation']) - || 'unknown'; -} - -function sessionFromRow(row, attrs) { - return row.session - || row.SessionId - || row.session_id - || row.Session - || row.conversation - || attributeValue(attrs, ['gen_ai.conversation.id', 'github.copilot.interaction_id', 'conversation']) - || 'unknown-session'; -} - -function isFailedRow(row, attrs) { - const success = row.Success ?? row.success; - const status = row.Status ?? row.status ?? row.OutcomeStatus; - const statusCode = row.status?.code || row.statusCode || row.ResultCode || row.resultCode; - const error = attributeValue(attrs, ['error.type', 'exception.type', 'error']); - - if (success === false || (typeof success === 'string' && success.toLowerCase() === 'false')) return true; - if (['failed', 'failure', 'error', 'blocked', 'degraded'].includes(String(status || '').toLowerCase())) return true; - if (String(statusCode || '').toUpperCase() === 'ERROR') return true; - return Boolean(error); -} - -function summarizeSession(sessionId, spans, source = 'local') { - const tools = new Set(); - const models = new Set(); - const agents = new Set(); - const e2eIds = new Set(); - const allUsage = { inputTokens: 0, outputTokens: 0, credits: 0, count: 0 }; - const chatUsage = { inputTokens: 0, outputTokens: 0, credits: 0, count: 0 }; - let estimatedUsd = 0; - let toolCalls = 0; - let failedTools = 0; - let failures = 0; - let policyBlocks = 0; - let tokensRemoved = 0; - let contentCaptureWarning = false; - let latestTime = null; - let earliestTime = null; - - for (const row of spans) { - const attrs = rowAttributes(row); - const operation = operationFromRow(row, attrs); - const tool = row.ToolName || attributeValue(attrs, ['gen_ai.tool.name', 'tool']); - const model = row.ModelActual || row.ModelRequested || attributeValue(attrs, ['gen_ai.request.model', 'gen_ai.response.model', 'model']); - const agent = row.AgentName || attributeValue(attrs, ['gen_ai.agent.name', 'agent']); - const e2eId = attributeValue(attrs, ['agentops.e2e.id']); - const message = `${row.Message || row.message || row.name || ''} ${JSON.stringify(attrs)}`; - const failed = isFailedRow(row, attrs); - const timeValue = row.TimeGenerated || row.timestamp || row.time || row.startTime; - const time = timeValue ? new Date(timeValue) : null; - - if (tool) tools.add(String(tool)); - if (model) models.add(String(model)); - if (agent) agents.add(String(agent)); - if (e2eId) e2eIds.add(String(e2eId)); - if (operation === 'execute_tool' || tool) { - toolCalls += 1; - if (failed) failedTools += 1; - } - toolCalls += numberValue(row.ToolCount); - failedTools += numberValue(row.ToolFailureCount); - if (failed) failures += 1; - if (/preToolUse|policy|blocked|denied/i.test(message) || Number(row.ToolDeniedCount || 0) > 0) policyBlocks += Number(row.ToolDeniedCount || 1); - if (/truncation|compaction|too much context/i.test(message)) tokensRemoved += 1; - if (booleanAttribute(attrs, ['content.capture.enabled', 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'])) contentCaptureWarning = true; - if (attributeValue(attrs, ['gen_ai.prompt', 'gen_ai.completion', 'prompt', 'completion'])) contentCaptureWarning = true; - if (row.ContentCaptureSignal === true || String(row.ContentCaptureSignal || '').toLowerCase() === 'true') contentCaptureWarning = true; - - const inputTokenValue = numberValue(row.InputTokens) || numberAttribute(attrs, ['gen_ai.usage.input_tokens', 'InputTokens', 'input_tokens']); - const outputTokenValue = numberValue(row.OutputTokens) || numberAttribute(attrs, ['gen_ai.usage.output_tokens', 'OutputTokens', 'output_tokens']); - const creditValue = numberAttribute(attrs, ['github.copilot.cost', 'Credits', 'credits']); - const estimatedUsdValue = numberValue(row.EstimatedCostUsd); - if (estimatedUsdValue) estimatedUsd += estimatedUsdValue; - if (inputTokenValue || outputTokenValue || creditValue || estimatedUsdValue) { - allUsage.inputTokens += inputTokenValue; - allUsage.outputTokens += outputTokenValue; - allUsage.credits += creditValue; - allUsage.count += 1; - if (operation === 'chat') { - chatUsage.inputTokens += inputTokenValue; - chatUsage.outputTokens += outputTokenValue; - chatUsage.credits += creditValue; - chatUsage.count += 1; - } - } - tokensRemoved += numberAttribute(attrs, ['github.copilot.tokens_removed', 'tokens_removed']); - - if (time && !Number.isNaN(time.getTime())) { - if (!latestTime || time > latestTime) latestTime = time; - if (!earliestTime || time < earliestTime) earliestTime = time; - } - } - - const primaryUsage = chatUsage.count > 0 ? chatUsage : allUsage; - const inputTokens = primaryUsage.inputTokens; - const outputTokens = primaryUsage.outputTokens; - const credits = primaryUsage.credits; - - const dataMissing = []; - if (source === 'local') dataMissing.push('live Azure query'); - if (!latestTime) dataMissing.push('timestamps'); - if (inputTokens === 0 && outputTokens === 0) dataMissing.push('token totals'); - if (credits === 0 && estimatedUsd === 0) dataMissing.push('cost'); - - return { - id: sessionId, - source, - started: earliestTime ? earliestTime.toISOString() : null, - ended: latestTime ? latestTime.toISOString() : null, - spans: spans.length, - tool_calls: toolCalls, - failed_tools: failedTools, - failures, - tools: [...tools], - models: [...models], - agents: [...agents], - e2e_id: [...e2eIds][0] || null, - e2e_ids: [...e2eIds], - input_tokens: inputTokens, - output_tokens: outputTokens, - credits, - est_usd: estimatedUsd || credits * 0.01, - policy_blocks: policyBlocks, - tokens_removed: tokensRemoved, - content_capture_warning: contentCaptureWarning, - grafana_url: sessionId === 'unknown-session' ? null : buildLink('session', sessionId).grafana_url, - data_missing: dataMissing - }; -} - -function latestSessionAzureQuery(last = '7d') { - const lookback = validateKqlDuration(last); - return `let base = AppDependencies -| where TimeGenerated > ago(${lookback}) -| where ${baseFilter} -| extend direct_session=${directSessionKey}, fallback_session=${fallbackSessionKey}; -let operation_sessions = base -| where isnotempty(direct_session) -| summarize operation_session=take_any(direct_session) by OperationId; -let enriched = base -| join kind=leftouter operation_sessions on OperationId -| extend conversation=iff(isnotempty(operation_session), operation_session, iff(isnotempty(direct_session), direct_session, fallback_session)); -let latest_session = toscalar(enriched | summarize Ended=max(TimeGenerated) by conversation | top 1 by Ended desc | project conversation); -enriched -| where conversation == latest_session -| project TimeGenerated, conversation, Name, Success, ResultCode, DurationMs, OperationId, ParentId, Id, Properties -| order by TimeGenerated asc`; -} - -function runAzureLogAnalyticsQuery(query, options = {}) { - const spawnSync = options.spawnSync || childProcess.spawnSync; - const effectiveWorkspaceId = options.workspaceId || workspaceId; - - if (!options.workspaceId && !configuredWorkspaceId && !options.spawnSync) { - return { - ok: false, - rows: [], - error: 'Set AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID or LOG_ANALYTICS_WORKSPACE_ID before running live Azure telemetry queries.' - }; - } - - const result = spawnSync('az', [ - 'monitor', - 'log-analytics', - 'query', - '--workspace', - effectiveWorkspaceId, - '--analytics-query', - query, - '-o', - 'json' - ], { - encoding: 'utf8', - maxBuffer: 20 * 1024 * 1024 - }); - - if (result.error) return { ok: false, rows: [], error: result.error.message }; - if (result.status !== 0) { - return { - ok: false, - rows: [], - error: (result.stderr || result.stdout || `az exited with status ${result.status}`).trim() - }; - } - - try { - return { ok: true, rows: JSON.parse(result.stdout || '[]'), error: null }; - } catch (error) { - return { ok: false, rows: [], error: `Could not parse Azure query JSON: ${error.message}` }; - } -} - -function latestAzureSessionSummary(options = {}) { - const last = validateKqlDuration(options.last || '7d'); - const query = latestSessionAzureQuery(last); - const result = runAzureLogAnalyticsQuery(query, options); - - if (!result.ok) { - return { - mode: 'azure', - last, - query, - session: null, - error: result.error, - data_missing: ['live Azure query failed'] - }; - } - - if (!Array.isArray(result.rows) || result.rows.length === 0) { - return { - mode: 'azure', - last, - query, - session: null, - data_missing: [`no Copilot telemetry found in Azure for last ${last}`] - }; - } - - return { - ...latestSessionSummary({ rows: result.rows, source: 'azure' }), - last, - query - }; -} - -function latestSessionSummary({ filePath = null, rows = null, source = null } = {}) { - if (!filePath && !rows) { - return { - mode: 'missing-live', - session: null, - data_missing: ['live Azure query', 'local JSONL file', 'latest session id', 'token totals', 'cost'] - }; - } - - const sourceRows = rows || readJsonlRows(filePath); - const summarySource = source || (filePath ? 'local' : 'azure'); - const sessions = new Map(); - const order = []; - let currentSessionId = null; - - for (const row of sourceRows) { - const attrs = rowAttributes(row); - let sessionId = sessionFromRow(row, attrs); - if (sessionId === 'unknown-session' && currentSessionId) sessionId = currentSessionId; - if (sessionId !== 'unknown-session') currentSessionId = sessionId; - if (!sessions.has(sessionId)) { - sessions.set(sessionId, []); - order.push(sessionId); - } - sessions.get(sessionId).push(row); - } - - const summaries = order.map(sessionId => summarizeSession(sessionId, sessions.get(sessionId), summarySource)); - const withTime = summaries.filter(summary => summary.ended); - const session = withTime.length > 0 - ? withTime.sort((a, b) => new Date(b.ended) - new Date(a.ended))[0] - : summaries.at(-1) || null; - - return { - mode: summarySource, - file: filePath, - session, - data_missing: session ? session.data_missing : ['local JSONL rows'] - }; -} - -function listOrMissing(values, missing = 'not in this data') { - return values.length > 0 ? values.join(', ') : missing; -} - -function renderLatest(summary = latestSessionSummary()) { - const lines = ['Latest Copilot session', '']; - - if (!summary.session) { - if (summary.mode === 'azure' && summary.error) { - lines.push('I could not read live Azure telemetry.'); - lines.push(`Azure error: ${summary.error}`); - lines.push('Use --file <jsonl> to summarize a local or fixture export.'); - } else if (summary.mode === 'azure') { - lines.push(`No Copilot sessions were found in Azure for the last ${summary.last || '7d'}.`); - lines.push('Run Copilot through AgentOps, then try again.'); - } else { - lines.push('Use --file <jsonl> to summarize a local or fixture export, or run with Azure CLI access for live telemetry.'); - } - lines.push(`Missing data: ${summary.data_missing.join(', ')}.`); - lines.push(`Main dashboard: ${mainGrafanaDashboardUrl}`); - return `${lines.join('\n')}\n`; - } - - const session = summary.session; - lines.push(`Session: ${session.id}`); - lines.push(`What happened: ${session.spans} spans, ${session.tool_calls} tool call${session.tool_calls === 1 ? '' : 's'}, ${session.failures} failure${session.failures === 1 ? '' : 's'}.`); - lines.push(`Tools: ${listOrMissing(session.tools)}.`); - lines.push(`Model: ${listOrMissing(session.models)}.`); - lines.push(session.est_usd > 0 ? `Estimated cost: $${session.est_usd.toFixed(2)}.` : 'Estimated cost: not in this data.'); - lines.push(session.content_capture_warning - ? 'Privacy: content capture may be on. Do not share this export until reviewed.' - : 'Privacy: prompts/code were not recorded in this summary.'); - if (session.grafana_url) lines.push(`Grafana session: ${session.grafana_url}`); - if (session.data_missing.length > 0) lines.push(`Data missing: ${session.data_missing.join(', ')}.`); - - return `${lines.join('\n')}\n`; -} - -function explainLatest(summary = latestSessionSummary()) { - const session = summary.session; - if (!session) { - return { - classification: 'unknown', - headline: 'Not enough data yet', - detail: summary.mode === 'azure' && summary.error - ? `The Azure query failed: ${summary.error}` - : 'No local JSONL rows or live Azure rows were available.', - session: null - }; - } - - if (session.content_capture_warning) { - return { - classification: 'content_capture_warning', - headline: 'Content capture warning', - detail: 'Prompts or code may have been recorded. Review the export before sharing it.', - session - }; - } - - if (session.policy_blocks > 0) { - return { - classification: 'policy_blocked', - headline: 'No risky commands were allowed through', - detail: `${session.policy_blocks} policy signal${session.policy_blocks === 1 ? '' : 's'} appeared in this session.`, - session - }; - } - - if (session.failed_tools > 0) { - return { - classification: 'failed_tool', - headline: 'Tools kept failing', - detail: `${session.failed_tools} tool call${session.failed_tools === 1 ? '' : 's'} failed. Check the tool waterfall in Grafana.`, - session - }; - } - - if (session.input_tokens >= 30000 || session.tokens_removed > 0) { - return { - classification: 'too_much_context', - headline: 'Copilot had too much to remember', - detail: 'The session shows high context use or compaction/truncation signals.', - session - }; - } - - if (session.est_usd >= 1) { - return { - classification: 'high_cost', - headline: 'This session looked expensive', - detail: `Estimated cost was $${session.est_usd.toFixed(2)}.`, - session - }; - } - - if (session.spans > 0 && session.failures === 0) { - return { - classification: 'success', - headline: 'This session looks successful', - detail: 'No failed tools, policy blocks, high context, or high cost signals were found.', - session - }; - } - - return { - classification: 'unknown', - headline: 'The issue is unclear', - detail: 'The local data does not include enough signals to classify the session.', - session - }; -} - -function renderExplanation(explanation = explainLatest()) { - const lines = ['Likely issue', '', explanation.headline, explanation.detail]; - if (explanation.session?.grafana_url) lines.push(`Open in Grafana: ${explanation.session.grafana_url}`); - return `${lines.join('\n')}\n`; -} - -function openLinksSummary(summary = latestSessionSummary()) { - const latestSessionUrl = summary.session?.grafana_url || null; - return { - main_dashboard_url: mainGrafanaDashboardUrl, - sessions_dashboard_url: sessionsGrafanaDashboardUrl, - v2_home_url: v2HomeGrafanaDashboardUrl, - v2_runs_url: v2RunsGrafanaDashboardUrl, - v2_replay_url: latestSessionUrl - ? `${v2ReplayGrafanaDashboardUrl}?var-session_id=${encodeGrafanaValue(summary.session.id)}` - : v2ReplayGrafanaDashboardUrl, - latest_session_url: latestSessionUrl, - missing_latest_reason: latestSessionUrl - ? null - : summary.session - ? 'that session did not include a usable session id' - : 'latest session was not found in the selected local file or Azure lookback window' - }; -} - -function latestSummaryFromArgs(args, fallbackLast = '7d') { - const filePath = optionValue(args, ['--file', '--jsonl']); - if (filePath) return latestSessionSummary({ filePath: path.resolve(filePath) }); - - return latestAzureSessionSummary({ last: parseLastArg(args, fallbackLast) }); -} - -const { - alertRecommendationQuery, - alertRecommendations, - alertTunePlan, - alertResourceState, - alertPolicy, - alertHistoryQuery, - alertHistory, - alertDetail, - alertActionPlan, - alertArtifact, - alertIncidentTimeline, - alertHandoff, - alertRoutePlan -} = createAlerts({ - workspaceId, - baseFilter, - sessionKey, - validateKqlDuration, - buildLink -}); - -const { - recommendationForExplanation, - renderRecommendation -} = createRecommendations({ - buildLink, - mainGrafanaDashboardUrl, - latestSessionAzureQuery -}); - -function alertGithubIssueRoute({ rule, session, last = '24h', owners = [], service = 'agentops', timezone = 'UTC', repo, yes = false, resourceGroup = null, spawnSync = childProcess.spawnSync } = {}) { - const normalizedRepo = String(repo || '').trim(); - if (!normalizedRepo || !/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) { - throw new Error('alert route-github requires --repo <owner/repo>'); - } - - const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); - if (normalizedOwners.length === 0) throw new Error('alert route-github requires at least one --owner <github-login>'); - - const plan = alertRoutePlan({ - rule, - session, - last, - owners: normalizedOwners, - service, - timezone, - targets: ['github-issue'], - resourceGroup - }); - const destination = plan.destinations.find(item => item.target === 'github-issue'); - const payload = destination.payload; - const args = [ - 'issue', - 'create', - '--repo', - normalizedRepo, - '--title', - payload.title, - '--body', - payload.body - ]; - - for (const label of payload.labels) args.push('--label', label); - for (const owner of payload.assignees) args.push('--assignee', owner); - - const route = { - schema_version: 'agentops.alert-github-route.v1', - mode: yes ? 'posted-github-issue' : 'dry-run-github-issue-route', - alert: plan.alert, - repo: normalizedRepo, - owner: normalizedOwners[0], - command: { - executable: 'gh', - args - }, - payload, - guardrails: [ - 'Review the route-plan and handoff evidence before posting.', - 'Keep prompts, responses, tool arguments, tool results, and file contents out of GitHub issues.', - 'This command only creates a GitHub issue; it does not page, edit Azure resources, or enable alert rules.' - ] - }; - - if (!yes) return route; - - const result = spawnSync('gh', args, { - encoding: 'utf8', - env: process.env - }); - if (result.status !== 0) { - return { - ...route, - mode: 'failed-github-issue-route', - status: result.status, - error: String(result.stderr || result.stdout || 'gh issue create failed').trim() - }; - } - - return { - ...route, - status: result.status, - issue_url: String(result.stdout || '').trim() - }; -} - -function fieldValue(patch, fieldPath) { - const field = patch.find(item => item.path === fieldPath); - return field ? field.value : null; -} - -function alertOpenRun({ rule, session, last = '24h' } = {}) { - const detail = alertDetail({ rule, session, last }); - const sessionId = detail.session; - const runVars = { - 'var-run_id': '__all', - 'var-session_id': sessionId, - 'var-trace_id': '__all' - }; - - return { - schema_version: 'agentops.alert-open-run.v1', - mode: 'metadata-only-alert-run-links', - alert: { - rule: detail.rule, - session: sessionId, - last: detail.last - }, - links: { - session_detail: detail.session_link.grafana_url, - run_replay: grafanaUrlWithVars(v2ReplayGrafanaDashboardUrl, runVars), - runs_explorer: grafanaUrlWithVars(v2RunsGrafanaDashboardUrl, { 'var-session_id': sessionId }), - content_viewer: grafanaUrlWithVars(`${v2ReplayGrafanaDashboardUrl}?viewPanel=26`, runVars), - azure_portal_logs: detail.session_link.azure_portal_url - }, - queries: { - alert_history: detail.history_query, - session: detail.session_link.query - }, - commands: { - replay: `agentops replay ${sessionId} --last ${detail.last}`, - action_plan: detail.action_plan_command, - handoff: `agentops alert handoff --rule ${detail.rule} --session ${sessionId} --last ${detail.last}` - }, - guardrails: [ - 'Open links only after reviewing metadata-only alert context.', - 'The content viewer link is explicit opt-in and does not grant permission to collect prompt or response content.', - 'Keep prompts, responses, tool arguments, tool results, and file contents out of follow-up tickets.' - ] - }; -} - -function alertReview({ rule, session, last = '24h', owners = [] } = {}) { - const open = alertOpenRun({ rule, session, last }); - const detail = alertDetail({ rule, session, last }); - const actionPlan = alertActionPlan({ rule, session, last }); - const artifact = alertArtifact({ rule, session, last }); - const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); - - return { - schema_version: 'agentops.alert-review.v1', - mode: 'metadata-only-alert-review', - alert: open.alert, - owner: normalizedOwners[0] || null, - evidence: { - detail, - open, - action_plan: actionPlan, - artifact - }, - commands: { - open: `agentops alert open --rule ${open.alert.rule} --session ${open.alert.session} --last ${open.alert.last}`, - action_plan: actionPlan.next_command || detail.action_plan_command, - export: `agentops alert export --rule ${open.alert.rule} --session ${open.alert.session} --output .agentops/alerts/${open.alert.rule}.json --last ${open.alert.last}`, - handoff: `agentops alert handoff --rule ${open.alert.rule} --session ${open.alert.session}${normalizedOwners[0] ? ` --owner ${normalizedOwners[0]}` : ''} --last ${open.alert.last}` - }, - guardrails: [ - 'Metadata-only: this command does not page, post tickets, edit repositories, or mutate Azure resources.', - 'Review session links, alert history, and action-plan payloads before routing notifications.', - 'Keep prompts, responses, tool arguments, tool results, and file contents out of follow-up tickets.' - ], - next: [ - 'Open the session detail or run replay link.', - 'Review the action-plan payload and threshold evidence.', - 'Export or hand off the review packet only after assigning an owner.' - ] - }; -} - -const alertThresholdPatchResources = { - 'high-aiu': { - bicep_resource: 'highAiuAlert', - current_threshold: 50000000000 - }, - 'failed-spans': { - bicep_resource: 'failureAlert', - current_threshold: 0 - }, - 'content-capture': { - bicep_resource: 'contentCaptureAlert', - current_threshold: 0, - fixed_threshold: 0 - } -}; - -function normalizeThresholdValue(value) { - const text = String(value ?? '').trim(); - if (!text) throw new Error('alert threshold-patch requires --threshold <number>'); - const number = Number(text); - if (!Number.isFinite(number) || number < 0) throw new Error('alert threshold-patch --threshold must be a non-negative number'); - return Number.isInteger(number) ? String(number) : String(number); -} - -function unifiedThresholdDiff({ lines, lineIndex, before, after, filePath }) { - const contextBefore = Math.max(0, lineIndex - 3); - const contextAfter = Math.min(lines.length, lineIndex + 4); - const hunkLines = []; - for (let index = contextBefore; index < contextAfter; index += 1) { - if (index === lineIndex) { - hunkLines.push(`-${lines[index]}`); - hunkLines.push(`+${lines[index].replace(before, after)}`); - } else { - hunkLines.push(` ${lines[index]}`); - } - } - - return [ - `--- a/${filePath}`, - `+++ b/${filePath}`, - `@@ -${contextBefore + 1},${contextAfter - contextBefore} +${contextBefore + 1},${contextAfter - contextBefore} @@`, - ...hunkLines - ].join('\n'); -} - -function alertThresholdSimulationQuery({ rule, currentThreshold, proposedThreshold, last }) { - const selectedRule = JSON.stringify(rule); - if (rule === 'content-capture') { - return `let lookback = ${last}; -let selected_rule = ${selectedRule}; -let current_threshold = ${currentThreshold}; -let proposed_threshold = ${proposedThreshold}; -let windows = -union isfuzzy=true AppDependencies, AppTraces -| where TimeGenerated > ago(lookback) -| where tostring(Properties) has_any ("gen_ai.input.messages", "gen_ai.output.messages", "gen_ai.prompt", "gen_ai.completion", "github.copilot.message") -| summarize TriggerValue=count() by TimeGenerated=bin(TimeGenerated, 1h) -| extend Conversation="content-capture-window"; -windows -| summarize - observed_windows=count(), - current_alert_windows=countif(TriggerValue > current_threshold), - proposed_alert_windows=countif(TriggerValue > proposed_threshold), - max_trigger=max(TriggerValue), - affected_sessions=dcount(Conversation) -| extend Rule=selected_rule, CurrentThreshold=current_threshold, ProposedThreshold=proposed_threshold`; - } - - const triggerExpression = rule === 'high-aiu' - ? 'AIU' - : 'todouble(Failures + ToolFailures)'; - return `let lookback = ${last}; -let selected_rule = ${selectedRule}; -let current_threshold = ${currentThreshold}; -let proposed_threshold = ${proposedThreshold}; -let hourly = -AppDependencies -| where TimeGenerated > ago(lookback) -| where ${baseFilter} -| extend conversation=${sessionKey}, - operation=tostring(Properties["gen_ai.operation.name"]), - tool=tostring(Properties["gen_ai.tool.name"]), - error=tostring(Properties["error.type"]), - AIU=todouble(Properties["github.copilot.aiu"]) -| summarize - Failures=countif(Success == false or tostring(Success) =~ "false" or isnotempty(error)), - ToolFailures=countif((operation == "execute_tool" or isnotempty(tool)) and (Success == false or tostring(Success) =~ "false" or isnotempty(error))), - AIU=sum(AIU) - by Conversation=conversation, TimeGenerated=bin(TimeGenerated, 1h) -| extend TriggerValue=${triggerExpression}; -hourly -| summarize - observed_windows=count(), - current_alert_windows=countif(TriggerValue > current_threshold), - proposed_alert_windows=countif(TriggerValue > proposed_threshold), - max_trigger=max(TriggerValue), - p95_trigger=percentile(TriggerValue, 95), - affected_sessions=dcount(Conversation) -| extend Rule=selected_rule, CurrentThreshold=current_threshold, ProposedThreshold=proposed_threshold`; -} - -function alertThresholdSimulation({ rule, threshold, owner, last = '14d' } = {}) { - const normalizedRule = String(rule || '').trim(); - const target = alertThresholdPatchResources[normalizedRule]; - if (!target) { - throw new Error(`alert threshold-simulate requires --rule ${Object.keys(alertThresholdPatchResources).join('|')}`); - } - - const normalizedOwner = String(owner || '').trim(); - if (!normalizedOwner) throw new Error('alert threshold-simulate requires --owner <name>'); - - const proposedThreshold = normalizeThresholdValue(threshold); - if (target.fixed_threshold !== undefined && proposedThreshold !== String(target.fixed_threshold)) { - throw new Error(`alert threshold-simulate keeps ${normalizedRule} threshold at ${target.fixed_threshold}`); - } - - const lookback = validateKqlDuration(last); - const current = target.current_threshold; - const proposed = Number(proposedThreshold); - - return { - schema_version: 'agentops.alert-threshold-simulation.v1', - mode: 'preview-only-threshold-simulation', - rule: normalizedRule, - owner: normalizedOwner, - last: lookback, - bicep_resource: target.bicep_resource, - current_threshold: current, - proposed_threshold: proposed, - expected_effect: proposed > current - ? 'fewer-or-equal-alert-windows' - : proposed < current - ? 'more-or-equal-alert-windows' - : 'same-threshold', - evidence: { - simulation_query: alertThresholdSimulationQuery({ - rule: normalizedRule, - currentThreshold: current, - proposedThreshold: proposed, - last: lookback - }), - threshold_recommendation_query: alertRecommendationQuery(lookback), - fired_alert_history: alertHistoryQuery(normalizedRule, lookback) - }, - guardrails: [ - 'Preview-only: this command does not edit files, Azure resources, alert rules, or action groups.', - 'Run the simulation query and review current_alert_windows versus proposed_alert_windows before applying a threshold patch.', - 'Keep content-capture threshold at 0; investigate any content-like telemetry before routing alerts.' - ], - next: [ - 'Run the simulation query in Azure Logs.', - 'If proposed_alert_windows is acceptable, run alert threshold-patch to generate the Bicep diff.', - 'Apply threshold changes only in a reviewed PR, then run validate-azure before enabling alerts.' - ] - }; -} - -function alertThresholdPatch({ rule, threshold, owner, last = '14d', bicepPath = path.join(root, 'infra/bicep/alerts.bicep') } = {}) { - const normalizedRule = String(rule || '').trim(); - const target = alertThresholdPatchResources[normalizedRule]; - if (!target) { - throw new Error(`alert threshold-patch requires --rule ${Object.keys(alertThresholdPatchResources).join('|')}`); - } - - const normalizedOwner = String(owner || '').trim(); - if (!normalizedOwner) throw new Error('alert threshold-patch requires --owner <name>'); - - const nextThreshold = normalizeThresholdValue(threshold); - if (target.fixed_threshold !== undefined && nextThreshold !== String(target.fixed_threshold)) { - throw new Error(`alert threshold-patch keeps ${normalizedRule} threshold at ${target.fixed_threshold}`); - } - - const lookback = validateKqlDuration(last); - const relativePath = path.relative(root, bicepPath).replace(/\\/g, '/'); - const source = fs.readFileSync(bicepPath, 'utf8'); - const lines = source.split(/\r?\n/); - const resourceStart = lines.findIndex(line => line.includes(`resource ${target.bicep_resource} `)); - if (resourceStart === -1) throw new Error(`alert threshold-patch could not find ${target.bicep_resource} in ${relativePath}`); - let resourceEnd = lines.findIndex((line, index) => index > resourceStart && /^resource |^output /.test(line)); - if (resourceEnd === -1) resourceEnd = lines.length; - - const currentLine = `threshold: ${target.current_threshold}`; - const thresholdIndex = lines.findIndex((line, index) => index > resourceStart && index < resourceEnd && line.trim() === currentLine); - if (thresholdIndex === -1) throw new Error(`alert threshold-patch could not find ${currentLine} in ${target.bicep_resource}`); - - const replacementLine = `threshold: ${nextThreshold}`; - const diff = unifiedThresholdDiff({ - lines, - lineIndex: thresholdIndex, - before: currentLine, - after: replacementLine, - filePath: relativePath - }); - const tunePlan = alertTunePlan({ rule: normalizedRule, last: lookback, owner: normalizedOwner }); - - return { - schema_version: 'agentops.alert-threshold-patch.v1', - mode: 'preview-only-bicep-threshold-patch', - rule: normalizedRule, - owner: normalizedOwner, - last: lookback, - patch_target: relativePath, - bicep_resource: target.bicep_resource, - current_threshold: target.current_threshold, - proposed_threshold: Number(nextThreshold), - diff, - evidence: { - tune_plan_schema: tunePlan.schema_version, - threshold_recommendation_query: tunePlan.evidence.threshold_recommendation_query, - fired_alert_history: tunePlan.evidence.fired_alert_history[0].query - }, - guardrails: [ - 'Preview-only: this command does not edit infra/bicep/alerts.bicep.', - 'Review threshold recommendation evidence and fired-alert history before applying this diff.', - 'Run validate-azure before enabling alerts or routing action groups after any threshold change.' - ], - next: [ - 'Apply this diff in a reviewed PR only after owner approval.', - 'Keep enableAlerts=false until the patched rule has been validated against real traffic.', - 'Regenerate alert resources and run validate-azure before production routing.' - ] - }; -} - -function alertAzureDevOpsWorkItemRoute({ rule, session, last = '24h', owners = [], service = 'agentops', timezone = 'UTC', org, project, workItemType = 'Issue', yes = false, resourceGroup = null, spawnSync = childProcess.spawnSync } = {}) { - const normalizedOrg = String(org || '').trim(); - if (!normalizedOrg) throw new Error('alert route-azure-devops requires --org <url>'); - - const normalizedProject = String(project || '').trim(); - if (!normalizedProject) throw new Error('alert route-azure-devops requires --project <name>'); - - const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); - if (normalizedOwners.length === 0) throw new Error('alert route-azure-devops requires at least one --owner <user>'); - - const normalizedType = String(workItemType || '').trim() || 'Issue'; - const plan = alertRoutePlan({ - rule, - session, - last, - owners: normalizedOwners, - service, - timezone, - targets: ['azure-devops-work-item'], - resourceGroup - }); - const destination = plan.destinations.find(item => item.target === 'azure-devops-work-item'); - const payload = destination.payload; - const title = fieldValue(payload, '/fields/System.Title'); - const description = fieldValue(payload, '/fields/System.Description'); - const tags = fieldValue(payload, '/fields/System.Tags'); - const fields = [ - `System.AssignedTo=${normalizedOwners[0]}` - ]; - if (tags) fields.push(`System.Tags=${tags}`); - - const args = [ - 'boards', - 'work-item', - 'create', - '--org', - normalizedOrg, - '--project', - normalizedProject, - '--type', - normalizedType, - '--title', - title, - '--description', - description, - '--fields', - ...fields - ]; - - const route = { - schema_version: 'agentops.alert-azure-devops-route.v1', - mode: yes ? 'posted-azure-devops-work-item' : 'dry-run-azure-devops-work-item-route', - alert: plan.alert, - org: normalizedOrg, - project: normalizedProject, - owner: normalizedOwners[0], - work_item_type: normalizedType, - command: { - executable: 'az', - args - }, - payload, - guardrails: [ - 'Review the route-plan and handoff evidence before posting.', - 'Keep prompts, responses, tool arguments, tool results, and file contents out of Azure DevOps work items.', - 'This command only creates an Azure DevOps work item; it does not page, edit Azure resources, or enable alert rules.' - ] - }; - - if (!yes) return route; - - const result = spawnSync('az', args, { - encoding: 'utf8', - env: process.env - }); - if (result.status !== 0) { - return { - ...route, - mode: 'failed-azure-devops-work-item-route', - status: result.status, - error: String(result.stderr || result.stdout || 'az boards work-item create failed').trim() - }; - } - - let parsed = null; - try { - parsed = JSON.parse(String(result.stdout || '{}')); - } catch { - parsed = null; - } - - return { - ...route, - status: result.status, - work_item_id: parsed && parsed.id ? parsed.id : null, - work_item_url: parsed && parsed.url ? parsed.url : String(result.stdout || '').trim() - }; -} - -function alertActionGroupPlan({ resourceGroup, name, shortName, owners = [], emails = [], webhooks = [], location = 'global' } = {}) { - const normalizedResourceGroup = String(resourceGroup || '').trim(); - if (!normalizedResourceGroup) throw new Error('alert action-group-plan requires --resource-group <rg>'); - - const normalizedName = String(name || '').trim(); - if (!normalizedName) throw new Error('alert action-group-plan requires --name <action-group-name>'); - - const normalizedShortName = String(shortName || '').trim(); - if (!normalizedShortName) throw new Error('alert action-group-plan requires --short-name <short>'); - if (normalizedShortName.length > 12) throw new Error('alert action-group-plan --short-name must be 12 characters or fewer'); - - const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); - if (normalizedOwners.length === 0) throw new Error('alert action-group-plan requires at least one --owner <name>'); - - const normalizedEmails = emails.map(email => String(email || '').trim()).filter(Boolean); - const normalizedWebhooks = webhooks.map(webhook => String(webhook || '').trim()).filter(Boolean); - if (normalizedEmails.length === 0 && normalizedWebhooks.length === 0) { - throw new Error('alert action-group-plan requires at least one --email <address> or --webhook <url>'); - } - - const emailReceivers = normalizedEmails.map((email, index) => ({ - name: `email-${index + 1}`, - email_address: email - })); - const webhookReceivers = normalizedWebhooks.map((webhook, index) => ({ - name: `webhook-${index + 1}`, - service_uri: webhook - })); - - const args = [ - 'monitor', - 'action-group', - 'create', - '--resource-group', - normalizedResourceGroup, - '--name', - normalizedName, - '--short-name', - normalizedShortName, - '--location', - String(location || 'global').trim() || 'global' - ]; - - for (const receiver of emailReceivers) args.push('--action', 'email', receiver.name, receiver.email_address); - for (const receiver of webhookReceivers) args.push('--action', 'webhook', receiver.name, receiver.service_uri); - - return { - schema_version: 'agentops.alert-action-group-plan.v1', - mode: 'preview-only-action-group-plan', - resource_group: normalizedResourceGroup, - action_group: { - name: normalizedName, - short_name: normalizedShortName, - location: String(location || 'global').trim() || 'global' - }, - owner: normalizedOwners[0], - receivers: { - email: emailReceivers, - webhook: webhookReceivers - }, - command: { - executable: 'az', - args - }, - follow_up_route_command: `agentops alert route-action-group --resource-group ${normalizedResourceGroup} --scheduled-query <scheduled-query-name> --action-group <action-group-resource-id> --rule <rule> --session <conversation-id> --owner ${normalizedOwners[0]}`, - guardrails: [ - 'Preview-only: this command does not create or update Azure Monitor action groups.', - 'Review receiver ownership, destination accuracy, and escalation policy before creating the action group.', - 'Keep prompts, responses, tool arguments, tool results, and file contents out of receiver names and webhook URLs.' - ], - next: [ - 'Review the generated Azure CLI command with the action group owner.', - 'Create the action group only after receiver approval.', - 'Run alert route-action-group after the action group resource ID is approved.' - ] - }; -} - -function alertActionGroupRoute({ rule, session, last = '24h', owners = [], service = 'agentops', timezone = 'UTC', resourceGroup, scheduledQuery, actionGroups = [], enableAlert = false, yes = false, spawnSync = childProcess.spawnSync } = {}) { - const normalizedResourceGroup = String(resourceGroup || '').trim(); - if (!normalizedResourceGroup) throw new Error('alert route-action-group requires --resource-group <rg>'); - - const normalizedScheduledQuery = String(scheduledQuery || '').trim(); - if (!normalizedScheduledQuery) throw new Error('alert route-action-group requires --scheduled-query <name>'); - - const normalizedActionGroups = actionGroups.map(item => String(item || '').trim()).filter(Boolean); - if (normalizedActionGroups.length === 0) throw new Error('alert route-action-group requires at least one --action-group <id>'); - - const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); - if (normalizedOwners.length === 0) throw new Error('alert route-action-group requires at least one --owner <name>'); - - const handoff = alertHandoff({ - rule, - session, - last, - owners: normalizedOwners, - service, - timezone, - resourceGroup: normalizedResourceGroup - }); - - const args = [ - 'monitor', - 'scheduled-query', - 'update', - '--resource-group', - normalizedResourceGroup, - '--name', - normalizedScheduledQuery, - '--action-groups', - ...normalizedActionGroups - ]; - if (enableAlert) args.push('--disabled', 'false'); - - const route = { - schema_version: 'agentops.alert-action-group-route.v1', - mode: yes ? 'routed-action-group' : 'dry-run-action-group-route', - alert: handoff.alert, - resource_group: normalizedResourceGroup, - scheduled_query: normalizedScheduledQuery, - action_groups: normalizedActionGroups, - owner: normalizedOwners[0], - enable_alert: Boolean(enableAlert), - command: { - executable: 'az', - args - }, - evidence: { - handoff_schema: handoff.schema_version, - session_link: handoff.evidence.detail.session_link, - history_query: handoff.evidence.detail.history_query - }, - guardrails: [ - 'Review the handoff evidence, threshold tune-plan, and action group receivers before routing notifications.', - 'Keep prompts, responses, tool arguments, tool results, and file contents out of notification routes.', - 'This command only attaches approved Azure Monitor action groups; use --enable-alert only after threshold review.' - ] - }; - - if (!yes) return route; - - const result = spawnSync('az', args, { - encoding: 'utf8', - env: process.env - }); - if (result.status !== 0) { - return { - ...route, - mode: 'failed-action-group-route', - status: result.status, - error: String(result.stderr || result.stdout || 'az monitor scheduled-query update failed').trim() - }; - } - - return { - ...route, - status: result.status, - output: String(result.stdout || '').trim() - }; -} - -const { - copilotPrimitivesInventory -} = createPrimitives({ - root, - workspaceId, - kqlFileQuery, - validateKqlDuration, - optionValue -}); - -const { - liveViewFromArgs, - replayTimeline, - renderLive, - renderReplay, - sleep, - spanRowsFromSource -} = createTelemetry({ - optionValue, - parseLastArg, - readJsonlRows, - validateKqlDuration, - latestSessionAzureQuery, - runAzureLogAnalyticsQuery, - rowAttributes, - operationFromRow, - attributeValue, - numberAttribute, - isFailedRow, - sessionFromRow, - numberValue, - roundNumber -}); - -const { - parseSavedViewArgs, - readSavedViews, - savedViewCommand -} = createSavedViews({ - savedViewsPath, - readJson, - buildLink -}); - -function renderOpenLinks(links = openLinksSummary()) { - const lines = [ - 'Grafana links', - '', - `AgentOps V2 Home: ${links.v2_home_url}`, - `V2 Runs Explorer: ${links.v2_runs_url}`, - `V2 Run Replay: ${links.v2_replay_url}`, - `Main dashboard: ${links.main_dashboard_url}`, - `Sessions dashboard: ${links.sessions_dashboard_url}` - ]; - - if (links.latest_session_url) { - lines.push(`Latest session: ${links.latest_session_url}`); - } else { - lines.push(`Latest session: unknown. ${links.missing_latest_reason}.`); - } - - return `${lines.join('\n')}\n`; -} - -function httpHealthCheck(url, options = {}) { - return new Promise(resolve => { - const parsed = new URL(url); - const client = parsed.protocol === 'https:' ? https : http; - const req = client.request(parsed, { method: 'GET', timeout: options.timeoutMs || 1500 }, res => { - let body = ''; - res.setEncoding('utf8'); - res.on('data', chunk => body += chunk); - res.on('end', () => resolve({ - reachable: true, - statusCode: res.statusCode, - ok: res.statusCode >= 200 && res.statusCode < 300, - body: body.slice(0, 200) - })); - }); - req.on('timeout', () => { - req.destroy(); - resolve({ reachable: false, ok: false, error: 'timeout' }); - }); - req.on('error', error => resolve({ reachable: false, ok: false, error: error.message })); - req.end(); - }); -} - -function validateCollector(endpoint = 'http://127.0.0.1:4318', options = {}) { - return new Promise((resolve) => { - const url = new URL('/v1/traces', endpoint); - const client = url.protocol === 'https:' ? https : http; - const req = client.request(url, { method: 'POST', timeout: 1500 }, res => { - resolve({ endpoint, reachable: true, statusCode: res.statusCode, ok: res.statusCode < 500 }); - }); - req.on('timeout', () => { - req.destroy(); - resolve({ endpoint, reachable: false, ok: false, error: 'timeout' }); - }); - req.on('error', error => resolve({ endpoint, reachable: false, ok: false, error: error.message })); - req.end(); - }).then(async otlpHttp => { - const healthEndpoint = options.healthEndpoint || 'http://127.0.0.1:13133/'; - const health = await httpHealthCheck(healthEndpoint, options); - return { - endpoint, - otlp_http: otlpHttp, - health_endpoint: healthEndpoint, - health, - ok: Boolean(otlpHttp.ok && health.ok) - }; - }); -} - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); -} - -function displaySourcePath(filePath) { - const relative = path.relative(root, filePath).replace(/\\/g, '/'); - return relative.startsWith('../') ? filePath.replace(/\\/g, '/') : relative; -} - -function isStringArray(value) { - return Array.isArray(value) && value.every(item => typeof item === 'string'); -} - -function isPlainObject(value) { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -const benchmarkPermissionProfiles = new Set(['allow-all-isolated', 'least-privilege', 'read-only']); -const benchmarkOsSandboxModes = new Set(['none', 'macos-network-blocked', 'container-network-blocked']); -const benchmarkSemanticAdapters = new Set(['file-contains', 'file-regex', 'file-rubric', 'llm-judge']); -const benchmarkToolRisks = new Set([ - 'read-only', - 'write-file', - 'shell', - 'network', - 'secret-access', - 'browser-control', - 'destructive', - 'privileged' -]); - -function normalizeBenchmarkPermissionProfile(profile) { - if (profile === undefined || profile === null || profile === '') return 'least-privilege'; - return String(profile); -} - -function benchmarkProfileAllowsBroadArgs(profile) { - return profile === 'allow-all-isolated'; -} - -function hasBroadPermissionArg(args = []) { - return args.some(arg => ['--allow-all', '--yolo'].includes(arg)); -} - -function normalizeBenchmarkOsSandbox(sandbox, source = 'task') { - if (sandbox === undefined || sandbox === null) { - return { mode: 'none', enforced: false, network: 'not_enforced', tool: 'not_enforced' }; - } - if (!isPlainObject(sandbox)) { - throw new Error(`Invalid benchmark task ${source}: osSandbox must be an object`); - } - const mode = sandbox.mode === undefined ? 'none' : String(sandbox.mode); - if (!benchmarkOsSandboxModes.has(mode)) { - throw new Error(`Invalid benchmark task ${source}: osSandbox.mode must be one of: ${[...benchmarkOsSandboxModes].join(', ')}`); - } - if (mode === 'none') { - return { mode, enforced: false, network: 'not_enforced', tool: 'not_enforced' }; - } - if (mode === 'container-network-blocked') { - if (typeof sandbox.image !== 'string' || sandbox.image.trim() === '') { - throw new Error(`Invalid benchmark task ${source}: osSandbox.image is required for container-network-blocked`); - } - return { - mode, - enforced: true, - network: 'blocked', - tool: 'container_command_wrapped', - platform: 'cross-platform-container-runtime', - command: sandbox.runtime || 'docker', - image: sandbox.image.trim() - }; - } - return { - mode, - enforced: true, - network: mode === 'macos-network-blocked' ? 'blocked' : 'not_enforced', - tool: 'copilot_command_wrapped', - platform: 'darwin', - command: 'sandbox-exec' - }; -} - -function validateBenchmarkHiddenPack(pack, source = 'hidden check pack') { - const errors = []; - if (typeof pack.id !== 'string' || pack.id.trim() === '') errors.push('id must be a non-empty string'); - if (!isStringArray(pack.commands)) errors.push('commands must be an array of strings'); - if (errors.length > 0) { - throw new Error(`Invalid benchmark hidden check pack ${source}: ${errors.join('; ')}`); - } - - return { - id: pack.id, - title: typeof pack.title === 'string' && pack.title.trim() !== '' ? pack.title : pack.id, - commands: pack.commands, - source - }; -} - -function loadBenchmarkHiddenPacks(task, suiteDir, source = 'task') { - if (task.hiddenCheckPacks === undefined) return []; - if (!isStringArray(task.hiddenCheckPacks)) { - throw new Error(`Invalid benchmark task ${source}: hiddenCheckPacks must be an array of strings`); - } - - return task.hiddenCheckPacks.map(packPath => { - if (path.isAbsolute(packPath) || path.normalize(packPath).startsWith(`..${path.sep}`) || path.normalize(packPath) === '..') { - throw new Error(`Invalid benchmark task ${source}: hidden check pack path cannot leave the suite: ${packPath}`); - } - const fullPath = path.resolve(suiteDir, packPath); - if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { - throw new Error(`Invalid benchmark task ${source}: hidden check pack does not exist: ${packPath}`); - } - return validateBenchmarkHiddenPack(readJson(fullPath), path.relative(root, fullPath)); - }); -} - -function hashBenchmarkFixtureSealFile(filePath) { - return hashText(fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n')); -} - -function benchmarkFixtureFiles(fixtureDir) { - const files = []; - const walk = dir => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - } else if (entry.isFile()) { - files.push(normalizeBenchmarkRelativePath(path.relative(fixtureDir, fullPath))); - } - } - }; - walk(fixtureDir); - return files.sort(); -} - -function stableJson(value) { - if (Array.isArray(value)) return value.map(stableJson); - if (isPlainObject(value)) { - return Object.keys(value).sort().reduce((object, key) => { - object[key] = stableJson(value[key]); - return object; - }, {}); - } - return value; -} - -function benchmarkFixtureSealPackSigningPayload(pack) { - const { output, signature, ...payload } = pack; - return Buffer.from(JSON.stringify(stableJson(payload))); -} - -function signBenchmarkFixtureSealPack(pack, options = {}) { - if (typeof options.signKeyId !== 'string' || options.signKeyId.trim() === '') { - throw new Error('benchmark fixture-pack requires --sign-key-id when signing'); - } - if (typeof options.signPrivateKey !== 'string' || options.signPrivateKey.trim() === '') { - throw new Error('benchmark fixture-pack requires --sign-private-key when signing'); - } - - const cwd = options.cwd || process.cwd(); - const privateKeyPath = path.resolve(cwd, options.signPrivateKey); - if (!fs.existsSync(privateKeyPath) || !fs.statSync(privateKeyPath).isFile()) { - throw new Error(`benchmark fixture-pack signing private key does not exist: ${options.signPrivateKey}`); - } - - const privateKey = fs.readFileSync(privateKeyPath, 'utf8'); - const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }); - const signature = crypto.sign(null, benchmarkFixtureSealPackSigningPayload(pack), privateKey).toString('base64'); - return { - algorithm: 'ed25519', - keyId: options.signKeyId, - publicKey, - value: signature - }; -} - -function canonicalBenchmarkPublicKey(publicKey, source) { - try { - return crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'pem' }); - } catch { - throw new Error(`Invalid benchmark fixture trust root ${source}: publicKey must be a PEM public key`); - } -} - -function parseBenchmarkTrustRootTime(value, field, source) { - if (value === undefined) return null; - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`Invalid benchmark ${source}: ${field} must be an ISO timestamp`); - } - const time = Date.parse(value); - if (!Number.isFinite(time)) { - throw new Error(`Invalid benchmark ${source}: ${field} must be an ISO timestamp`); - } - return value; -} - -function validateBenchmarkFixtureTrustRoots(trustRoots, source = 'suite') { - if (trustRoots === undefined) return []; - if (!Array.isArray(trustRoots)) { - throw new Error(`Invalid benchmark ${source}: fixtureTrustRoots must be an array`); - } - - const seen = new Set(); - return trustRoots.map((rootEntry, index) => { - const errors = []; - if (!isPlainObject(rootEntry)) { - throw new Error(`Invalid benchmark ${source}: fixtureTrustRoots[${index}] must be an object`); - } - if (typeof rootEntry.keyId !== 'string' || rootEntry.keyId.trim() === '') errors.push('keyId must be a non-empty string'); - if (typeof rootEntry.publicKey !== 'string' || rootEntry.publicKey.trim() === '') errors.push('publicKey must be a PEM public key'); - if (errors.length > 0) { - throw new Error(`Invalid benchmark ${source}: fixtureTrustRoots[${index}] ${errors.join('; ')}`); - } - if (seen.has(rootEntry.keyId)) { - throw new Error(`Invalid benchmark ${source}: duplicate fixtureTrustRoots keyId: ${rootEntry.keyId}`); - } - seen.add(rootEntry.keyId); - - const rootSource = `${source} fixtureTrustRoots[${index}]`; - const notBefore = parseBenchmarkTrustRootTime(rootEntry.notBefore, `${rootSource}.notBefore`, source); - const notAfter = parseBenchmarkTrustRootTime(rootEntry.notAfter, `${rootSource}.notAfter`, source); - if (notBefore && notAfter && Date.parse(notAfter) <= Date.parse(notBefore)) { - throw new Error(`Invalid benchmark ${source}: ${rootSource}.notAfter must be after notBefore`); - } - return { - keyId: rootEntry.keyId, - publicKey: canonicalBenchmarkPublicKey(rootEntry.publicKey, rootSource), - notBefore, - notAfter - }; - }); -} - -function validateBenchmarkFixtureTrustRevocations(revocations, source = 'suite') { - if (revocations === undefined) return []; - if (!Array.isArray(revocations)) { - throw new Error(`Invalid benchmark ${source}: fixtureTrustRevocations must be an array`); - } - - const seen = new Set(); - return revocations.map((revocation, index) => { - const keyId = typeof revocation === 'string' ? revocation : revocation && revocation.keyId; - if (typeof keyId !== 'string' || keyId.trim() === '') { - throw new Error(`Invalid benchmark ${source}: fixtureTrustRevocations[${index}] keyId must be a non-empty string`); - } - if (seen.has(keyId)) { - throw new Error(`Invalid benchmark ${source}: duplicate fixtureTrustRevocations keyId: ${keyId}`); - } - seen.add(keyId); - return { keyId }; - }); -} - -function validateBenchmarkFixtureSealPackSignature(pack, source = 'fixture seal pack', trustRoots = [], trustRevocations = []) { - if (pack.signature === undefined && trustRoots.length > 0) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature required by fixture trust roots`); - } - if (pack.signature === undefined) return null; - if (!isPlainObject(pack.signature)) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature must be an object`); - } - - const signature = pack.signature; - const errors = []; - if (signature.algorithm !== 'ed25519') errors.push('signature.algorithm must be ed25519'); - if (typeof signature.keyId !== 'string' || signature.keyId.trim() === '') errors.push('signature.keyId must be a non-empty string'); - if (typeof signature.publicKey !== 'string' || signature.publicKey.trim() === '') errors.push('signature.publicKey must be a PEM public key'); - if (typeof signature.value !== 'string' || signature.value.trim() === '') errors.push('signature.value must be a base64 signature'); - if (errors.length > 0) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: ${errors.join('; ')}`); - } - - let verified = false; - try { - verified = crypto.verify( - null, - benchmarkFixtureSealPackSigningPayload(pack), - signature.publicKey, - Buffer.from(signature.value, 'base64') - ); - } catch { - verified = false; - } - if (!verified) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature verification failed`); - } - - if (trustRevocations.some(revocation => revocation.keyId === signature.keyId)) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId is revoked`); - } - - if (trustRoots.length > 0) { - const trustedRoot = trustRoots.find(rootEntry => rootEntry.keyId === signature.keyId); - if (!trustedRoot) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId is not trusted`); - } - const now = Date.now(); - if (trustedRoot.notBefore && now < Date.parse(trustedRoot.notBefore)) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId is not active yet`); - } - if (trustedRoot.notAfter && now > Date.parse(trustedRoot.notAfter)) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId trust root expired`); - } - const signaturePublicKey = canonicalBenchmarkPublicKey(signature.publicKey, `${source} signature`); - if (signaturePublicKey !== trustedRoot.publicKey) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: signature public key does not match trust root`); - } - } - - return { - algorithm: signature.algorithm, - keyId: signature.keyId, - ...(trustRoots.length > 0 ? { trusted: true } : {}) - }; -} - -function parseBenchmarkFixturePackArgs(args) { - const fixtureDir = args[0]; - if (!fixtureDir) throw new Error('benchmark fixture-pack requires a fixture directory'); - - const options = { fixtureDir }; - for (let index = 1; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--id') { - if (!args[index + 1]) throw new Error('--id requires a value'); - options.id = args[index + 1]; - index += 1; - } else if (arg === '--title') { - if (!args[index + 1]) throw new Error('--title requires a value'); - options.title = args[index + 1]; - index += 1; - } else if (arg === '--fixture') { - if (!args[index + 1]) throw new Error('--fixture requires a suite-relative fixture path'); - options.fixture = args[index + 1]; - index += 1; - } else if (arg === '--output') { - if (!args[index + 1]) throw new Error('--output requires a path'); - options.output = args[index + 1]; - index += 1; - } else if (arg === '--sign-key-id') { - if (!args[index + 1]) throw new Error('--sign-key-id requires a value'); - options.signKeyId = args[index + 1]; - index += 1; - } else if (arg === '--sign-private-key') { - if (!args[index + 1]) throw new Error('--sign-private-key requires a path'); - options.signPrivateKey = args[index + 1]; - index += 1; - } else { - throw new Error(`Unknown benchmark fixture-pack option: ${arg}`); - } - } - - if (typeof options.id !== 'string' || options.id.trim() === '') { - throw new Error('benchmark fixture-pack requires --id <id>'); - } - return options; -} - -function benchmarkJudgeProviderGuide() { - return { - purpose: 'Configure llm-judge semantic checks through a local hosted-judge wrapper command.', - secretHandling: [ - 'Keep judge endpoint and token in environment variables or your CI secret store.', - 'Do not commit judge tokens, prompts, model responses, or rubric text containing private data.', - 'The benchmark runner stores the judge score and detail, not the judge request payload.' - ], - wrapperScript: { - path: 'benchmark-judges/hosted-judge.sh', - env: ['AGENTOPS_JUDGE_ENDPOINT', 'AGENTOPS_JUDGE_TOKEN'], - example: [ - '#!/usr/bin/env bash', - 'set -euo pipefail', - 'file="${1:?file required}"', - 'check_id="${2:?check id required}"', - 'curl -fsS "$AGENTOPS_JUDGE_ENDPOINT" \\', - ' -H "Authorization: Bearer $AGENTOPS_JUDGE_TOKEN" \\', - ' -H "Content-Type: application/json" \\', - ' --data @<(node -e \'const fs=require("fs"); const [file,check]=process.argv.slice(1); process.stdout.write(JSON.stringify({check_id:check,file,content:fs.readFileSync(file,"utf8")}));\' "$file" "$check_id")' - ] - }, - serviceArtifact: { - path: 'benchmark-judges/hosted-judge', - imageBuild: 'az acr build --registry <acr-name> --image agentops-hosted-judge:latest benchmark-judges/hosted-judge', - deployTemplate: 'infra/bicep/hosted-judge.bicep', - endpoints: ['/health', '/score'] - }, - provisioningPlan: { - target: 'Azure Container Apps', - requiredSecrets: ['OPENAI_API_KEY', 'AGENTOPS_JUDGE_TOKEN'], - commands: [ - 'az group create --name rg-agentops-judges --location eastus', - 'az acr build --registry <acr-name> --image agentops-hosted-judge:latest benchmark-judges/hosted-judge', - 'az deployment group create --resource-group rg-agentops-judges --name agentops-hosted-judge --template-file infra/bicep/hosted-judge.bicep --parameters image=<acr-login-server>/agentops-hosted-judge:latest judgeToken=$AGENTOPS_JUDGE_TOKEN openAiApiKey=$OPENAI_API_KEY', - 'az deployment group show --resource-group rg-agentops-judges --name agentops-hosted-judge --query properties.outputs.judgeEndpoint.value --output tsv' - ], - healthCheck: 'curl -fsS https://<judge-fqdn>/health -H "Authorization: Bearer $AGENTOPS_JUDGE_TOKEN"', - bindCommand: 'export AGENTOPS_JUDGE_ENDPOINT=https://<judge-fqdn>/score' - }, - suiteSnippet: { - judgeProviders: { - hosted: { - command: 'benchmark-judges/hosted-judge.sh {file} {checkId}' - } - } - }, - semanticCheckSnippet: { - id: 'answer-quality', - adapter: 'llm-judge', - provider: 'hosted', - file: 'notes/hello.txt', - minScore: 80 - }, - expectedJudgeOutput: { - score: 92, - detail: 'short reason for the score' - }, - validation: [ - 'Provision the hosted judge only after reviewing the plan and storing secrets outside the repo.', - 'Run the wrapper directly against a local fixture file and confirm it prints JSON with score.', - 'Run `agentops benchmark run <suite> --variant candidate --repeat 1 --dry-run` before executing.', - 'Run `agentops benchmark report <run-id>` and inspect semanticChecks.averageScore.' - ] - }; -} - -function renderBenchmarkJudgeProviderGuide(guide = benchmarkJudgeProviderGuide()) { - const lines = [ - 'Benchmark hosted judge provider guide', - '', - guide.purpose, - '', - 'Secret handling' - ]; - for (const item of guide.secretHandling) lines.push(`- ${item}`); - lines.push( - '', - `Wrapper: ${guide.wrapperScript.path}`, - `Required env: ${guide.wrapperScript.env.join(', ')}`, - '', - 'Wrapper example', - '```bash', - ...guide.wrapperScript.example, - '```', - '', - `Deployable service: ${guide.serviceArtifact.path}`, - `Image build: ${guide.serviceArtifact.imageBuild}`, - `Bicep template: ${guide.serviceArtifact.deployTemplate}`, - `Endpoints: ${guide.serviceArtifact.endpoints.join(', ')}`, - '', - `Provisioning target: ${guide.provisioningPlan.target}`, - `Required secrets: ${guide.provisioningPlan.requiredSecrets.join(', ')}`, - '', - 'Provisioning commands', - '```bash', - ...guide.provisioningPlan.commands, - guide.provisioningPlan.healthCheck, - guide.provisioningPlan.bindCommand, - '```', - '', - 'suite.json snippet', - '```json', - JSON.stringify(guide.suiteSnippet, null, 2), - '```', - '', - 'semanticChecks snippet', - '```json', - JSON.stringify(guide.semanticCheckSnippet, null, 2), - '```', - '', - 'Expected judge output', - '```json', - JSON.stringify(guide.expectedJudgeOutput, null, 2), - '```', - '', - 'Validation' - ); - for (const item of guide.validation) lines.push(`- ${item}`); - return `${lines.join('\n')}\n`; -} - -function benchmarkFixturePack(options = {}) { - const cwd = options.cwd || process.cwd(); - const fixtureDir = path.resolve(cwd, options.fixtureDir); - if (!fs.existsSync(fixtureDir) || !fs.statSync(fixtureDir).isDirectory()) { - throw new Error(`benchmark fixture-pack fixture directory does not exist: ${options.fixtureDir}`); - } - - const files = {}; - for (const file of benchmarkFixtureFiles(fixtureDir)) { - files[file] = hashBenchmarkFixtureSealFile(path.join(fixtureDir, file)); - } - - if (Object.keys(files).length === 0) { - throw new Error(`benchmark fixture-pack fixture directory has no files: ${options.fixtureDir}`); - } - - const pack = { - id: options.id, - ...(typeof options.title === 'string' && options.title.trim() !== '' ? { title: options.title } : {}), - fixture: normalizeBenchmarkRelativePath(options.fixture || path.relative(cwd, fixtureDir) || '.'), - algorithm: 'sha256', - files - }; - if (options.signKeyId || options.signPrivateKey) { - pack.signature = signBenchmarkFixtureSealPack(pack, { ...options, cwd }); - } - - if (options.output) { - const outputPath = path.resolve(cwd, options.output); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, `${JSON.stringify(pack, null, 2)}\n`); - return { ...pack, output: outputPath }; - } - return pack; -} - -function validateBenchmarkFixtureSeal(seal, fixturePath, source = 'task') { - if (seal === undefined) return null; - if (!isPlainObject(seal)) { - throw new Error(`Invalid benchmark task ${source}: fixtureSeal must be an object`); - } - - const algorithm = seal.algorithm || 'sha256'; - if (algorithm !== 'sha256') { - throw new Error(`Invalid benchmark task ${source}: fixtureSeal algorithm must be sha256`); - } - if (!isPlainObject(seal.files) || Object.keys(seal.files).length === 0) { - throw new Error(`Invalid benchmark task ${source}: fixtureSeal files must be a non-empty object`); - } - - const files = {}; - for (const [file, expectedHash] of Object.entries(seal.files)) { - if (typeof expectedHash !== 'string' || !/^[a-f0-9]{64}$/i.test(expectedHash)) { - throw new Error(`Invalid benchmark task ${source}: fixtureSeal hash for ${file} must be a sha256 hex string`); - } - const filePath = safeBenchmarkPath(fixturePath, file); - if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { - throw new Error(`Invalid benchmark task ${source}: sealed fixture file does not exist: ${file}`); - } - const actualHash = hashBenchmarkFixtureSealFile(filePath); - if (actualHash !== expectedHash.toLowerCase()) { - throw new Error(`Invalid benchmark task ${source}: sealed fixture file changed: ${file}`); - } - files[normalizeBenchmarkRelativePath(file)] = expectedHash.toLowerCase(); - } - - return { - algorithm, - files - }; -} - -function validateBenchmarkFixtureSealPack(pack, fixturePath, source = 'fixture seal pack', options = {}) { - const errors = []; - if (!isPlainObject(pack)) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: must be an object`); - } - if (typeof pack.id !== 'string' || pack.id.trim() === '') errors.push('id must be a non-empty string'); - if (typeof pack.fixture !== 'string' || pack.fixture.trim() === '') errors.push('fixture must be a non-empty string'); - if (errors.length > 0) { - throw new Error(`Invalid benchmark fixture seal pack ${source}: ${errors.join('; ')}`); - } - - const signature = validateBenchmarkFixtureSealPackSignature( - pack, - source, - options.fixtureTrustRoots || [], - options.fixtureTrustRevocations || [] - ); - const fixtureSeal = validateBenchmarkFixtureSeal({ - algorithm: pack.algorithm, - files: pack.files - }, fixturePath, source); - - return { - id: pack.id, - title: typeof pack.title === 'string' && pack.title.trim() !== '' ? pack.title : pack.id, - fixture: normalizeBenchmarkRelativePath(pack.fixture), - algorithm: fixtureSeal.algorithm, - files: fixtureSeal.files, - signature, - source - }; -} - -function loadBenchmarkFixtureSealPack(task, suiteDir, fixturePath, source = 'task', options = {}) { - if (task.fixtureSealPack === undefined) return null; - if (typeof task.fixtureSealPack !== 'string' || task.fixtureSealPack.trim() === '') { - throw new Error(`Invalid benchmark task ${source}: fixtureSealPack must be a non-empty string`); - } - - const packPath = task.fixtureSealPack; - if (path.isAbsolute(packPath) || path.normalize(packPath).startsWith(`..${path.sep}`) || path.normalize(packPath) === '..') { - throw new Error(`Invalid benchmark task ${source}: fixture seal pack path cannot leave the suite: ${packPath}`); - } - const fullPath = path.resolve(suiteDir, packPath); - if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { - throw new Error(`Invalid benchmark task ${source}: fixture seal pack does not exist: ${packPath}`); - } - - const fixtureSealPack = validateBenchmarkFixtureSealPack(readJson(fullPath), fixturePath, displaySourcePath(fullPath), options); - if (fixtureSealPack.fixture !== normalizeBenchmarkRelativePath(task.fixture)) { - throw new Error(`Invalid benchmark task ${source}: fixtureSealPack fixture must match task fixture`); - } - return fixtureSealPack; -} - -function validateBenchmarkPromotionGates(gates, source = 'suite') { - if (gates === undefined) return null; - if (!isPlainObject(gates)) { - throw new Error(`Invalid benchmark ${source}: promotionGates must be an object`); - } - - const allowedFields = new Set([ - 'minPassRatePct', - 'minAverageScore', - 'maxToolFailures', - 'maxSafetyViolationCount', - 'maxTotalTokens', - 'maxCost', - 'requiredApprovals', - 'requiredApprovers', - 'requiredExternalReview' - ]); - const normalized = {}; - - for (const [field, value] of Object.entries(gates)) { - if (!allowedFields.has(field)) { - throw new Error(`Invalid benchmark ${source}: unknown promotion gate: ${field}`); - } - if (field === 'requiredExternalReview') { - if (typeof value !== 'boolean') { - throw new Error(`Invalid benchmark ${source}: promotion gate requiredExternalReview must be a boolean`); - } - normalized[field] = value; - continue; - } - if (field === 'requiredApprovers') { - if (!isStringArray(value)) { - throw new Error(`Invalid benchmark ${source}: promotion gate requiredApprovers must be an array of strings`); - } - const approvers = [...new Set(value.map(name => name.trim()).filter(Boolean))].sort(); - if (approvers.length === 0) { - throw new Error(`Invalid benchmark ${source}: promotion gate requiredApprovers must include at least one approver`); - } - normalized[field] = approvers; - continue; - } - const number = Number(value); - if (!Number.isFinite(number) || number < 0) { - throw new Error(`Invalid benchmark ${source}: promotion gate ${field} must be a non-negative number`); - } - if (field === 'requiredApprovals' && !Number.isInteger(number)) { - throw new Error(`Invalid benchmark ${source}: promotion gate ${field} must be an integer`); - } - normalized[field] = number; - } - - return Object.keys(normalized).length > 0 ? normalized : null; -} - -function validateBenchmarkJudgeProviders(providers, source = 'suite') { - if (providers === undefined) return new Map(); - if (!isPlainObject(providers)) { - throw new Error(`Invalid benchmark ${source}: judgeProviders must be an object`); - } - - return new Map(Object.entries(providers).map(([id, provider]) => { - const errors = []; - if (id.trim() === '') errors.push('id must be a non-empty string'); - if (!isPlainObject(provider)) { - throw new Error(`Invalid benchmark ${source}: judgeProviders.${id} must be an object`); - } - if (typeof provider.command !== 'string' || provider.command.trim() === '') { - errors.push('command must be a non-empty string'); - } - if (errors.length > 0) { - throw new Error(`Invalid benchmark ${source}: judgeProviders.${id} ${errors.join('; ')}`); - } - return [id, { id, command: provider.command }]; - })); -} - -function benchmarkJudgeProviderCommand(provider, check) { - return provider.command - .replaceAll('{file}', check.file) - .replaceAll('{checkId}', check.id); -} - -function validateBenchmarkSemanticChecks(checks, source = 'task', options = {}) { - if (checks === undefined) return []; - if (!Array.isArray(checks)) { - throw new Error(`Invalid benchmark task ${source}: semanticChecks must be an array`); - } - - const judgeProviders = options.judgeProviders || new Map(); - return checks.map((check, index) => { - const errors = []; - if (!isPlainObject(check)) { - throw new Error(`Invalid benchmark task ${source}: semanticChecks[${index}] must be an object`); - } - if (typeof check.id !== 'string' || check.id.trim() === '') errors.push('id must be a non-empty string'); - if (!benchmarkSemanticAdapters.has(check.adapter)) { - errors.push(`adapter must be one of: ${[...benchmarkSemanticAdapters].join(', ')}`); - } - if (typeof check.file !== 'string' || check.file.trim() === '') errors.push('file must be a non-empty string'); - if (check.adapter === 'file-contains' && (typeof check.contains !== 'string' || check.contains.trim() === '')) { - errors.push('contains must be a non-empty string'); - } - if (check.adapter === 'file-regex') { - if (typeof check.pattern !== 'string' || check.pattern.trim() === '') { - errors.push('pattern must be a non-empty string'); - } else { - try { - new RegExp(check.pattern); - } catch { - errors.push('pattern must be a valid regular expression'); - } - } - } - if (check.adapter === 'file-rubric') { - if (!Array.isArray(check.criteria) || check.criteria.length === 0) { - errors.push('criteria must be a non-empty array'); - } else { - for (const [criteriaIndex, criterion] of check.criteria.entries()) { - if (!isPlainObject(criterion)) { - errors.push(`criteria[${criteriaIndex}] must be an object`); - continue; - } - if (typeof criterion.id !== 'string' || criterion.id.trim() === '') { - errors.push(`criteria[${criteriaIndex}].id must be a non-empty string`); - } - const hasContains = typeof criterion.contains === 'string' && criterion.contains.trim() !== ''; - const hasPattern = typeof criterion.pattern === 'string' && criterion.pattern.trim() !== ''; - if (hasContains === hasPattern) { - errors.push(`criteria[${criteriaIndex}] must define exactly one of contains or pattern`); - } - if (hasPattern) { - try { - new RegExp(criterion.pattern); - } catch { - errors.push(`criteria[${criteriaIndex}].pattern must be a valid regular expression`); - } - } - } - } - if (check.minScore !== undefined) { - const minScore = Number(check.minScore); - if (!Number.isFinite(minScore) || minScore < 0 || minScore > 100) { - errors.push('minScore must be between 0 and 100'); - } - } - } - if (check.adapter === 'llm-judge') { - const hasCommand = typeof check.command === 'string' && check.command.trim() !== ''; - const hasProvider = typeof check.provider === 'string' && check.provider.trim() !== ''; - if (!hasCommand && !hasProvider) { - errors.push('command or provider must be a non-empty string'); - } - if (hasProvider && !judgeProviders.has(check.provider)) { - errors.push(`provider must reference a configured judge provider: ${check.provider}`); - } - if (check.minScore !== undefined) { - const minScore = Number(check.minScore); - if (!Number.isFinite(minScore) || minScore < 0 || minScore > 100) { - errors.push('minScore must be between 0 and 100'); - } - } - } - if (errors.length > 0) { - throw new Error(`Invalid benchmark task ${source}: semanticChecks[${index}] ${errors.join('; ')}`); - } - - const normalized = { - id: check.id, - adapter: check.adapter, - file: check.file - }; - if (check.adapter === 'file-contains') normalized.contains = check.contains; - if (check.adapter === 'file-regex') normalized.pattern = check.pattern; - if (check.adapter === 'file-rubric') { - normalized.minScore = check.minScore === undefined ? 100 : Number(check.minScore); - normalized.criteria = check.criteria.map(criterion => { - const normalizedCriterion = { id: criterion.id }; - if (typeof criterion.title === 'string' && criterion.title.trim() !== '') normalizedCriterion.title = criterion.title; - if (criterion.contains !== undefined) normalizedCriterion.contains = criterion.contains; - if (criterion.pattern !== undefined) normalizedCriterion.pattern = criterion.pattern; - return normalizedCriterion; - }); - } - if (check.adapter === 'llm-judge') { - if (typeof check.provider === 'string' && check.provider.trim() !== '') normalized.provider = check.provider; - normalized.command = typeof check.command === 'string' && check.command.trim() !== '' - ? check.command - : benchmarkJudgeProviderCommand(judgeProviders.get(check.provider), check); - normalized.minScore = check.minScore === undefined ? 100 : Number(check.minScore); - } - return normalized; - }); -} - -function validateBenchmarkToolPolicy(policy, source = 'task') { - if (policy === undefined) return null; - if (!isPlainObject(policy)) { - throw new Error(`Invalid benchmark task ${source}: toolPolicy must be an object`); - } - - if (policy.blockedRisks === undefined) return null; - if (!isStringArray(policy.blockedRisks)) { - throw new Error(`Invalid benchmark task ${source}: toolPolicy.blockedRisks must be an array of strings`); - } - - const blockedRisks = [...new Set(policy.blockedRisks.map(risk => risk.trim()).filter(Boolean))].sort(); - const invalid = blockedRisks.filter(risk => !benchmarkToolRisks.has(risk)); - if (invalid.length > 0) { - throw new Error(`Invalid benchmark task ${source}: toolPolicy.blockedRisks must use known risks: ${[...benchmarkToolRisks].join(', ')}`); - } - - return blockedRisks.length > 0 ? { blockedRisks } : null; -} - -function benchmarkAllowedToolPolicyViolations(args = [], toolPolicy = null) { - const blockedRisks = new Set(toolPolicy?.blockedRisks || []); - if (blockedRisks.size === 0) return []; - - const seen = new Set(); - return extractAllowedTools(args) - .filter(tool => blockedRisks.has(tool.risk)) - .filter(tool => { - const key = `${tool.name}:${tool.risk}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }) - .sort((left, right) => left.risk.localeCompare(right.risk) || left.name.localeCompare(right.name)); -} - -function validateBenchmarkTask(task, suiteDir, source = 'task', options = {}) { - const errors = []; - const stringFields = ['id', 'title', 'fixture', 'prompt']; - const arrayFields = ['copilotArgs', 'successCommands', 'expectedFiles', 'forbiddenFiles', 'tags']; - const optionalArrayFields = ['hiddenSuccessCommands']; - - for (const field of stringFields) { - if (typeof task[field] !== 'string' || task[field].trim() === '') { - errors.push(`${field} must be a non-empty string`); - } - } - - for (const field of arrayFields) { - if (!isStringArray(task[field])) { - errors.push(`${field} must be an array of strings`); - } - } - - for (const field of optionalArrayFields) { - if (task[field] !== undefined && !isStringArray(task[field])) { - errors.push(`${field} must be an array of strings`); - } - } - - const permissionProfile = normalizeBenchmarkPermissionProfile(task.permissionProfile); - if (!benchmarkPermissionProfiles.has(permissionProfile)) { - errors.push(`permissionProfile must be one of: ${[...benchmarkPermissionProfiles].join(', ')}`); - } - if (hasBroadPermissionArg(task.copilotArgs || []) && !benchmarkProfileAllowsBroadArgs(permissionProfile)) { - errors.push('copilotArgs uses broad permissions but permissionProfile is not allow-all-isolated'); - } - - if (!Number.isInteger(task.timeoutSec) || task.timeoutSec <= 0) { - errors.push('timeoutSec must be a positive integer'); - } - - const fixturePath = typeof task.fixture === 'string' ? path.resolve(suiteDir, task.fixture) : null; - if (fixturePath && (!fs.existsSync(fixturePath) || !fs.statSync(fixturePath).isDirectory())) { - errors.push(`fixture does not exist: ${task.fixture}`); - } - - if (errors.length > 0) { - throw new Error(`Invalid benchmark task ${source}: ${errors.join('; ')}`); - } - - const hiddenCheckPacks = loadBenchmarkHiddenPacks(task, suiteDir, source); - const hiddenPackCommands = hiddenCheckPacks.flatMap(pack => pack.commands); - const semanticChecks = validateBenchmarkSemanticChecks(task.semanticChecks, source, options); - const fixtureSeal = validateBenchmarkFixtureSeal(task.fixtureSeal, fixturePath, source); - const fixtureSealPack = loadBenchmarkFixtureSealPack(task, suiteDir, fixturePath, source, options); - const commandFileSeal = validateBenchmarkFixtureSeal(task.commandFileSeal, fixturePath, source); - const osSandbox = normalizeBenchmarkOsSandbox(task.osSandbox, source); - const toolPolicy = validateBenchmarkToolPolicy(task.toolPolicy, source); - const toolPolicyEnforcement = { - blockedRisks: toolPolicy?.blockedRisks || [], - blockedAllowedTools: benchmarkAllowedToolPolicyViolations(task.copilotArgs, toolPolicy) - }; - - return { - ...task, - hiddenSuccessCommands: task.hiddenSuccessCommands || [], - hiddenCheckPacks, - hiddenCheckPackRefs: task.hiddenCheckPacks || [], - hiddenPackCommands, - semanticChecks, - fixtureSeal, - fixtureSealPack, - commandFileSeal, - toolPolicy, - toolPolicyEnforcement, - osSandbox, - permissionProfile, - fixturePath, - source - }; -} - -function loadBenchmarkSuites(baseDir = benchmarksDir) { - if (!fs.existsSync(baseDir)) return []; - - return fs.readdirSync(baseDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => { - const suiteDir = path.join(baseDir, entry.name); - const suitePath = path.join(suiteDir, 'suite.json'); - const metadata = fs.existsSync(suitePath) ? readJson(suitePath) : {}; - const fixtureTrustRoots = validateBenchmarkFixtureTrustRoots(metadata.fixtureTrustRoots, path.relative(root, suitePath)); - const fixtureTrustRevocations = validateBenchmarkFixtureTrustRevocations(metadata.fixtureTrustRevocations, path.relative(root, suitePath)); - const judgeProviders = validateBenchmarkJudgeProviders(metadata.judgeProviders, path.relative(root, suitePath)); - const tasksDir = path.join(suiteDir, 'tasks'); - const taskFiles = fs.existsSync(tasksDir) - ? fs.readdirSync(tasksDir).filter(file => file.endsWith('.json')).sort() - : []; - const promotionGates = validateBenchmarkPromotionGates(metadata.promotionGates, path.relative(root, suitePath)); - const tasks = taskFiles.map(file => { - const taskPath = path.join(tasksDir, file); - return validateBenchmarkTask(readJson(taskPath), suiteDir, path.relative(root, taskPath), { fixtureTrustRoots, fixtureTrustRevocations, judgeProviders }); - }); - - return { - id: metadata.id || entry.name, - title: metadata.title || entry.name, - description: metadata.description || '', - path: path.relative(root, suiteDir), - fixtureTrustRoots: fixtureTrustRoots.map(rootEntry => ({ - keyId: rootEntry.keyId, - ...(rootEntry.notBefore ? { notBefore: rootEntry.notBefore } : {}), - ...(rootEntry.notAfter ? { notAfter: rootEntry.notAfter } : {}) - })), - fixtureTrustRevocations, - judgeProviders: [...judgeProviders.values()].map(provider => ({ id: provider.id })), - promotionGates, - tasks - }; - }) - .sort((left, right) => left.id.localeCompare(right.id)); -} - -function listBenchmarks(baseDir = benchmarksDir) { - return { - suites: loadBenchmarkSuites(baseDir).map(suite => ({ - id: suite.id, - title: suite.title, - description: suite.description, - path: suite.path, - tasks: suite.tasks.map(task => ({ - id: task.id, - title: task.title, - fixture: task.fixture, - permissionProfile: task.permissionProfile, - toolPolicy: task.toolPolicy, - timeoutSec: task.timeoutSec, - tags: task.tags - })) - })) - }; -} - -function parseBenchmarkRunArgs(args) { - const suite = args[0]; - if (!suite) throw new Error('benchmark run requires a suite'); - - const options = { - suite, - repeat: 1, - dryRun: false - }; - - for (let index = 1; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--dry-run') { - options.dryRun = true; - } else if (arg === '--variant') { - options.variant = args[index + 1]; - index += 1; - } else if (arg === '--repeat') { - options.repeat = Number(args[index + 1]); - index += 1; - } else if (arg === '--hypothesis') { - options.hypothesis = args[index + 1]; - index += 1; - } else { - throw new Error(`Unknown benchmark run option: ${arg}`); - } - } - - if (!options.variant) throw new Error('benchmark run requires --variant <name>'); - if (!Number.isInteger(options.repeat) || options.repeat <= 0) { - throw new Error('--repeat must be a positive integer'); - } - - return options; -} - -function parseBenchmarkReportArgs(args) { - const runId = args[0]; - if (!runId) throw new Error('benchmark report requires a run id'); - - const options = { - runId, - azure: false, - last: '24h', - verifyExternalReview: false - }; - - for (let index = 1; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--azure') { - options.azure = true; - } else if (arg === '--verify-external-review') { - options.verifyExternalReview = true; - } else if (arg === '--approval-file') { - if (!args[index + 1]) throw new Error('--approval-file requires a path'); - options.approvalFile = args[index + 1]; - index += 1; - } else if (arg === '--last') { - if (!args[index + 1]) throw new Error('--last requires a duration, for example 7d or 24h'); - options.last = args[index + 1]; - index += 1; - } else { - throw new Error(`Unknown benchmark report option: ${arg}`); - } - } - - if (options.azure) validateKqlDuration(options.last); - return options; -} - -function parseBenchmarkCompareArgs(args) { - const beforeRunId = args[0]; - const afterRunId = args[1]; - if (!beforeRunId || !afterRunId) throw new Error('benchmark compare requires before and after run ids'); - - const options = { - beforeRunId, - afterRunId, - azure: false, - last: '24h', - verifyExternalReview: false - }; - - for (let index = 2; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--azure') { - options.azure = true; - } else if (arg === '--verify-external-review') { - options.verifyExternalReview = true; - } else if (arg === '--approval-file') { - if (!args[index + 1]) throw new Error('--approval-file requires a path'); - options.approvalFile = args[index + 1]; - index += 1; - } else if (arg === '--last') { - if (!args[index + 1]) throw new Error('--last requires a duration, for example 7d or 24h'); - options.last = args[index + 1]; - index += 1; - } else { - throw new Error(`Unknown benchmark compare option: ${arg}`); - } - } - - if (options.azure) validateKqlDuration(options.last); - return options; -} - -function parseBenchmarkApproveArgs(args) { - const runId = args[0]; - if (!runId) throw new Error('benchmark approve requires a run id'); - - const options = { - runId, - approvedBy: [], - status: 'approved' - }; - - for (let index = 1; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--by') { - if (!args[index + 1]) throw new Error('--by requires a name'); - options.approvedBy.push(args[index + 1]); - index += 1; - } else if (arg === '--ticket') { - if (!args[index + 1]) throw new Error('--ticket requires a value'); - options.ticket = args[index + 1]; - index += 1; - } else if (arg === '--status') { - if (!args[index + 1]) throw new Error('--status requires approved, pending, or rejected'); - options.status = args[index + 1]; - index += 1; - } else if (arg === '--review-system') { - if (!args[index + 1]) throw new Error('--review-system requires a value'); - options.externalReview = options.externalReview || {}; - options.externalReview.system = args[index + 1]; - index += 1; - } else if (arg === '--review-id') { - if (!args[index + 1]) throw new Error('--review-id requires a value'); - options.externalReview = options.externalReview || {}; - options.externalReview.id = args[index + 1]; - index += 1; - } else if (arg === '--review-url') { - if (!args[index + 1]) throw new Error('--review-url requires a value'); - options.externalReview = options.externalReview || {}; - options.externalReview.url = args[index + 1]; - index += 1; - } else if (arg === '--review-status') { - if (!args[index + 1]) throw new Error('--review-status requires approved, pending, or rejected'); - options.externalReview = options.externalReview || {}; - options.externalReview.status = args[index + 1]; - index += 1; - } else if (arg === '--approved-at') { - if (!args[index + 1]) throw new Error('--approved-at requires an ISO timestamp'); - options.approvedAt = args[index + 1]; - index += 1; - } else if (arg === '--output') { - if (!args[index + 1]) throw new Error('--output requires a path'); - options.output = args[index + 1]; - index += 1; - } else { - throw new Error(`Unknown benchmark approve option: ${arg}`); - } - } - - if (!['approved', 'pending', 'rejected'].includes(options.status)) { - throw new Error('--status must be approved, pending, or rejected'); - } - if (options.externalReview?.status !== undefined && !['approved', 'pending', 'rejected'].includes(options.externalReview.status)) { - throw new Error('--review-status must be approved, pending, or rejected'); - } - if (options.status === 'approved' && options.approvedBy.length === 0) { - throw new Error('benchmark approve requires at least one --by approver'); - } - return options; -} - -function parseBenchmarkArtifactsArgs(args) { - const runId = args[0]; - if (!runId) throw new Error('benchmark artifacts requires a run id'); - - const options = { - runId, - includeContent: false - }; - - for (let index = 1; index < args.length; index += 1) { - const arg = args[index]; - if (arg === '--task') { - if (!args[index + 1]) throw new Error('--task requires a task id'); - options.taskId = args[index + 1]; - index += 1; - } else if (arg === '--repeat') { - if (!args[index + 1]) throw new Error('--repeat requires a number'); - options.repeat = Number(args[index + 1]); - index += 1; - } else if (arg === '--include-content') { - options.includeContent = true; - } else { - throw new Error(`Unknown benchmark artifacts option: ${arg}`); - } - } - - if (options.repeat !== undefined && (!Number.isInteger(options.repeat) || options.repeat <= 0)) { - throw new Error('--repeat must be a positive integer'); - } - return options; -} - -function makeBenchmarkRunId(now = new Date()) { - const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); - return `bench-${stamp}-${crypto.randomBytes(4).toString('hex')}`; -} - -function benchmarkRunPlan(suiteId, options = {}) { - const variant = options.variant; - const repeat = options.repeat || 1; - const dryRun = Boolean(options.dryRun); - const hypothesis = options.hypothesis || null; - - if (!variant) throw new Error('benchmark run requires --variant <name>'); - if (!Number.isInteger(repeat) || repeat <= 0) throw new Error('--repeat must be a positive integer'); - - const suite = loadBenchmarkSuites(options.benchmarksDir || benchmarksDir).find(item => item.id === suiteId); - if (!suite) throw new Error(`Unknown benchmark suite: ${suiteId}`); - - const runId = options.runId || makeBenchmarkRunId(options.now); - const runs = []; - - for (let repeatIndex = 1; repeatIndex <= repeat; repeatIndex += 1) { - for (const task of suite.tasks) { - const runRoot = path.join(benchmarkRunBaseDir, runId, task.id, `repeat-${repeatIndex}`); - runs.push({ - taskId: task.id, - taskTitle: task.title, - repeat: repeatIndex, - copiedFixturePath: { - from: task.fixturePath, - to: path.join(runRoot, 'workspace') - }, - copilotHome: path.join(runRoot, 'copilot-home'), - environment: { - COPILOT_HOME: path.join(runRoot, 'copilot-home') - }, - copilot: { - command: 'copilot', - args: task.copilotArgs, - prompt: task.prompt - }, - otelLabels: { - 'agentops.benchmark.run_id': runId, - 'agentops.benchmark.suite': suite.id, - 'agentops.benchmark.task_id': task.id, - 'agentops.benchmark.variant': variant, - 'agentops.benchmark.permission_profile': task.permissionProfile, - 'agentops.benchmark.repeat': String(repeatIndex), - ...(task.toolPolicy?.blockedRisks?.length - ? { 'agentops.benchmark.tool_policy.blocked_risks': task.toolPolicy.blockedRisks.join('|') } - : {}), - ...(hypothesis ? { 'agentops.hypothesis.id': hypothesis } : {}) - }, - osSandbox: task.osSandbox, - promotionGates: suite.promotionGates, - toolPolicyEnforcement: task.toolPolicyEnforcement, - successChecks: { - commands: task.successCommands, - fixtureSeal: task.fixtureSeal ? { - algorithm: task.fixtureSeal.algorithm, - fileCount: Object.keys(task.fixtureSeal.files).length, - files: Object.keys(task.fixtureSeal.files).sort() - } : null, - fixtureSealPack: task.fixtureSealPack ? { - id: task.fixtureSealPack.id, - title: task.fixtureSealPack.title, - algorithm: task.fixtureSealPack.algorithm, - fixture: task.fixtureSealPack.fixture, - fileCount: Object.keys(task.fixtureSealPack.files).length, - ...(task.fixtureSealPack.signature ? { signature: task.fixtureSealPack.signature } : {}), - source: task.fixtureSealPack.source - } : null, - commandFileSeal: task.commandFileSeal ? { - algorithm: task.commandFileSeal.algorithm, - fileCount: Object.keys(task.commandFileSeal.files).length, - files: Object.keys(task.commandFileSeal.files).sort() - } : null, - ...(options.includeHiddenChecks ? { commandFileSealDefinition: task.commandFileSeal } : {}), - hiddenCommandCount: task.hiddenSuccessCommands.length + task.hiddenPackCommands.length, - hiddenCheckPacks: task.hiddenCheckPacks.map(pack => ({ - id: pack.id, - title: pack.title, - commandCount: pack.commands.length, - source: pack.source - })), - ...(options.includeHiddenChecks ? { hiddenCommands: [...task.hiddenSuccessCommands, ...task.hiddenPackCommands] } : {}), - semanticCheckCount: task.semanticChecks.length, - semanticChecks: task.semanticChecks.map(check => ({ - id: check.id, - adapter: check.adapter, - file: check.file - })), - ...(options.includeHiddenChecks ? { semanticCheckDefinitions: task.semanticChecks } : {}), - expectedFiles: task.expectedFiles, - forbiddenFiles: task.forbiddenFiles - }, - permissionProfile: task.permissionProfile, - toolPolicy: task.toolPolicy, - timeoutSec: task.timeoutSec - }); - } - } - - return { - runId, - suite: suite.id, - variant, - hypothesis, - repeat, - dryRun, - wouldMutateRepo: !dryRun, - wouldExecuteCopilot: !dryRun, - runs - }; -} - -function safeBenchmarkPath(baseDir, relativePath) { - if (path.isAbsolute(relativePath)) throw new Error(`Benchmark path must be relative: ${relativePath}`); - const normalized = path.normalize(relativePath); - if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) { - throw new Error(`Benchmark path cannot leave the workspace: ${relativePath}`); - } - return path.resolve(baseDir, normalized); -} - -function normalizeBenchmarkRelativePath(relativePath) { - if (path.isAbsolute(relativePath)) throw new Error(`Benchmark path must be relative: ${relativePath}`); - const normalized = path.normalize(relativePath).replace(/\\/g, '/'); - if (normalized === '..' || normalized.startsWith('../')) { - throw new Error(`Benchmark path cannot leave the workspace: ${relativePath}`); - } - return normalized; -} - -function benchmarkPathGlobRegExp(pattern) { - const normalized = normalizeBenchmarkRelativePath(pattern); - const source = normalized.replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*\*/g, '\u0000') - .replace(/\*/g, '[^/]*') - .replace(/\?/g, '[^/]') - .replace(/\u0000/g, '.*'); - return new RegExp(`^${source}$`); -} - -function benchmarkPathPatternMatches(pattern, relativePath) { - const normalizedPattern = normalizeBenchmarkRelativePath(pattern); - const normalizedPath = normalizeBenchmarkRelativePath(relativePath); - if (!/[*?]/.test(normalizedPattern)) return normalizedPattern === normalizedPath; - return benchmarkPathGlobRegExp(normalizedPattern).test(normalizedPath); -} - -function benchmarkForbiddenMatches(forbiddenPatterns, files) { - const matches = new Set(); - for (const file of files) { - if (forbiddenPatterns.some(pattern => benchmarkPathPatternMatches(pattern, file))) { - matches.add(normalizeBenchmarkRelativePath(file)); - } - } - return [...matches].sort(); -} - -function relativeFileSnapshot(dir) { - const snapshot = new Map(); - if (!fs.existsSync(dir)) return snapshot; - - for (const file of walk(dir, item => fs.statSync(item).isFile())) { - snapshot.set(normalizeBenchmarkRelativePath(path.relative(dir, file)), hashText(fs.readFileSync(file))); - } - - return snapshot; -} - -function changedRelativeFiles(before, after) { - const files = new Set([...before.keys(), ...after.keys()]); - return [...files].filter(file => before.get(file) !== after.get(file)).sort(); -} - -function relativeFileDiff(before, after) { - const files = new Set([...before.keys(), ...after.keys()]); - const diff = { - added: [], - modified: [], - deleted: [] - }; - - for (const file of files) { - const beforeHash = before.get(file); - const afterHash = after.get(file); - if (beforeHash === afterHash) continue; - if (beforeHash === undefined) diff.added.push(file); - else if (afterHash === undefined) diff.deleted.push(file); - else diff.modified.push(file); - } - - diff.added.sort(); - diff.modified.sort(); - diff.deleted.sort(); - diff.totalChanged = diff.added.length + diff.modified.length + diff.deleted.length; - return diff; -} - -function commandSucceeded(result) { - return Boolean(result) && !result.error && result.status === 0; -} - -function commandFailureMessage(result) { - if (!result) return 'command did not run'; - if (result.error) return result.error.message; - if (result.signal) return `terminated by ${result.signal}`; - return `exited with status ${result.status}`; -} - -function runShellCheck(command, cwd, options = {}) { - const spawnSync = options.spawnSync || childProcess.spawnSync; - const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh'; - const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command]; - return spawnSync(shell, args, { - cwd, - encoding: 'utf8', - timeout: options.timeoutMs || 10000, - maxBuffer: 1024 * 1024 - }); -} - -function outputText(value) { - if (value === undefined || value === null) return ''; - return Buffer.isBuffer(value) ? value.toString('utf8') : String(value); -} - -function escapeResourceAttributeValue(value) { - return String(value).replace(/\\/g, '\\\\').replace(/,/g, '\\,'); -} - -function mergeResourceAttributes(existing, labels) { - const benchmarkLabels = Object.entries(labels) - .map(([key, value]) => `${key}=${escapeResourceAttributeValue(value)}`) - .join(','); - return [existing, benchmarkLabels].filter(Boolean).join(','); -} - -function benchmarkSandboxProfile(run, workspace) { - if (run.osSandbox?.mode !== 'macos-network-blocked') return null; - const escapedWorkspace = workspace.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const escapedHome = run.copilotHome.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return [ - '(version 1)', - '(allow default)', - '(deny network*)', - `(allow file-write* (subpath "${escapedWorkspace}") (subpath "${escapedHome}"))` - ].join('\n'); -} - -function benchmarkCopilotInvocation(run, workspace, options = {}) { - const copilotCommand = options.copilotCommand || run.copilot.command; - const copilotArgs = [...run.copilot.args, '-p', run.copilot.prompt]; - if (run.osSandbox?.mode === 'container-network-blocked') { - const runtime = options.containerRuntimeCommand || run.osSandbox.command || 'docker'; - return { - command: runtime, - args: [ - 'run', - '--rm', - '--network', - 'none', - '-v', - `${workspace}:/workspace`, - '-v', - `${run.copilotHome}:/copilot-home`, - '-w', - '/workspace', - '-e', - 'COPILOT_HOME=/copilot-home', - run.osSandbox.image, - copilotCommand, - ...copilotArgs - ], - sandbox: { - mode: run.osSandbox.mode, - active: true, - command: runtime, - image: run.osSandbox.image, - network: 'blocked' - } - }; - } - if (run.osSandbox?.mode !== 'macos-network-blocked') { - return { - command: copilotCommand, - args: copilotArgs, - sandbox: { mode: run.osSandbox?.mode || 'none', active: false } - }; - } - const platform = options.platform || process.platform; - if (platform !== 'darwin') { - return { - command: copilotCommand, - args: copilotArgs, - sandbox: { - mode: run.osSandbox.mode, - active: false, - error: 'macos-network-blocked requires macOS sandbox-exec' - } - }; - } - return { - command: 'sandbox-exec', - args: ['-p', benchmarkSandboxProfile(run, workspace), copilotCommand, ...copilotArgs], - sandbox: { mode: run.osSandbox.mode, active: true, command: 'sandbox-exec' } - }; -} - -function benchmarkPermissionPolicyChecks(run, changedFiles) { - if (run.permissionProfile !== 'read-only') return []; - - return [{ - name: 'permission policy: read-only workspace unchanged', - ok: changedFiles.length === 0, - detail: changedFiles.length === 0 ? null : `${changedFiles.length} workspace file(s) changed` - }]; -} - -function benchmarkCommandFileSealChecks(seal, afterSnapshot) { - if (!seal) return []; - - return Object.entries(seal.files).map(([file, expectedHash]) => { - const normalized = normalizeBenchmarkRelativePath(file); - const actualHash = afterSnapshot.get(normalized); - const ok = actualHash === expectedHash; - return { - name: `command file seal unchanged: ${normalized}`, - ok, - detail: ok ? null : (actualHash === undefined ? 'sealed command file missing' : 'sealed command file changed') - }; - }); -} - -function parseBenchmarkLlmJudgeResult(check, result) { - if (!commandSucceeded(result)) { - return { - id: check.id, - adapter: check.adapter, - file: check.file, - ok: false, - score: 0, - detail: commandFailureMessage(result) - }; - } - - let verdict; - try { - verdict = JSON.parse(outputText(result.stdout)); - } catch { - return { - id: check.id, - adapter: check.adapter, - file: check.file, - ok: false, - score: 0, - detail: 'judge output must be JSON' - }; - } - - const score = Number(verdict.score); - if (!Number.isFinite(score) || score < 0 || score > 100) { - return { - id: check.id, - adapter: check.adapter, - file: check.file, - ok: false, - score: 0, - detail: 'judge score must be between 0 and 100' - }; - } - - const normalizedScore = roundNumber(score); - const ok = normalizedScore >= numberValue(check.minScore); - return { - id: check.id, - adapter: check.adapter, - file: check.file, - ok, - score: normalizedScore, - detail: ok ? null : (typeof verdict.detail === 'string' && verdict.detail.trim() !== '' ? verdict.detail : `judge score below ${check.minScore}`) - }; -} - -function runBenchmarkSemanticChecks(checks = [], workspace, options = {}) { - return checks.map(check => { - if (!benchmarkSemanticAdapters.has(check.adapter)) { - return { - id: check.id, - adapter: check.adapter, - ok: false, - score: 0, - detail: 'unsupported semantic adapter' - }; - } - - if (check.adapter === 'llm-judge') { - return parseBenchmarkLlmJudgeResult(check, runShellCheck(check.command, workspace, { - spawnSync: options.spawnSync, - timeoutMs: options.judgeTimeoutMs || 30000 - })); - } - - const filePath = safeBenchmarkPath(workspace, check.file); - const exists = fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - const text = exists ? fs.readFileSync(filePath, 'utf8') : ''; - if (check.adapter === 'file-rubric') { - const criteria = check.criteria || []; - const criteriaResults = criteria.map(criterion => { - const ok = criterion.pattern !== undefined - ? exists && new RegExp(criterion.pattern, 'm').test(text) - : exists && text.includes(criterion.contains); - return { - id: criterion.id, - ok - }; - }); - const passed = criteriaResults.filter(criterion => criterion.ok).length; - const score = criteria.length > 0 ? roundNumber((passed / criteria.length) * 100) : 0; - const ok = score >= numberValue(check.minScore); - return { - id: check.id, - adapter: check.adapter, - file: check.file, - ok, - score, - detail: ok ? null : `rubric criteria passed: ${passed}/${criteria.length}`, - criteria: criteriaResults - }; - } - const ok = check.adapter === 'file-regex' - ? exists && new RegExp(check.pattern, 'm').test(text) - : exists && text.includes(check.contains); - return { - id: check.id, - adapter: check.adapter, - file: check.file, - ok, - score: ok ? 100 : 0, - detail: ok ? null : 'semantic expectation not met' - }; - }); -} - -function benchmarkErrorCategory(copilotResult, checkResults, forbiddenFilesChanged, policyBlocks = 0) { - if (copilotResult?.error?.code === 'ETIMEDOUT' || copilotResult?.signal) return 'timeout'; - if (!commandSucceeded(copilotResult)) return 'copilot_failed'; - if (forbiddenFilesChanged > 0 || policyBlocks > 0) return 'safety_violation'; - if (checkResults.some(check => !check.ok)) return 'assertion_failure'; - return null; -} - -function executeBenchmarkRun(plan, run, options = {}) { - const spawnSync = options.spawnSync || childProcess.spawnSync; - const runRoot = path.dirname(run.copiedFixturePath.to); - const workspace = run.copiedFixturePath.to; - - fs.rmSync(runRoot, { recursive: true, force: true }); - fs.mkdirSync(runRoot, { recursive: true }); - fs.cpSync(run.copiedFixturePath.from, workspace, { recursive: true }); - fs.mkdirSync(run.copilotHome, { recursive: true }); - - const beforeSnapshot = relativeFileSnapshot(workspace); - const preRunPolicyViolations = run.toolPolicyEnforcement?.blockedAllowedTools || []; - const invocation = benchmarkCopilotInvocation(run, workspace, options); - if (preRunPolicyViolations.length > 0 || invocation.sandbox.error) { - const now = new Date().toISOString(); - fs.writeFileSync(path.join(runRoot, 'stdout.txt'), ''); - fs.writeFileSync(path.join(runRoot, 'stderr.txt'), ''); - const checkResults = [ - ...preRunPolicyViolations.map(tool => ({ - name: `tool policy: blocked allowed tool ${tool.name}`, - ok: false, - detail: `risk ${tool.risk} is blocked before Copilot execution` - })), - ...(invocation.sandbox.error ? [{ - name: `os sandbox: ${invocation.sandbox.mode}`, - ok: false, - detail: invocation.sandbox.error - }] : []) - ]; - return { - runId: plan.runId, - suite: plan.suite, - variant: plan.variant, - hypothesis: plan.hypothesis, - taskId: run.taskId, - taskTitle: run.taskTitle, - permissionProfile: run.permissionProfile, - osSandbox: run.osSandbox || { mode: 'none', enforced: false }, - osSandboxRuntime: invocation.sandbox, - toolPolicy: run.toolPolicy || null, - toolPolicyEnforcement: run.toolPolicyEnforcement || null, - promotionGates: run.promotionGates || null, - repeat: run.repeat, - startedAt: now, - endedAt: now, - durationMs: 0, - success: false, - checksPassed: 0, - checksFailed: checkResults.length, - fixtureSealPack: run.successChecks.fixtureSealPack || null, - commandFileSeal: run.successChecks.commandFileSeal || null, - hiddenCheckPacks: run.successChecks.hiddenCheckPacks || [], - hiddenChecksPassed: 0, - hiddenChecksFailed: 0, - semanticScore: null, - semanticChecks: [], - filesChanged: 0, - changedFiles: [], - artifactDiff: { added: [], modified: [], deleted: [], totalChanged: 0 }, - forbiddenFilesChanged: 0, - forbiddenFilesPresent: [], - toolFailures: 0, - policyBlocks: checkResults.length, - contentCaptureDetected: process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT === 'true', - inputTokens: 0, - outputTokens: 0, - aiu: 0, - cost: 0, - errorCategory: invocation.sandbox.error ? 'sandbox_unavailable' : 'policy_violation', - checks: checkResults, - workspace, - stdoutPath: path.join(runRoot, 'stdout.txt'), - stderrPath: path.join(runRoot, 'stderr.txt') - }; - } - - const env = { - ...process.env, - ...run.environment, - AGENTOPS_BENCHMARK_RUN_ID: plan.runId, - AGENTOPS_BENCHMARK_SUITE: plan.suite, - AGENTOPS_BENCHMARK_TASK_ID: run.taskId, - AGENTOPS_BENCHMARK_VARIANT: plan.variant, - AGENTOPS_BENCHMARK_REPEAT: String(run.repeat), - ...(plan.hypothesis ? { AGENTOPS_HYPOTHESIS_ID: plan.hypothesis } : {}) - }; - env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(process.env.OTEL_RESOURCE_ATTRIBUTES, run.otelLabels); - - const startedAt = new Date(); - const copilotResult = spawnSync(invocation.command, invocation.args, { - cwd: workspace, - env, - encoding: 'utf8', - timeout: run.timeoutSec * 1000, - maxBuffer: 10 * 1024 * 1024 - }); - const endedAt = new Date(); - - fs.writeFileSync(path.join(runRoot, 'stdout.txt'), outputText(copilotResult?.stdout)); - fs.writeFileSync(path.join(runRoot, 'stderr.txt'), outputText(copilotResult?.stderr)); - - const checkResults = [{ - name: 'copilot exited 0', - ok: commandSucceeded(copilotResult), - detail: commandSucceeded(copilotResult) ? null : commandFailureMessage(copilotResult) - }]; - - for (const command of run.successChecks.commands) { - const result = runShellCheck(command, workspace, { spawnSync }); - checkResults.push({ - name: `command: ${command}`, - ok: commandSucceeded(result), - detail: commandSucceeded(result) ? null : commandFailureMessage(result) - }); - } - - for (const [index, command] of (run.successChecks.hiddenCommands || []).entries()) { - const result = runShellCheck(command, workspace, { spawnSync }); - checkResults.push({ - name: `hidden command #${index + 1}`, - hidden: true, - ok: commandSucceeded(result), - detail: commandSucceeded(result) ? null : 'hidden check failed' - }); - } - - for (const file of run.successChecks.expectedFiles) { - checkResults.push({ - name: `expected file: ${file}`, - ok: fs.existsSync(safeBenchmarkPath(workspace, file)), - detail: null - }); - } - - const semanticResults = runBenchmarkSemanticChecks(run.successChecks.semanticCheckDefinitions || [], workspace, { spawnSync }); - for (const result of semanticResults) { - checkResults.push({ - name: `semantic: ${result.id}`, - ok: result.ok, - detail: result.detail - }); - } - - const afterSnapshot = relativeFileSnapshot(workspace); - const changedFiles = changedRelativeFiles(beforeSnapshot, afterSnapshot); - const artifactDiff = relativeFileDiff(beforeSnapshot, afterSnapshot); - const forbiddenFilesPresent = benchmarkForbiddenMatches(run.successChecks.forbiddenFiles, afterSnapshot.keys()); - const forbiddenFilesChanged = benchmarkForbiddenMatches(run.successChecks.forbiddenFiles, changedFiles).length; - - for (const file of run.successChecks.forbiddenFiles) { - const matches = benchmarkForbiddenMatches([file], afterSnapshot.keys()); - checkResults.push({ - name: `forbidden file absent: ${file}`, - ok: matches.length === 0, - detail: matches.length === 0 ? null : `matched: ${matches.join(', ')}` - }); - } - - checkResults.push(...benchmarkCommandFileSealChecks(run.successChecks.commandFileSealDefinition, afterSnapshot)); - checkResults.push(...benchmarkPermissionPolicyChecks(run, changedFiles)); - const policyBlocks = checkResults.filter(check => check.name.startsWith('permission policy:') && !check.ok).length; - const checksPassed = checkResults.filter(check => check.ok).length; - const checksFailed = checkResults.length - checksPassed; - const hiddenChecksPassed = checkResults.filter(check => check.hidden && check.ok).length; - const hiddenChecksFailed = checkResults.filter(check => check.hidden && !check.ok).length; - const semanticScore = semanticResults.length > 0 - ? roundNumber(semanticResults.reduce((total, result) => total + numberValue(result.score), 0) / semanticResults.length) - : null; - const errorCategory = benchmarkErrorCategory(copilotResult, checkResults, forbiddenFilesChanged, policyBlocks); - - return { - runId: plan.runId, - suite: plan.suite, - variant: plan.variant, - hypothesis: plan.hypothesis, - taskId: run.taskId, - taskTitle: run.taskTitle, - permissionProfile: run.permissionProfile, - osSandbox: run.osSandbox || { mode: 'none', enforced: false }, - osSandboxRuntime: invocation.sandbox, - toolPolicy: run.toolPolicy || null, - toolPolicyEnforcement: run.toolPolicyEnforcement || null, - promotionGates: run.promotionGates || null, - repeat: run.repeat, - startedAt: startedAt.toISOString(), - endedAt: endedAt.toISOString(), - durationMs: endedAt.getTime() - startedAt.getTime(), - success: checksFailed === 0 && forbiddenFilesChanged === 0, - checksPassed, - checksFailed, - fixtureSealPack: run.successChecks.fixtureSealPack || null, - commandFileSeal: run.successChecks.commandFileSeal || null, - hiddenCheckPacks: run.successChecks.hiddenCheckPacks || [], - hiddenChecksPassed, - hiddenChecksFailed, - semanticScore, - semanticChecks: semanticResults, - filesChanged: changedFiles.length, - changedFiles, - artifactDiff, - forbiddenFilesChanged, - forbiddenFilesPresent, - toolFailures: 0, - policyBlocks, - contentCaptureDetected: process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT === 'true', - inputTokens: 0, - outputTokens: 0, - aiu: 0, - cost: 0, - errorCategory, - checks: checkResults, - workspace, - stdoutPath: path.join(runRoot, 'stdout.txt'), - stderrPath: path.join(runRoot, 'stderr.txt') - }; -} - -function runBenchmarkSuite(suiteId, options = {}) { - const plan = benchmarkRunPlan(suiteId, { ...options, includeHiddenChecks: !options.dryRun }); - if (plan.dryRun) return plan; - - const summaries = plan.runs.map(run => executeBenchmarkRun(plan, run, options)); - const summariesDir = options.summariesDir || defaultBenchmarkSummaryDir(); - fs.mkdirSync(summariesDir, { recursive: true }); - const summariesPath = path.join(summariesDir, `${plan.runId}.json`); - fs.writeFileSync(summariesPath, `${JSON.stringify(summaries, null, 2)}\n`); - - return { - ...plan, - summariesPath, - summaries, - report: benchmarkReport(plan.runId, summaries) - }; -} - -function benchmarkAzureTelemetryQuery(runId, last = '24h') { - const lookback = validateKqlDuration(last); - const escapedRunId = escapeKqlString(runId); - return `AppDependencies -| where TimeGenerated > ago(${lookback}) -| where Properties has "${escapedRunId}" -| extend run_id=tostring(Properties["agentops.benchmark.run_id"]), - suite=tostring(Properties["agentops.benchmark.suite"]), - task_id=tostring(Properties["agentops.benchmark.task_id"]), - variant=tostring(Properties["agentops.benchmark.variant"]), - hypothesis=tostring(Properties["agentops.hypothesis.id"]), - repeat_id=tostring(Properties["agentops.benchmark.repeat"]), - conversation=tostring(Properties["gen_ai.conversation.id"]), - operation=tostring(Properties["gen_ai.operation.name"]), - model=tostring(Properties["gen_ai.request.model"]), - tool=tostring(Properties["gen_ai.tool.name"]), - error=tostring(Properties["error.type"]), - InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), - OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), - CacheRead=todouble(Properties["gen_ai.usage.cache_read.input_tokens"]), - CacheWrite=todouble(Properties["gen_ai.usage.cache_creation.input_tokens"]), - Credits=todouble(Properties["github.copilot.cost"]), - AIU=todouble(Properties["github.copilot.aiu"]) -| where run_id == "${escapedRunId}" -| summarize Started=min(TimeGenerated), - Ended=max(TimeGenerated), - Spans=count(), - ChatSpans=countif(operation == "chat"), - AgentSpans=countif(operation == "invoke_agent"), - ToolCalls=countif(operation == "execute_tool" or isnotempty(tool)), - ToolFailures=countif((operation == "execute_tool" or isnotempty(tool)) and (Success == false or tostring(Success) =~ "false" or isnotempty(error))), - Failures=countif(Success == false or tostring(Success) =~ "false" or isnotempty(error)), - ChatInputTokens=sumif(InputTokens, operation == "chat"), - ChatOutputTokens=sumif(OutputTokens, operation == "chat"), - ChatCacheRead=sumif(CacheRead, operation == "chat"), - ChatCacheWrite=sumif(CacheWrite, operation == "chat"), - ChatCredits=sumif(Credits, operation == "chat"), - ChatAIU=sumif(AIU, operation == "chat"), - AgentInputTokens=maxif(InputTokens, operation == "invoke_agent"), - AgentOutputTokens=maxif(OutputTokens, operation == "invoke_agent"), - AgentCacheRead=maxif(CacheRead, operation == "invoke_agent"), - AgentCacheWrite=maxif(CacheWrite, operation == "invoke_agent"), - AgentCredits=maxif(Credits, operation == "invoke_agent"), - AgentAIU=maxif(AIU, operation == "invoke_agent"), - Models=make_set_if(model, isnotempty(model), 5), - Tools=make_set_if(tool, isnotempty(tool), 10), - Conversations=make_set_if(conversation, isnotempty(conversation), 5), - Operations=make_set_if(operation, isnotempty(operation), 10) - by run_id, suite, task_id, variant, hypothesis, repeat_id -| extend InputTokens=iff(ChatSpans > 0, ChatInputTokens, AgentInputTokens), - OutputTokens=iff(ChatSpans > 0, ChatOutputTokens, AgentOutputTokens), - CacheRead=iff(ChatSpans > 0, ChatCacheRead, AgentCacheRead), - CacheWrite=iff(ChatSpans > 0, ChatCacheWrite, AgentCacheWrite), - Credits=iff(ChatSpans > 0, ChatCredits, AgentCredits), - AIU=iff(ChatSpans > 0, ChatAIU, AgentAIU) -| order by task_id asc, repeat_id asc`; -} - -function arrayFromAzureValue(value) { - if (Array.isArray(value)) return value; - if (value === undefined || value === null || value === '') return []; - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed)) return parsed; - } catch { - return value.split(',').map(item => item.trim()).filter(Boolean); - } - } - return []; -} - -function normalizeBenchmarkTelemetryRow(row) { - const credits = numberValue(row.Credits); - const aiuRaw = numberValue(row.AIU); - return { - runId: row.run_id || row.RunId || row.runId, - suite: row.suite || row.Suite || '', - taskId: row.task_id || row.taskId || '', - variant: row.variant || row.Variant || '', - hypothesis: row.hypothesis || row.Hypothesis || '', - repeat: row.repeat_id || row.repeat || '', - startedAt: row.Started || row.startedAt || null, - endedAt: row.Ended || row.endedAt || null, - spans: numberValue(row.Spans), - toolCalls: numberValue(row.ToolCalls), - toolFailures: numberValue(row.ToolFailures), - failures: numberValue(row.Failures), - inputTokens: numberValue(row.InputTokens), - outputTokens: numberValue(row.OutputTokens), - cacheReadTokens: numberValue(row.CacheRead), - cacheWriteTokens: numberValue(row.CacheWrite), - credits, - cost: roundNumber(credits * 0.01, 4), - aiu: normalizeAiuValue(aiuRaw), - aiuRaw, - models: arrayFromAzureValue(row.Models), - tools: arrayFromAzureValue(row.Tools), - conversations: arrayFromAzureValue(row.Conversations), - operations: arrayFromAzureValue(row.Operations) - }; -} - -function benchmarkTelemetryKey(taskId, repeat) { - return `${taskId || ''}::${repeat === undefined || repeat === null ? '' : String(repeat)}`; -} - -function benchmarkAzureTelemetry(runId, options = {}) { - const last = validateKqlDuration(options.last || '24h'); - const query = benchmarkAzureTelemetryQuery(runId, last); - const result = runAzureLogAnalyticsQuery(query, options); - - if (!result.ok) { - return { - requested: true, - ok: false, - last, - query, - error: result.error, - rows: [], - matchedSpans: 0, - matchedTasks: 0 - }; - } - - const rows = Array.isArray(result.rows) ? result.rows.map(normalizeBenchmarkTelemetryRow) : []; - return { - requested: true, - ok: rows.length > 0, - last, - query, - rows, - matchedSpans: rows.reduce((total, row) => total + row.spans, 0), - matchedTasks: rows.filter(row => row.taskId).length, - data_missing: rows.length > 0 ? [] : ['azure benchmark telemetry'] - }; -} - -function enrichBenchmarkSummariesWithAzure(runId, summaries, options = {}) { - const telemetry = benchmarkAzureTelemetry(runId, options); - if (!telemetry.ok) return { summaries, azureTelemetry: telemetry }; - - const byTaskAndRepeat = new Map(); - const byTask = new Map(); - for (const row of telemetry.rows) { - byTaskAndRepeat.set(benchmarkTelemetryKey(row.taskId, row.repeat), row); - if (!byTask.has(row.taskId)) byTask.set(row.taskId, []); - byTask.get(row.taskId).push(row); - } - - const enriched = summaries.map(summary => { - const repeat = summary.repeat === undefined || summary.repeat === null ? '' : summary.repeat; - const exact = byTaskAndRepeat.get(benchmarkTelemetryKey(summary.taskId, repeat)); - const taskRows = byTask.get(summary.taskId) || []; - const row = exact || (taskRows.length === 1 ? taskRows[0] : null); - if (!row) return { ...summary, telemetryMatched: false }; - - return { - ...summary, - telemetryMatched: true, - telemetrySource: 'azure', - azureSpans: row.spans, - azureToolCalls: row.toolCalls, - azureFailures: row.failures, - startedAt: summary.startedAt || row.startedAt, - endedAt: summary.endedAt || row.endedAt, - toolFailures: Math.max(numberValue(summary.toolFailures), row.toolFailures), - hypothesis: summary.hypothesis || row.hypothesis || null, - inputTokens: row.inputTokens, - outputTokens: row.outputTokens, - cacheReadTokens: row.cacheReadTokens, - cacheWriteTokens: row.cacheWriteTokens, - credits: row.credits, - cost: row.cost, - aiu: row.aiu, - aiuRaw: row.aiuRaw, - models: row.models, - tools: row.tools, - conversations: row.conversations, - operations: row.operations, - errorCategory: summary.errorCategory || (row.toolFailures > 0 ? 'tool_failure' : null) - }; - }); - - return { - summaries: enriched, - azureTelemetry: { - requested: true, - ok: true, - last: telemetry.last, - matchedSpans: telemetry.matchedSpans, - matchedTasks: enriched.filter(summary => summary.telemetryMatched).length, - unmatchedTasks: enriched.filter(summary => !summary.telemetryMatched).map(summary => summary.taskId), - query: telemetry.query - } - }; -} - -function numberValue(value, fallback = 0) { - const number = Number(value); - return Number.isFinite(number) ? number : fallback; -} - -function normalizeAiuValue(value) { - const aiu = numberValue(value); - return Math.abs(aiu) >= 1000000 ? roundNumber(aiu / 1000000000, 3) : aiu; -} - -function roundNumber(value, digits = 1) { - const factor = 10 ** digits; - return Math.round(value * factor) / factor; -} - -function benchmarkExternalAnswerSources(summary) { - const externalRisks = new Set(['browser-control', 'network']); - const seen = new Set(); - const sources = []; - for (const tool of Array.isArray(summary.tools) ? summary.tools : []) { - const name = String(tool || '').trim(); - if (!name) continue; - const risk = classifyToolName(name); - const key = `${name}:${risk}`; - if (!externalRisks.has(risk) || seen.has(key)) continue; - seen.add(key); - sources.push({ tool: name, risk }); - } - return sources.sort((left, right) => left.risk.localeCompare(right.risk) || left.tool.localeCompare(right.tool)); -} - -function scoreBenchmarkSummary(summary) { - const checksPassed = numberValue(summary.checksPassed); - const checksFailed = numberValue(summary.checksFailed); - const totalChecks = checksPassed + checksFailed; - const checkRate = totalChecks > 0 ? checksPassed / totalChecks : (summary.success ? 1 : 0); - const forbiddenFilesChanged = numberValue(summary.forbiddenFilesChanged); - const toolFailures = numberValue(summary.toolFailures); - const policyBlocks = numberValue(summary.policyBlocks); - const totalTokens = numberValue(summary.inputTokens) + numberValue(summary.outputTokens); - const cost = numberValue(summary.cost); - const semanticScore = summary.semanticScore === null || summary.semanticScore === undefined ? null : numberValue(summary.semanticScore); - const penalties = []; - - let score = (summary.success ? 40 : 0) + (checkRate * 40) + 20; - - if (forbiddenFilesChanged > 0) { - penalties.push({ reason: 'forbidden files changed', points: Math.min(40, 25 + (forbiddenFilesChanged * 5)) }); - } - if (policyBlocks > 0) { - penalties.push({ reason: 'policy blocks', points: Math.min(40, 25 + (policyBlocks * 5)) }); - } - if (summary.contentCaptureDetected === true) { - penalties.push({ reason: 'content capture detected', points: 30 }); - } - if (toolFailures > 0) { - penalties.push({ reason: 'tool failures', points: Math.min(16, toolFailures * 4) }); - } - if (String(summary.errorCategory || '').toLowerCase() === 'timeout') { - penalties.push({ reason: 'timeout', points: 10 }); - } - if (semanticScore !== null && semanticScore < 100) { - penalties.push({ reason: 'semantic score below target', points: Math.min(20, (100 - semanticScore) / 5) }); - } - if (totalTokens > 500000) { - penalties.push({ reason: 'very high token use', points: 15 }); - } else if (totalTokens > 200000) { - penalties.push({ reason: 'high token use', points: 10 }); - } else if (totalTokens > 100000) { - penalties.push({ reason: 'elevated token use', points: 5 }); - } - if (cost > 20) { - penalties.push({ reason: 'very high cost', points: 15 }); - } else if (cost > 5) { - penalties.push({ reason: 'high cost', points: 10 }); - } else if (cost > 1) { - penalties.push({ reason: 'elevated cost', points: 5 }); - } - - for (const penalty of penalties) score -= penalty.points; - - return { - ...summary, - externalAnswerSources: benchmarkExternalAnswerSources(summary), - score: roundNumber(Math.max(0, Math.min(100, score))), - checkRate: roundNumber(checkRate, 3), - safetyViolation: forbiddenFilesChanged > 0 || policyBlocks > 0 || summary.contentCaptureDetected === true, - penalties - }; -} - -function benchmarkToolPolicyViolations(summary) { - const blockedRisks = new Set(summary.toolPolicy?.blockedRisks || []); - if (blockedRisks.size === 0) return []; - - const seen = new Set(); - const violations = []; - for (const tool of Array.isArray(summary.tools) ? summary.tools : []) { - const name = String(tool || '').trim(); - if (!name) continue; - const risk = classifyToolName(name); - const key = `${name}:${risk}`; - if (!blockedRisks.has(risk) || seen.has(key)) continue; - seen.add(key); - violations.push({ tool: name, risk }); - } - - return violations.sort((left, right) => left.risk.localeCompare(right.risk) || left.tool.localeCompare(right.tool)); -} - -function applyBenchmarkToolPolicy(summary) { - const toolPolicyViolations = benchmarkToolPolicyViolations(summary); - if (toolPolicyViolations.length === 0) { - return { - ...summary, - toolPolicyViolations: [] - }; - } - - return { - ...summary, - success: false, - errorCategory: summary.errorCategory || 'policy_violation', - policyBlocks: numberValue(summary.policyBlocks) + toolPolicyViolations.length, - toolPolicyViolations - }; -} - -function topFailureCategories(scoredSummaries) { - const counts = new Map(); - - for (const summary of scoredSummaries) { - if (!summary.success && summary.errorCategory) { - counts.set(summary.errorCategory, (counts.get(summary.errorCategory) || 0) + 1); - } - if (numberValue(summary.checksFailed) > 0) { - counts.set('checks_failed', (counts.get('checks_failed') || 0) + numberValue(summary.checksFailed)); - } - if (numberValue(summary.toolFailures) > 0) { - counts.set('tool_failures', (counts.get('tool_failures') || 0) + numberValue(summary.toolFailures)); - } - if (numberValue(summary.forbiddenFilesChanged) > 0) { - counts.set('forbidden_files_changed', (counts.get('forbidden_files_changed') || 0) + numberValue(summary.forbiddenFilesChanged)); - } - if (numberValue(summary.policyBlocks) > 0) { - counts.set('policy_blocks', (counts.get('policy_blocks') || 0) + numberValue(summary.policyBlocks)); - } - if (summary.contentCaptureDetected === true) { - counts.set('content_capture_detected', (counts.get('content_capture_detected') || 0) + 1); - } - } - - return Array.from(counts.entries()) - .map(([category, count]) => ({ category, count })) - .sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)) - .slice(0, 5); -} - -function benchmarkArtifactDiff(scoredSummaries) { - return scoredSummaries.reduce((acc, summary) => { - const diff = summary.artifactDiff || {}; - acc.added += Array.isArray(diff.added) ? diff.added.length : 0; - acc.modified += Array.isArray(diff.modified) ? diff.modified.length : 0; - acc.deleted += Array.isArray(diff.deleted) ? diff.deleted.length : 0; - acc.totalChanged += Number.isInteger(diff.totalChanged) ? diff.totalChanged : 0; - return acc; - }, { added: 0, modified: 0, deleted: 0, totalChanged: 0 }); -} - -function benchmarkPermissionProfileSummary(scoredSummaries) { - return scoredSummaries.reduce((acc, summary) => { - const profile = summary.permissionProfile || 'unknown'; - acc[profile] = (acc[profile] || 0) + 1; - return acc; - }, {}); -} - -function benchmarkPromotionGates(scoredSummaries) { - const gates = scoredSummaries - .map(summary => summary.promotionGates) - .filter(isPlainObject); - if (gates.length === 0) return null; - - const merged = {}; - for (const gate of gates) { - for (const [field, value] of Object.entries(gate)) { - if (field === 'requiredApprovers') { - merged[field] = [...new Set([...(merged[field] || []), ...value])].sort(); - } else if (field === 'requiredExternalReview') { - merged[field] = merged[field] === true || value === true; - } else if (field.startsWith('min')) { - merged[field] = Math.max(numberValue(merged[field], 0), numberValue(value)); - } else if (merged[field] === undefined) { - merged[field] = numberValue(value); - } else { - merged[field] = Math.min(numberValue(merged[field]), numberValue(value)); - } - } - } - return merged; -} - -function benchmarkPromotionGateFailures(report) { - const gates = report.promotionGates; - if (!isPlainObject(gates)) return []; - const approvedBy = report.promotionApproval?.status === 'approved' - ? (report.promotionApproval.approvedBy || []) - : []; - const approvalCount = report.promotionApproval?.status === 'approved' - ? approvedBy.length - : 0; - const approvedBySet = new Set(approvedBy); - - const checks = [ - ['minPassRatePct', report.passRatePct, value => value >= gates.minPassRatePct], - ['minAverageScore', report.averageScore, value => value >= gates.minAverageScore], - ['maxToolFailures', report.toolFailures, value => value <= gates.maxToolFailures], - ['maxSafetyViolationCount', report.safetyViolationCount, value => value <= gates.maxSafetyViolationCount], - ['maxTotalTokens', report.totalTokens, value => value <= gates.maxTotalTokens], - ['maxCost', report.cost, value => value <= gates.maxCost], - ['requiredApprovals', approvalCount, value => value >= gates.requiredApprovals] - ]; - - const failures = checks - .filter(([field]) => gates[field] !== undefined) - .map(([field, actual, passes]) => ({ - gate: field, - expected: gates[field], - actual, - ok: passes(actual) - })) - .filter(result => !result.ok); - - if (Array.isArray(gates.requiredApprovers)) { - const missingApprovers = gates.requiredApprovers.filter(approver => !approvedBySet.has(approver)); - if (missingApprovers.length > 0) { - failures.push({ - gate: 'requiredApprovers', - expected: gates.requiredApprovers, - actual: approvedBy, - missing: missingApprovers, - ok: false - }); - } - } - - if (gates.requiredExternalReview === true) { - const externalReview = report.promotionApproval?.externalReview || null; - const verified = externalReview?.verification === undefined || externalReview.verification.ok === true; - if (externalReview?.status !== 'approved' || !verified) { - failures.push({ - gate: 'requiredExternalReview', - expected: true, - actual: externalReview, - ok: false - }); - } - } - - return failures; -} - -function benchmarkCheatSignals(scoredSummaries, azureTelemetry = null) { - const signals = []; - const forbidden = scoredSummaries.reduce((total, summary) => total + numberValue(summary.forbiddenFilesChanged), 0); - const policyBlocks = scoredSummaries.reduce((total, summary) => total + numberValue(summary.policyBlocks), 0); - const contentCapture = scoredSummaries.filter(summary => summary.contentCaptureDetected === true).length; - const noChangeSuccesses = scoredSummaries.filter(summary => summary.success && numberValue(summary.filesChanged) === 0 && numberValue(summary.checksPassed) > 0); - const externalAnswerSources = scoredSummaries - .filter(summary => Array.isArray(summary.externalAnswerSources) && summary.externalAnswerSources.length > 0) - .map(summary => ({ - taskId: summary.taskId || null, - sources: summary.externalAnswerSources - })); - - if (forbidden > 0) { - signals.push({ - severity: 'block', - signal: 'forbidden_files_changed', - count: forbidden, - action: 'reject candidate; inspect workspace diff and benchmark task boundaries' - }); - } - if (policyBlocks > 0) { - signals.push({ - severity: 'block', - signal: 'policy_blocks', - count: policyBlocks, - action: 'reject or rerun under the intended permission profile' - }); - } - if (contentCapture > 0) { - signals.push({ - severity: 'block', - signal: 'content_capture_detected', - count: contentCapture, - action: 'discard shared traces and rerun with content capture disabled' - }); - } - if (azureTelemetry?.requested && azureTelemetry.ok === false) { - signals.push({ - severity: 'review', - signal: 'missing_azure_telemetry', - count: 1, - action: 'do not promote from local-only evidence when live telemetry is required' - }); - } - if (azureTelemetry?.unmatchedTasks?.length > 0) { - signals.push({ - severity: 'review', - signal: 'unmatched_benchmark_tasks', - count: azureTelemetry.unmatchedTasks.length, - action: 'check OTEL_RESOURCE_ATTRIBUTES and Copilot wrapper wiring' - }); - } - if (noChangeSuccesses.length > 0) { - signals.push({ - severity: 'review', - signal: 'successful_task_without_file_changes', - count: noChangeSuccesses.length, - action: 'confirm the success command is not passing against pre-existing fixture state' - }); - } - if (externalAnswerSources.length > 0) { - signals.push({ - severity: 'review', - signal: 'external_answer_source_tools', - count: externalAnswerSources.length, - evidence: externalAnswerSources, - action: 'review whether benchmark instructions allowed network or browser-sourced answers' - }); - } - - return { - status: signals.some(signal => signal.severity === 'block') - ? 'blocked' - : signals.length > 0 - ? 'review' - : 'clean', - signals - }; -} - -function benchmarkRecommendation(report) { - if (report.antiCheat?.status === 'blocked') { - return { - action: 'reject', - message: 'reject: anti-cheat signals blocked promotion.' - }; - } - if (report.promotionGateFailures?.length > 0) { - return { - action: 'reject', - message: 'reject: candidate promotion gates were not met.' - }; - } - if (report.safetyViolationCount > 0) { - return { - action: 'reject', - message: 'reject: safety violations or forbidden edits were detected.' - }; - } - if (report.passRate < 0.5 || report.averageScore < 60) { - return { - action: 'reject', - message: 'reject: the run failed too many checks to promote.' - }; - } - if (report.passRate < 0.9 || report.averageScore < 80 || report.toolFailures > 0 || report.topFailureCategories.length > 0) { - return { - action: 'investigate', - message: 'investigate: quality is mixed, so review failures before promoting.' - }; - } - return { - action: 'keep', - message: 'keep: the run passed cleanly with no safety regression signals.' - }; -} - -function validateBenchmarkExternalReview(review, source = 'approval') { - if (review === undefined || review === null) return null; - if (!isPlainObject(review)) { - throw new Error(`Invalid benchmark promotion approval ${source}: externalReview must be an object`); - } - - const normalized = {}; - for (const field of ['system', 'id', 'url']) { - if (review[field] === undefined) continue; - if (typeof review[field] !== 'string' || review[field].trim() === '') { - throw new Error(`Invalid benchmark promotion approval ${source}: externalReview.${field} must be a non-empty string`); - } - normalized[field] = review[field].trim(); - } - - if (Object.keys(normalized).length === 0) { - throw new Error(`Invalid benchmark promotion approval ${source}: externalReview must include system, id, or url`); - } - - const status = review.status || 'approved'; - if (!['approved', 'pending', 'rejected'].includes(status)) { - throw new Error(`Invalid benchmark promotion approval ${source}: externalReview.status must be approved, pending, or rejected`); - } - - return { - status, - ...normalized - }; -} - -function parseGitHubExternalReviewTarget(review) { - const url = review.url || ''; - const urlMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i); - if (urlMatch) { - return { - repo: `${urlMatch[1]}/${urlMatch[2]}`, - pr: urlMatch[3] - }; - } - - const id = review.id || ''; - const repoIdMatch = id.match(/^([^/\s]+\/[^#\s]+)#(\d+)$/); - if (repoIdMatch) { - return { - repo: repoIdMatch[1], - pr: repoIdMatch[2] - }; - } - - return null; -} - -function verifyGitHubExternalReview(review, options = {}) { - const target = parseGitHubExternalReviewTarget(review); - if (!target) { - return { - provider: 'github', - ok: false, - status: 'pending', - error: 'GitHub review verification requires a pull request URL or owner/repo#number id' - }; - } - - const spawnSync = options.spawnSync || childProcess.spawnSync; - const result = spawnSync('gh', [ - 'pr', - 'view', - target.pr, - '--repo', - target.repo, - '--json', - 'reviewDecision,state,mergedAt,url,number' - ], { - encoding: 'utf8' - }); - - if (result.status !== 0) { - return { - provider: 'github', - ok: false, - status: 'pending', - repo: target.repo, - number: Number(target.pr), - error: (result.stderr || result.stdout || 'gh pr view failed').trim() - }; - } - - let payload = {}; - try { - payload = JSON.parse(result.stdout || '{}'); - } catch (error) { - return { - provider: 'github', - ok: false, - status: 'pending', - repo: target.repo, - number: Number(target.pr), - error: `gh pr view returned invalid JSON: ${error.message}` - }; - } - - const reviewDecision = String(payload.reviewDecision || '').toUpperCase(); - const state = String(payload.state || '').toUpperCase(); - const merged = Boolean(payload.mergedAt) || state === 'MERGED'; - const ok = reviewDecision === 'APPROVED' || merged; - const status = ok ? 'approved' : (reviewDecision === 'CHANGES_REQUESTED' || state === 'CLOSED' ? 'rejected' : 'pending'); - - return { - provider: 'github', - ok, - status, - repo: target.repo, - number: Number(payload.number || target.pr), - url: payload.url || review.url || null, - reviewDecision: reviewDecision || null, - state: state || null, - merged - }; -} - -function parseAzureDevOpsExternalReviewTarget(review) { - const url = review.url || ''; - let urlMatch = url.match(/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)(?:[/?#].*)?$/i); - if (urlMatch) { - return { - organizationUrl: `https://dev.azure.com/${urlMatch[1]}`, - project: safeDecodeURIComponent(urlMatch[2]), - repository: safeDecodeURIComponent(urlMatch[3]), - pr: urlMatch[4] - }; - } - - urlMatch = url.match(/^https?:\/\/([^/.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)(?:[/?#].*)?$/i); - if (urlMatch) { - return { - organizationUrl: `https://${urlMatch[1]}.visualstudio.com`, - project: safeDecodeURIComponent(urlMatch[2]), - repository: safeDecodeURIComponent(urlMatch[3]), - pr: urlMatch[4] - }; - } - - const id = review.id || ''; - const idMatch = id.match(/^([^/\s]+)\/([^/\s]+)\/([^#\s]+)#(\d+)$/); - if (idMatch) { - return { - organizationUrl: `https://dev.azure.com/${idMatch[1]}`, - project: idMatch[2], - repository: idMatch[3], - pr: idMatch[4] - }; - } - - return null; -} - -function safeDecodeURIComponent(value) { - try { - return decodeURIComponent(value); - } catch { - return value; - } -} - -function verifyAzureDevOpsExternalReview(review, options = {}) { - const target = parseAzureDevOpsExternalReviewTarget(review); - if (!target) { - return { - provider: 'azure-devops', - ok: false, - status: 'pending', - error: 'Azure DevOps review verification requires a pull request URL or org/project/repo#number id' - }; - } - - const spawnSync = options.spawnSync || childProcess.spawnSync; - const result = spawnSync('az', [ - 'repos', - 'pr', - 'show', - '--id', - target.pr, - '--organization', - target.organizationUrl, - '--project', - target.project, - '--repository', - target.repository, - '--output', - 'json' - ], { - encoding: 'utf8' - }); - - if (result.status !== 0) { - return { - provider: 'azure-devops', - ok: false, - status: 'pending', - organizationUrl: target.organizationUrl, - project: target.project, - repository: target.repository, - number: Number(target.pr), - error: (result.stderr || result.stdout || 'az repos pr show failed').trim() - }; - } - - let payload = {}; - try { - payload = JSON.parse(result.stdout || '{}'); - } catch (error) { - return { - provider: 'azure-devops', - ok: false, - status: 'pending', - organizationUrl: target.organizationUrl, - project: target.project, - repository: target.repository, - number: Number(target.pr), - error: `az repos pr show returned invalid JSON: ${error.message}` - }; - } - - const pullRequestStatus = String(payload.status || '').toLowerCase(); - const reviewers = Array.isArray(payload.reviewers) ? payload.reviewers : []; - const approvals = reviewers.filter(reviewer => Number(reviewer.vote || 0) >= 5).length; - const rejections = reviewers.filter(reviewer => Number(reviewer.vote || 0) <= -5).length; - const completed = pullRequestStatus === 'completed'; - const rejected = pullRequestStatus === 'abandoned' || rejections > 0; - const ok = completed || (approvals > 0 && !rejected); - - return { - provider: 'azure-devops', - ok, - status: ok ? 'approved' : (rejected ? 'rejected' : 'pending'), - organizationUrl: target.organizationUrl, - project: target.project, - repository: target.repository, - number: Number(payload.pullRequestId || target.pr), - url: payload.url || review.url || null, - pullRequestStatus: pullRequestStatus || null, - approvals, - rejections - }; -} - -function parseJiraExternalReviewTarget(review) { - const id = review.id || ''; - const idMatch = id.match(/[A-Z][A-Z0-9]+-\d+/); - if (idMatch) return { key: idMatch[0] }; - - const url = review.url || ''; - const urlMatch = url.match(/\/(?:browse|issues?)\/([A-Z][A-Z0-9]+-\d+)(?:[/?#].*)?$/); - if (urlMatch) { - return { - key: urlMatch[1], - baseUrl: safeUrlOrigin(url) - }; - } - - return null; -} - -function safeUrlOrigin(url) { - try { - return new URL(url).origin; - } catch { - return null; - } -} - -function jiraApprovedStatuses(options = {}) { - const configured = options.jiraApprovedStatuses || process.env.AGENTOPS_JIRA_APPROVED_STATUSES; - const statuses = configured - ? String(configured).split(',').map(status => status.trim().toLowerCase()).filter(Boolean) - : []; - return new Set(['approved', 'closed', 'done', 'resolved', ...statuses]); -} - -function jiraApiBaseUrl(review, target) { - if (target.baseUrl) return target.baseUrl; - if (review.url) return safeUrlOrigin(review.url); - if (process.env.JIRA_BASE_URL) return process.env.JIRA_BASE_URL.replace(/\/+$/, ''); - return null; -} - -function fetchExternalReviewJson(url, options = {}) { - if (options.fetchJson) return options.fetchJson(url, options); - - const headers = []; - if (process.env.JIRA_API_TOKEN && process.env.JIRA_EMAIL) { - const token = Buffer.from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64'); - headers.push('Authorization', `Basic ${token}`); - } else if (process.env.JIRA_API_TOKEN) { - headers.push('Authorization', `Bearer ${process.env.JIRA_API_TOKEN}`); - } - - const spawnSync = options.spawnSync || childProcess.spawnSync; - const args = ['-fsSL', '--max-time', '10']; - for (let index = 0; index < headers.length; index += 2) { - args.push('-H', `${headers[index]}: ${headers[index + 1]}`); - } - args.push(url); - - const result = spawnSync('curl', args, { encoding: 'utf8' }); - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || 'curl failed').trim()); - } - return JSON.parse(result.stdout || '{}'); -} - -function verifyJiraExternalReview(review, options = {}) { - const target = parseJiraExternalReviewTarget(review); - if (!target) { - return { - provider: 'jira', - ok: false, - status: 'pending', - error: 'Jira review verification requires an issue key or Jira issue URL' - }; - } - - const baseUrl = jiraApiBaseUrl(review, target); - if (!baseUrl) { - return { - provider: 'jira', - ok: false, - status: 'pending', - issueKey: target.key, - error: 'Jira review verification requires a Jira issue URL or JIRA_BASE_URL' - }; - } - - const url = `${baseUrl.replace(/\/+$/, '')}/rest/api/3/issue/${encodeURIComponent(target.key)}?fields=status`; - let payload = {}; - try { - payload = fetchExternalReviewJson(url, options); - } catch (error) { - return { - provider: 'jira', - ok: false, - status: 'pending', - issueKey: target.key, - url, - error: error.message - }; - } - - const issueStatus = payload.fields?.status || {}; - const statusName = String(issueStatus.name || '').toLowerCase(); - const categoryKey = String(issueStatus.statusCategory?.key || '').toLowerCase(); - const categoryName = String(issueStatus.statusCategory?.name || '').toLowerCase(); - const approved = categoryKey === 'done' || categoryName === 'done' || jiraApprovedStatuses(options).has(statusName); - const rejected = ['canceled', 'cancelled', 'declined', 'rejected'].some(value => statusName.includes(value)); - - return { - provider: 'jira', - ok: approved, - status: approved ? 'approved' : (rejected ? 'rejected' : 'pending'), - issueKey: payload.key || target.key, - url: review.url || `${baseUrl.replace(/\/+$/, '')}/browse/${encodeURIComponent(target.key)}`, - issueStatus: issueStatus.name || null, - statusCategory: issueStatus.statusCategory?.key || issueStatus.statusCategory?.name || null - }; -} - -function verifyBenchmarkExternalReview(review, options = {}) { - if (!review) return null; - const system = String(review.system || '').toLowerCase(); - if (system === 'github') return verifyGitHubExternalReview(review, options); - if (['azure-devops', 'azdo', 'ado'].includes(system)) return verifyAzureDevOpsExternalReview(review, options); - if (system === 'jira') return verifyJiraExternalReview(review, options); - { - return { - provider: system || 'unknown', - ok: false, - status: 'pending', - error: 'external review verification currently supports GitHub pull requests, Azure DevOps pull requests, and Jira issues only' - }; - } -} - -function validateBenchmarkPromotionApproval(approval, source = 'approval') { - if (approval === undefined || approval === null) return null; - if (!isPlainObject(approval)) { - throw new Error(`Invalid benchmark promotion approval ${source}: approval must be an object`); - } - - if (approval.approvedBy !== undefined && !isStringArray(approval.approvedBy)) { - throw new Error(`Invalid benchmark promotion approval ${source}: approvedBy must be an array of strings`); - } - const approvedBy = approval.approvedBy === undefined - ? [] - : [...new Set(approval.approvedBy.map(name => name.trim()).filter(Boolean))].sort(); - - const status = approval.status || (approvedBy.length > 0 ? 'approved' : 'pending'); - if (!['approved', 'pending', 'rejected'].includes(status)) { - throw new Error(`Invalid benchmark promotion approval ${source}: status must be approved, pending, or rejected`); - } - - if (approval.approvedAt !== undefined && typeof approval.approvedAt !== 'string') { - throw new Error(`Invalid benchmark promotion approval ${source}: approvedAt must be a string`); - } - if (approval.ticket !== undefined && typeof approval.ticket !== 'string') { - throw new Error(`Invalid benchmark promotion approval ${source}: ticket must be a string`); - } - if (approval.runId !== undefined && (typeof approval.runId !== 'string' || approval.runId.trim() === '')) { - throw new Error(`Invalid benchmark promotion approval ${source}: runId must be a non-empty string`); - } - - const externalReview = validateBenchmarkExternalReview(approval.externalReview, source); - - return { - status, - ...(approval.runId !== undefined ? { runId: approval.runId } : {}), - approvedBy, - approvedAt: approval.approvedAt || null, - ticket: approval.ticket || null, - ...(externalReview ? { externalReview } : {}), - source - }; -} - -function benchmarkPromotionApprovalFromOptions(options = {}) { - const verifyApproval = approval => { - if (!approval || !options.verifyExternalReview) return approval; - const verification = verifyBenchmarkExternalReview(approval.externalReview, options); - if (!verification) return approval; - return { - ...approval, - externalReview: { - ...approval.externalReview, - status: verification.status || approval.externalReview.status, - verification - } - }; - }; - - if (options.promotionApproval !== undefined) { - return verifyApproval(validateBenchmarkPromotionApproval(options.promotionApproval, 'options.promotionApproval')); - } - if (!options.approvalFile) return null; - return verifyApproval(validateBenchmarkPromotionApproval(readJson(path.resolve(options.approvalFile)), options.approvalFile)); -} - -function benchmarkApproval(options = {}) { - if (typeof options.runId !== 'string' || options.runId.trim() === '') { - throw new Error('benchmark approve requires a run id'); - } - const status = options.status || 'approved'; - const approvedAt = options.approvedAt || (status === 'approved' ? (options.now || new Date()).toISOString() : undefined); - const approval = validateBenchmarkPromotionApproval({ - runId: options.runId, - status, - approvedBy: options.approvedBy || [], - approvedAt, - ticket: options.ticket || undefined, - externalReview: options.externalReview - }, 'benchmark approve'); - - if (approval.status === 'approved' && approval.approvedBy.length === 0) { - throw new Error('benchmark approve requires at least one --by approver'); - } - - if (options.output) { - const outputPath = path.resolve(options.cwd || process.cwd(), options.output); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - const { source, ...approvalFile } = approval; - fs.writeFileSync(outputPath, `${JSON.stringify(approvalFile, null, 2)}\n`); - return { ...approval, output: outputPath }; - } - - return approval; -} - -function defaultBenchmarkSummaryDir() { - return process.env.AGENTOPS_BENCHMARK_RUNS_DIR || path.join(benchmarksDir, 'runs'); -} - -function benchmarkSummariesFromPayload(payload) { - if (Array.isArray(payload)) return payload; - if (Array.isArray(payload.summaries)) return payload.summaries; - if (Array.isArray(payload.runs)) return payload.runs; - if (Array.isArray(payload.results)) return payload.results; - if (payload && payload.runId) return [payload]; - return []; -} - -function benchmarkTaskBySummary(summary, options = {}) { - const suite = loadBenchmarkSuites(options.benchmarksDir || benchmarksDir).find(item => item.id === summary.suite); - return suite?.tasks.find(task => task.id === summary.taskId) || null; -} - -function benchmarkArtifactText(filePath) { - if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null; - if (fs.statSync(filePath).size > 64 * 1024) return null; - return fs.readFileSync(filePath, 'utf8'); -} - -function benchmarkUnifiedDiff(file, beforeText, afterText) { - if (beforeText === null && afterText === null) return []; - const beforeLines = beforeText === null ? [] : beforeText.replace(/\r\n/g, '\n').split('\n'); - const afterLines = afterText === null ? [] : afterText.replace(/\r\n/g, '\n').split('\n'); - return [ - `--- a/${file}`, - `+++ b/${file}`, - ...beforeLines.filter((line, index) => line !== afterLines[index]).map(line => `-${line}`), - ...afterLines.filter((line, index) => line !== beforeLines[index]).map(line => `+${line}`) - ]; -} - -function benchmarkArtifactReview(runId, summaries = null, options = {}) { - if (!runId) throw new Error('benchmark artifacts requires a run id'); - const runSummaries = (summaries || loadBenchmarkSummaries(runId, options)) - .filter(summary => summary.runId === runId) - .filter(summary => !options.taskId || summary.taskId === options.taskId) - .filter(summary => options.repeat === undefined || numberValue(summary.repeat) === options.repeat); - - const tasks = runSummaries.map(summary => { - const task = benchmarkTaskBySummary(summary, options); - const workspace = summary.workspace || null; - const diff = summary.artifactDiff || { added: [], modified: [], deleted: [], totalChanged: 0 }; - const files = [ - ...(Array.isArray(diff.added) ? diff.added.map(file => ({ file: normalizeBenchmarkRelativePath(file), status: 'added' })) : []), - ...(Array.isArray(diff.modified) ? diff.modified.map(file => ({ file: normalizeBenchmarkRelativePath(file), status: 'modified' })) : []), - ...(Array.isArray(diff.deleted) ? diff.deleted.map(file => ({ file: normalizeBenchmarkRelativePath(file), status: 'deleted' })) : []) - ].sort((left, right) => left.file.localeCompare(right.file)); - - return { - taskId: summary.taskId, - repeat: summary.repeat || null, - workspace, - fixture: task?.fixture || null, - files: files.map(entry => { - const beforePath = task?.fixturePath && entry.status !== 'added' ? safeBenchmarkPath(task.fixturePath, entry.file) : null; - const afterPath = workspace && entry.status !== 'deleted' ? safeBenchmarkPath(workspace, entry.file) : null; - const beforeText = options.includeContent ? benchmarkArtifactText(beforePath) : null; - const afterText = options.includeContent ? benchmarkArtifactText(afterPath) : null; - return { - ...entry, - beforeExists: Boolean(beforePath && fs.existsSync(beforePath)), - afterExists: Boolean(afterPath && fs.existsSync(afterPath)), - ...(options.includeContent ? { diff: benchmarkUnifiedDiff(entry.file, beforeText, afterText) } : {}) - }; - }) - }; - }); - - return { - runId, - taskCount: tasks.length, - includeContent: Boolean(options.includeContent), - tasks - }; -} - -function loadBenchmarkSummaries(runId, options = {}) { - if (!runId) throw new Error('benchmark report requires a run id'); - - const summariesDir = options.summariesDir || defaultBenchmarkSummaryDir(); - if (!fs.existsSync(summariesDir)) return []; - - const files = walk(summariesDir, file => file.endsWith('.json')); - const preferredNames = new Set([`${runId}.json`, `${runId}.summary.json`, `run-${runId}.json`]); - const preferredFiles = files.filter(file => preferredNames.has(path.basename(file))); - const searchFiles = preferredFiles.length > 0 ? preferredFiles : files; - const summaries = []; - - for (const file of searchFiles) { - summaries.push(...benchmarkSummariesFromPayload(readJson(file))); - } - - return summaries.filter(summary => summary.runId === runId); -} - -function benchmarkReport(runId, summaries = null, options = {}) { - if (!runId) throw new Error('benchmark report requires a run id'); - let runSummaries = (summaries || loadBenchmarkSummaries(runId)).filter(summary => summary.runId === runId); - let azureTelemetry = null; - const promotionApproval = benchmarkPromotionApprovalFromOptions(options); - if (promotionApproval?.runId && promotionApproval.runId !== runId) { - throw new Error(`benchmark approval file is for run ${promotionApproval.runId}, not ${runId}`); - } - - if (runSummaries.length === 0) { - if (options.azure) azureTelemetry = benchmarkAzureTelemetry(runId, options); - const missingReport = { - runId, - ok: false, - message: 'no benchmark summaries were found for this run' - }; - if (azureTelemetry) missingReport.azureTelemetry = azureTelemetry; - return missingReport; - } - - if (options.azure) { - const enriched = enrichBenchmarkSummariesWithAzure(runId, runSummaries, options); - runSummaries = enriched.summaries; - azureTelemetry = enriched.azureTelemetry; - } - - const policySummaries = runSummaries.map(applyBenchmarkToolPolicy); - const scoredSummaries = policySummaries.map(scoreBenchmarkSummary); - const passed = scoredSummaries.filter(summary => summary.success).length; - const inputTokens = scoredSummaries.reduce((total, summary) => total + numberValue(summary.inputTokens), 0); - const outputTokens = scoredSummaries.reduce((total, summary) => total + numberValue(summary.outputTokens), 0); - const semanticScores = scoredSummaries - .map(summary => summary.semanticScore) - .filter(score => score !== null && score !== undefined); - const report = { - runId, - suites: [...new Set(scoredSummaries.map(summary => summary.suite).filter(Boolean))].sort(), - variants: [...new Set(scoredSummaries.map(summary => summary.variant).filter(Boolean))].sort(), - hypotheses: [...new Set(scoredSummaries.map(summary => summary.hypothesis).filter(Boolean))].sort(), - startedAt: scoredSummaries.map(summary => summary.startedAt).filter(Boolean).sort()[0] || null, - taskCount: scoredSummaries.length, - passed, - failed: scoredSummaries.length - passed, - passRate: roundNumber(passed / scoredSummaries.length, 3), - passRatePct: roundNumber((passed / scoredSummaries.length) * 100), - averageScore: roundNumber(scoredSummaries.reduce((total, summary) => total + summary.score, 0) / scoredSummaries.length), - toolFailures: scoredSummaries.reduce((total, summary) => total + numberValue(summary.toolFailures), 0), - forbiddenFilesChanged: scoredSummaries.reduce((total, summary) => total + numberValue(summary.forbiddenFilesChanged), 0), - policyBlocks: scoredSummaries.reduce((total, summary) => total + numberValue(summary.policyBlocks), 0), - contentCaptureDetected: scoredSummaries.some(summary => summary.contentCaptureDetected === true), - permissionProfiles: benchmarkPermissionProfileSummary(scoredSummaries), - hiddenChecks: { - passed: scoredSummaries.reduce((total, summary) => total + numberValue(summary.hiddenChecksPassed), 0), - failed: scoredSummaries.reduce((total, summary) => total + numberValue(summary.hiddenChecksFailed), 0) - }, - semanticChecks: { - count: scoredSummaries.reduce((total, summary) => total + (Array.isArray(summary.semanticChecks) ? summary.semanticChecks.length : 0), 0), - averageScore: semanticScores.length > 0 - ? roundNumber(semanticScores.reduce((total, score) => total + numberValue(score), 0) / semanticScores.length) - : null - }, - safetyViolationCount: scoredSummaries.filter(summary => summary.safetyViolation).length, - inputTokens, - outputTokens, - totalTokens: inputTokens + outputTokens, - aiu: roundNumber(scoredSummaries.reduce((total, summary) => total + numberValue(summary.aiu), 0), 3), - cost: roundNumber(scoredSummaries.reduce((total, summary) => total + numberValue(summary.cost), 0), 4), - artifactDiff: benchmarkArtifactDiff(scoredSummaries), - topFailureCategories: topFailureCategories(scoredSummaries), - tasks: scoredSummaries.map(summary => ({ - taskId: summary.taskId, - hypothesis: summary.hypothesis || null, - permissionProfile: summary.permissionProfile || null, - osSandbox: summary.osSandbox || null, - osSandboxRuntime: summary.osSandboxRuntime || null, - toolPolicy: summary.toolPolicy || null, - toolPolicyEnforcement: summary.toolPolicyEnforcement || null, - success: Boolean(summary.success), - score: summary.score, - fixtureSealPack: summary.fixtureSealPack || null, - commandFileSeal: summary.commandFileSeal || null, - hiddenChecksPassed: numberValue(summary.hiddenChecksPassed), - hiddenChecksFailed: numberValue(summary.hiddenChecksFailed), - hiddenCheckPacks: summary.hiddenCheckPacks || [], - semanticScore: summary.semanticScore === undefined ? null : summary.semanticScore, - semanticChecks: summary.semanticChecks || [], - externalAnswerSources: summary.externalAnswerSources || [], - policyBlocks: numberValue(summary.policyBlocks), - toolPolicyViolations: summary.toolPolicyViolations || [], - safetyViolation: summary.safetyViolation, - errorCategory: summary.errorCategory || null, - telemetryMatched: Boolean(summary.telemetryMatched), - azureSpans: numberValue(summary.azureSpans), - artifactDiff: summary.artifactDiff || { added: [], modified: [], deleted: [], totalChanged: 0 }, - models: summary.models || [], - tools: summary.tools || [], - penalties: summary.penalties - })) - }; - - if (azureTelemetry) report.azureTelemetry = azureTelemetry; - report.antiCheat = benchmarkCheatSignals(scoredSummaries, azureTelemetry); - report.promotionGates = benchmarkPromotionGates(scoredSummaries); - report.promotionApproval = promotionApproval; - report.promotionGateFailures = benchmarkPromotionGateFailures(report); - report.recommendation = benchmarkRecommendation(report); - report.promotion = benchmarkPromotionSummary(report); - return report; -} - -function benchmarkPromotionSummary(report) { - const action = report.recommendation?.action || 'investigate'; - const decision = action === 'keep' ? 'promote' : action; - return { - decision, - evidence: { - runId: report.runId, - passRatePct: report.passRatePct, - averageScore: report.averageScore, - toolFailures: report.toolFailures, - safetyViolationCount: report.safetyViolationCount, - totalTokens: report.totalTokens, - cost: report.cost - }, - gates: report.promotionGates || null, - gateFailures: report.promotionGateFailures || [], - approval: report.promotionApproval || null, - validation: report.azureTelemetry?.ok === false - ? 'local benchmark summary only; rerun with --azure when live telemetry is required' - : 'benchmark summary includes local checks' + (report.azureTelemetry ? ' and Azure telemetry' : ''), - rollback: decision === 'promote' - ? 'revert the agent, skill, hook, or MCP change if pass rate drops, safety violations appear, or token/cost increases beyond the accepted budget' - : 'do not promote until failures, safety signals, and cost deltas are explained' - }; -} - -function compareTelemetryHarmWarnings(before, after, options = {}) { - if (!options.azure || before.azureTelemetry?.ok !== true || after.azureTelemetry?.ok !== true) return []; - const improved = after.passRate > before.passRate || after.averageScore > before.averageScore; - if (!improved) return []; - - const warnings = []; - const tokenDelta = after.totalTokens - before.totalTokens; - const costDelta = roundNumber(after.cost - before.cost, 4); - const tokenThreshold = Math.max(1000, numberValue(before.totalTokens) * 0.25); - const costThreshold = Math.max(0.1, numberValue(before.cost) * 0.25); - - if (tokenDelta > tokenThreshold) { - warnings.push('after run improved benchmark quality but live telemetry token use increased'); - } - if (costDelta > costThreshold) { - warnings.push('after run improved benchmark quality but live telemetry cost increased'); - } - if (after.toolFailures > before.toolFailures) { - warnings.push('after run improved benchmark quality but live telemetry tool failures increased'); - } - if (after.safetyViolationCount > before.safetyViolationCount) { - warnings.push('after run improved benchmark quality but live telemetry safety violations increased'); - } - return warnings; -} - -function compareRecommendation(comparison) { - if (comparison.safetyRegressionWarnings.length > 0) { - return { - action: 'reject', - message: 'reject: the after run introduces safety regressions.' - }; - } - if (comparison.afterPromotionGateFailures.length > 0) { - return { - action: 'reject', - message: 'reject: the after run misses candidate promotion gates.' - }; - } - if (comparison.passRateDelta < -0.05 || comparison.averageScoreDelta < -5) { - return { - action: 'reject', - message: 'reject: the after run is materially worse than the before run.' - }; - } - if (comparison.telemetryHarmWarnings.length > 0) { - return { - action: 'investigate', - message: 'investigate: benchmark quality improved, but live telemetry harm warnings need review.' - }; - } - if (comparison.passRateDelta > 0 || comparison.averageScoreDelta >= 2) { - return { - action: 'keep', - message: 'keep: the after run improves benchmark quality without safety regressions.' - }; - } - return { - action: 'investigate', - message: 'investigate: the before and after runs are close, so review details before deciding.' - }; -} - -function compareBenchmarkRuns(beforeRunId, afterRunId, summaries = null, options = {}) { - if (!beforeRunId || !afterRunId) throw new Error('benchmark compare requires before and after run ids'); - - const allSummaries = summaries || [ - ...loadBenchmarkSummaries(beforeRunId), - ...loadBenchmarkSummaries(afterRunId) - ]; - const before = benchmarkReport(beforeRunId, allSummaries, { ...options, approvalFile: null, promotionApproval: null }); - const after = benchmarkReport(afterRunId, allSummaries, options); - if (before.ok === false || after.ok === false) { - const missingComparison = { - ok: false, - beforeRunId, - afterRunId, - message: [ - before.ok === false ? `missing before run summaries for ${beforeRunId}` : null, - after.ok === false ? `missing after run summaries for ${afterRunId}` : null - ].filter(Boolean).join('; ') - }; - if (options.azure) { - missingComparison.azureTelemetry = { - before: before.azureTelemetry || null, - after: after.azureTelemetry || null - }; - } - return missingComparison; - } - const safetyRegressionWarnings = []; - - if (after.safetyViolationCount > before.safetyViolationCount) { - safetyRegressionWarnings.push('after run has more tasks with safety violations'); - } - if (after.forbiddenFilesChanged > before.forbiddenFilesChanged) { - safetyRegressionWarnings.push('after run changed more forbidden files'); - } - if (after.policyBlocks > before.policyBlocks) { - safetyRegressionWarnings.push('after run triggered more policy blocks'); - } - if (after.contentCaptureDetected && !before.contentCaptureDetected) { - safetyRegressionWarnings.push('after run detected content capture'); - } - const telemetryHarmWarnings = compareTelemetryHarmWarnings(before, after, options); - - const comparison = { - beforeRunId, - afterRunId, - before: { - passRate: before.passRate, - passRatePct: before.passRatePct, - averageScore: before.averageScore, - toolFailures: before.toolFailures, - totalTokens: before.totalTokens, - cost: before.cost, - promotionGateFailures: before.promotionGateFailures || [] - }, - after: { - passRate: after.passRate, - passRatePct: after.passRatePct, - averageScore: after.averageScore, - toolFailures: after.toolFailures, - totalTokens: after.totalTokens, - cost: after.cost, - promotionGates: after.promotionGates || null, - promotionGateFailures: after.promotionGateFailures || [] - }, - passRateDelta: roundNumber(after.passRate - before.passRate, 3), - averageScoreDelta: roundNumber(after.averageScore - before.averageScore), - toolFailuresDelta: after.toolFailures - before.toolFailures, - tokenDelta: after.totalTokens - before.totalTokens, - costDelta: roundNumber(after.cost - before.cost, 4), - safetyRegressionWarnings, - telemetryHarmWarnings, - afterPromotionGateFailures: after.promotionGateFailures || [], - topFailureCategories: after.topFailureCategories - }; - - if (options.azure) { - comparison.azureTelemetry = { - before: before.azureTelemetry || null, - after: after.azureTelemetry || null - }; - } - - comparison.recommendation = compareRecommendation(comparison); - comparison.promotion = { - decision: comparison.recommendation.action === 'keep' ? 'promote' : comparison.recommendation.action, - evidence: { - beforeRunId, - afterRunId, - passRateDelta: comparison.passRateDelta, - averageScoreDelta: comparison.averageScoreDelta, - toolFailuresDelta: comparison.toolFailuresDelta, - tokenDelta: comparison.tokenDelta, - costDelta: comparison.costDelta, - safetyRegressionWarnings: comparison.safetyRegressionWarnings, - telemetryHarmWarnings: comparison.telemetryHarmWarnings, - afterPromotionGateFailures: comparison.afterPromotionGateFailures - }, - rollback: 'revert the candidate if the benchmark or live telemetry later shows lower pass rate, new safety warnings, or unacceptable token/cost growth' - }; - return comparison; -} - -async function main(argv) { - const [command, ...args] = argv; - - if (!command || command === '--help' || command === '-h') { - process.stdout.write(usage()); - return; - } - - if (command === 'setup') { - const options = parseSetupArgs(args); - const result = agentopsSetupGuide(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderSetupGuide(result)); - process.exitCode = 0; - return; - } - - if (command === 'status') { - process.stdout.write(renderStatus()); - return; - } - - if (command === 'latest') { - process.stdout.write(renderLatest(latestSummaryFromArgs(args))); - return; - } - - if (command === 'live' || command === 'tail') { - const intervalIndex = args.indexOf('--interval'); - const intervalSec = intervalIndex === -1 ? 5 : Number(args[intervalIndex + 1]); - if (intervalIndex !== -1 && (!Number.isFinite(intervalSec) || intervalSec <= 0)) { - throw new Error('--interval must be a positive number of seconds'); - } - const follow = args.includes('--follow'); - do { - process.stdout.write(renderLive(liveViewFromArgs(args))); - if (!follow) return; - await sleep(intervalSec * 1000); - } while (follow); - return; - } - - if (command === 'replay') { - const sessionId = args[0]; - if (!sessionId) throw new Error('replay requires a session id or latest'); - const replayArgs = args.slice(1); - let source; - if (sessionId === 'latest' || optionValue(replayArgs, ['--file', '--jsonl'])) { - source = spanRowsFromSource(replayArgs, '7d'); - } else { - const last = validateKqlDuration(parseLastArg(replayArgs, '7d')); - const query = sessionQuery(sessionId, last); - const result = runAzureLogAnalyticsQuery(query); - source = { mode: 'azure', last, rows: result.ok ? result.rows : [], query, error: result.ok ? null : result.error }; - } - if (source.error) { - process.stdout.write(`Session replay: ${sessionId}\n\nCould not read telemetry: ${source.error}\n`); - return; - } - process.stdout.write(renderReplay(replayTimeline(source.rows, { sessionId, source: source.mode }))); - return; - } - - if (command === 'explain') { - if (args[0] !== 'latest') throw new Error('explain currently supports: explain latest'); - const summary = latestSummaryFromArgs(args.slice(1)); - process.stdout.write(renderExplanation(explainLatest(summary))); - return; - } - - if (command === 'recommend') { - if (args[0] !== 'latest') throw new Error('recommend currently supports: recommend latest'); - const recommendArgs = args.slice(1); - const summary = latestSummaryFromArgs(recommendArgs); - const last = parseLastArg(recommendArgs, '7d'); - process.stdout.write(renderRecommendation(recommendationForExplanation(explainLatest(summary), { last }))); - return; - } - - if (command === 'open') { - const summary = latestSummaryFromArgs(args); - process.stdout.write(renderOpenLinks(openLinksSummary(summary))); - return; - } - - if (command === 'workflows') { - const options = parseWorkflowsArgs(args); - const workflows = agentopsWorkflows(); - if (options.subcommand === 'list') { - process.stdout.write(options.json ? JSON.stringify({ workflows }, null, 2) + '\n' : renderWorkflowsList(workflows)); - return; - } - if (options.subcommand === 'show') { - if (!options.name) throw new Error('workflows show requires a workflow name'); - const workflow = workflows.find(item => item.name === options.name); - if (!workflow) throw new Error(`Unknown workflow: ${options.name}`); - process.stdout.write(options.json ? JSON.stringify(workflow, null, 2) + '\n' : renderWorkflow(workflow)); - return; - } - throw new Error('workflows requires list or show'); - } - - if (command === 'plugin') { - const options = parseSkillsArgs(args); - if (options.subcommand === 'install') { - const result = installPlugin(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderPluginInstall(result)); - return; - } - if (options.subcommand === 'uninstall' || options.subcommand === 'remove') { - const result = uninstallPlugin(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderPluginUninstall(result)); - return; - } - throw new Error('plugin requires install or uninstall'); - } - - if (command === 'agents') { - const options = parseSkillsArgs(args); - if (options.subcommand === 'list') { - process.stdout.write(JSON.stringify({ agents: listDefaultAgents() }, null, 2) + '\n'); - return; - } - if (options.subcommand === 'path') { - process.stdout.write(`${agentInstallTarget(options).targetDir}\n`); - return; - } - if (options.subcommand === 'install') { - const result = installDefaultAgents(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderAgentsInstall(result)); - return; - } - if (options.subcommand === 'uninstall' || options.subcommand === 'remove') { - const result = uninstallDefaultAgents(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderAgentsUninstall(result)); - return; - } - throw new Error('agents requires list, path, install, or uninstall'); - } - - if (command === 'skills') { - const options = parseSkillsArgs(args); - if (options.subcommand === 'list') { - process.stdout.write(JSON.stringify({ skills: listDefaultSkills() }, null, 2) + '\n'); - return; - } - if (options.subcommand === 'path') { - process.stdout.write(`${skillInstallTarget(options).targetDir}\n`); - return; - } - if (options.subcommand === 'install') { - const result = installDefaultSkills(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderSkillsInstall(result)); - return; - } - if (options.subcommand === 'uninstall' || options.subcommand === 'remove') { - const result = uninstallDefaultSkills(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderSkillsUninstall(result)); - return; - } - throw new Error('skills requires list, path, install, or uninstall'); - } - - if (command === 'scan') { - process.stdout.write(JSON.stringify(scan(), null, 2) + '\n'); - return; - } - - if (command === 'primitives') { - process.stdout.write(JSON.stringify(copilotPrimitivesInventory(args), null, 2) + '\n'); - return; - } - - if (command === 'doctor') { - const checks = doctor({ localOnly: args.includes('--local-only') }); - process.stdout.write(JSON.stringify({ checks, ok: checks.every(check => check.ok) }, null, 2) + '\n'); - process.exitCode = checks.every(check => check.ok) ? 0 : 1; - return; - } - - if (command === 'import-jsonl') { - const filePath = args[0]; - if (!filePath) throw new Error('import-jsonl requires a file path'); - process.stdout.write(JSON.stringify(importJsonl(path.resolve(filePath)), null, 2) + '\n'); - return; - } - - if (command === 'custom') { - const options = parseCustomArgs(args); - if (options.subcommand === 'emit') { - const result = await agentopsCustomEmit(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderCustom(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - if (options.subcommand === 'import') { - if (!options.file) throw new Error('custom import requires a file path'); - const result = await agentopsCustomImport(path.resolve(options.file), options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderCustom(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - throw new Error('custom requires emit or import'); - } - - if (command === 'annotation' || command === 'annotate') { - const options = parseAnnotationArgs(args); - if (options.subcommand === 'config-change') { - const result = await agentopsAnnotationConfigChange(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderCustom(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - throw new Error('annotation requires config-change'); - } - - if (command === 'configure' || command === 'config') { - const options = parseConfigureArgs(args); - const result = agentopsConfigure(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderConfigure(result)); - process.exitCode = result.ok === false ? 1 : 0; - return; - } - - if (command === 'otel-setup') { - const options = parseOtelSetupArgs(args); - const result = buildOtelSetup(options); - process.stdout.write(renderOtelSetup(result, options)); - return; - } - - if (command === 'compat-check') { - const last = parseLastArg(args, '2h'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: otelCompatibilityQuery(last) }, null, 2) + '\n'); - return; - } - - if (command === 'validate-collector') { - process.stdout.write(JSON.stringify(await validateCollector(args[0]), null, 2) + '\n'); - return; - } - - if (command === 'validate-azure') { - const last = parseLastArg(args, '2h'); - const result = validateAzure({ - last, - importDashboards: args.includes('--import-dashboards'), - production: args.includes('--production'), - remediationPlan: args.includes('--remediation-plan') - }); - process.stdout.write(args.includes('--json') ? JSON.stringify(result, null, 2) + '\n' : renderValidateAzure(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - - if (command === 'validate-enterprise') { - const result = validateEnterprise(); - process.stdout.write(args.includes('--json') ? JSON.stringify(result, null, 2) + '\n' : renderValidateEnterprise(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - - if (command === 'init') { - const options = parseInitArgs(args); - const result = agentopsInit(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderInit(result)); - process.exitCode = 0; - return; - } - - if (command === 'smoke') { - const options = parseSmokeArgs(args); - const result = await agentopsSmoke(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderSmoke(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - - if (command === 'attribution-smoke') { - const options = parseSmokeArgs(args); - const result = await agentopsAttributionSmoke(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderSmoke(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - - if (command === 'live-replay-smoke') { - const options = parseSmokeArgs(args); - const result = await agentopsLiveReplaySmoke(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderSmoke(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - - if (command === 'ask-context') { - const options = parseAskContextArgs(args); - const result = askAgentOpsContext(options); - process.stdout.write(options.json ? JSON.stringify(result, null, 2) + '\n' : renderAskContext(result)); - process.exitCode = result.ok ? 0 : 1; - return; - } - - if (['install', 'enable-shadow', 'disable-shadow', 'uninstall', 'collector', 'start', 'stop', 'copilot', 'codex'].includes(command)) { - runPlannedCommand(commandPlan(command, args)); - return; - } - - if (command === 'saved-view') { - process.stdout.write(JSON.stringify(savedViewCommand(parseSavedViewArgs(args)), null, 2) + '\n'); - return; - } - - if (command === 'benchmark') { - const [subcommand, ...benchmarkArgs] = args; - if (subcommand === 'list') { - process.stdout.write(JSON.stringify(listBenchmarks(), null, 2) + '\n'); - return; - } - - if (subcommand === 'fixture-pack') { - const options = parseBenchmarkFixturePackArgs(benchmarkArgs); - process.stdout.write(JSON.stringify(benchmarkFixturePack(options), null, 2) + '\n'); - return; - } - - if (subcommand === 'judge-provider') { - const guide = benchmarkJudgeProviderGuide(); - process.stdout.write(benchmarkArgs.includes('--json') - ? JSON.stringify(guide, null, 2) + '\n' - : renderBenchmarkJudgeProviderGuide(guide)); - return; - } - - if (subcommand === 'approve') { - const options = parseBenchmarkApproveArgs(benchmarkArgs); - process.stdout.write(JSON.stringify(benchmarkApproval(options), null, 2) + '\n'); - return; - } - - if (subcommand === 'artifacts') { - const options = parseBenchmarkArtifactsArgs(benchmarkArgs); - process.stdout.write(JSON.stringify(benchmarkArtifactReview(options.runId, null, options), null, 2) + '\n'); - return; - } - - if (subcommand === 'run') { - const options = parseBenchmarkRunArgs(benchmarkArgs); - process.stdout.write(JSON.stringify(runBenchmarkSuite(options.suite, options), null, 2) + '\n'); - return; - } - - if (subcommand === 'report') { - const options = parseBenchmarkReportArgs(benchmarkArgs); - process.stdout.write(JSON.stringify(benchmarkReport(options.runId, null, options), null, 2) + '\n'); - return; - } - - if (subcommand === 'compare') { - const options = parseBenchmarkCompareArgs(benchmarkArgs); - process.stdout.write(JSON.stringify(compareBenchmarkRuns(options.beforeRunId, options.afterRunId, null, options), null, 2) + '\n'); - return; - } - - throw new Error('benchmark requires list, fixture-pack, judge-provider, approve, artifacts, run, report, or compare'); - } - - if (command === 'link') { - const [kind, id, ...linkArgs] = args; - if (!kind || !id) throw new Error('link requires a kind and id, for example: link session <conversation>'); - const last = parseLastArg(linkArgs, '24h'); - process.stdout.write(JSON.stringify(buildLink(kind, id, { last }), null, 2) + '\n'); - return; - } - - if (command === 'fields') { - const last = parseLastArg(args, '7d'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: fieldCatalogQuery(last) }, null, 2) + '\n'); - return; - } - - if (command === 'context') { - const last = parseLastArg(args, '7d'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: contextPressureQuery(last) }, null, 2) + '\n'); - return; - } - - if (command === 'token-rollup-audit') { - const last = parseLastArg(args, '7d'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: tokenRollupAuditQuery(last) }, null, 2) + '\n'); - return; - } - - if (command === 'collector-health') { - const last = parseLastArg(args, '24h'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: collectorHealthQuery(last) }, null, 2) + '\n'); - return; - } - - if (command === 'attribution') { - const last = parseLastArg(args, '7d'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: attributionUsageQuery(last) }, null, 2) + '\n'); - return; - } - - if (command === 'permission-friction') { - const last = parseLastArg(args, '7d'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: kqlFileQuery('17-permission-friction.kql', last) }, null, 2) + '\n'); - return; - } - - if (command === 'alert') { - const [subcommand, ...alertArgs] = args; - if (subcommand === 'recommend') { - const last = parseLastArg(alertArgs, '14d'); - process.stdout.write(JSON.stringify(alertRecommendations(last), null, 2) + '\n'); - return; - } - if (subcommand === 'tune-plan') { - const last = parseLastArg(alertArgs, '14d'); - const rule = optionValue(alertArgs, ['--rule']); - const owner = optionValue(alertArgs, ['--owner']); - const output = optionValue(alertArgs, ['--output', '--out']); - const plan = alertTunePlan({ last, rule, owner }); - if (output) { - const outputPath = path.resolve(process.cwd(), output); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, `${JSON.stringify(plan, null, 2)}\n`); - process.stdout.write(JSON.stringify({ output: outputPath, plan }, null, 2) + '\n'); - return; - } - process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); - return; - } - if (subcommand === 'threshold-simulate') { - const last = parseLastArg(alertArgs, '14d'); - const rule = optionValue(alertArgs, ['--rule']); - const threshold = optionValue(alertArgs, ['--threshold']); - const owner = optionValue(alertArgs, ['--owner']); - process.stdout.write(JSON.stringify(alertThresholdSimulation({ rule, threshold, owner, last }), null, 2) + '\n'); - return; - } - if (subcommand === 'threshold-patch') { - const last = parseLastArg(alertArgs, '14d'); - const rule = optionValue(alertArgs, ['--rule']); - const threshold = optionValue(alertArgs, ['--threshold']); - const owner = optionValue(alertArgs, ['--owner']); - process.stdout.write(JSON.stringify(alertThresholdPatch({ rule, threshold, owner, last }), null, 2) + '\n'); - return; - } - if (subcommand === 'policy') { - const owners = optionValues(alertArgs, '--owner'); - const service = optionValue(alertArgs, ['--service']) || 'agentops'; - const timezone = optionValue(alertArgs, ['--timezone', '--tz']) || 'UTC'; - process.stdout.write(JSON.stringify(alertPolicy({ owners, service, timezone }), null, 2) + '\n'); - return; - } - if (subcommand === 'resources') { - const cloud = configuredCloudValues(); - const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || cloud.resourceGroup; - if (!resourceGroup) throw new Error('alert resources requires --resource-group <name> or AGENTOPS_AZURE_RESOURCE_GROUP'); - if (!azAvailable()) { - process.stdout.write(JSON.stringify(alertResourceState({ - resourceGroup, - error: 'Azure CLI was not found on PATH.' - }), null, 2) + '\n'); - return; - } - const alertResult = runAz(['monitor', 'scheduled-query', 'list', '--resource-group', resourceGroup, '-o', 'json']); - const resources = alertResult.status === 0 ? agentOpsScheduledQueryRules(parseJsonOutput(alertResult)) : []; - process.stdout.write(JSON.stringify(alertResourceState({ - resourceGroup, - resources, - error: alertResult.status === 0 ? null : azErrorDetail(alertResult, 'could not list scheduled query rules') - }), null, 2) + '\n'); - return; - } - if (subcommand === 'action-plan') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const last = parseLastArg(alertArgs, '24h'); - process.stdout.write(JSON.stringify(alertActionPlan({ rule, session, last }), null, 2) + '\n'); - return; - } - if (subcommand === 'history') { - const rule = optionValue(alertArgs, ['--rule']); - const last = parseLastArg(alertArgs, '24h'); - process.stdout.write(JSON.stringify(alertHistory({ rule, last }), null, 2) + '\n'); - return; - } - if (subcommand === 'detail') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const last = parseLastArg(alertArgs, '24h'); - process.stdout.write(JSON.stringify(alertDetail({ rule, session, last }), null, 2) + '\n'); - return; - } - if (subcommand === 'open') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const last = parseLastArg(alertArgs, '24h'); - process.stdout.write(JSON.stringify(alertOpenRun({ rule, session, last }), null, 2) + '\n'); - return; - } - if (subcommand === 'review') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const owners = optionValues(alertArgs, '--owner'); - const last = parseLastArg(alertArgs, '24h'); - process.stdout.write(JSON.stringify(alertReview({ rule, session, owners, last }), null, 2) + '\n'); - return; - } - if (subcommand === 'export') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const output = optionValue(alertArgs, ['--output', '--out']); - if (!output) throw new Error('alert export requires --output <json>'); - const last = parseLastArg(alertArgs, '24h'); - const artifact = alertArtifact({ rule, session, last }); - const outputPath = path.resolve(process.cwd(), output); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, `${JSON.stringify(artifact, null, 2)}\n`); - process.stdout.write(JSON.stringify({ output: outputPath, artifact }, null, 2) + '\n'); - return; - } - if (subcommand === 'handoff') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const owners = optionValues(alertArgs, '--owner'); - const service = optionValue(alertArgs, ['--service']) || 'agentops'; - const timezone = optionValue(alertArgs, ['--timezone', '--tz']) || 'UTC'; - const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup; - const output = optionValue(alertArgs, ['--output', '--out']); - const eventsFile = optionValue(alertArgs, ['--events']); - const events = eventsFile ? readJsonlRows(path.resolve(process.cwd(), eventsFile)) : []; - const last = parseLastArg(alertArgs, '24h'); - const handoff = alertHandoff({ rule, session, last, owners, service, timezone, resourceGroup, events }); - if (output) { - const outputPath = path.resolve(process.cwd(), output); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, `${JSON.stringify(handoff, null, 2)}\n`); - process.stdout.write(JSON.stringify({ output: outputPath, handoff }, null, 2) + '\n'); - return; - } - process.stdout.write(JSON.stringify(handoff, null, 2) + '\n'); - return; - } - if (subcommand === 'route-plan') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const owners = optionValues(alertArgs, '--owner'); - const targets = optionValues(alertArgs, '--target'); - const service = optionValue(alertArgs, ['--service']) || 'agentops'; - const timezone = optionValue(alertArgs, ['--timezone', '--tz']) || 'UTC'; - const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup; - const output = optionValue(alertArgs, ['--output', '--out']); - const eventsFile = optionValue(alertArgs, ['--events']); - const events = eventsFile ? readJsonlRows(path.resolve(process.cwd(), eventsFile)) : []; - const last = parseLastArg(alertArgs, '24h'); - const plan = alertRoutePlan({ rule, session, last, owners, service, timezone, targets, resourceGroup, events }); - if (output) { - const outputPath = path.resolve(process.cwd(), output); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, `${JSON.stringify(plan, null, 2)}\n`); - process.stdout.write(JSON.stringify({ output: outputPath, plan }, null, 2) + '\n'); - return; - } - process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); - return; - } - if (subcommand === 'route-github') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const owners = optionValues(alertArgs, '--owner'); - const service = optionValue(alertArgs, ['--service']) || 'agentops'; - const timezone = optionValue(alertArgs, ['--timezone', '--tz']) || 'UTC'; - const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup; - const repo = optionValue(alertArgs, ['--repo']); - const last = parseLastArg(alertArgs, '24h'); - const yes = alertArgs.includes('--yes'); - process.stdout.write(JSON.stringify(alertGithubIssueRoute({ - rule, - session, - last, - owners, - service, - timezone, - repo, - yes, - resourceGroup - }), null, 2) + '\n'); - return; - } - if (subcommand === 'route-azure-devops') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const owners = optionValues(alertArgs, '--owner'); - const service = optionValue(alertArgs, ['--service']) || 'agentops'; - const timezone = optionValue(alertArgs, ['--timezone', '--tz']) || 'UTC'; - const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup; - const org = optionValue(alertArgs, ['--org', '--organization']); - const project = optionValue(alertArgs, ['--project']); - const workItemType = optionValue(alertArgs, ['--type', '--work-item-type']) || 'Issue'; - const last = parseLastArg(alertArgs, '24h'); - const yes = alertArgs.includes('--yes'); - process.stdout.write(JSON.stringify(alertAzureDevOpsWorkItemRoute({ - rule, - session, - last, - owners, - service, - timezone, - org, - project, - workItemType, - yes, - resourceGroup - }), null, 2) + '\n'); - return; - } - if (subcommand === 'action-group-plan') { - const owners = optionValues(alertArgs, '--owner'); - const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup; - const name = optionValue(alertArgs, ['--name']); - const shortName = optionValue(alertArgs, ['--short-name']); - const emails = optionValues(alertArgs, '--email'); - const webhooks = optionValues(alertArgs, '--webhook'); - const location = optionValue(alertArgs, ['--location']) || 'global'; - process.stdout.write(JSON.stringify(alertActionGroupPlan({ - resourceGroup, - name, - shortName, - owners, - emails, - webhooks, - location - }), null, 2) + '\n'); - return; - } - if (subcommand === 'route-action-group') { - const rule = optionValue(alertArgs, ['--rule']); - const session = optionValue(alertArgs, ['--session', '--conversation']); - const owners = optionValues(alertArgs, '--owner'); - const service = optionValue(alertArgs, ['--service']) || 'agentops'; - const timezone = optionValue(alertArgs, ['--timezone', '--tz']) || 'UTC'; - const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup; - const scheduledQuery = optionValue(alertArgs, ['--scheduled-query', '--scheduled-query-rule', '--alert-rule']); - const actionGroups = optionValues(alertArgs, '--action-group'); - const last = parseLastArg(alertArgs, '24h'); - const yes = alertArgs.includes('--yes'); - const enableAlert = alertArgs.includes('--enable-alert'); - process.stdout.write(JSON.stringify(alertActionGroupRoute({ - rule, - session, - last, - owners, - service, - timezone, - resourceGroup, - scheduledQuery, - actionGroups, - enableAlert, - yes - }), null, 2) + '\n'); - return; - } - throw new Error('alert currently supports: alert recommend, alert tune-plan, alert threshold-simulate, alert threshold-patch, alert policy, alert resources, alert history, alert detail, alert open, alert review, alert action-plan, alert export, alert handoff, alert route-plan, alert route-github, alert route-azure-devops, alert action-group-plan, alert route-action-group'); - } - - if (command === 'incident') { - const [subcommand, ...incidentArgs] = args; - if (subcommand === 'timeline') { - const artifactPaths = optionValues(incidentArgs, '--artifact'); - const output = optionValue(incidentArgs, ['--output', '--out']); - const incidentId = optionValue(incidentArgs, ['--incident', '--incident-id']); - if (artifactPaths.length === 0) throw new Error('incident timeline requires --artifact <json>'); - if (!output) throw new Error('incident timeline requires --output <json>'); - const artifacts = artifactPaths.map(artifactPath => JSON.parse(fs.readFileSync(path.resolve(process.cwd(), artifactPath), 'utf8'))); - const timeline = alertIncidentTimeline({ artifacts, incidentId }); - const outputPath = path.resolve(process.cwd(), output); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, `${JSON.stringify(timeline, null, 2)}\n`); - process.stdout.write(JSON.stringify({ output: outputPath, timeline }, null, 2) + '\n'); - return; - } - throw new Error('incident currently supports: incident timeline'); - } - - if (command === 'lineage') { - const last = parseLastArg(args, '24h'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: kqlFileQuery('19-agent-flow-lineage.kql', last) }, null, 2) + '\n'); - return; - } - - if (command === 'policy') { - const last = parseLastArg(args, '7d'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: kqlFileQuery('15-policy-governance.kql', last) }, null, 2) + '\n'); - return; - } - - if (command === 'mcp') { - const last = parseLastArg(args, '7d'); - process.stdout.write(JSON.stringify({ workspace_id: workspaceId, query: kqlFileQuery('16-mcp-tool-usage.kql', last) }, null, 2) + '\n'); - return; - } - - throw new Error(`Unknown command: ${command}`); -} +const runtime = require('./lib/legacy-runtime'); if (require.main === module) { - main(process.argv.slice(2)).catch(error => { + runtime.main(process.argv.slice(2)).catch(error => { process.stderr.write(`${error.message}\n`); process.exit(1); }); } -module.exports = { - main, - agentopsAttributionSmoke, - agentopsInit, - agentopsConfigure, - agentopsSetupGuide, - agentopsSmoke, - agentopsLiveReplaySmoke, - agentopsStatusSummary, - agentopsWorkflows, - alertRecommendationQuery, - alertRecommendations, - alertTunePlan, - alertThresholdSimulation, - alertThresholdPatch, - alertResourceState, - alertPolicy, - alertHistoryQuery, - alertHistory, - alertDetail, - alertOpenRun, - alertReview, - alertActionPlan, - alertArtifact, - alertActionGroupPlan, - alertActionGroupRoute, - alertIncidentTimeline, - alertAzureDevOpsWorkItemRoute, - alertHandoff, - alertGithubIssueRoute, - alertRoutePlan, - askAgentOpsContext, - attributionUsageQuery, - benchmarkCheatSignals, - benchmarkAzureTelemetry, - benchmarkAzureTelemetryQuery, - benchmarkApproval, - benchmarkArtifactReview, - benchmarkFixturePack, - benchmarkJudgeProviderGuide, - benchmarkReport, - benchmarkRunBaseDir, - benchmarkRunPlan, - buildOtelSetup, - buildLink, - commandPlan, - compareBenchmarkRuns, - collectorHealthQuery, - compactConfig, - contextPressureQuery, - copilotPrimitivesInventory, - agentopsCustomEmit, - agentopsCustomImport, - agentopsAnnotationConfigChange, - customAzureQuery, - customEventAttributes, - customEventId, - doctor, - durationToMs, - explainLatest, - fieldCatalogQuery, - configFromEnvValues, - importJsonl, - installedShimStatus, - agentInstallTarget, - attributionSmokeId, - liveReplaySmokeId, - installDefaultAgents, - installDefaultSkills, - installPlugin, - kqlFileQuery, - latestAzureSessionSummary, - latestSessionAzureQuery, - latestSessionSummary, - latestSummaryFromArgs, - listGrafanaDashboardFiles, - listDefaultAgents, - listDefaultSkills, - listBenchmarks, - loadBenchmarkSummaries, - loadBenchmarkSuites, - liveViewFromArgs, - openLinksSummary, - otlpAttributionSmokeTracePayload, - otlpCustomEventPayload, - otlpLiveReplaySmokeTracePayload, - parseBenchmarkCompareArgs, - parseBenchmarkApproveArgs, - parseBenchmarkArtifactsArgs, - parseBenchmarkFixturePackArgs, - parseBenchmarkReportArgs, - parseBenchmarkRunArgs, - parseConfigureArgs, - parseConfigureSetArgs, - parseCustomArgs, - parseAnnotationArgs, - parseEnvAssignments, - parseOtelSetupArgs, - parseFrontmatter, - parseSavedViewArgs, - parseSetupArgs, - parseSmokeArgs, - replayTimeline, - renderExplanation, - renderAskContext, - renderConfigure, - renderCustom, - renderInit, - renderLatest, - renderLive, - renderOpenLinks, - renderOtelSetup, - renderRecommendation, - renderReplay, - renderSetupGuide, - renderSmoke, - renderAgentsInstall, - renderAgentsUninstall, - renderBenchmarkJudgeProviderGuide, - renderPluginInstall, - renderPluginUninstall, - renderSkillsInstall, - renderSkillsUninstall, - renderStatus, - renderValidateEnterprise, - renderValidateAzure, - renderWorkflow, - renderWorkflowsList, - recommendationForExplanation, - readAgentOpsConfig, - readJsonlRows, - readSavedViews, - runAzureLogAnalyticsQuery, - runBenchmarkSuite, - savedViewCommand, - scan, - sessionQuery, - spanRowsFromSource, - skillInstallTarget, - otelCompatibilityQuery, - tokenRollupAuditQuery, - enrichBenchmarkSummariesWithAzure, - traceQuery, - validateEnterprise, - validateAzure, - validateKqlDuration, - validateBenchmarkTask, - validateCollector, - verifySmokeInAzure, - uninstallDefaultAgents, - uninstallDefaultSkills, - uninstallPlugin, - writeAgentOpsConfig -}; +module.exports = runtime; diff --git a/agentops-cli/src/lib/agentops-config.js b/agentops-cli/src/lib/agentops-config.js new file mode 100644 index 0000000..da42f80 --- /dev/null +++ b/agentops-cli/src/lib/agentops-config.js @@ -0,0 +1,226 @@ +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { writeJsonFile } = require('./command-output'); +const { readJson } = require('./json'); +const { defaultUserAgentOpsPath } = require('./paths'); + +const defaultConfigPath = process.env.AGENTOPS_CONFIG_PATH || defaultUserAgentOpsPath('config.json'); + +function normalizeAgentOpsConfig(raw = {}) { + return { + subscriptionId: raw.subscriptionId || raw.azureSubscriptionId || raw.AZURE_SUBSCRIPTION_ID || raw.AGENTOPS_AZURE_SUBSCRIPTION_ID || '', + resourceGroup: raw.resourceGroup || raw.azureResourceGroup || raw.AZURE_RESOURCE_GROUP || raw.AGENTOPS_AZURE_RESOURCE_GROUP || '', + workspaceId: raw.workspaceId || raw.logAnalyticsWorkspaceId || raw.LOG_ANALYTICS_WORKSPACE_ID || raw.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID || '', + workspaceName: raw.workspaceName || raw.logAnalyticsWorkspaceName || raw.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME || '', + grafanaBaseUrl: (raw.grafanaBaseUrl || raw.grafanaUrl || raw.AGENTOPS_GRAFANA_BASE_URL || '').replace(/\/$/, ''), + grafanaName: raw.grafanaName || raw.GRAFANA_NAME || raw.AGENTOPS_GRAFANA_NAME || '', + grafanaDatasourceUid: raw.grafanaDatasourceUid || raw.datasourceUid || raw.AGENTOPS_GRAFANA_DATASOURCE_UID || '', + appInsightsName: raw.appInsightsName || raw.applicationInsightsName || raw.APPLICATIONINSIGHTS_NAME || raw.AGENTOPS_APPLICATIONINSIGHTS_NAME || '', + agentsViewUrl: raw.agentsViewUrl || raw.azureAgentsUrl || raw.AGENTOPS_AZURE_AGENTS_URL || '', + logsIngestionEndpoint: raw.logsIngestionEndpoint || raw.AGENTOPS_LOGS_INGESTION_ENDPOINT || '', + dcrImmutableId: raw.dcrImmutableId || raw.AGENTOPS_DCR_IMMUTABLE_ID || '', + portalLogsUrl: raw.portalLogsUrl || raw.AGENTOPS_AZURE_PORTAL_LOGS_URL || '' + }; +} + +function compactConfig(config) { + return Object.fromEntries(Object.entries(normalizeAgentOpsConfig(config)).filter(([, value]) => value !== undefined && value !== null && value !== '')); +} + +function readAgentOpsConfig(options = {}) { + const configPath = options.configPath || defaultConfigPath; + if (!fs.existsSync(configPath)) { + return { path: configPath, exists: false, values: {} }; + } + + try { + return { + path: configPath, + exists: true, + values: compactConfig(readJson(configPath)) + }; + } catch (error) { + if (options.quiet) return { path: configPath, exists: true, values: {}, error: error.message }; + throw new Error(`Could not read AgentOps config at ${configPath}: ${error.message}`); + } +} + +function writeAgentOpsConfig(values, options = {}) { + const configPath = options.configPath || defaultConfigPath; + const existing = readAgentOpsConfig({ configPath, quiet: true }).values; + const next = compactConfig({ ...existing, ...values }); + if (!options.dryRun) { + writeJsonFile(configPath, next); + } + return { path: configPath, exists: true, values: next, dryRun: Boolean(options.dryRun) }; +} + +function parseEnvAssignments(text) { + const values = {}; + for (const line of String(text || '').split(/\r?\n/)) { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + values[match[1]] = value.replace(/\\"/g, '"'); + } + return values; +} + +function configFromEnvValues(values = {}) { + return compactConfig({ + subscriptionId: values.AGENTOPS_AZURE_SUBSCRIPTION_ID || values.AZURE_SUBSCRIPTION_ID, + resourceGroup: values.AGENTOPS_AZURE_RESOURCE_GROUP || values.AZURE_RESOURCE_GROUP, + workspaceId: values.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID || values.LOG_ANALYTICS_WORKSPACE_ID, + workspaceName: values.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME || values.LOG_ANALYTICS_WORKSPACE_NAME, + grafanaBaseUrl: values.AGENTOPS_GRAFANA_BASE_URL || values.GRAFANA_ENDPOINT, + grafanaName: values.AGENTOPS_GRAFANA_NAME || values.GRAFANA_NAME, + grafanaDatasourceUid: values.AGENTOPS_GRAFANA_DATASOURCE_UID, + appInsightsName: values.AGENTOPS_APPLICATIONINSIGHTS_NAME || values.APPLICATIONINSIGHTS_NAME, + agentsViewUrl: values.AGENTOPS_AZURE_AGENTS_URL, + logsIngestionEndpoint: values.AGENTOPS_LOGS_INGESTION_ENDPOINT, + dcrImmutableId: values.AGENTOPS_DCR_IMMUTABLE_ID, + portalLogsUrl: values.AGENTOPS_AZURE_PORTAL_LOGS_URL + }); +} + +function parseConfigureSetArgs(args) { + const map = { + '--subscription-id': 'subscriptionId', + '--resource-group': 'resourceGroup', + '--workspace-id': 'workspaceId', + '--workspace-name': 'workspaceName', + '--grafana-url': 'grafanaBaseUrl', + '--grafana-name': 'grafanaName', + '--datasource-uid': 'grafanaDatasourceUid', + '--app-insights-name': 'appInsightsName', + '--agents-url': 'agentsViewUrl', + '--logs-ingestion-endpoint': 'logsIngestionEndpoint', + '--dcr-immutable-id': 'dcrImmutableId', + '--portal-logs-url': 'portalLogsUrl' + }; + const values = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--json' || arg === '--dry-run') continue; + const key = map[arg]; + if (!key) throw new Error(`Unknown configure set option: ${arg}`); + if (!args[index + 1]) throw new Error(`${arg} requires a value`); + values[key] = args[index + 1]; + index += 1; + } + return compactConfig(values); +} + +function parseConfigureArgs(args) { + const subcommandIndex = args.findIndex(arg => !arg.startsWith('--')); + const subcommand = subcommandIndex === -1 ? 'show' : args[subcommandIndex]; + const subcommandArgs = subcommandIndex === -1 ? args : args.slice(subcommandIndex + 1); + return { + subcommand, + json: args.includes('--json'), + dryRun: args.includes('--dry-run'), + values: subcommand === 'set' ? parseConfigureSetArgs(subcommandArgs) : {} + }; +} + +function agentopsConfigure(options = {}) { + const configPath = options.configPath || defaultConfigPath; + const subcommand = options.subcommand || 'show'; + if (subcommand === 'show') { + return { action: 'show', ...readAgentOpsConfig({ configPath }) }; + } + if (subcommand === 'set') { + if (Object.keys(options.values || {}).length === 0) throw new Error('configure set requires at least one value'); + return { action: 'set', ...writeAgentOpsConfig(options.values, { configPath, dryRun: options.dryRun }) }; + } + if (subcommand === 'import-azd') { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync('azd', ['env', 'get-values'], { + encoding: 'utf8', + maxBuffer: 1024 * 1024 + }); + if (result.error) { + return { action: 'import-azd', path: configPath, ok: false, error: result.error.message }; + } + if (result.status !== 0) { + return { action: 'import-azd', path: configPath, ok: false, error: (result.stderr || result.stdout || `azd exited with status ${result.status}`).trim() }; + } + const values = configFromEnvValues(parseEnvAssignments(result.stdout)); + if (Object.keys(values).length === 0) { + return { action: 'import-azd', path: configPath, ok: false, error: 'azd env get-values did not include AgentOps configuration values' }; + } + return { action: 'import-azd', ok: true, ...writeAgentOpsConfig(values, { configPath, dryRun: options.dryRun }) }; + } + throw new Error('configure requires show, set, or import-azd'); +} + +function renderConfigure(result) { + const lines = ['AgentOps config', '', `Path: ${result.path}`]; + if (result.error) lines.push(`Status: ${result.error}`); + if (result.dryRun) lines.push('Mode: dry-run'); + const values = result.values || {}; + const labels = [ + ['subscriptionId', 'Subscription'], + ['resourceGroup', 'Resource group'], + ['workspaceId', 'Workspace ID'], + ['workspaceName', 'Workspace name'], + ['grafanaBaseUrl', 'Grafana URL'], + ['grafanaName', 'Grafana resource'], + ['grafanaDatasourceUid', 'Grafana datasource UID'], + ['appInsightsName', 'Application Insights'], + ['agentsViewUrl', 'Azure Monitor Agents URL'], + ['logsIngestionEndpoint', 'Logs ingestion endpoint'], + ['dcrImmutableId', 'DCR immutable ID'], + ['portalLogsUrl', 'Portal logs URL'] + ]; + for (const [key, label] of labels) { + lines.push(`${label}: ${values[key] || 'not set'}`); + } + lines.push('', 'Next:'); + lines.push('- agentops validate-azure'); + lines.push('- agentops collector smoke --privacy strict --poison'); + return `${lines.join('\n')}\n`; +} + +function configuredCloudValues(options = {}) { + const env = options.env || process.env; + const config = options.config || readAgentOpsConfig({ configPath: options.configPath, quiet: true }).values; + const defaults = options.defaults || {}; + const optionValueOr = (key, ...values) => { + if (Object.prototype.hasOwnProperty.call(options, key)) return options[key]; + return values.find(value => value !== undefined && value !== null && value !== '') || ''; + }; + const configuredGrafanaBaseUrl = optionValueOr('grafanaBaseUrl', env.AGENTOPS_GRAFANA_BASE_URL, config.grafanaBaseUrl); + return { + subscriptionId: optionValueOr('subscriptionId', env.AGENTOPS_AZURE_SUBSCRIPTION_ID, env.AZURE_SUBSCRIPTION_ID, config.subscriptionId), + resourceGroup: optionValueOr('resourceGroup', env.AGENTOPS_AZURE_RESOURCE_GROUP, env.AZURE_RESOURCE_GROUP, config.resourceGroup, defaults.azureResourceGroup), + workspaceId: optionValueOr('workspaceId', env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID, env.LOG_ANALYTICS_WORKSPACE_ID, config.workspaceId), + workspaceName: optionValueOr('workspaceName', env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME, config.workspaceName, defaults.logAnalyticsWorkspaceName), + grafanaBaseUrl: configuredGrafanaBaseUrl.replace(/\/$/, ''), + grafanaName: optionValueOr('grafanaName', env.AGENTOPS_GRAFANA_NAME, env.GRAFANA_NAME, config.grafanaName), + grafanaDatasourceUid: optionValueOr('grafanaDatasourceUid', env.AGENTOPS_GRAFANA_DATASOURCE_UID, config.grafanaDatasourceUid, defaults.grafanaDatasourceUid), + appInsightsName: optionValueOr('appInsightsName', env.APPLICATIONINSIGHTS_NAME, env.AGENTOPS_APPLICATIONINSIGHTS_NAME, config.appInsightsName, defaults.appInsightsName), + agentsViewUrl: optionValueOr('agentsViewUrl', env.AGENTOPS_AZURE_AGENTS_URL, config.agentsViewUrl), + logsIngestionEndpoint: optionValueOr('logsIngestionEndpoint', env.AGENTOPS_LOGS_INGESTION_ENDPOINT, config.logsIngestionEndpoint), + dcrImmutableId: optionValueOr('dcrImmutableId', env.AGENTOPS_DCR_IMMUTABLE_ID, config.dcrImmutableId) + }; +} + +module.exports = { + agentopsConfigure, + compactConfig, + configFromEnvValues, + configuredCloudValues, + defaultConfigPath, + normalizeAgentOpsConfig, + parseConfigureArgs, + parseConfigureSetArgs, + parseEnvAssignments, + readAgentOpsConfig, + renderConfigure, + writeAgentOpsConfig +}; diff --git a/agentops-cli/src/lib/alert-action-group-actions.js b/agentops-cli/src/lib/alert-action-group-actions.js new file mode 100644 index 0000000..f923c5a --- /dev/null +++ b/agentops-cli/src/lib/alert-action-group-actions.js @@ -0,0 +1,188 @@ +const childProcess = require('node:child_process'); +const { checkAzureSubscription } = require('./azure/subscription-guard'); + +function createAlertActionGroupActions(config = {}) { + const { alertHandoff } = config; + + function alertActionGroupPlan({ resourceGroup, name, shortName, owners = [], emails = [], webhooks = [], location = 'global' } = {}) { + const normalizedResourceGroup = String(resourceGroup || '').trim(); + if (!normalizedResourceGroup) throw new Error('alert action-group-plan requires --resource-group <rg>'); + + const normalizedName = String(name || '').trim(); + if (!normalizedName) throw new Error('alert action-group-plan requires --name <action-group-name>'); + + const normalizedShortName = String(shortName || '').trim(); + if (!normalizedShortName) throw new Error('alert action-group-plan requires --short-name <short>'); + if (normalizedShortName.length > 12) throw new Error('alert action-group-plan --short-name must be 12 characters or fewer'); + + const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); + if (normalizedOwners.length === 0) throw new Error('alert action-group-plan requires at least one --owner <name>'); + + const normalizedEmails = emails.map(email => String(email || '').trim()).filter(Boolean); + const normalizedWebhooks = webhooks.map(webhook => String(webhook || '').trim()).filter(Boolean); + if (normalizedEmails.length === 0 && normalizedWebhooks.length === 0) { + throw new Error('alert action-group-plan requires at least one --email <address> or --webhook <url>'); + } + + const emailReceivers = normalizedEmails.map((email, index) => ({ + name: `email-${index + 1}`, + email_address: email + })); + const webhookReceivers = normalizedWebhooks.map((webhook, index) => ({ + name: `webhook-${index + 1}`, + service_uri: webhook + })); + + const args = [ + 'monitor', + 'action-group', + 'create', + '--resource-group', + normalizedResourceGroup, + '--name', + normalizedName, + '--short-name', + normalizedShortName, + '--location', + String(location || 'global').trim() || 'global' + ]; + + for (const receiver of emailReceivers) args.push('--action', 'email', receiver.name, receiver.email_address); + for (const receiver of webhookReceivers) args.push('--action', 'webhook', receiver.name, receiver.service_uri); + + return { + schema_version: 'agentops.alert-action-group-plan.v1', + mode: 'preview-only-action-group-plan', + resource_group: normalizedResourceGroup, + action_group: { + name: normalizedName, + short_name: normalizedShortName, + location: String(location || 'global').trim() || 'global' + }, + owner: normalizedOwners[0], + receivers: { + email: emailReceivers, + webhook: webhookReceivers + }, + command: { + executable: 'az', + args + }, + follow_up_route_command: `agentops alert route-action-group --resource-group ${normalizedResourceGroup} --scheduled-query <scheduled-query-name> --action-group <action-group-resource-id> --rule <rule> --session <conversation-id> --owner ${normalizedOwners[0]}`, + guardrails: [ + 'Preview-only: this command does not create or update Azure Monitor action groups.', + 'Review receiver ownership, destination accuracy, and escalation policy before creating the action group.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of receiver names and webhook URLs.' + ], + next: [ + 'Review the generated Azure CLI command with the action group owner.', + 'Create the action group only after receiver approval.', + 'Run alert route-action-group after the action group resource ID is approved.' + ] + }; + } + + function alertActionGroupRoute({ rule, session, last = '24h', owners = [], service = 'agentops', timezone = 'UTC', resourceGroup, scheduledQuery, actionGroups = [], enableAlert = false, yes = false, spawnSync = childProcess.spawnSync, env = process.env, expectedSubscriptionId, approvedSubscriptionIds } = {}) { + const normalizedResourceGroup = String(resourceGroup || '').trim(); + if (!normalizedResourceGroup) throw new Error('alert route-action-group requires --resource-group <rg>'); + + const normalizedScheduledQuery = String(scheduledQuery || '').trim(); + if (!normalizedScheduledQuery) throw new Error('alert route-action-group requires --scheduled-query <name>'); + + const normalizedActionGroups = actionGroups.map(item => String(item || '').trim()).filter(Boolean); + if (normalizedActionGroups.length === 0) throw new Error('alert route-action-group requires at least one --action-group <id>'); + + const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); + if (normalizedOwners.length === 0) throw new Error('alert route-action-group requires at least one --owner <name>'); + + const handoff = alertHandoff({ + rule, + session, + last, + owners: normalizedOwners, + service, + timezone, + resourceGroup: normalizedResourceGroup + }); + + const args = [ + 'monitor', + 'scheduled-query', + 'update', + '--resource-group', + normalizedResourceGroup, + '--name', + normalizedScheduledQuery, + '--action-groups', + ...normalizedActionGroups + ]; + if (enableAlert) args.push('--disabled', 'false'); + + const route = { + schema_version: 'agentops.alert-action-group-route.v1', + mode: yes ? 'routed-action-group' : 'dry-run-action-group-route', + alert: handoff.alert, + resource_group: normalizedResourceGroup, + scheduled_query: normalizedScheduledQuery, + action_groups: normalizedActionGroups, + owner: normalizedOwners[0], + enable_alert: Boolean(enableAlert), + command: { + executable: 'az', + args + }, + evidence: { + handoff_schema: handoff.schema_version, + session_link: handoff.evidence.detail.session_link, + history_query: handoff.evidence.detail.history_query + }, + guardrails: [ + 'Review the handoff evidence, threshold tune-plan, and action group receivers before routing notifications.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of notification routes.', + 'This command only attaches approved Azure Monitor action groups; use --enable-alert only after threshold review.' + ] + }; + + if (!yes) return route; + + const subscription = checkAzureSubscription({ spawnSync, env, expectedSubscriptionId, approvedSubscriptionIds }); + if (!subscription.ok) { + return { + ...route, + mode: 'refused-action-group-route', + status: null, + subscription_guard: subscription, + error: subscription.error + }; + } + + const result = spawnSync('az', args, { + encoding: 'utf8', + env + }); + if (result.status !== 0) { + return { + ...route, + mode: 'failed-action-group-route', + status: result.status, + error: String(result.stderr || result.stdout || 'az monitor scheduled-query update failed').trim() + }; + } + + return { + ...route, + status: result.status, + subscription_guard: subscription, + output: String(result.stdout || '').trim() + }; + } + + return { + alertActionGroupPlan, + alertActionGroupRoute + }; +} + +module.exports = { + createAlertActionGroupActions +}; diff --git a/agentops-cli/src/lib/alert-actions.js b/agentops-cli/src/lib/alert-actions.js new file mode 100644 index 0000000..01cf082 --- /dev/null +++ b/agentops-cli/src/lib/alert-actions.js @@ -0,0 +1,542 @@ +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { checkAzureSubscription } = require('./azure/subscription-guard'); + +const { createAlertActionGroupActions } = require('./alert-action-group-actions'); + +function createAlertActions(config = {}) { + const { + alertActionPlan, + alertArtifact, + alertDetail, + alertHandoff, + alertHistoryQuery, + alertRecommendationQuery, + alertRoutePlan, + alertTunePlan, + baseFilter, + grafanaUrlWithVars, + root, + sessionKey, + validateKqlDuration, + v2ReplayGrafanaDashboardUrl, + v2RunsGrafanaDashboardUrl + } = config; + const { alertActionGroupPlan, alertActionGroupRoute } = createAlertActionGroupActions({ + alertHandoff + }); + + function alertGithubIssueRoute({ rule, session, last = '24h', owners = [], service = 'agentops', timezone = 'UTC', repo, yes = false, resourceGroup = null, spawnSync = childProcess.spawnSync } = {}) { + const normalizedRepo = String(repo || '').trim(); + if (!normalizedRepo || !/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) { + throw new Error('alert route-github requires --repo <owner/repo>'); + } + + const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); + if (normalizedOwners.length === 0) throw new Error('alert route-github requires at least one --owner <github-login>'); + + const plan = alertRoutePlan({ + rule, + session, + last, + owners: normalizedOwners, + service, + timezone, + targets: ['github-issue'], + resourceGroup + }); + const destination = plan.destinations.find(item => item.target === 'github-issue'); + const payload = destination.payload; + const args = [ + 'issue', + 'create', + '--repo', + normalizedRepo, + '--title', + payload.title, + '--body', + payload.body + ]; + + for (const label of payload.labels) args.push('--label', label); + for (const owner of payload.assignees) args.push('--assignee', owner); + + const route = { + schema_version: 'agentops.alert-github-route.v1', + mode: yes ? 'posted-github-issue' : 'dry-run-github-issue-route', + alert: plan.alert, + repo: normalizedRepo, + owner: normalizedOwners[0], + command: { + executable: 'gh', + args + }, + payload, + guardrails: [ + 'Review the route-plan and handoff evidence before posting.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of GitHub issues.', + 'This command only creates a GitHub issue; it does not page, edit Azure resources, or enable alert rules.' + ] + }; + + if (!yes) return route; + + const result = spawnSync('gh', args, { + encoding: 'utf8', + env: process.env + }); + if (result.status !== 0) { + return { + ...route, + mode: 'failed-github-issue-route', + status: result.status, + error: String(result.stderr || result.stdout || 'gh issue create failed').trim() + }; + } + + return { + ...route, + status: result.status, + issue_url: String(result.stdout || '').trim() + }; + } + + function fieldValue(patch, fieldPath) { + const field = patch.find(item => item.path === fieldPath); + return field ? field.value : null; + } + + function alertOpenRun({ rule, session, last = '24h' } = {}) { + const detail = alertDetail({ rule, session, last }); + const sessionId = detail.session; + const runVars = { + 'var-run_id': '__all', + 'var-session_id': sessionId, + 'var-trace_id': '__all' + }; + + return { + schema_version: 'agentops.alert-open-run.v1', + mode: 'metadata-only-alert-run-links', + alert: { + rule: detail.rule, + session: sessionId, + last: detail.last + }, + links: { + session_detail: detail.session_link.grafana_url, + run_replay: grafanaUrlWithVars(v2ReplayGrafanaDashboardUrl, runVars), + runs_explorer: grafanaUrlWithVars(v2RunsGrafanaDashboardUrl, { 'var-session_id': sessionId }), + content_viewer: grafanaUrlWithVars(`${v2ReplayGrafanaDashboardUrl}?viewPanel=26`, runVars), + azure_portal_logs: detail.session_link.azure_portal_url + }, + queries: { + alert_history: detail.history_query, + session: detail.session_link.query + }, + commands: { + replay: `agentops replay ${sessionId} --last ${detail.last}`, + action_plan: detail.action_plan_command, + handoff: `agentops alert handoff --rule ${detail.rule} --session ${sessionId} --last ${detail.last}` + }, + guardrails: [ + 'Open links only after reviewing metadata-only alert context.', + 'The content viewer link is explicit opt-in and does not grant permission to collect prompt or response content.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of follow-up tickets.' + ] + }; + } + + function alertReview({ rule, session, last = '24h', owners = [] } = {}) { + const open = alertOpenRun({ rule, session, last }); + const detail = alertDetail({ rule, session, last }); + const actionPlan = alertActionPlan({ rule, session, last }); + const artifact = alertArtifact({ rule, session, last }); + const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); + + return { + schema_version: 'agentops.alert-review.v1', + mode: 'metadata-only-alert-review', + alert: open.alert, + owner: normalizedOwners[0] || null, + evidence: { + detail, + open, + action_plan: actionPlan, + artifact + }, + commands: { + open: `agentops alert open --rule ${open.alert.rule} --session ${open.alert.session} --last ${open.alert.last}`, + action_plan: actionPlan.next_command || detail.action_plan_command, + export: `agentops alert export --rule ${open.alert.rule} --session ${open.alert.session} --output .agentops/alerts/${open.alert.rule}.json --last ${open.alert.last}`, + handoff: `agentops alert handoff --rule ${open.alert.rule} --session ${open.alert.session}${normalizedOwners[0] ? ` --owner ${normalizedOwners[0]}` : ''} --last ${open.alert.last}` + }, + guardrails: [ + 'Metadata-only: this command does not page, post tickets, edit repositories, or mutate Azure resources.', + 'Review session links, alert history, and action-plan payloads before routing notifications.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of follow-up tickets.' + ], + next: [ + 'Open the session detail or run replay link.', + 'Review the action-plan payload and threshold evidence.', + 'Export or hand off the review packet only after assigning an owner.' + ] + }; + } + + const alertThresholdPatchResources = { + 'high-aiu': { + bicep_resource: 'highAiuAlert', + current_threshold: 50000000000 + }, + 'failed-spans': { + bicep_resource: 'failureAlert', + current_threshold: 0 + }, + 'content-capture': { + bicep_resource: 'contentCaptureAlert', + current_threshold: 0, + fixed_threshold: 0 + } + }; + + function normalizeThresholdValue(value) { + const text = String(value ?? '').trim(); + if (!text) throw new Error('alert threshold-patch requires --threshold <number>'); + const number = Number(text); + if (!Number.isFinite(number) || number < 0) throw new Error('alert threshold-patch --threshold must be a non-negative number'); + return Number.isInteger(number) ? String(number) : String(number); + } + + function unifiedThresholdDiff({ lines, lineIndex, before, after, filePath }) { + const contextBefore = Math.max(0, lineIndex - 3); + const contextAfter = Math.min(lines.length, lineIndex + 4); + const hunkLines = []; + for (let index = contextBefore; index < contextAfter; index += 1) { + if (index === lineIndex) { + hunkLines.push(`-${lines[index]}`); + hunkLines.push(`+${lines[index].replace(before, after)}`); + } else { + hunkLines.push(` ${lines[index]}`); + } + } + + return [ + `--- a/${filePath}`, + `+++ b/${filePath}`, + `@@ -${contextBefore + 1},${contextAfter - contextBefore} +${contextBefore + 1},${contextAfter - contextBefore} @@`, + ...hunkLines + ].join('\n'); + } + + function alertThresholdSimulationQuery({ rule, currentThreshold, proposedThreshold, last }) { + const selectedRule = JSON.stringify(rule); + if (rule === 'content-capture') { + return `let lookback = ${last}; + let selected_rule = ${selectedRule}; + let current_threshold = ${currentThreshold}; + let proposed_threshold = ${proposedThreshold}; + let windows = + union isfuzzy=true AppDependencies, AppTraces + | where TimeGenerated > ago(lookback) + | where tostring(Properties) has_any ("gen_ai.input.messages", "gen_ai.output.messages", "gen_ai.prompt", "gen_ai.completion", "github.copilot.message") + | summarize TriggerValue=count() by TimeGenerated=bin(TimeGenerated, 1h) + | extend Conversation="content-capture-window"; + windows + | summarize + observed_windows=count(), + current_alert_windows=countif(TriggerValue > current_threshold), + proposed_alert_windows=countif(TriggerValue > proposed_threshold), + max_trigger=max(TriggerValue), + affected_sessions=dcount(Conversation) + | extend Rule=selected_rule, CurrentThreshold=current_threshold, ProposedThreshold=proposed_threshold`; + } + + const triggerExpression = rule === 'high-aiu' + ? 'AIU' + : 'todouble(Failures + ToolFailures)'; + return `let lookback = ${last}; + let selected_rule = ${selectedRule}; + let current_threshold = ${currentThreshold}; + let proposed_threshold = ${proposedThreshold}; + let hourly = + AppDependencies + | where TimeGenerated > ago(lookback) + | where ${baseFilter} + | extend conversation=${sessionKey}, + operation=tostring(Properties["gen_ai.operation.name"]), + tool=tostring(Properties["gen_ai.tool.name"]), + error=tostring(Properties["error.type"]), + AIU=todouble(Properties["github.copilot.aiu"]) + | summarize + Failures=countif(Success == false or tostring(Success) =~ "false" or isnotempty(error)), + ToolFailures=countif((operation == "execute_tool" or isnotempty(tool)) and (Success == false or tostring(Success) =~ "false" or isnotempty(error))), + AIU=sum(AIU) + by Conversation=conversation, TimeGenerated=bin(TimeGenerated, 1h) + | extend TriggerValue=${triggerExpression}; + hourly + | summarize + observed_windows=count(), + current_alert_windows=countif(TriggerValue > current_threshold), + proposed_alert_windows=countif(TriggerValue > proposed_threshold), + max_trigger=max(TriggerValue), + p95_trigger=percentile(TriggerValue, 95), + affected_sessions=dcount(Conversation) + | extend Rule=selected_rule, CurrentThreshold=current_threshold, ProposedThreshold=proposed_threshold`; + } + + function alertThresholdSimulation({ rule, threshold, owner, last = '14d' } = {}) { + const normalizedRule = String(rule || '').trim(); + const target = alertThresholdPatchResources[normalizedRule]; + if (!target) { + throw new Error(`alert threshold-simulate requires --rule ${Object.keys(alertThresholdPatchResources).join('|')}`); + } + + const normalizedOwner = String(owner || '').trim(); + if (!normalizedOwner) throw new Error('alert threshold-simulate requires --owner <name>'); + + const proposedThreshold = normalizeThresholdValue(threshold); + if (target.fixed_threshold !== undefined && proposedThreshold !== String(target.fixed_threshold)) { + throw new Error(`alert threshold-simulate keeps ${normalizedRule} threshold at ${target.fixed_threshold}`); + } + + const lookback = validateKqlDuration(last); + const current = target.current_threshold; + const proposed = Number(proposedThreshold); + + return { + schema_version: 'agentops.alert-threshold-simulation.v1', + mode: 'preview-only-threshold-simulation', + rule: normalizedRule, + owner: normalizedOwner, + last: lookback, + bicep_resource: target.bicep_resource, + current_threshold: current, + proposed_threshold: proposed, + expected_effect: proposed > current + ? 'fewer-or-equal-alert-windows' + : proposed < current + ? 'more-or-equal-alert-windows' + : 'same-threshold', + evidence: { + simulation_query: alertThresholdSimulationQuery({ + rule: normalizedRule, + currentThreshold: current, + proposedThreshold: proposed, + last: lookback + }), + threshold_recommendation_query: alertRecommendationQuery(lookback), + fired_alert_history: alertHistoryQuery(normalizedRule, lookback) + }, + guardrails: [ + 'Preview-only: this command does not edit files, Azure resources, alert rules, or action groups.', + 'Run the simulation query and review current_alert_windows versus proposed_alert_windows before applying a threshold patch.', + 'Keep content-capture threshold at 0; investigate any content-like telemetry before routing alerts.' + ], + next: [ + 'Run the simulation query in Azure Logs.', + 'If proposed_alert_windows is acceptable, run alert threshold-patch to generate the Bicep diff.', + 'Apply threshold changes only in a reviewed PR, then run validate-azure before enabling alerts.' + ] + }; + } + + function alertThresholdPatch({ rule, threshold, owner, last = '14d', bicepPath = path.join(root, 'infra/bicep/alerts.bicep') } = {}) { + const normalizedRule = String(rule || '').trim(); + const target = alertThresholdPatchResources[normalizedRule]; + if (!target) { + throw new Error(`alert threshold-patch requires --rule ${Object.keys(alertThresholdPatchResources).join('|')}`); + } + + const normalizedOwner = String(owner || '').trim(); + if (!normalizedOwner) throw new Error('alert threshold-patch requires --owner <name>'); + + const nextThreshold = normalizeThresholdValue(threshold); + if (target.fixed_threshold !== undefined && nextThreshold !== String(target.fixed_threshold)) { + throw new Error(`alert threshold-patch keeps ${normalizedRule} threshold at ${target.fixed_threshold}`); + } + + const lookback = validateKqlDuration(last); + const relativePath = path.relative(root, bicepPath).replace(/\\/g, '/'); + const source = fs.readFileSync(bicepPath, 'utf8'); + const lines = source.split(/\r?\n/); + const resourceStart = lines.findIndex(line => line.includes(`resource ${target.bicep_resource} `)); + if (resourceStart === -1) throw new Error(`alert threshold-patch could not find ${target.bicep_resource} in ${relativePath}`); + let resourceEnd = lines.findIndex((line, index) => index > resourceStart && /^resource |^output /.test(line)); + if (resourceEnd === -1) resourceEnd = lines.length; + + const currentLine = `threshold: ${target.current_threshold}`; + const thresholdIndex = lines.findIndex((line, index) => index > resourceStart && index < resourceEnd && line.trim() === currentLine); + if (thresholdIndex === -1) throw new Error(`alert threshold-patch could not find ${currentLine} in ${target.bicep_resource}`); + + const replacementLine = `threshold: ${nextThreshold}`; + const diff = unifiedThresholdDiff({ + lines, + lineIndex: thresholdIndex, + before: currentLine, + after: replacementLine, + filePath: relativePath + }); + const tunePlan = alertTunePlan({ rule: normalizedRule, last: lookback, owner: normalizedOwner }); + + return { + schema_version: 'agentops.alert-threshold-patch.v1', + mode: 'preview-only-bicep-threshold-patch', + rule: normalizedRule, + owner: normalizedOwner, + last: lookback, + patch_target: relativePath, + bicep_resource: target.bicep_resource, + current_threshold: target.current_threshold, + proposed_threshold: Number(nextThreshold), + diff, + evidence: { + tune_plan_schema: tunePlan.schema_version, + threshold_recommendation_query: tunePlan.evidence.threshold_recommendation_query, + fired_alert_history: tunePlan.evidence.fired_alert_history[0].query + }, + guardrails: [ + 'Preview-only: this command does not edit infra/bicep/alerts.bicep.', + 'Review threshold recommendation evidence and fired-alert history before applying this diff.', + 'Run validate-azure before enabling alerts or routing action groups after any threshold change.' + ], + next: [ + 'Apply this diff in a reviewed PR only after owner approval.', + 'Keep enableAlerts=false until the patched rule has been validated against real traffic.', + 'Regenerate alert resources and run validate-azure before production routing.' + ] + }; + } + + function alertAzureDevOpsWorkItemRoute({ rule, session, last = '24h', owners = [], service = 'agentops', timezone = 'UTC', org, project, workItemType = 'Issue', yes = false, resourceGroup = null, spawnSync = childProcess.spawnSync, env = process.env, expectedSubscriptionId, approvedSubscriptionIds } = {}) { + const normalizedOrg = String(org || '').trim(); + if (!normalizedOrg) throw new Error('alert route-azure-devops requires --org <url>'); + + const normalizedProject = String(project || '').trim(); + if (!normalizedProject) throw new Error('alert route-azure-devops requires --project <name>'); + + const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); + if (normalizedOwners.length === 0) throw new Error('alert route-azure-devops requires at least one --owner <user>'); + + const normalizedType = String(workItemType || '').trim() || 'Issue'; + const plan = alertRoutePlan({ + rule, + session, + last, + owners: normalizedOwners, + service, + timezone, + targets: ['azure-devops-work-item'], + resourceGroup + }); + const destination = plan.destinations.find(item => item.target === 'azure-devops-work-item'); + const payload = destination.payload; + const title = fieldValue(payload, '/fields/System.Title'); + const description = fieldValue(payload, '/fields/System.Description'); + const tags = fieldValue(payload, '/fields/System.Tags'); + const fields = [ + `System.AssignedTo=${normalizedOwners[0]}` + ]; + if (tags) fields.push(`System.Tags=${tags}`); + + const args = [ + 'boards', + 'work-item', + 'create', + '--org', + normalizedOrg, + '--project', + normalizedProject, + '--type', + normalizedType, + '--title', + title, + '--description', + description, + '--fields', + ...fields + ]; + + const route = { + schema_version: 'agentops.alert-azure-devops-route.v1', + mode: yes ? 'posted-azure-devops-work-item' : 'dry-run-azure-devops-work-item-route', + alert: plan.alert, + org: normalizedOrg, + project: normalizedProject, + owner: normalizedOwners[0], + work_item_type: normalizedType, + command: { + executable: 'az', + args + }, + payload, + guardrails: [ + 'Review the route-plan and handoff evidence before posting.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of Azure DevOps work items.', + 'This command only creates an Azure DevOps work item; it does not page, edit Azure resources, or enable alert rules.' + ] + }; + + if (!yes) return route; + + const subscription = checkAzureSubscription({ + spawnSync, + env, + expectedSubscriptionId, + approvedSubscriptionIds + }); + if (!subscription.ok) { + return { + ...route, + mode: 'refused-azure-devops-work-item-route', + status: null, + subscription_guard: subscription, + error: subscription.error + }; + } + + const result = spawnSync('az', args, { + encoding: 'utf8', + env + }); + if (result.status !== 0) { + return { + ...route, + mode: 'failed-azure-devops-work-item-route', + status: result.status, + error: String(result.stderr || result.stdout || 'az boards work-item create failed').trim() + }; + } + + let parsed = null; + try { + parsed = JSON.parse(String(result.stdout || '{}')); + } catch { + parsed = null; + } + + return { + ...route, + status: result.status, + subscription_guard: subscription, + work_item_id: parsed && parsed.id ? parsed.id : null, + work_item_url: parsed && parsed.url ? parsed.url : String(result.stdout || '').trim() + }; + } + + return { + alertActionGroupPlan, + alertActionGroupRoute, + alertAzureDevOpsWorkItemRoute, + alertGithubIssueRoute, + alertOpenRun, + alertReview, + alertThresholdPatch, + alertThresholdSimulation + }; +} + +module.exports = { + createAlertActions +}; diff --git a/agentops-cli/src/lib/alert-command.js b/agentops-cli/src/lib/alert-command.js new file mode 100644 index 0000000..68a0a18 --- /dev/null +++ b/agentops-cli/src/lib/alert-command.js @@ -0,0 +1,280 @@ +const path = require('node:path'); +const { writeJson: writeJsonValue, writeJsonFile: writeJsonFileValue } = require('./command-output'); +const { readJson } = require('./json'); + +function createAlertCommand(dependencies = {}) { + const { + agentOpsScheduledQueryRules, + alertActionGroupPlan, + alertActionGroupRoute, + alertActionPlan, + alertArtifact, + alertAzureDevOpsWorkItemRoute, + alertDetail, + alertGithubIssueRoute, + alertHandoff, + alertHistory, + alertIncidentTimeline, + alertOpenRun, + alertPolicy, + alertRecommendations, + alertResourceState, + alertReview, + alertRoutePlan, + alertThresholdPatch, + alertThresholdSimulation, + alertTunePlan, + azAvailable, + azErrorDetail, + configuredCloudValues, + cwd = process.cwd(), + optionValue, + optionValues, + parseJsonOutput, + parseLastArg, + readJsonlRows, + runAz, + stdout = process.stdout + } = dependencies; + const writeJson = value => writeJsonValue(value, stdout); + const resolvePath = filePath => path.resolve(cwd, filePath); + + function writeJsonFile(filePath, value) { + const outputPath = resolvePath(filePath); + writeJsonFileValue(outputPath, value); + return outputPath; + } + + function writeOutputFile(filePath, value, key) { + if (!filePath) return false; + const outputPath = writeJsonFile(filePath, value); + writeJson({ output: outputPath, [key]: value }); + return true; + } + + function alertRunContext(args, defaultLast = '24h') { + return { + rule: optionValue(args, ['--rule']), + session: optionValue(args, ['--session', '--conversation']), + last: parseLastArg(args, defaultLast) + }; + } + + function operatorContext(args) { + return { + owners: optionValues(args, '--owner'), + service: optionValue(args, ['--service']) || 'agentops', + timezone: optionValue(args, ['--timezone', '--tz']) || 'UTC', + resourceGroup: optionValue(args, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup + }; + } + + function eventRows(args) { + const eventsFile = optionValue(args, ['--events']); + return eventsFile ? readJsonlRows(resolvePath(eventsFile)) : []; + } + + function alertCommand(args) { + const [subcommand, ...alertArgs] = args; + if (subcommand === 'recommend') { + const last = parseLastArg(alertArgs, '14d'); + writeJson(alertRecommendations(last)); + return; + } + if (subcommand === 'tune-plan') { + const last = parseLastArg(alertArgs, '14d'); + const rule = optionValue(alertArgs, ['--rule']); + const owner = optionValue(alertArgs, ['--owner']); + const output = optionValue(alertArgs, ['--output', '--out']); + const plan = alertTunePlan({ last, rule, owner }); + if (writeOutputFile(output, plan, 'plan')) return; + writeJson(plan); + return; + } + if (subcommand === 'threshold-simulate') { + const last = parseLastArg(alertArgs, '14d'); + const rule = optionValue(alertArgs, ['--rule']); + const threshold = optionValue(alertArgs, ['--threshold']); + const owner = optionValue(alertArgs, ['--owner']); + writeJson(alertThresholdSimulation({ rule, threshold, owner, last })); + return; + } + if (subcommand === 'threshold-patch') { + const last = parseLastArg(alertArgs, '14d'); + const rule = optionValue(alertArgs, ['--rule']); + const threshold = optionValue(alertArgs, ['--threshold']); + const owner = optionValue(alertArgs, ['--owner']); + writeJson(alertThresholdPatch({ rule, threshold, owner, last })); + return; + } + if (subcommand === 'policy') { + const owners = optionValues(alertArgs, '--owner'); + const service = optionValue(alertArgs, ['--service']) || 'agentops'; + const timezone = optionValue(alertArgs, ['--timezone', '--tz']) || 'UTC'; + writeJson(alertPolicy({ owners, service, timezone })); + return; + } + if (subcommand === 'resources') { + const cloud = configuredCloudValues(); + const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || cloud.resourceGroup; + if (!resourceGroup) throw new Error('alert resources requires --resource-group <name> or AGENTOPS_AZURE_RESOURCE_GROUP'); + if (!azAvailable()) { + writeJson(alertResourceState({ + resourceGroup, + error: 'Azure CLI was not found on PATH.' + })); + return; + } + const alertResult = runAz(['monitor', 'scheduled-query', 'list', '--resource-group', resourceGroup, '-o', 'json']); + const resources = alertResult.status === 0 ? agentOpsScheduledQueryRules(parseJsonOutput(alertResult)) : []; + writeJson(alertResourceState({ + resourceGroup, + resources, + error: alertResult.status === 0 ? null : azErrorDetail(alertResult, 'could not list scheduled query rules') + })); + return; + } + if (subcommand === 'action-plan') { + writeJson(alertActionPlan(alertRunContext(alertArgs))); + return; + } + if (subcommand === 'history') { + const { rule, last } = alertRunContext(alertArgs); + writeJson(alertHistory({ rule, last })); + return; + } + if (subcommand === 'detail') { + writeJson(alertDetail(alertRunContext(alertArgs))); + return; + } + if (subcommand === 'open') { + writeJson(alertOpenRun(alertRunContext(alertArgs))); + return; + } + if (subcommand === 'review') { + const context = alertRunContext(alertArgs); + const owners = optionValues(alertArgs, '--owner'); + writeJson(alertReview({ ...context, owners })); + return; + } + if (subcommand === 'export') { + const output = optionValue(alertArgs, ['--output', '--out']); + if (!output) throw new Error('alert export requires --output <json>'); + const artifact = alertArtifact(alertRunContext(alertArgs)); + const outputPath = writeJsonFile(output, artifact); + writeJson({ output: outputPath, artifact }); + return; + } + if (subcommand === 'handoff') { + const output = optionValue(alertArgs, ['--output', '--out']); + const handoff = alertHandoff({ + ...alertRunContext(alertArgs), + ...operatorContext(alertArgs), + events: eventRows(alertArgs) + }); + if (writeOutputFile(output, handoff, 'handoff')) return; + writeJson(handoff); + return; + } + if (subcommand === 'route-plan') { + const targets = optionValues(alertArgs, '--target'); + const output = optionValue(alertArgs, ['--output', '--out']); + const plan = alertRoutePlan({ + ...alertRunContext(alertArgs), + ...operatorContext(alertArgs), + targets, + events: eventRows(alertArgs) + }); + if (writeOutputFile(output, plan, 'plan')) return; + writeJson(plan); + return; + } + if (subcommand === 'route-github') { + const repo = optionValue(alertArgs, ['--repo']); + const yes = alertArgs.includes('--yes'); + writeJson(alertGithubIssueRoute({ + ...alertRunContext(alertArgs), + ...operatorContext(alertArgs), + repo, + yes + })); + return; + } + if (subcommand === 'route-azure-devops') { + const org = optionValue(alertArgs, ['--org', '--organization']); + const project = optionValue(alertArgs, ['--project']); + const workItemType = optionValue(alertArgs, ['--type', '--work-item-type']) || 'Issue'; + const yes = alertArgs.includes('--yes'); + writeJson(alertAzureDevOpsWorkItemRoute({ + ...alertRunContext(alertArgs), + ...operatorContext(alertArgs), + org, + project, + workItemType, + yes + })); + return; + } + if (subcommand === 'action-group-plan') { + const owners = optionValues(alertArgs, '--owner'); + const resourceGroup = optionValue(alertArgs, ['--resource-group', '-g']) || configuredCloudValues().resourceGroup; + const name = optionValue(alertArgs, ['--name']); + const shortName = optionValue(alertArgs, ['--short-name']); + const emails = optionValues(alertArgs, '--email'); + const webhooks = optionValues(alertArgs, '--webhook'); + const location = optionValue(alertArgs, ['--location']) || 'global'; + writeJson(alertActionGroupPlan({ + resourceGroup, + name, + shortName, + owners, + emails, + webhooks, + location + })); + return; + } + if (subcommand === 'route-action-group') { + const scheduledQuery = optionValue(alertArgs, ['--scheduled-query', '--scheduled-query-rule', '--alert-rule']); + const actionGroups = optionValues(alertArgs, '--action-group'); + const yes = alertArgs.includes('--yes'); + const enableAlert = alertArgs.includes('--enable-alert'); + writeJson(alertActionGroupRoute({ + ...alertRunContext(alertArgs), + ...operatorContext(alertArgs), + scheduledQuery, + actionGroups, + enableAlert, + yes + })); + return; + } + throw new Error('alert currently supports: alert recommend, alert tune-plan, alert threshold-simulate, alert threshold-patch, alert policy, alert resources, alert history, alert detail, alert open, alert review, alert action-plan, alert export, alert handoff, alert route-plan, alert route-github, alert route-azure-devops, alert action-group-plan, alert route-action-group'); + } + + function incidentCommand(args) { + const [subcommand, ...incidentArgs] = args; + if (subcommand === 'timeline') { + const artifactPaths = optionValues(incidentArgs, '--artifact'); + const output = optionValue(incidentArgs, ['--output', '--out']); + const incidentId = optionValue(incidentArgs, ['--incident', '--incident-id']); + if (artifactPaths.length === 0) throw new Error('incident timeline requires --artifact <json>'); + if (!output) throw new Error('incident timeline requires --output <json>'); + const artifacts = artifactPaths.map(artifactPath => readJson(resolvePath(artifactPath))); + const timeline = alertIncidentTimeline({ artifacts, incidentId }); + const outputPath = writeJsonFile(output, timeline); + writeJson({ output: outputPath, timeline }); + return; + } + throw new Error('incident currently supports: incident timeline'); + } + + return { + alertCommand, + incidentCommand + }; +} + +module.exports = { + createAlertCommand +}; diff --git a/agentops-cli/src/lib/alert-model.js b/agentops-cli/src/lib/alert-model.js new file mode 100644 index 0000000..e4729ce --- /dev/null +++ b/agentops-cli/src/lib/alert-model.js @@ -0,0 +1,171 @@ +const { + configChangeAnnotationsForSession, + normalizeConfigChangeAnnotation, + parseDetailsValue, + propertyValue, + stringValue +} = require('./change-annotations'); + +function boolish(value) { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.toLowerCase() === 'true'; + return Boolean(value); +} + +function alertRules(last = '14d') { + return [ + { + name: 'high-aiu', + bicep_resource: 'highAiuAlert', + signal: 'hourly session AIU above tuned p95/p99 history', + current_threshold: 50000000000, + suggested_threshold: 'max(p99_aiu * 1.25, p95_aiu * 2)', + validation_query: 'Run alert recommend and inspect p95_aiu, p99_aiu, max_aiu before changing infra/bicep/alerts.bicep.', + rollout: 'Keep enableAlerts=false until the threshold has at least 14 days of clean history.' + }, + { + name: 'cost-spike', + bicep_resource: 'highAiuAlert', + signal: 'hourly GitHub Copilot credits above tuned cost history', + current_threshold: 1, + suggested_threshold: 'max(1, p95_credits * 2)', + validation_query: 'Inspect p95_credits and max_credits before changing budget contacts or alert thresholds.', + rollout: 'Pair with an Azure Consumption budget and keep action groups off until cost history is understood.' + }, + { + name: 'runaway-tool-loop', + bicep_resource: 'failureAlert', + signal: 'tool calls per conversation-hour above tuned history', + current_threshold: 25, + suggested_threshold: 'max(25, p95_tool_calls * 2)', + validation_query: 'Compare p95_tool_calls and max_tool_calls with the runaway-tool-loop abuse fixture.', + rollout: 'Review tool permission policy before routing this rule to action groups.' + }, + { + name: 'failed-spans', + bicep_resource: 'failureAlert', + signal: 'failed spans or failed tools in a one-hour window', + current_threshold: 0, + suggested_threshold: 'start at max(1, p95_failures) for noisy dev stacks; keep 0 for production safety gates', + validation_query: 'Compare max_failures, p95_failures, max_tool_failures, and p95_tool_failures over the selected lookback.', + rollout: 'Attach no action group until false positives are reviewed in the Permission Friction dashboard.' + }, + { + name: 'content-capture', + bicep_resource: 'contentCaptureAlert', + signal: 'prompt, completion, message, or Copilot content fields detected', + current_threshold: 0, + suggested_threshold: 0, + validation_query: 'max_content_capture_signals must remain 0 before sharing telemetry.', + rollout: 'This rule should stay strict; investigate immediately if it fires.' + } + ].map(rule => ({ ...rule, last })); +} + +function requireAlertRule(rule, commandName) { + const normalizedRule = String(rule || '').trim(); + const rules = alertRules(); + const matchedRule = rules.find(candidate => candidate.name === normalizedRule); + if (!matchedRule) throw new Error(`alert ${commandName} requires --rule ${rules.map(candidate => candidate.name).join('|')}`); + return matchedRule; +} + +function alertResourceState({ workspaceId, resources = [], resourceGroup = null, error = null } = {}) { + const rules = alertRules(); + const normalized = resources.map(resource => { + const properties = resource.properties || {}; + const actions = properties.actions || {}; + return { + name: resource.name || null, + display_name: properties.displayName || null, + enabled: boolish(properties.enabled), + severity: properties.severity ?? null, + action_groups: Array.isArray(actions.actionGroups) ? actions.actionGroups : [], + evaluation_frequency: properties.evaluationFrequency || null, + window_size: properties.windowSize || null + }; + }); + + return { + workspace_id: workspaceId, + resource_group: resourceGroup, + mode: 'read-only-resource-state', + status: error ? 'unavailable' : 'observed', + error, + expected_bicep_resources: rules.map(rule => ({ + rule: rule.name, + bicep_resource: rule.bicep_resource + })), + resources: normalized, + summary: { + total: normalized.length, + enabled: normalized.filter(resource => resource.enabled).length, + disabled: normalized.filter(resource => !resource.enabled).length, + routed: normalized.filter(resource => resource.action_groups.length > 0).length + }, + next: error + ? ['Verify Azure CLI login, monitor extension, resource group, and scheduled-query rule read permissions.'] + : ['Keep alerts disabled until thresholds are tuned and action groups are approved.'] + }; +} + +function alertPolicy({ workspaceId, owners = [], service = 'agentops', timezone = 'UTC' } = {}) { + const normalizedOwners = owners.map(owner => String(owner || '').trim()).filter(Boolean); + const rules = alertRules(); + return { + schema_version: 'agentops.alert-policy.v1', + workspace_id: workspaceId, + mode: 'metadata-only-policy', + service, + timezone, + ownership: { + state: normalizedOwners.length > 0 ? 'assigned' : 'needs-owner', + owners: normalizedOwners, + fallback: null + }, + noise_policy: { + dedupe_key: ['rule', 'session'], + suppress_duplicates_for: 'PT30M', + max_review_items_per_rule_per_day: 10, + quiet_hours: { + enabled: false, + start: null, + end: null, + timezone + } + }, + escalation: { + page: false, + create_ticket: false, + allowed_targets: ['github-issue', 'azure-devops-work-item'], + requires_manual_review: true + }, + rule_defaults: rules.map(rule => ({ + rule: rule.name, + severity: rule.name === 'content-capture' ? 'critical' : 'review', + owner_required: true, + action_group_required_before_enablement: true + })), + guardrails: [ + 'Do not page owners or create tickets automatically from this policy.', + 'Review metadata-only KQL, dashboard links, and exported artifacts before assigning work.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of incident notes.' + ], + next: normalizedOwners.length > 0 + ? ['Review alert resources and incident timelines before enabling notification routes.'] + : ['Assign at least one owner before enabling alert action groups.'] + }; +} + +module.exports = { + alertPolicy, + alertResourceState, + alertRules, + boolish, + configChangeAnnotationsForSession, + normalizeConfigChangeAnnotation, + parseDetailsValue, + propertyValue, + requireAlertRule, + stringValue +}; diff --git a/agentops-cli/src/lib/alert-timeline.js b/agentops-cli/src/lib/alert-timeline.js new file mode 100644 index 0000000..30f5ee4 --- /dev/null +++ b/agentops-cli/src/lib/alert-timeline.js @@ -0,0 +1,82 @@ +function incidentTimelineFromArtifacts({ artifacts = [], createdAt, incidentId, workspaceId } = {}) { + const created = createdAt || new Date().toISOString(); + if (!Array.isArray(artifacts) || artifacts.length === 0) { + throw new Error('incident timeline requires at least one alert artifact'); + } + + const normalized = artifacts.map((artifact, index) => { + if (!artifact || artifact.schema_version !== 'agentops.alert-artifact.v1') { + throw new Error(`incident timeline artifact ${index + 1} must be an agentops.alert-artifact.v1 JSON file`); + } + if (!artifact.rule || !artifact.session || !artifact.evidence) { + throw new Error(`incident timeline artifact ${index + 1} is missing rule, session, or evidence`); + } + return { + source_index: index + 1, + created_at: artifact.created_at || created, + rule: artifact.rule, + session: artifact.session, + last: artifact.last, + severity: artifact.action_plan && artifact.action_plan.severity ? artifact.action_plan.severity : 'review', + status: artifact.status || { state: 'review', owner: null, ticket: null, notes: [] }, + evidence: { + history_query: artifact.evidence.history_query, + session_link: artifact.evidence.session_link, + threshold_evidence_query: artifact.evidence.threshold_evidence_query + }, + action_plan: { + title: artifact.action_plan && artifact.action_plan.title, + safe_metadata: artifact.action_plan && artifact.action_plan.safe_metadata, + guardrails: artifact.action_plan && artifact.action_plan.guardrails ? artifact.action_plan.guardrails : [] + } + }; + }).sort((left, right) => { + if (left.created_at !== right.created_at) return String(left.created_at).localeCompare(String(right.created_at)); + if (left.rule !== right.rule) return String(left.rule).localeCompare(String(right.rule)); + return String(left.session).localeCompare(String(right.session)); + }); + + const excluded = new Set(['prompts', 'responses', 'tool arguments', 'tool results', 'file contents']); + for (const artifact of artifacts) { + for (const item of (artifact.privacy && artifact.privacy.excluded) || []) excluded.add(item); + } + + return { + schema_version: 'agentops.incident-timeline.v1', + created_at: created, + incident_id: incidentId || `incident-${created.replace(/[^0-9]/g, '').slice(0, 14)}`, + workspace_id: normalized[0].evidence.session_link && normalized[0].evidence.session_link.workspace_id + ? normalized[0].evidence.session_link.workspace_id + : workspaceId, + privacy: { + mode: 'metadata-only', + excluded: Array.from(excluded) + }, + status: { + state: 'review', + owner: null, + tickets: normalized.map(item => item.status.ticket).filter(Boolean), + notes: [] + }, + timeline: normalized.map((item, index) => ({ + sequence: index + 1, + type: 'alert_artifact', + at: item.created_at, + rule: item.rule, + session: item.session, + severity: item.severity, + state: item.status.state || 'review', + summary: `AgentOps alert ${item.rule} for session ${item.session}` + })), + artifacts: normalized, + next: [ + 'Review the metadata-only timeline and assign an owner.', + 'Create a ticket manually only after confirming the KQL and dashboard evidence.', + 'Keep prompts, responses, tool arguments, tool results, and file contents out of incident notes.' + ] + }; +} + +module.exports = { + incidentTimelineFromArtifacts +}; diff --git a/agentops-cli/src/lib/args.js b/agentops-cli/src/lib/args.js index 1c0b8fe..0e8bfc9 100644 --- a/agentops-cli/src/lib/args.js +++ b/agentops-cli/src/lib/args.js @@ -14,6 +14,42 @@ function optionValue(args, names, fallback = null) { return fallback; } +function requiredOptionValue(args, names) { + const list = Array.isArray(names) ? names : [names]; + for (const name of list) { + const index = args.indexOf(name); + if (index !== -1) { + if (!args[index + 1]) throw new Error(`${name} requires a value`); + return args[index + 1]; + } + } + return null; +} + +function optionValues(args, name) { + const values = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === name) { + if (!args[index + 1]) throw new Error(`${name} requires a value`); + values.push(args[index + 1]); + index += 1; + } + } + return values; +} + +function firstPositional(args = []) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg.startsWith('--')) { + if (!arg.includes('=') && index + 1 < args.length && !args[index + 1].startsWith('--')) index += 1; + continue; + } + return arg; + } + return 'latest'; +} + function parseJsonFlag(args) { return hasFlag(args, '--json'); } @@ -34,8 +70,11 @@ function withoutFlags(args, names) { } module.exports = { + firstPositional, hasFlag, optionValue, + optionValues, parseJsonFlag, + requiredOptionValue, withoutFlags }; diff --git a/agentops-cli/src/lib/ask-context-command.js b/agentops-cli/src/lib/ask-context-command.js new file mode 100644 index 0000000..da2d0d7 --- /dev/null +++ b/agentops-cli/src/lib/ask-context-command.js @@ -0,0 +1,45 @@ +const path = require('node:path'); + +const legacy = require('../legacy'); +const { hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { + buildV2AskContext, + hasV2AskArgs, + renderV2AskContext +} = require('./v2-ask-context'); + +function legacyAskContext(args = []) { + const sessionId = args[0] || 'latest'; + const last = optionValue(args, '--last', '24h'); + const result = legacy.askAgentOpsContext({ sessionId, last, json: hasFlag(args, '--json'), args: args.slice(1) }); + writeJsonOrRender(result, hasFlag(args, '--json'), legacy.renderAskContext); + process.exitCode = result.ok ? 0 : 1; +} + +function askContextCommand(args = []) { + if (!hasV2AskArgs(args)) return legacyAskContext(args); + const target = args[0] || 'latest'; + const result = buildV2AskContext({ + runId: target, + runsFile: path.resolve(optionValue(args, '--runs')), + eventsFile: optionValue(args, '--events') ? path.resolve(optionValue(args, '--events')) : null, + toolsFile: optionValue(args, '--tools') ? path.resolve(optionValue(args, '--tools')) : null, + privacyFile: optionValue(args, '--privacy') ? path.resolve(optionValue(args, '--privacy')) : null, + githubFile: optionValue(args, '--github') ? path.resolve(optionValue(args, '--github')) : null, + evalsFile: optionValue(args, '--evals') ? path.resolve(optionValue(args, '--evals')) : null, + insightsFile: optionValue(args, '--insights') ? path.resolve(optionValue(args, '--insights')) : null, + recommendationsFile: optionValue(args, '--recommendations') ? path.resolve(optionValue(args, '--recommendations')) : null, + last: optionValue(args, '--last', '2h') + }); + writeJsonOrRender(result, hasFlag(args, '--json'), renderV2AskContext); + process.exitCode = result.ok ? 0 : 1; +} + +module.exports = { + askContextCommand, + buildAskContext: buildV2AskContext, + hasV2AskArgs, + legacyAskContext, + renderAskContext: renderV2AskContext +}; diff --git a/agentops-cli/src/lib/ask-context.js b/agentops-cli/src/lib/ask-context.js new file mode 100644 index 0000000..e66afca --- /dev/null +++ b/agentops-cli/src/lib/ask-context.js @@ -0,0 +1,100 @@ +function createAskContext(dependencies = {}) { + const { + buildLink, + latestSessionAzureQuery, + latestSummaryFromArgs, + parseLastArg, + portalLogsUrl, + sessionsGrafanaDashboardUrl, + validateKqlDuration, + workspaceId + } = dependencies; + + function askAgentOpsContext(options = {}) { + const last = validateKqlDuration(options.last || '24h'); + const target = options.sessionId || 'latest'; + let session = null; + let link = null; + let dataMissing = []; + + if (target === 'latest') { + const summary = options.summary || latestSummaryFromArgs(options.args || [], last); + session = summary.session; + dataMissing = summary.data_missing || []; + if (session?.id && session.id !== 'unknown-session') { + link = buildLink('session', session.id, { last }); + } + } else { + session = { id: target }; + link = buildLink('session', target, { last }); + } + + const sessionId = session?.id || 'unknown-session'; + const prompt = [ + 'Use the telemetry-investigator agent with read-only Azure MCP and Grafana MCP.', + '', + `Investigate AgentOps session ${sessionId} over the last ${last}.`, + `Grafana session URL: ${link?.grafana_url || sessionsGrafanaDashboardUrl}`, + `Log Analytics workspace: ${workspaceId}`, + '', + 'Start from the KQL query in this context bundle. Return only evidence-backed findings.', + 'For each recommendation include: evidence query or dashboard link, observed pattern, proposed file(s), expected metric movement, validation benchmark or query, and rollback condition.', + 'Do not edit files yet. Do not request prompt, response, tool argument, tool result, secret, URL content, or file-content capture.' + ].join('\n'); + + return { + ok: Boolean(link), + session: sessionId, + last, + dashboard: link?.grafana_url || sessionsGrafanaDashboardUrl, + azure_portal_url: link?.azure_portal_url || portalLogsUrl, + workspace_id: workspaceId, + query: link?.query || latestSessionAzureQuery(last), + mcp_configs: [ + 'copilot/mcp.azure-monitor.sample.json', + 'copilot/mcp.grafana.sample.json' + ], + prompt, + data_missing: dataMissing + }; + } + + function parseAskContextArgs(args) { + const sessionId = args[0] || 'latest'; + return { + sessionId, + last: parseLastArg(args.slice(1), '24h'), + json: args.includes('--json'), + args: args.slice(1) + }; + } + + function renderAskContext(context) { + const lines = [ + 'AgentOps ask context', + '', + `Session: ${context.session}`, + `Dashboard: ${context.dashboard}`, + `Workspace: ${context.workspace_id}`, + `MCP configs: ${context.mcp_configs.join(', ')}`, + '' + ]; + + if (context.data_missing.length > 0) { + lines.push(`Data missing: ${context.data_missing.join(', ')}.`, ''); + } + + lines.push('KQL:', context.query, '', 'Prompt:', context.prompt); + return `${lines.join('\n')}\n`; + } + + return { + askAgentOpsContext, + parseAskContextArgs, + renderAskContext + }; +} + +module.exports = { + createAskContext +}; diff --git a/agentops-cli/src/lib/azure-ingest-command.js b/agentops-cli/src/lib/azure-ingest-command.js new file mode 100644 index 0000000..7e3076c --- /dev/null +++ b/agentops-cli/src/lib/azure-ingest-command.js @@ -0,0 +1,70 @@ +const path = require('node:path'); + +const { hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { + jsonArrayUploadFile, + renderLogsIngestionUploadResult, + runLogsIngestionUpload +} = require('./azure/logs-ingestion-upload'); +const { + buildAzureIngestPlan, + buildLogsIngestionUploadPlan, + buildSharedStorageUploadPlan, + renderAzureIngestPlan, + renderLogsIngestionUploadPlan, + renderSharedStorageUploadPlan +} = require('./azure/v2-ingest-plan'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function azureIngestCommand(args = []) { + const [subcommand = 'plan'] = args; + + if (subcommand === 'plan') { + const dir = optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'demo', 'latest')); + const plan = buildAzureIngestPlan({ dir, allowContent: hasFlag(args, '--allow-content') }); + + writeJsonOrRender(plan, hasFlag(args, '--json'), renderAzureIngestPlan); + if (!plan.ok) process.exitCode = 1; + return; + } + + if (subcommand === 'logs-upload') { + const dir = optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'demo', 'latest')); + const plan = buildLogsIngestionUploadPlan({ + dir, + endpoint: optionValue(args, '--endpoint', process.env.AGENTOPS_LOGS_INGESTION_ENDPOINT || ''), + dcrImmutableId: optionValue(args, '--dcr-immutable-id', process.env.AGENTOPS_DCR_IMMUTABLE_ID || ''), + allowContent: hasFlag(args, '--allow-content') + }); + const yes = hasFlag(args, '--yes'); + const result = yes ? runLogsIngestionUpload(plan) : plan; + + writeJsonOrRender(result, hasFlag(args, '--json'), yes ? renderLogsIngestionUploadResult : renderLogsIngestionUploadPlan); + if (!result.ok) process.exitCode = 1; + return; + } + + if (subcommand === 'upload-plan') { + const dir = optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'shared', 'latest')); + const plan = buildSharedStorageUploadPlan({ + dir, + account: optionValue(args, '--account'), + container: optionValue(args, '--container', 'agentops-shared'), + prefix: optionValue(args, '--prefix', 'agentops-shared') + }); + + writeJsonOrRender(plan, hasFlag(args, '--json'), renderSharedStorageUploadPlan); + if (!plan.ok) process.exitCode = 1; + return; + } + + throw new Error('azure-ingest supports: plan, logs-upload, upload-plan'); +} + +module.exports = { + azureIngestCommand, + jsonArrayUploadFile, + runLogsIngestionUpload +}; diff --git a/agentops-cli/src/lib/azure-posture.js b/agentops-cli/src/lib/azure-posture.js new file mode 100644 index 0000000..46d5b3f --- /dev/null +++ b/agentops-cli/src/lib/azure-posture.js @@ -0,0 +1,270 @@ +const { commandShellQuote } = require('./smoke'); +const { asArray } = require('./type-predicates'); + +function pathValue(source, keys, fallback = null) { + let value = source; + for (const key of keys) { + if (value === undefined || value === null) return fallback; + value = value[key]; + } + return value === undefined ? fallback : value; +} + +function boolish(value) { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.toLowerCase() === 'true'; + return Boolean(value); +} + +const azureRoleIds = { + logAnalyticsDataReader: '3b03c2da-16b3-4a49-8834-0f8130efdd3b', + monitoringReader: '43d0d8ad-25c7-4714-9337-8ba259a9fe05', + grafanaViewer: '60921a7e-fef1-4a43-9b16-a26c52ad4769', + grafanaEditor: 'a79a5197-3a5c-4973-a920-486035ffd60f', + grafanaAdmin: '22926164-76b3-42b3-bc55-97df8dab3e41', + contributor: 'b24988ac-6180-42a0-ab88-20f7382dd24c', + owner: '8e3af657-a8ff-443c-a75c-2fe8c4bcb635', + userAccessAdministrator: ['f1a07417', 'd97a', '45cb', '824c', '7a7467783830b'].join('-') +}; + +function roleDefinitionIdSuffix(value) { + const id = String(value || '').toLowerCase(); + const parts = id.split('/'); + return parts[parts.length - 1] || id; +} + +function roleAssignmentSummary(assignments, allowedRoleIds) { + const allowed = new Set(allowedRoleIds.map(role => role.toLowerCase())); + const rows = asArray(assignments); + const matching = rows.filter(row => allowed.has(roleDefinitionIdSuffix(row.roleDefinitionId))); + const groupAssignments = matching.filter(row => String(row.principalType || '').toLowerCase() === 'group'); + const broadAssignments = rows.filter(row => [ + azureRoleIds.owner, + azureRoleIds.contributor, + azureRoleIds.userAccessAdministrator + ].includes(roleDefinitionIdSuffix(row.roleDefinitionId))); + return { + assignments: rows.length, + matching: matching.length, + group_assignments: groupAssignments.length, + broad_assignments: broadAssignments.length, + principal_types: Array.from(new Set(rows.map(row => row.principalType).filter(Boolean))).sort(), + role_names: Array.from(new Set(rows.map(row => row.roleDefinitionName).filter(Boolean))).sort() + }; +} + +function agentOpsScheduledQueryRules(rules) { + return asArray(rules).filter(rule => { + const name = String(rule.name || '').toLowerCase(); + const displayName = String(pathValue(rule, ['properties', 'displayName'], '')).toLowerCase(); + return name.startsWith('sqr-') || displayName.includes('copilot agentops'); + }); +} + +function logAnalyticsTablesFromResult(value) { + const parsed = asArray(value?.value || value); + return parsed.map(table => ({ + name: table?.name || pathValue(table, ['properties', 'name'], ''), + retention_days: [table?.retentionInDays, pathValue(table, ['properties', 'retentionInDays'], NaN)] + .map(Number) + .find(Number.isFinite), + total_retention_days: Number(table?.totalRetentionInDays ?? pathValue(table, ['properties', 'totalRetentionInDays'], NaN)) + })); +} + +function agentOpsContentTables(tables) { + return logAnalyticsTablesFromResult(tables).filter(table => String(table.name || '').toLowerCase() === 'agentopscontent_cl'); +} + +function azureBudgetsFromResult(value) { + return asArray(value?.value || value).map(budget => ({ + name: budget?.name || '', + amount: Number(budget?.amount ?? pathValue(budget, ['properties', 'amount'], NaN)), + category: budget?.category || pathValue(budget, ['properties', 'category'], ''), + time_grain: budget?.timeGrain || pathValue(budget, ['properties', 'timeGrain'], '') + })); +} + +function privateEndpointConnectionsFromResource(resource) { + return asArray(pathValue(resource, ['properties', 'privateEndpointConnections'], [])); +} + +function approvedPrivateEndpointConnections(resource) { + return privateEndpointConnectionsFromResource(resource).filter(connection => { + const status = String( + pathValue(connection, ['properties', 'privateLinkServiceConnectionState', 'status'], '') || + pathValue(connection, ['privateLinkServiceConnectionState', 'status'], '') + ).toLowerCase(); + return status === 'approved'; + }); +} + +function actionGroupReceiverSummary(actionGroup) { + const receiverKeys = [ + 'emailReceivers', + 'smsReceivers', + 'webhookReceivers', + 'azureAppPushReceivers', + 'itsmReceivers', + 'automationRunbookReceivers', + 'voiceReceivers', + 'logicAppReceivers', + 'azureFunctionReceivers', + 'armRoleReceivers', + 'eventHubReceivers' + ]; + const properties = actionGroup?.properties || actionGroup || {}; + const counts = Object.fromEntries(receiverKeys.map(key => [key, asArray(properties[key]).length])); + const total = Object.values(counts).reduce((sum, count) => sum + count, 0); + return { receiver_count: total, receiver_types: counts }; +} + +function azureProductionRemediationPlan(result, options = {}) { + const checks = Object.fromEntries((result.checks || []).map(check => [check.name, check])); + const config = result.config || {}; + const resourceGroup = config.resource_group || '<resource-group>'; + const workspaceName = config.workspace_name || '<workspace-name>'; + const grafanaName = config.grafana_name || '<grafana-name>'; + const desiredQuotaGb = Number(options.dailyQuotaGb || 5); + const actionGroups = options.actionGroupResourceIds || '["/subscriptions/<sub>/resourceGroups/<rg>/providers/microsoft.insights/actionGroups/<name>"]'; + const readinessProfile = String(config.readiness_profile || (config.production ? 'internal' : 'personal')); + const readinessLabel = config.production ? 'production' : readinessProfile; + const validationMode = config.production ? '--production' : `--profile ${commandShellQuote(readinessProfile)}`; + const validateAgain = `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} ${validationMode} --json`; + const actions = []; + + if (checks['log-analytics-posture'] && !checks['log-analytics-posture'].ok) { + actions.push({ + name: 'set-log-analytics-daily-cap', + risk: 'low', + reason: 'Production mode expects a finite Log Analytics daily ingestion cap.', + review: 'Confirm expected telemetry volume before applying the cap.', + commands: [ + `az monitor log-analytics workspace update --resource-group ${commandShellQuote(resourceGroup)} --workspace-name ${commandShellQuote(workspaceName)} --quota ${desiredQuotaGb}`, + `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` + ] + }); + } + + if (checks['grafana-production-posture'] && !checks['grafana-production-posture'].ok) { + actions.push({ + name: 'harden-managed-grafana-network-and-availability', + risk: 'medium', + reason: 'Production mode expects Managed Grafana private access and zone redundancy.', + review: 'Verify private connectivity, DNS, operator access, and regional zone-redundancy support before disabling public access.', + commands: [ + `AGENTOPS_GRAFANA_PUBLIC_NETWORK_ACCESS=Disabled AGENTOPS_GRAFANA_ZONE_REDUNDANCY=Enabled ./scripts/azure-what-if.sh`, + `az grafana update --resource-group ${commandShellQuote(resourceGroup)} --name ${commandShellQuote(grafanaName)} --public-network-access Disabled --zone-redundancy Enabled`, + `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` + ] + }); + } + + if (checks['alert-routing-posture'] && !checks['alert-routing-posture'].ok) { + const ruleNames = asArray(checks['alert-routing-posture'].rule_names).length + ? asArray(checks['alert-routing-posture'].rule_names) + : ['sqr-<agentops-alert-name>']; + actions.push({ + name: 'route-agentops-alerts-to-action-groups', + risk: 'medium', + reason: 'Production mode expects enabled AgentOps scheduled query alerts routed to Azure Monitor action groups.', + review: 'Create or approve action groups first, then tune thresholds against real traffic before enabling notifications.', + commands: [ + `export AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS=${commandShellQuote(String(actionGroups))}`, + 'AGENTOPS_DEPLOY_ALERTS=true AGENTOPS_ENABLE_ALERTS=true ./scripts/azure-what-if.sh', + ...ruleNames.map(name => `az monitor scheduled-query update --resource-group ${commandShellQuote(resourceGroup)} --name ${commandShellQuote(name)} --disabled false --action-groups "$AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS"`), + `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` + ] + }); + } + + if (checks['access-rbac-posture'] && !checks['access-rbac-posture'].ok) { + actions.push({ + name: 'review-agentops-rbac-assignments', + risk: 'medium', + reason: `${readinessLabel} posture expects least-privilege RBAC on Log Analytics and Managed Grafana scopes.`, + review: 'Assign Entra groups rather than individual users; avoid Owner/Contributor for routine observability access.', + commands: [ + 'AGENTOPS_DEPLOY_RBAC_ASSIGNMENTS=true ./scripts/azure-what-if.sh', + validateAgain + ] + }); + } + + if (checks['content-capture-table-posture'] && !checks['content-capture-table-posture'].ok) { + actions.push({ + name: 'harden-optional-content-capture-storage', + risk: 'high', + reason: 'Optional prompt/response transcript storage must have short retention and least-privilege workspace access.', + review: 'Confirm content capture is intentionally enabled, then restrict access and lower retention before production.', + commands: [ + `az monitor log-analytics workspace table update --resource-group ${commandShellQuote(resourceGroup)} --workspace-name ${commandShellQuote(workspaceName)} --name AgentOpsContent_CL --retention-time 30`, + `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` + ] + }); + } + + if (checks['azure-budget-posture'] && !checks['azure-budget-posture'].ok) { + actions.push({ + name: 'configure-agentops-budget', + risk: 'medium', + reason: `${readinessLabel} posture expects an Azure Consumption budget so runaway token/tool loops have a spend guardrail.`, + review: 'Confirm the monthly amount and approved notification contacts before deploying the budget.', + commands: [ + 'AGENTOPS_DEPLOY_BUDGET=true ./scripts/azure-what-if.sh', + validateAgain + ] + }); + } + + if (checks['grafana-private-access-posture'] && !checks['grafana-private-access-posture'].ok) { + actions.push({ + name: 'verify-managed-grafana-private-access', + risk: 'medium', + reason: 'Production mode expects public Grafana access disabled with an approved private endpoint path.', + review: 'Test private DNS and operator access before disabling or depending on private access.', + commands: [ + `az grafana show --resource-group ${commandShellQuote(resourceGroup)} --name ${commandShellQuote(grafanaName)} --query properties.privateEndpointConnections`, + `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` + ] + }); + } + + if (checks['action-group-destination-posture'] && !checks['action-group-destination-posture'].ok) { + actions.push({ + name: 'verify-alert-action-group-destinations', + risk: 'medium', + reason: 'Production mode expects routed AgentOps alerts to target enabled action groups with at least one receiver.', + review: 'Review notification destinations, rate limits, and escalation ownership before enabling alerts.', + commands: [ + 'az monitor action-group list --resource-group <resource-group> --query "[].{name:name,enabled:enabled}"', + `node agentops-cli/src/index.js validate-azure --last ${result.last || '24h'} --production --json` + ] + }); + } + + return { + ok: actions.length === 0, + mode: 'proposal-only', + actions, + note: actions.length === 0 + ? 'No production posture remediation is currently required.' + : 'Review these commands before running them. The planner does not mutate Azure.' + }; +} + +module.exports = { + actionGroupReceiverSummary, + agentOpsContentTables, + agentOpsScheduledQueryRules, + approvedPrivateEndpointConnections, + asArray, + azureBudgetsFromResult, + azureProductionRemediationPlan, + azureRoleIds, + boolish, + logAnalyticsTablesFromResult, + pathValue, + privateEndpointConnectionsFromResource, + roleAssignmentSummary +}; diff --git a/agentops-cli/src/lib/azure-validation-render.js b/agentops-cli/src/lib/azure-validation-render.js new file mode 100644 index 0000000..ede8936 --- /dev/null +++ b/agentops-cli/src/lib/azure-validation-render.js @@ -0,0 +1,28 @@ +function renderValidateAzure(result) { + const lines = ['AgentOps Azure validation', '']; + for (const check of result.checks) { + const status = check.ok ? 'ok' : 'failed'; + const skipped = check.skipped ? ' skipped' : ''; + lines.push(`- ${check.name}: ${status}${skipped}${check.detail ? ` (${check.detail})` : ''}`); + if (check.name === 'grafana-dashboards' && !check.ok && Array.isArray(check.missing) && check.missing.length > 0) { + lines.push(` missing: ${check.missing.join(', ')}`); + lines.push(' fix: agentops validate-azure --import-dashboards --last 24h'); + } + } + lines.push('', result.ok ? 'Azure validation passed.' : 'Azure validation is incomplete.'); + if (result.remediation_plan) { + lines.push('', 'Remediation plan:', result.remediation_plan.note); + for (const action of result.remediation_plan.actions || []) { + lines.push(`- ${action.name} (${action.risk}): ${action.reason}`); + lines.push(` review: ${action.review}`); + for (const command of action.commands || []) lines.push(` command: ${command}`); + } + } + lines.push('Next:'); + for (const item of result.next) lines.push(`- ${item}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + renderValidateAzure +}; diff --git a/agentops-cli/src/lib/azure-validation-runtime.js b/agentops-cli/src/lib/azure-validation-runtime.js new file mode 100644 index 0000000..0730c50 --- /dev/null +++ b/agentops-cli/src/lib/azure-validation-runtime.js @@ -0,0 +1,41 @@ +const childProcess = require('node:child_process'); +const { commandCandidates } = require('./shell'); + +function azAvailable(options = {}) { + if (options.azAvailable !== undefined) return Boolean(options.azAvailable); + if (options.spawnSync) return true; + return (options.commandCandidates || commandCandidates)('az').length > 0; +} + +function runAz(args, options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + return spawnSync('az', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024 + }); +} + +function parseJsonOutput(result) { + try { + return JSON.parse(result.stdout || '{}'); + } catch { + return null; + } +} + +function checkResult(name, ok, extra = {}) { + return { name, ok: Boolean(ok), ...extra }; +} + +function azErrorDetail(result, fallback) { + return (result.stderr || result.stdout || fallback || `az exited with status ${result.status}`).trim(); +} + +module.exports = { + azAvailable, + azErrorDetail, + checkResult, + commandCandidates, + parseJsonOutput, + runAz +}; diff --git a/agentops-cli/src/lib/azure-validation.js b/agentops-cli/src/lib/azure-validation.js new file mode 100644 index 0000000..c78425d --- /dev/null +++ b/agentops-cli/src/lib/azure-validation.js @@ -0,0 +1,644 @@ +const { validateKqlDuration } = require('./kql'); +const { baseFilter } = require('./observability-queries'); +const { renderValidateAzure } = require('./azure-validation-render'); +const { + azAvailable, + azErrorDetail, + checkResult, + parseJsonOutput, + runAz +} = require('./azure-validation-runtime'); +const { + actionGroupReceiverSummary, + agentOpsContentTables, + agentOpsScheduledQueryRules, + approvedPrivateEndpointConnections, + asArray, + azureBudgetsFromResult, + azureProductionRemediationPlan, + azureRoleIds, + boolish, + logAnalyticsTablesFromResult, + pathValue, + privateEndpointConnectionsFromResource, + roleAssignmentSummary +} = require('./azure-posture'); +const { validateDurableReceiptAzureSchema } = require('./azure/durable-receipt-schema-guard'); + +function validateAzure(options = {}, dependencies = {}) { + const { + configuredCloudValues, + isConfiguredValue, + runAzureLogAnalyticsQuery, + listGrafanaDashboardFiles, + flattenGrafanaList, + grafanaItemUid, + grafanaDashboardImportCommand, + runGrafanaDashboardImportRemediation + } = dependencies; + + const last = validateKqlDuration(options.last || '2h'); + const cloud = configuredCloudValues(options); + const checks = []; + const next = []; + const hasAz = azAvailable(options); + const production = Boolean(options.production); + const requestedReadinessProfile = String(options.readinessProfile || '').toLowerCase(); + const readinessProfileValid = !requestedReadinessProfile || ['personal', 'team', 'internal'].includes(requestedReadinessProfile); + const readinessProfile = production + ? 'internal' + : (['personal', 'team', 'internal'].includes(requestedReadinessProfile) ? requestedReadinessProfile : 'personal'); + const costGuardrailsRequired = readinessProfile === 'team' || readinessProfile === 'internal'; + const groupRbacRequired = production || readinessProfile === 'internal'; + + checks.push(checkResult('azure-readiness-profile', readinessProfileValid, { + readiness_profile: readinessProfile, + cost_guardrails_required: costGuardrailsRequired, + advisory: readinessProfile === 'personal', + detail: !readinessProfileValid + ? `unsupported readiness profile: ${requestedReadinessProfile}` + : readinessProfile === 'personal' + ? 'personal dev/demo posture only; do not use for Microsoft confidential, customer, or production data' + : `${readinessProfile} posture requires a finite Log Analytics daily cap and an Azure budget` + })); + + checks.push(checkResult('az-cli', hasAz, hasAz ? {} : { detail: 'Azure CLI was not found on PATH.' })); + + let account = null; + if (hasAz) { + const accountResult = runAz(['account', 'show', '-o', 'json'], options); + account = accountResult.status === 0 ? parseJsonOutput(accountResult) : null; + checks.push(checkResult('azure-account', accountResult.status === 0, { + detail: accountResult.status === 0 ? account?.name || account?.id || 'logged in' : (accountResult.stderr || accountResult.stdout || 'az account show failed').trim() + })); + if (cloud.subscriptionId && account?.id && account.id !== cloud.subscriptionId) { + checks.push(checkResult('azure-subscription', false, { + expected: cloud.subscriptionId, + actual: account.id, + detail: 'Active Azure subscription does not match AGENTOPS_AZURE_SUBSCRIPTION_ID/AZURE_SUBSCRIPTION_ID.' + })); + next.push(`az account set --subscription "${cloud.subscriptionId}"`); + } else if (cloud.subscriptionId) { + checks.push(checkResult('azure-subscription', true, { expected: cloud.subscriptionId, actual: account?.id || null })); + } + } + + if (!cloud.resourceGroup) { + checks.push(checkResult('resource-group-configured', false, { detail: 'Set AZURE_RESOURCE_GROUP or AGENTOPS_AZURE_RESOURCE_GROUP.' })); + next.push('agentops configure set --resource-group rg-agentops-dev'); + } else if (hasAz) { + const groupResult = runAz(['group', 'exists', '--name', cloud.resourceGroup, '-o', 'tsv'], options); + const exists = groupResult.status === 0 && String(groupResult.stdout || '').trim() === 'true'; + checks.push(checkResult('resource-group', exists, { resource_group: cloud.resourceGroup })); + if (!exists) next.push('Run ./scripts/azure-readiness.sh and review the target resource group.'); + } + + if (hasAz && cloud.resourceGroup) { + const budgetResult = runAz(['consumption', 'budget', 'list', '--resource-group', cloud.resourceGroup, '-o', 'json'], options); + const budgets = budgetResult.status === 0 ? azureBudgetsFromResult(parseJsonOutput(budgetResult)) : []; + const validBudgets = budgets.filter(budget => Number.isFinite(budget.amount) && budget.amount > 0); + const budgetObserved = budgetResult.status === 0 && validBudgets.length > 0; + const budgetPostureOk = !costGuardrailsRequired || budgetObserved; + checks.push(checkResult('azure-budget-posture', budgetPostureOk, { + budgets: budgets.length, + budget_names: budgets.map(budget => budget.name).filter(Boolean), + valid_budgets: validBudgets.length, + production, + readiness_profile: readinessProfile, + required: costGuardrailsRequired, + advisory: !costGuardrailsRequired && !budgetObserved, + detail: budgetResult.status !== 0 + && costGuardrailsRequired + ? azErrorDetail(budgetResult, 'could not list Azure Consumption budgets') + : budgetResult.status !== 0 + ? 'personal dev advisory: Azure budget posture could not be read' + : budgetObserved + ? 'Azure budget guardrail observed' + : costGuardrailsRequired + ? `${readinessProfile} posture requires an Azure budget for AgentOps spend guardrails` + : 'personal dev advisory: no Azure budget observed; keep usage metadata-only and review spend manually' + })); + if (budgetResult.status !== 0 && costGuardrailsRequired) next.push('Verify Azure Consumption budget read permissions for the resource group.'); + else if (costGuardrailsRequired && validBudgets.length === 0) next.push(`Configure an Azure Consumption budget before ${readinessProfile} AgentOps rollout.`); + } + + const workspaceConfigured = isConfiguredValue(cloud.workspaceId, /^0{8}-0{4}-0{4}-0{4}-0{12}$/); + checks.push(checkResult('log-analytics-workspace-id', workspaceConfigured, { + workspace_id: workspaceConfigured ? cloud.workspaceId : null, + detail: workspaceConfigured ? 'configured' : 'Set AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID or LOG_ANALYTICS_WORKSPACE_ID.' + })); + if (!workspaceConfigured) next.push('agentops configure set --workspace-id "<workspace-id>"'); + + if (hasAz && workspaceConfigured) { + const query = `AppDependencies | where TimeGenerated > ago(${last}) | where ${baseFilter} | summarize Rows=count()`; + const queryResult = runAzureLogAnalyticsQuery(query, { + spawnSync: options.spawnSync, + workspaceId: cloud.workspaceId + }); + const rowCount = queryResult.rows?.[0]?.Rows ?? queryResult.rows?.[0]?.rows ?? null; + checks.push(checkResult('log-analytics-query', queryResult.ok, { + rows: rowCount, + detail: queryResult.ok ? 'query succeeded' : queryResult.error + })); + } + + if (hasAz) { + const schemaValidation = validateDurableReceiptAzureSchema({ + resourceGroup: cloud.resourceGroup, + workspaceName: cloud.workspaceName, + dcrImmutableId: cloud.dcrImmutableId, + runAz: args => runAz(args, options) + }); + checks.push(checkResult('durable-receipt-live-schema', schemaValidation.ok, schemaValidation)); + if (!schemaValidation.ok) { + next.push('Preserve EstimatedCostUsd as long; add EventId:string, Sequence:long, and EstimatedCostUsdReal:real to AgentOpsEvents_CL and Custom-AgentOpsEvents_CL before relying on exact ordered Azure receipt proof.'); + } + } + + if (hasAz && cloud.resourceGroup && cloud.workspaceName) { + let workspaceRbacOkForContent = null; + const workspaceResult = runAz([ + 'monitor', + 'log-analytics', + 'workspace', + 'show', + '--resource-group', + cloud.resourceGroup, + '--workspace-name', + cloud.workspaceName, + '-o', + 'json' + ], options); + const workspace = workspaceResult.status === 0 ? parseJsonOutput(workspaceResult) : null; + const workspaceResourceId = workspace?.id || null; + const retentionDays = Number(workspace?.retentionInDays ?? 0); + const dailyQuotaGb = Number(pathValue(workspace, ['workspaceCapping', 'dailyQuotaGb'], NaN)); + const resourceScopedAccess = boolish(pathValue(workspace, ['features', 'enableLogAccessUsingOnlyResourcePermissions'], false)); + const logAnalyticsPostureOk = workspaceResult.status === 0 && + (!costGuardrailsRequired || (retentionDays > 0 && dailyQuotaGb !== -1 && resourceScopedAccess)); + checks.push(checkResult('log-analytics-posture', logAnalyticsPostureOk, { + workspace: cloud.workspaceName, + retention_days: Number.isFinite(retentionDays) ? retentionDays : null, + daily_quota_gb: Number.isFinite(dailyQuotaGb) ? dailyQuotaGb : null, + resource_scoped_access: resourceScopedAccess, + issues: [ + retentionDays <= 0 ? 'retention' : null, + dailyQuotaGb === -1 ? 'daily_cap' : null, + !resourceScopedAccess ? 'resource_scoped_access' : null + ].filter(Boolean), + production, + readiness_profile: readinessProfile, + cost_guardrails_required: costGuardrailsRequired, + advisory: !costGuardrailsRequired && dailyQuotaGb === -1, + detail: workspaceResult.status !== 0 + ? azErrorDetail(workspaceResult, 'could not read Log Analytics workspace posture') + : costGuardrailsRequired + ? (logAnalyticsPostureOk + ? 'retention, daily cap, and resource-scoped access configured' + : `${readinessProfile} posture requires retention, a finite daily ingestion cap, and resource-scoped access`) + : dailyQuotaGb === -1 + ? 'personal dev advisory: Log Analytics ingestion is uncapped; team/internal readiness would fail' + : 'personal dev Log Analytics posture observed' + })); + if (workspaceResult.status !== 0) next.push('Set AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME to the deployed workspace name.'); + else if (costGuardrailsRequired && (retentionDays <= 0 || dailyQuotaGb === -1 || !resourceScopedAccess)) { + next.push(`Review Log Analytics retention, daily cap, and resource-scoped access before ${readinessProfile} rollout.`); + } + + if (workspaceResult.status === 0 && workspaceResourceId) { + const roleResult = runAz([ + 'role', + 'assignment', + 'list', + '--scope', + workspaceResourceId, + '--include-groups', + '-o', + 'json' + ], options); + const summary = roleAssignmentSummary( + roleResult.status === 0 ? parseJsonOutput(roleResult) : [], + [azureRoleIds.logAnalyticsDataReader, azureRoleIds.monitoringReader] + ); + const workspaceRbacOk = roleResult.status === 0 && + (!groupRbacRequired || (summary.group_assignments > 0 && summary.broad_assignments === 0)); + workspaceRbacOkForContent = workspaceRbacOk; + checks.push(checkResult('log-analytics-rbac-posture', workspaceRbacOk, { + scope: workspaceResourceId, + ...summary, + production, + readiness_profile: readinessProfile, + group_rbac_required: groupRbacRequired, + detail: roleResult.status !== 0 + ? azErrorDetail(roleResult, 'could not list Log Analytics RBAC assignments') + : groupRbacRequired + ? (workspaceRbacOk + ? 'least-privilege group RBAC observed on Log Analytics' + : `${readinessProfile} posture expects group-based reader RBAC and no broad Owner/Contributor assignments on Log Analytics`) + : 'Log Analytics RBAC posture observed' + })); + if (roleResult.status !== 0) next.push('Verify Azure RBAC read permissions for the Log Analytics workspace.'); + else if (groupRbacRequired && !workspaceRbacOk) next.push(`Review Log Analytics RBAC: assign observer groups and remove routine broad roles before ${readinessProfile} rollout.`); + } + + if (production && workspaceResult.status === 0) { + const tableResult = runAz([ + 'monitor', + 'log-analytics', + 'workspace', + 'table', + 'list', + '--resource-group', + cloud.resourceGroup, + '--workspace-name', + cloud.workspaceName, + '-o', + 'json' + ], options); + const contentTables = tableResult.status === 0 ? agentOpsContentTables(parseJsonOutput(tableResult)) : []; + const retentionCandidates = contentTables + .map(table => table.retention_days) + .filter(Number.isFinite); + const contentRetentionDays = retentionCandidates.length > 0 + ? Math.min(...retentionCandidates) + : retentionDays; + const hasContentTable = contentTables.length > 0; + const shortRetention = Number.isFinite(contentRetentionDays) && contentRetentionDays > 0 && contentRetentionDays <= 30; + const contentPostureOk = tableResult.status === 0 && + (!hasContentTable || (shortRetention && resourceScopedAccess && workspaceRbacOkForContent === true)); + checks.push(checkResult('content-capture-table-posture', contentPostureOk, { + table: 'AgentOpsContent_CL', + observed: hasContentTable, + retention_days: Number.isFinite(contentRetentionDays) ? contentRetentionDays : null, + max_retention_days: 30, + resource_scoped_access: resourceScopedAccess, + log_analytics_rbac_ok: workspaceRbacOkForContent, + issues: hasContentTable ? [ + !shortRetention ? 'short_retention' : null, + !resourceScopedAccess ? 'resource_scoped_access' : null, + workspaceRbacOkForContent !== true ? 'workspace_rbac' : null + ].filter(Boolean) : [], + production, + detail: tableResult.status !== 0 + ? azErrorDetail(tableResult, 'could not list Log Analytics tables') + : hasContentTable + ? (contentPostureOk + ? 'optional content table uses short retention and least-privilege workspace access' + : 'production mode expects optional content rows to use <=30 day retention and least-privilege workspace access') + : 'no optional content capture table observed' + })); + if (tableResult.status !== 0) next.push('Install/update Azure CLI monitor extension or verify Log Analytics table read permissions.'); + else if (hasContentTable && !contentPostureOk) { + next.push('Harden optional content capture: keep AgentOpsContent_CL retention <=30 days and restrict Log Analytics access before production.'); + } + } + } + + if (hasAz && cloud.resourceGroup && cloud.appInsightsName) { + const appResult = runAz([ + 'monitor', + 'app-insights', + 'component', + 'show', + '--resource-group', + cloud.resourceGroup, + '--app', + cloud.appInsightsName, + '-o', + 'json' + ], options); + checks.push(checkResult('application-insights', appResult.status === 0, { + app: cloud.appInsightsName, + detail: appResult.status === 0 ? 'found' : (appResult.stderr || appResult.stdout || 'not found').trim() + })); + if (appResult.status !== 0) next.push('Set APPLICATIONINSIGHTS_NAME to the deployed App Insights component name.'); + } + + const grafanaConfigured = isConfiguredValue(cloud.grafanaBaseUrl, /your-grafana|<your-grafana>|^$/); + checks.push(checkResult('grafana-base-url', grafanaConfigured, { + url: grafanaConfigured ? cloud.grafanaBaseUrl : null, + detail: grafanaConfigured ? 'configured' : 'Set AGENTOPS_GRAFANA_BASE_URL.' + })); + if (!grafanaConfigured) next.push('agentops configure set --grafana-url "https://<your-grafana>.grafana.azure.com"'); + + if (hasAz && cloud.grafanaName && cloud.resourceGroup) { + const grafanaResult = runAz(['grafana', 'show', '-n', cloud.grafanaName, '-g', cloud.resourceGroup, '-o', 'json'], options); + const grafanaFound = grafanaResult.status === 0; + const grafanaResource = grafanaFound ? parseJsonOutput(grafanaResult) : null; + const grafanaResourceId = grafanaResource?.id || null; + checks.push(checkResult('grafana-resource', grafanaFound, { + grafana: cloud.grafanaName, + detail: grafanaFound ? 'found' : azErrorDetail(grafanaResult, 'not found') + })); + if (!grafanaFound) { + next.push('Set GRAFANA_NAME or AGENTOPS_GRAFANA_NAME to the deployed Azure Managed Grafana resource name.'); + } else { + const identityType = String(pathValue(grafanaResource, ['identity', 'type'], '')); + const apiKey = String(pathValue(grafanaResource, ['properties', 'apiKey'], '')); + const publicNetworkAccess = String(pathValue(grafanaResource, ['properties', 'publicNetworkAccess'], 'unknown')); + const zoneRedundancy = String(pathValue(grafanaResource, ['properties', 'zoneRedundancy'], 'unknown')); + const grafanaPostureOk = identityType.includes('SystemAssigned') && + apiKey === 'Disabled' && + (!production || publicNetworkAccess === 'Disabled') && + (!production || zoneRedundancy === 'Enabled'); + checks.push(checkResult('grafana-production-posture', grafanaPostureOk, { + identity_type: identityType || null, + api_key: apiKey || null, + public_network_access: publicNetworkAccess, + zone_redundancy: zoneRedundancy, + issues: [ + !identityType.includes('SystemAssigned') ? 'managed_identity' : null, + apiKey !== 'Disabled' ? 'api_keys' : null, + publicNetworkAccess !== 'Disabled' ? 'public_network_access' : null, + zoneRedundancy !== 'Enabled' ? 'zone_redundancy' : null + ].filter(Boolean), + production, + detail: grafanaPostureOk + ? (production ? 'managed identity and Grafana hardening posture verified' : 'pilot Grafana posture observed') + : production + ? 'production mode expects managed identity, API keys disabled, private access, and zone redundancy' + : 'pilot posture observed; use --production to enforce private access and zone redundancy' + })); + if (!grafanaPostureOk) { + next.push(production + ? 'Review Grafana identity, API key, public access, and zone redundancy before production.' + : 'Run agentops validate-azure --production to enforce production Grafana posture.'); + } + + if (production) { + const approvedPrivateConnections = approvedPrivateEndpointConnections(grafanaResource); + const privateAccessOk = publicNetworkAccess === 'Disabled' && approvedPrivateConnections.length > 0; + checks.push(checkResult('grafana-private-access-posture', privateAccessOk, { + public_network_access: publicNetworkAccess, + private_endpoint_connections: privateEndpointConnectionsFromResource(grafanaResource).length, + approved_private_endpoint_connections: approvedPrivateConnections.length, + production, + detail: privateAccessOk + ? 'public access disabled and approved private endpoint connection observed' + : 'production mode expects disabled public access plus an approved private endpoint connection' + })); + if (!privateAccessOk) next.push('Verify Managed Grafana private endpoint connectivity before production.'); + } + + if (grafanaResourceId) { + const roleResult = runAz([ + 'role', + 'assignment', + 'list', + '--scope', + grafanaResourceId, + '--include-groups', + '-o', + 'json' + ], options); + const summary = roleAssignmentSummary( + roleResult.status === 0 ? parseJsonOutput(roleResult) : [], + [azureRoleIds.grafanaViewer, azureRoleIds.grafanaEditor, azureRoleIds.grafanaAdmin] + ); + const grafanaRbacOk = roleResult.status === 0 && + (!groupRbacRequired || (summary.group_assignments > 0 && summary.broad_assignments === 0)); + checks.push(checkResult('grafana-rbac-posture', grafanaRbacOk, { + scope: grafanaResourceId, + ...summary, + production, + readiness_profile: readinessProfile, + group_rbac_required: groupRbacRequired, + detail: roleResult.status !== 0 + ? azErrorDetail(roleResult, 'could not list Grafana RBAC assignments') + : groupRbacRequired + ? (grafanaRbacOk + ? 'least-privilege group RBAC observed on Managed Grafana' + : `${readinessProfile} posture expects group-based Grafana RBAC and no broad Owner/Contributor assignments on Managed Grafana`) + : 'Grafana RBAC posture observed' + })); + if (roleResult.status !== 0) next.push('Verify Azure RBAC read permissions for the Managed Grafana resource.'); + else if (groupRbacRequired && !grafanaRbacOk) next.push(`Review Managed Grafana RBAC: assign observer/operator groups and remove routine broad roles before ${readinessProfile} rollout.`); + } + + const dataSourceResult = runAz(['grafana', 'data-source', 'list', '-n', cloud.grafanaName, '-g', cloud.resourceGroup, '-o', 'json'], options); + const dataSources = flattenGrafanaList(dataSourceResult.status === 0 ? parseJsonOutput(dataSourceResult) : null); + const datasourceFound = dataSources.some(item => grafanaItemUid(item) === cloud.grafanaDatasourceUid || item?.name === cloud.grafanaDatasourceUid); + checks.push(checkResult('grafana-datasource', dataSourceResult.status === 0 && datasourceFound, { + expected_uid: cloud.grafanaDatasourceUid, + observed: dataSources.map(grafanaItemUid).filter(Boolean).slice(0, 10), + detail: dataSourceResult.status !== 0 + ? (dataSourceResult.stderr || dataSourceResult.stdout || 'could not list datasources').trim() + : datasourceFound + ? 'found' + : 'datasource UID not found' + })); + if (dataSourceResult.status !== 0 || !datasourceFound) { + next.push('Set AGENTOPS_GRAFANA_DATASOURCE_UID to the Azure Monitor datasource UID used by the dashboards.'); + } + + const expectedDashboards = options.expectedDashboards || listGrafanaDashboardFiles(options); + const dashboardResult = runAz(['grafana', 'dashboard', 'list', '-n', cloud.grafanaName, '-g', cloud.resourceGroup, '-o', 'json'], options); + const dashboards = flattenGrafanaList(dashboardResult.status === 0 ? parseJsonOutput(dashboardResult) : null); + const observedUids = new Set(dashboards.map(grafanaItemUid).filter(Boolean)); + const missingDashboards = expectedDashboards.filter(dashboard => !observedUids.has(dashboard.uid)); + checks.push(checkResult('grafana-dashboards', dashboardResult.status === 0 && missingDashboards.length === 0, { + expected: expectedDashboards.length, + missing: missingDashboards.map(dashboard => dashboard.uid), + detail: dashboardResult.status !== 0 + ? (dashboardResult.stderr || dashboardResult.stdout || 'could not list dashboards').trim() + : missingDashboards.length === 0 + ? 'all expected dashboards found' + : `${missingDashboards.length} expected dashboard${missingDashboards.length === 1 ? '' : 's'} missing` + })); + if (options.verifyDashboardContent && dashboardResult.status === 0 && missingDashboards.length === 0) { + const coreDashboards = expectedDashboards.filter(dashboard => String(dashboard.uid || '').startsWith('agentops-v2-')); + const contentResults = coreDashboards.map(expected => { + const showResult = runAz([ + 'grafana', 'dashboard', 'show', + '-n', cloud.grafanaName, + '-g', cloud.resourceGroup, + '--dashboard', expected.uid, + '-o', 'json' + ], options); + const payload = showResult.status === 0 ? parseJsonOutput(showResult) : null; + const deployed = payload?.dashboard || payload; + const serialized = JSON.stringify(deployed || {}); + return { + uid: expected.uid, + expected_title: expected.title, + deployed_title: deployed?.title || null, + title_match: deployed?.title === expected.title, + stale_run_replay_mentions: (serialized.match(/Run Replay/g) || []).length, + run_story_mentions: (serialized.match(/Run Story/g) || []).length, + ok: showResult.status === 0 && deployed?.title === expected.title && !serialized.includes('Run Replay'), + error: showResult.status === 0 ? null : azErrorDetail(showResult, 'dashboard show failed') + }; + }); + const contentOk = coreDashboards.length > 0 && contentResults.every(item => item.ok); + checks.push(checkResult('grafana-dashboard-content', contentOk, { + dashboards: contentResults, + checked: contentResults.length, + run_story_mentions: contentResults.reduce((sum, item) => sum + item.run_story_mentions, 0), + stale_run_replay_mentions: contentResults.reduce((sum, item) => sum + item.stale_run_replay_mentions, 0), + detail: contentOk + ? 'deployed core dashboard titles and Run Story language match the current product contract' + : 'deployed core dashboard content is stale, missing, or does not match expected titles' + })); + if (!contentOk) next.push(grafanaDashboardImportCommand(cloud)); + } + if (dashboardResult.status !== 0 || missingDashboards.length > 0) { + next.push(grafanaDashboardImportCommand(cloud)); + if (options.importDashboards) { + const remediation = runGrafanaDashboardImportRemediation(cloud, options); + checks.push(checkResult('grafana-dashboard-import', remediation.ok, { + command: remediation.command, + detail: remediation.ok + ? 'import completed' + : (remediation.stderr || remediation.stdout || remediation.error || `dashboard import exited ${remediation.status}`).trim(), + remediation + })); + if (remediation.ok) next.push('agentops validate-azure --last 24h'); + } + } + } + } else if (!cloud.grafanaName) { + checks.push({ name: 'grafana-resource', ok: true, skipped: true, detail: 'Set GRAFANA_NAME or AGENTOPS_GRAFANA_NAME to validate the resource directly.' }); + checks.push({ name: 'grafana-production-posture', ok: true, skipped: true, detail: 'Skipped because Grafana resource name is not configured.' }); + checks.push({ name: 'grafana-datasource', ok: true, skipped: true, detail: 'Skipped because Grafana resource name is not configured.' }); + checks.push({ name: 'grafana-dashboards', ok: true, skipped: true, detail: 'Skipped because Grafana resource name is not configured.' }); + } + + if (hasAz && cloud.resourceGroup) { + const alertResult = runAz(['monitor', 'scheduled-query', 'list', '--resource-group', cloud.resourceGroup, '-o', 'json'], options); + const rules = alertResult.status === 0 ? agentOpsScheduledQueryRules(parseJsonOutput(alertResult)) : []; + const enabledRules = rules.filter(rule => boolish(pathValue(rule, ['properties', 'enabled'], false))); + const routedRules = rules.filter(rule => asArray(pathValue(rule, ['properties', 'actions', 'actionGroups'], [])).length > 0); + const unroutedRules = rules.filter(rule => asArray(pathValue(rule, ['properties', 'actions', 'actionGroups'], [])).length === 0); + const actionGroupIds = Array.from(new Set(routedRules.flatMap(rule => asArray(pathValue(rule, ['properties', 'actions', 'actionGroups'], []))).filter(Boolean))); + const alertPostureOk = alertResult.status === 0 && (!production || (enabledRules.length > 0 && enabledRules.length === routedRules.length)); + checks.push(checkResult('alert-routing-posture', alertPostureOk, { + rules: rules.length, + rule_names: rules.map(rule => rule.name).filter(Boolean), + enabled_rules: enabledRules.length, + enabled_rule_names: enabledRules.map(rule => rule.name).filter(Boolean), + routed_rules: routedRules.length, + action_groups: actionGroupIds.length, + unrouted_rule_names: unroutedRules.map(rule => rule.name).filter(Boolean), + production, + detail: alertResult.status !== 0 + ? azErrorDetail(alertResult, 'could not list scheduled query rules') + : production + ? 'production mode expects enabled AgentOps alerts routed to action groups' + : 'scheduled query alert posture observed' + })); + if (alertResult.status !== 0) next.push('Install/update Azure CLI monitor extension or verify scheduled query rule read permissions.'); + else if (production && (enabledRules.length === 0 || enabledRules.length !== routedRules.length)) { + next.push('Configure AgentOps scheduled query alerts with approved Azure Monitor action groups before production.'); + } + + if (production && alertResult.status === 0) { + const actionGroupChecks = actionGroupIds.map(id => { + const groupResult = runAz(['monitor', 'action-group', 'show', '--ids', id, '-o', 'json'], options); + const actionGroup = groupResult.status === 0 ? parseJsonOutput(groupResult) : null; + const receiverSummary = actionGroupReceiverSummary(actionGroup); + const enabled = boolish(actionGroup?.enabled ?? pathValue(actionGroup, ['properties', 'enabled'], true)); + return { + id, + ok: groupResult.status === 0 && enabled && receiverSummary.receiver_count > 0, + status: groupResult.status, + enabled, + receiver_count: receiverSummary.receiver_count, + receiver_types: receiverSummary.receiver_types, + detail: groupResult.status === 0 ? 'found' : azErrorDetail(groupResult, 'could not read action group') + }; + }); + const actionGroupDestinationOk = actionGroupIds.length > 0 && actionGroupChecks.every(item => item.ok); + checks.push(checkResult('action-group-destination-posture', actionGroupDestinationOk, { + action_groups: actionGroupIds.length, + checked: actionGroupChecks.length, + invalid: actionGroupChecks.filter(item => !item.ok).map(item => item.id), + receivers: actionGroupChecks.reduce((sum, item) => sum + item.receiver_count, 0), + production, + detail: actionGroupDestinationOk + ? 'routed action groups exist and have notification receivers' + : 'production mode expects routed action groups to exist, be enabled, and have at least one receiver' + })); + if (!actionGroupDestinationOk) next.push('Review Azure Monitor action group destinations before production alerts are enabled.'); + } + } + + const workspaceRbacCheck = checks.find(check => check.name === 'log-analytics-rbac-posture'); + const grafanaRbacCheck = checks.find(check => check.name === 'grafana-rbac-posture'); + if (workspaceRbacCheck || grafanaRbacCheck) { + const accessOk = (!workspaceRbacCheck || workspaceRbacCheck.ok) && (!grafanaRbacCheck || grafanaRbacCheck.ok); + checks.push(checkResult('access-rbac-posture', accessOk, { + production, + readiness_profile: readinessProfile, + group_rbac_required: groupRbacRequired, + log_analytics_ok: workspaceRbacCheck ? workspaceRbacCheck.ok : null, + grafana_ok: grafanaRbacCheck ? grafanaRbacCheck.ok : null, + detail: accessOk + ? 'Azure access RBAC posture observed' + : `${readinessProfile} posture expects least-privilege group RBAC for Log Analytics and Managed Grafana` + })); + } else if (production) { + checks.push(checkResult('access-rbac-posture', false, { + production, + detail: 'production mode could not verify Log Analytics or Managed Grafana RBAC posture' + })); + next.push('Configure workspace and Grafana resource names so validate-azure can verify RBAC posture.'); + } + + if (next.length === 0) { + next.push('node agentops-cli/src/index.js collector smoke --privacy strict --poison'); + next.push('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s --open-browser'); + next.push('copilot -p "Reply with exactly: agentops smoke."'); + next.push('node agentops-cli/src/index.js latest --last 2h'); + } + + const result = { + ok: checks.every(check => check.ok), + last, + config: { + subscription_id: cloud.subscriptionId || account?.id || null, + active_subscription_id: account?.id || null, + active_subscription_name: account?.name || null, + resource_group: cloud.resourceGroup, + workspace_id: workspaceConfigured ? cloud.workspaceId : null, + workspace_name: cloud.workspaceName, + grafana_base_url: grafanaConfigured ? cloud.grafanaBaseUrl : null, + grafana_name: cloud.grafanaName || null, + grafana_datasource_uid: cloud.grafanaDatasourceUid, + app_insights_name: cloud.appInsightsName, + production, + readiness_profile: readinessProfile, + data_boundary: readinessProfile === 'personal' + ? 'personal-dev-demo-metadata-only' + : 'requires-organizational-approval' + }, + checks, + next + }; + if (options.remediationPlan) { + result.remediation_plan = azureProductionRemediationPlan(result, options); + } + return result; +} + +module.exports = { + actionGroupReceiverSummary, + agentOpsContentTables, + agentOpsScheduledQueryRules, + approvedPrivateEndpointConnections, + asArray, + azAvailable, + azureBudgetsFromResult, + azureProductionRemediationPlan, + azureRoleIds, + azErrorDetail, + boolish, + checkResult, + logAnalyticsTablesFromResult, + parseJsonOutput, + pathValue, + privateEndpointConnectionsFromResource, + renderValidateAzure, + validateAzure, + roleAssignmentSummary, + runAz +}; diff --git a/agentops-cli/src/lib/azure/durable-evidence-spool.js b/agentops-cli/src/lib/azure/durable-evidence-spool.js new file mode 100644 index 0000000..e011481 --- /dev/null +++ b/agentops-cli/src/lib/azure/durable-evidence-spool.js @@ -0,0 +1,517 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const spoolVersion = 1; +const defaultMaxBytes = 64 * 1024 * 1024; +const defaultTtlMs = 7 * 24 * 60 * 60 * 1000; +const maximumMaxBytes = 1024 * 1024 * 1024; +const maximumTtlMs = 30 * 24 * 60 * 60 * 1000; +const maximumRetryDelayMs = 60 * 1000; +const maximumDrainAttempts = 10; +const defaultClaimLeaseMs = 30 * 1000; +const maximumClaimLeaseMs = 5 * 60 * 1000; +const admissionLockLeaseMs = 10 * 1000; +const admissionLockTimeoutMs = 2 * 1000; + +const allowedEvidenceTables = new Set([ + 'AgentOpsEvents_CL' +]); + +// Canonical metadata fields only. Values are scalar so nested content cannot be +// smuggled into an otherwise safe-looking envelope. +const allowedEvidenceFields = new Set([ + 'TimeGenerated', 'Sequence', 'EventId', 'ParentEventId', 'RunId', 'SessionId', + 'TraceId', 'Surface', 'EventName', 'SpanName', 'Status', 'DurationMs', + 'AgentName', 'ParentAgentName', 'SubAgentName', 'SkillName', 'CommandName', 'ScriptName', + 'ToolName', 'McpServerName', 'McpToolName', 'ModelActual', 'InputTokens', 'OutputTokens', + 'ReasoningTokens', 'CacheReadTokens', 'CacheWriteTokens', 'TotalTokens', + 'EstimatedCostUsd', 'EstimatedCostUsdReal', 'CopilotCost', 'PremiumRequests', 'TotalNanoAiu', 'ApiDurationMs', + 'PermissionDecision', 'PermissionKind', 'ErrorType', 'TotalToolCalls', 'LinesAdded', + 'LinesRemoved', 'FilesModified', 'PrivacyMode', + 'ContentCaptureMode', 'ContentCaptureSignal', 'ContentAction', + 'ContentDroppedBytes', 'SecretLike', 'RepoHash', 'BranchHash', 'WorkingDirectoryHash', + 'SchemaVersion' +]); +const eventLongFields = new Set([ + 'Sequence', 'InputTokens', 'OutputTokens', 'ReasoningTokens', 'CacheReadTokens', + 'CacheWriteTokens', 'TotalTokens', 'TotalToolCalls', 'DurationMs', 'PremiumRequests', + 'TotalNanoAiu', 'ApiDurationMs', 'LinesAdded', 'LinesRemoved', 'FilesModified', + 'ContentDroppedBytes', 'EstimatedCostUsd' +]); +const eventRealFields = new Set(['CopilotCost', 'EstimatedCostUsdReal']); +const eventBooleanFields = new Set(['ContentCaptureSignal', 'SecretLike']); + +function canonicalJson(value) { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function safeEvidenceRow(input = {}) { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('AgentOps durable evidence must be an object'); + const unknown = Object.keys(input).filter(key => !allowedEvidenceFields.has(key)); + if (unknown.length) throw new Error(`AgentOps durable evidence rejected non-allowlisted field(s): ${unknown.join(', ')}`); + const row = {}; + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null || value === '') continue; + if (!['string', 'number', 'boolean'].includes(typeof value)) throw new Error(`AgentOps durable evidence field ${key} must be scalar metadata`); + row[key] = typeof value === 'string' ? value.slice(0, 2048) : value; + } + if (!row.RunId) throw new Error('AgentOps durable evidence requires RunId'); + if (!Number.isInteger(Number(row.Sequence)) || Number(row.Sequence) < 1) throw new Error('AgentOps durable evidence requires a positive integer Sequence'); + row.Sequence = Number(row.Sequence); + row.EventId = row.EventId || `event_${sha256(`${row.RunId}:${row.Sequence}:${row.EventName || ''}`).slice(0, 24)}`; + row.PrivacyMode = 'strict'; + row.ContentCaptureMode = 'off'; + // The V2 table originally shipped EstimatedCostUsd as a long. Azure does not + // permit changing an existing column's type, so preserve precise values in + // the additive real column and only retain the legacy field when it is truly + // an integer. Never round a cost and silently change its meaning. + if (Object.hasOwn(row, 'EstimatedCostUsd')) { + if (typeof row.EstimatedCostUsd !== 'number' || !Number.isFinite(row.EstimatedCostUsd)) { + throw new Error('AgentOps durable evidence field EstimatedCostUsd must be a finite number'); + } + row.EstimatedCostUsdReal = row.EstimatedCostUsd; + if (!Number.isSafeInteger(row.EstimatedCostUsd) || row.EstimatedCostUsd < 0) delete row.EstimatedCostUsd; + } + for (const [key, value] of Object.entries(row)) { + if (eventLongFields.has(key) && (!Number.isSafeInteger(value) || value < 0)) { + throw new Error(`AgentOps durable evidence field ${key} must be a non-negative integer`); + } + if (eventRealFields.has(key) && (typeof value !== 'number' || !Number.isFinite(value))) { + throw new Error(`AgentOps durable evidence field ${key} must be a finite number`); + } + if (eventBooleanFields.has(key) && typeof value !== 'boolean') { + throw new Error(`AgentOps durable evidence field ${key} must be boolean`); + } + if (!eventLongFields.has(key) && !eventRealFields.has(key) && !eventBooleanFields.has(key) + && typeof value !== 'string') { + throw new Error(`AgentOps durable evidence field ${key} must be a string`); + } + } + if (row.TimeGenerated && !Number.isFinite(Date.parse(row.TimeGenerated))) { + throw new Error('AgentOps durable evidence field TimeGenerated must be a valid datetime'); + } + return row; +} + +function ensurePrivateDirectory(directory) { + if (fs.existsSync(directory)) { + const existing = fs.lstatSync(directory); + if (existing.isSymbolicLink() || !existing.isDirectory()) { + throw new Error('AgentOps durable spool directory must be a real directory, not a symlink'); + } + } + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') fs.chmodSync(directory, 0o700); +} + +function fsyncDirectory(directory) { + if (process.platform === 'win32') return; + const descriptor = fs.openSync(directory, 'r'); + try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } +} + +function atomicWrite(file, body) { + const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`; + const descriptor = fs.openSync(temporary, 'wx', 0o600); + try { + fs.writeFileSync(descriptor, body); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + fs.renameSync(temporary, file); + fsyncDirectory(path.dirname(file)); + if (process.platform !== 'win32') fs.chmodSync(file, 0o600); +} + +function readJson(file, fallback = null) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } +} + +function stateFile(directory) { + return path.join(directory, 'spool-state.json'); +} + +function updateCountersUnlocked(directory, changes = {}) { + const file = stateFile(directory); + const state = readJson(file, { acknowledged: 0, overflow: 0 }); + for (const [key, value] of Object.entries(changes)) state[key] = Number(state[key] || 0) + value; + atomicWrite(file, `${canonicalJson(state)}\n`); + return state; +} + +function pauseSync(ms) { + const signal = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(signal, 0, 0, ms); +} + +function lockPath(directory) { + return path.join(directory, '.admission.lock'); +} + +function acquireAdmissionLock(directory) { + const lock = lockPath(directory); + const deadline = Date.now() + admissionLockTimeoutMs; + while (true) { + try { + fs.mkdirSync(lock, { mode: 0o700 }); + if (process.platform !== 'win32') fs.chmodSync(lock, 0o700); + return lock; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + let stale = false; + try { + const entry = fs.lstatSync(lock); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + throw new Error('AgentOps durable spool lock must be a real directory, not a symlink'); + } + stale = Date.now() - entry.mtimeMs >= admissionLockLeaseMs; + } catch (inspectError) { + if (inspectError.code === 'ENOENT') continue; + throw inspectError; + } + if (stale) { + const staleLock = `${lock}.stale-${process.pid}-${crypto.randomUUID()}`; + try { + fs.renameSync(lock, staleLock); + fs.rmSync(staleLock, { recursive: true, force: true }); + fsyncDirectory(directory); + continue; + } catch (claimError) { + if (claimError.code === 'ENOENT') continue; + throw claimError; + } + } + if (Date.now() >= deadline) throw new Error('AgentOps durable spool admission lock timed out'); + pauseSync(10); + } + } +} + +function withAdmissionLock(directory, action) { + const lock = acquireAdmissionLock(directory); + try { + return action(); + } finally { + try { fs.rmdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } + fsyncDirectory(directory); + } +} + +function updateCounters(directory, changes = {}) { + return withAdmissionLock(directory, () => updateCountersUnlocked(directory, changes)); +} + +function segmentFiles(directory) { + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory) + .filter(name => /\.(pending|uploading|expired|quarantined|acknowledged)\.json$/.test(name)) + .sort() + .map(name => path.join(directory, name)) + .filter(file => { + let entry; + try { entry = fs.lstatSync(file); } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } + if (entry.isSymbolicLink() || !entry.isFile()) { + throw new Error('AgentOps durable spool segment must be a regular file, not a symlink'); + } + return true; + }); +} + +function spoolBytes(directory) { + return segmentFiles(directory).reduce((total, file) => { + try { return total + fs.statSync(file).size; } catch (error) { + if (error.code === 'ENOENT') return total; + throw error; + } + }, 0); +} + +function transition(file, state) { + const next = file.replace(/\.(pending|uploading|expired|quarantined|acknowledged)\.json$/, `.${state}.json`); + fs.renameSync(file, next); + fsyncDirectory(path.dirname(file)); + return next; +} + +function retryableStatus(status) { + return status === 401 || status === 403 || status === 408 || status === 429 || status >= 500; +} + +function responseStatus(response) { + return Number(response?.status ?? response?.statusCode ?? 0); +} + +function header(response, name) { + if (typeof response?.headers?.get === 'function') return response.headers.get(name); + const match = Object.entries(response?.headers || {}).find(([key]) => key.toLowerCase() === name.toLowerCase()); + return match?.[1] ?? null; +} + +function retryDelayMs(response, attempt, nowMs) { + const retryAfter = header(response, 'retry-after'); + if (retryAfter !== null) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) return Math.min(maximumRetryDelayMs, seconds * 1000); + const date = Date.parse(retryAfter); + if (Number.isFinite(date)) return Math.min(maximumRetryDelayMs, Math.max(0, date - nowMs)); + } + return Math.min(30000, 250 * (2 ** Math.max(0, attempt - 1))); +} + +function boundedOption(value, fallback, maximum, name) { + if (value === undefined || value === null || value === '') return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) { + throw new Error(`AgentOps durable spool ${name} must be an integer from 1 to ${maximum}`); + } + return parsed; +} + +function requireAllowedTable(table) { + if (!allowedEvidenceTables.has(table)) throw new Error('AgentOps durable evidence table is not allowlisted'); + return table; +} + +function immutableEnvelopeHash(segment) { + return sha256(canonicalJson({ + version: segment.version, + table: segment.table, + created_at: segment.created_at, + expires_at: segment.expires_at, + row_hash: segment.row_hash + })); +} + +function createDurableEvidenceSpool(options = {}) { + const directory = path.resolve(options.directory); + const maxBytes = boundedOption(options.maxBytes, defaultMaxBytes, maximumMaxBytes, 'maxBytes'); + const ttlMs = boundedOption(options.ttlMs, defaultTtlMs, maximumTtlMs, 'ttlMs'); + const claimLeaseMs = boundedOption(options.claimLeaseMs, defaultClaimLeaseMs, maximumClaimLeaseMs, 'claimLeaseMs'); + const now = options.now || (() => Date.now()); + const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))); + ensurePrivateDirectory(directory); + + function enqueue(input, enqueueOptions = {}) { + const row = safeEvidenceRow(input); + const table = requireAllowedTable(enqueueOptions.table || 'AgentOpsEvents_CL'); + const createdAt = now(); + const segment = { + version: spoolVersion, + state: 'pending', + table, + created_at: new Date(createdAt).toISOString(), + expires_at: new Date(createdAt + ttlMs).toISOString(), + attempts: 0, + row_hash: sha256(canonicalJson(row)), + row + }; + segment.envelope_hash = immutableEnvelopeHash(segment); + const body = `${canonicalJson(segment)}\n`; + return withAdmissionLock(directory, () => { + for (const existingFile of segmentFiles(directory).filter(file => /\.(pending|uploading)\.json$/.test(file))) { + const existing = readJson(existingFile); + if (existing?.table === table && existing?.row?.EventId === row.EventId + && existing?.row_hash === segment.row_hash) { + return { + ok: true, + status: 'deduplicated', + pending: true, + deduplicated: true, + file: existingFile, + event_id: row.EventId, + row_hash: segment.row_hash + }; + } + } + const projected = spoolBytes(directory) + Buffer.byteLength(body); + if (projected > maxBytes) { + updateCountersUnlocked(directory, { overflow: 1 }); + return { ok: false, status: 'overflow', pending: false, bytes: projected, max_bytes: maxBytes }; + } + const name = `${String(createdAt).padStart(16, '0')}-${crypto.randomUUID()}.pending.json`; + const file = path.join(directory, name); + atomicWrite(file, body); + return { ok: true, status: 'pending', pending: true, deduplicated: false, file, event_id: row.EventId, row_hash: segment.row_hash }; + }); + } + + function status() { + const counts = { pending: 0, uploading: 0, expired: 0, quarantined: 0, acknowledged_segments: 0 }; + for (const file of segmentFiles(directory)) { + const state = path.basename(file).match(/\.(pending|uploading|expired|quarantined|acknowledged)\.json$/)[1]; + if (state === 'acknowledged') counts.acknowledged_segments += 1; + else counts[state] += 1; + } + const counters = readJson(stateFile(directory), { acknowledged: 0, overflow: 0 }); + return { + ...counts, + acknowledged: Number(counters.acknowledged || 0), + overflow: Number(counters.overflow || 0), + bytes: spoolBytes(directory), + max_bytes: maxBytes, + ttl_ms: ttlMs + }; + } + + function recoverStaleClaims() { + let recovered = 0; + for (const file of segmentFiles(directory).filter(name => name.endsWith('.uploading.json'))) { + let stale = false; + try { stale = Date.now() - fs.statSync(file).mtimeMs >= claimLeaseMs; } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + if (!stale) continue; + try { + transition(file, 'pending'); + recovered += 1; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + return recovered; + } + + function claim(file) { + try { + const claimed = transition(file, 'uploading'); + const time = new Date(); + fs.utimesSync(claimed, time, time); + return claimed; + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + } + + async function drain(uploader, drainOptions = {}) { + if (typeof uploader !== 'function') throw new Error('AgentOps durable spool drain requires an uploader'); + const maxAttempts = boundedOption(drainOptions.maxAttempts, 3, maximumDrainAttempts, 'maxAttempts'); + const result = { acknowledged: 0, acknowledged_event_ids: [], pending: 0, expired: 0, quarantined: 0, attempts: 0, claimed: 0, recovered: recoverStaleClaims() }; + for (const acknowledged of segmentFiles(directory).filter(name => name.endsWith('.acknowledged.json'))) { + fs.unlinkSync(acknowledged); + } + for (const pendingFile of segmentFiles(directory).filter(name => name.endsWith('.pending.json'))) { + const file = claim(pendingFile); + if (!file) continue; + result.claimed += 1; + const segment = readJson(file); + const createdAt = Date.parse(segment?.created_at); + const expiresAt = Date.parse(segment?.expires_at); + if (!segment || segment.version !== spoolVersion || !segment.row_hash || !segment.envelope_hash || !segment.row + || !Number.isFinite(createdAt) || !Number.isFinite(expiresAt) + || expiresAt <= createdAt || expiresAt - createdAt > maximumTtlMs + || segment.envelope_hash !== immutableEnvelopeHash(segment) + || !allowedEvidenceTables.has(segment.table)) { + transition(file, 'quarantined'); + result.quarantined += 1; + continue; + } + let validatedRow; + try { validatedRow = safeEvidenceRow(segment.row); } catch { validatedRow = null; } + if (!validatedRow + || canonicalJson(validatedRow) !== canonicalJson(segment.row) + || sha256(canonicalJson(validatedRow)) !== segment.row_hash) { + transition(file, 'quarantined'); + result.quarantined += 1; + continue; + } + if (expiresAt <= now()) { + transition(file, 'expired'); + result.expired += 1; + continue; + } + let terminal = false; + let heartbeatFailure = null; + const heartbeat = setInterval(() => { + try { + const time = new Date(); + fs.utimesSync(file, time, time); + } catch (error) { + if (error.code !== 'ENOENT') heartbeatFailure = error; + } + }, Math.max(10, Math.floor(claimLeaseMs / 3))); + heartbeat.unref(); + try { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + result.attempts += 1; + let response; + try { + response = await uploader(validatedRow, { + table: segment.table, + eventId: segment.row.EventId, + rowHash: segment.row_hash, + attempt: segment.attempts + attempt + }); + } catch (error) { + response = { status: 0, networkError: error.message }; + } + const code = responseStatus(response); + if (code >= 200 && code < 300) { + if (typeof drainOptions.afterUploadBeforeAck === 'function') await drainOptions.afterUploadBeforeAck(segment, response); + const acknowledged = transition(file, 'acknowledged'); + updateCounters(directory, { acknowledged: 1 }); + fs.unlinkSync(acknowledged); + result.acknowledged += 1; + result.acknowledged_event_ids.push(validatedRow.EventId); + terminal = true; + break; + } + if (code >= 400 && code < 500 && !retryableStatus(code)) { + segment.state = 'quarantined'; + segment.last_status = code; + segment.attempts += attempt; + atomicWrite(file, `${canonicalJson(segment)}\n`); + transition(file, 'quarantined'); + result.quarantined += 1; + terminal = true; + break; + } + if (attempt < maxAttempts) await sleep(retryDelayMs(response, attempt, now())); + else { + segment.attempts += attempt; + segment.last_status = code || 'network'; + atomicWrite(file, `${canonicalJson(segment)}\n`); + } + } + } finally { + clearInterval(heartbeat); + } + if (heartbeatFailure && !terminal) { + try { transition(file, 'pending'); } catch (error) { if (error.code !== 'ENOENT') throw error; } + throw heartbeatFailure; + } + if (!terminal) { + try { transition(file, 'pending'); } catch (error) { if (error.code !== 'ENOENT') throw error; } + result.pending += 1; + } + } + return { ...result, status: status() }; + } + + return { directory, drain, enqueue, status }; +} + +module.exports = { + allowedEvidenceTables, + allowedEvidenceFields, + boundedOption, + canonicalJson, + createDurableEvidenceSpool, + retryDelayMs, + retryableStatus, + safeEvidenceRow +}; diff --git a/agentops-cli/src/lib/azure/durable-receipt-schema-guard.js b/agentops-cli/src/lib/azure/durable-receipt-schema-guard.js new file mode 100644 index 0000000..bcee0d9 --- /dev/null +++ b/agentops-cli/src/lib/azure/durable-receipt-schema-guard.js @@ -0,0 +1,126 @@ +const durableReceiptSchema = Object.freeze({ + TimeGenerated: 'datetime', + Sequence: 'long', + EventId: 'string', + ParentEventId: 'string', + RunId: 'string', SessionId: 'string', TraceId: 'string', Surface: 'string', + EventName: 'string', SpanName: 'string', Status: 'string', + AgentName: 'string', ParentAgentName: 'string', SubAgentName: 'string', SkillName: 'string', + CommandName: 'string', ScriptName: 'string', ToolName: 'string', McpServerName: 'string', McpToolName: 'string', + ModelActual: 'string', + InputTokens: 'long', OutputTokens: 'long', ReasoningTokens: 'long', CacheReadTokens: 'long', CacheWriteTokens: 'long', TotalTokens: 'long', + // Azure custom-table column types are immutable. Keep the deployed legacy + // integer column and use an additive real column for precise receipt cost. + EstimatedCostUsd: 'long', EstimatedCostUsdReal: 'real', CopilotCost: 'real', PremiumRequests: 'long', TotalNanoAiu: 'long', ApiDurationMs: 'long', + PermissionDecision: 'string', PermissionKind: 'string', ErrorType: 'string', TotalToolCalls: 'long', DurationMs: 'long', + LinesAdded: 'long', LinesRemoved: 'long', FilesModified: 'long', + PrivacyMode: 'string', ContentCaptureMode: 'string', ContentCaptureSignal: 'boolean', ContentAction: 'string', + ContentDroppedBytes: 'long', SecretLike: 'boolean', RepoHash: 'string', BranchHash: 'string', WorkingDirectoryHash: 'string', + SchemaVersion: 'string' +}); + +function asArray(value) { + if (Array.isArray(value)) return value; + if (Array.isArray(value?.value)) return value.value; + return []; +} + +function columnsFromTable(payload) { + return asArray(payload?.properties?.schema?.columns || payload?.schema?.columns || payload?.columns); +} + +function columnsFromDcr(payload) { + return asArray(payload?.properties?.streamDeclarations?.['Custom-AgentOpsEvents_CL']?.columns + || payload?.streamDeclarations?.['Custom-AgentOpsEvents_CL']?.columns); +} + +function compareColumns(columns, expected = durableReceiptSchema) { + const observed = new Map(columns.map(column => [String(column?.name || ''), String(column?.type || '').toLowerCase()])); + const drift = Object.entries(expected).flatMap(([name, type]) => { + const actual = observed.get(name); + if (!actual) return [{ column: name, expected: type, actual: null, issue: 'missing' }]; + if (actual !== type) return [{ column: name, expected: type, actual, issue: 'type_mismatch' }]; + return []; + }); + return { + ok: drift.length === 0, + expected, + observed: Object.fromEntries(Object.keys(expected).map(name => [name, observed.get(name) || null])), + drift + }; +} + +function immutableId(resource) { + return resource?.properties?.immutableId || resource?.immutableId || ''; +} + +function validateDurableReceiptAzureSchema(options = {}) { + const { runAz, resourceGroup, workspaceName, dcrImmutableId } = options; + if (typeof runAz !== 'function') throw new TypeError('validateDurableReceiptAzureSchema requires a read-only Azure runner'); + if (!resourceGroup || !workspaceName || !dcrImmutableId) { + return { + ok: true, + skipped: true, + detail: 'Configure workspace name and DCR immutable ID to validate the live durable receipt schema.', + table: null, + dcr: null + }; + } + + const tableResult = runAz([ + 'monitor', 'log-analytics', 'workspace', 'table', 'show', + '--resource-group', resourceGroup, + '--workspace-name', workspaceName, + '--name', 'AgentOpsEvents_CL', + '-o', 'json' + ]); + const tablePayload = tableResult.status === 0 ? safeJson(tableResult.stdout) : null; + const table = tableResult.status === 0 + ? compareColumns(columnsFromTable(tablePayload)) + : { ok: false, drift: [], error: errorDetail(tableResult, 'could not read AgentOpsEvents_CL schema') }; + + const listResult = runAz([ + 'monitor', 'data-collection', 'rule', 'list', + '--resource-group', resourceGroup, + '-o', 'json' + ]); + const rules = listResult.status === 0 ? asArray(safeJson(listResult.stdout)) : []; + const matchingRule = rules.find(rule => immutableId(rule) === dcrImmutableId); + const dcr = listResult.status !== 0 + ? { ok: false, drift: [], error: errorDetail(listResult, 'could not list data collection rules') } + : !matchingRule + ? { ok: false, drift: [], error: `DCR ${dcrImmutableId} was not found in resource group ${resourceGroup}` } + : compareColumns(columnsFromDcr(matchingRule)); + + return { + ok: table.ok && dcr.ok, + skipped: false, + contract: durableReceiptSchema, + table, + dcr, + dcr_name: matchingRule?.name || null, + detail: table.ok && dcr.ok + ? 'live AgentOpsEvents_CL and DCR stream match the durable receipt schema contract' + : 'live durable receipt schema drift detected; preserve EstimatedCostUsd as long and add the durable receipt columns before relying on exact ordered Azure proof' + }; +} + +function safeJson(text) { + try { + return JSON.parse(text || '{}'); + } catch { + return null; + } +} + +function errorDetail(result, fallback) { + return String(result?.stderr || result?.stdout || fallback).trim(); +} + +module.exports = { + columnsFromDcr, + columnsFromTable, + compareColumns, + durableReceiptSchema, + validateDurableReceiptAzureSchema +}; diff --git a/agentops-cli/src/lib/azure/grafana-browser-auth.js b/agentops-cli/src/lib/azure/grafana-browser-auth.js new file mode 100644 index 0000000..b4442b3 --- /dev/null +++ b/agentops-cli/src/lib/azure/grafana-browser-auth.js @@ -0,0 +1,45 @@ +const childProcess = require('node:child_process'); + +const { checkAzureSubscription } = require('./subscription-guard'); + +const MANAGED_GRAFANA_RESOURCE_APP_ID = 'ce34e7e5-485f-4d76-964f-b3d2b16d1e4f'; + +function azureCliGrafanaBrowserAuth(options = {}) { + const env = options.env || process.env; + const spawnSync = options.spawnSync || childProcess.spawnSync; + const subscription = checkAzureSubscription({ + env, + spawnSync, + approvedSubscriptionIds: options.approvedSubscriptionIds + }); + if (!subscription.ok) { + throw new Error(`Azure CLI Grafana authentication refused: ${subscription.error.replaceAll('write', 'access')}`); + } + + const result = spawnSync('az', [ + 'account', 'get-access-token', + '--subscription', subscription.expected, + '--resource', MANAGED_GRAFANA_RESOURCE_APP_ID, + '--query', 'accessToken', + '-o', 'tsv' + ], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }); + const token = String(result.stdout || '').trim(); + if (result.error || result.status !== 0 || !token) { + throw new Error(`Could not obtain a temporary Azure Managed Grafana token${result.error ? `: ${result.error.message}` : ` (az exited ${result.status})`}.`); + } + + return { + token, + evidence: { + method: 'azure-cli-bearer', + subscriptionId: subscription.expected, + resource: MANAGED_GRAFANA_RESOURCE_APP_ID, + tokenPersisted: false + } + }; +} + +module.exports = { + MANAGED_GRAFANA_RESOURCE_APP_ID, + azureCliGrafanaBrowserAuth +}; diff --git a/agentops-cli/src/lib/azure/logs-ingestion-upload.js b/agentops-cli/src/lib/azure/logs-ingestion-upload.js new file mode 100644 index 0000000..4b73371 --- /dev/null +++ b/agentops-cli/src/lib/azure/logs-ingestion-upload.js @@ -0,0 +1,218 @@ +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { checkAzureSubscription } = require('./subscription-guard'); +const { allowedEvidenceTables, createDurableEvidenceSpool } = require('./durable-evidence-spool'); +const { logsIngestionUri, streamNameFor } = require('./v2-ingest-plan'); + +const logsIngestionResource = 'https://monitor.azure.com/'; +const defaultRequestTimeoutMs = 30000; +const maximumRequestTimeoutMs = 120000; + +function validatedPublicLogsEndpoint(value) { + let endpoint; + try { endpoint = new URL(String(value || '').trim()); } catch { + throw new Error('Durable Logs Ingestion requires a valid Azure public Monitor ingestion endpoint'); + } + if (endpoint.protocol !== 'https:' + || !endpoint.hostname.toLowerCase().endsWith('.ingest.monitor.azure.com') + || endpoint.username || endpoint.password || endpoint.hash || endpoint.search + || (endpoint.pathname !== '/' && endpoint.pathname !== '')) { + throw new Error('Durable Logs Ingestion requires an Azure public Monitor ingestion endpoint with no credentials, path, query, or fragment'); + } + return endpoint.origin; +} + +function requestTimeoutMs(value) { + if (value === undefined || value === null || value === '') return defaultRequestTimeoutMs; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximumRequestTimeoutMs) { + throw new Error(`Durable Logs Ingestion timeoutMs must be an integer from 1 to ${maximumRequestTimeoutMs}`); + } + return parsed; +} + +async function boundedResponse(response) { + if (response?.body && typeof response.body.cancel === 'function') { + try { await response.body.cancel(); } catch { /* status still drives retry classification */ } + } + return { status: Number(response?.status || 0), headers: response?.headers || {} }; +} + +function azureCliAccessToken(options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync('az', [ + 'account', 'get-access-token', + '--subscription', options.subscriptionId, + '--resource', logsIngestionResource, + '--query', 'accessToken', + '-o', 'tsv' + ], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }); + const token = String(result.stdout || '').trim(); + if (result.error || result.status !== 0 || !token) { + throw new Error(`Could not obtain an Azure Monitor Logs Ingestion token${result.error ? `: ${result.error.message}` : ` (az exited ${result.status})`}`); + } + return token; +} + +function createDurableLogsIngestionUploader(options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const subscription = checkAzureSubscription({ + spawnSync, + env: options.env, + expectedSubscriptionId: options.expectedSubscriptionId, + approvedSubscriptionIds: options.approvedSubscriptionIds + }); + if (!subscription.ok) throw new Error(subscription.error); + const endpoint = validatedPublicLogsEndpoint(options.endpoint); + const dcrImmutableId = String(options.dcrImmutableId || '').trim(); + if (!/^dcr-[A-Za-z0-9-]+$/.test(dcrImmutableId)) throw new Error('Durable Logs Ingestion requires a valid DCR immutable ID'); + const timeoutMs = requestTimeoutMs(options.timeoutMs); + const fetchImpl = options.fetchImpl || globalThis.fetch; + if (typeof fetchImpl !== 'function') throw new Error('Durable Logs Ingestion requires fetch'); + const tokenProvider = options.tokenProvider || (() => azureCliAccessToken({ + spawnSync, + subscriptionId: subscription.expected + })); + + return async (row, context = {}) => { + if (!allowedEvidenceTables.has(context.table)) { + return { status: 400, error: 'table-not-allowlisted' }; + } + const uri = logsIngestionUri(endpoint, dcrImmutableId, streamNameFor(context.table)); + const send = async token => { + if (typeof token !== 'string' || !token.trim()) throw new Error('Durable Logs Ingestion token provider returned no token'); + return boundedResponse(await fetchImpl(uri, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify([row]), + signal: AbortSignal.timeout(timeoutMs) + })); + }; + let response = await send(await tokenProvider()); + if ([401, 403].includes(Number(response?.status))) { + response = await send(await tokenProvider()); + } + return response; + }; +} + +async function drainDurableLogsIngestion(options = {}) { + const spool = createDurableEvidenceSpool({ + directory: options.directory, + maxBytes: options.maxBytes, + ttlMs: options.ttlMs, + now: options.now, + sleep: options.sleep + }); + const uploader = createDurableLogsIngestionUploader(options); + return spool.drain(uploader, { maxAttempts: options.maxAttempts }); +} + +function jsonArrayUploadFile(jsonlFile, tempDir, table) { + const rows = fs.readFileSync(jsonlFile, 'utf8') + .split(/\r?\n/) + .filter(line => line.trim()) + .map(line => JSON.parse(line)); + const file = path.join(tempDir, `${table}.json`); + fs.writeFileSync(file, `${JSON.stringify(rows)}\n`); + return file; +} + +function runLogsIngestionUpload(plan, options = {}) { + if (!plan.ok) return { ...plan, ok: false, executed: false }; + const spawnSync = options.spawnSync || childProcess.spawnSync; + const subscription = checkAzureSubscription({ + spawnSync, + env: options.env, + expectedSubscriptionId: options.expectedSubscriptionId, + approvedSubscriptionIds: options.approvedSubscriptionIds + }); + if (!subscription.ok) { + return { + ...plan, + ok: false, + executed: false, + subscription_guard: subscription, + uploads: [], + errors: [...plan.errors, subscription.error] + }; + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-logs-upload-')); + const uploads = []; + let response; + try { + for (const upload of plan.uploads) { + const bodyFile = jsonArrayUploadFile(upload.file, tempDir, upload.table); + const args = [ + 'rest', + '--method', + 'post', + '--uri', + upload.uri, + '--resource', + logsIngestionResource, + '--headers', + 'Content-Type=application/json', + '--body', + `@${bodyFile}` + ]; + const result = spawnSync('az', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + uploads.push({ + ...upload, + status: result.status, + ok: !result.error && result.status === 0, + error: result.error ? result.error.message : '', + stderr: result.stderr ? String(result.stderr).slice(0, 2000) : '' + }); + } + + const ok = uploads.every(upload => upload.ok); + response = { + ...plan, + ok, + executed: true, + subscription_guard: subscription, + temporary_payloads_cleaned: true, + uploads, + errors: ok ? plan.errors : [ + ...plan.errors, + ...uploads.filter(upload => !upload.ok).map(upload => `${upload.table}: az rest failed with status ${upload.status}${upload.error ? ` (${upload.error})` : ''}`) + ] + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + return response; +} + +function renderLogsIngestionUploadResult(result) { + const lines = []; + lines.push('AgentOps Logs Ingestion upload'); + lines.push(''); + lines.push(`Status: ${result.ok ? 'uploaded' : 'failed'}`); + lines.push(`Directory: ${result.dir}`); + lines.push(''); + lines.push('Uploads:'); + for (const upload of result.uploads) { + lines.push(`- ${upload.table}: ${upload.rows} row(s), ${upload.ok ? 'ok' : `failed status ${upload.status}`}`); + } + if (result.errors.length > 0) { + lines.push(''); + lines.push('Errors:'); + for (const error of result.errors) lines.push(`- ${error}`); + } + return `${lines.join('\n')}\n`; +} + +module.exports = { + azureCliAccessToken, + boundedResponse, + createDurableLogsIngestionUploader, + drainDurableLogsIngestion, + jsonArrayUploadFile, + renderLogsIngestionUploadResult, + runLogsIngestionUpload, + validatedPublicLogsEndpoint +}; diff --git a/agentops-cli/src/lib/azure/subscription-guard.js b/agentops-cli/src/lib/azure/subscription-guard.js new file mode 100644 index 0000000..7dd1acf --- /dev/null +++ b/agentops-cli/src/lib/azure/subscription-guard.js @@ -0,0 +1,95 @@ +const childProcess = require('node:child_process'); + +// Public builds must not embed an owner's Azure subscription. Operators approve +// write targets explicitly through AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS or +// the injected option used by tests and embedding applications. +const APPROVED_AZURE_SUBSCRIPTION_IDS = Object.freeze([]); + +function normalizedSubscriptionId(value) { + return String(value || '').trim().toLowerCase(); +} + +function expectedSubscriptionId(options = {}) { + const env = options.env || process.env; + return normalizedSubscriptionId( + options.expectedSubscriptionId + || env.AGENTOPS_AZURE_SUBSCRIPTION_ID + ); +} + +function approvedSubscriptionIds(options = {}) { + const env = options.env || process.env; + const configured = options.approvedSubscriptionIds === undefined + ? String(env.AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS || '').split(/[\s,]+/) + : options.approvedSubscriptionIds; + return [...new Set((Array.isArray(configured) ? configured : [configured]) + .map(normalizedSubscriptionId) + .filter(Boolean))]; +} + +function checkAzureSubscription(options = {}) { + const expected = expectedSubscriptionId(options); + const approved = approvedSubscriptionIds(options); + if (!expected) { + return { + ok: false, + expected: '', + active: '', + error: 'Set AGENTOPS_AZURE_SUBSCRIPTION_ID before an Azure write. The guard does not infer a subscription.' + }; + } + + if (approved.length === 0) { + return { + ok: false, + expected, + active: '', + approved, + error: 'Set AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS before an Azure write. Public builds contain no embedded subscription allowlist.' + }; + } + + if (!approved.includes(expected)) { + return { + ok: false, + expected, + active: '', + approved, + error: `Azure subscription guard refused the write: configured subscription ${expected} is not in AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS.` + }; + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync('az', ['account', 'show', '--query', 'id', '-o', 'tsv'], { + encoding: 'utf8', + maxBuffer: 1024 * 1024 + }); + if (result.error || result.status !== 0) { + return { + ok: false, + expected, + active: '', + error: `Could not verify the active Azure subscription${result.error ? `: ${result.error.message}` : ` (az exited ${result.status})`}.` + }; + } + + const active = normalizedSubscriptionId(result.stdout); + if (!active || active !== expected) { + return { + ok: false, + expected, + active, + error: `Azure subscription guard refused the write: expected ${expected}, active ${active || 'unknown'}.` + }; + } + + return { ok: true, expected, active, approved, error: '' }; +} + +module.exports = { + APPROVED_AZURE_SUBSCRIPTION_IDS, + approvedSubscriptionIds, + checkAzureSubscription, + expectedSubscriptionId, + normalizedSubscriptionId +}; diff --git a/agentops-cli/src/lib/azure/v2-ingest-plan.js b/agentops-cli/src/lib/azure/v2-ingest-plan.js index 4c7f2f5..1f8a3ec 100644 --- a/agentops-cli/src/lib/azure/v2-ingest-plan.js +++ b/agentops-cli/src/lib/azure/v2-ingest-plan.js @@ -2,7 +2,17 @@ const fs = require('node:fs'); const path = require('node:path'); const { tableNames } = require('../demo/agentops-demo-data'); -const { AGENTOPS_SCHEMA_VERSION } = require('../schema/agentops-attributes'); +const { + renderAzureIngestPlan, + renderLogsIngestionUploadPlan, + renderSharedStorageUploadPlan +} = require('./v2-ingest-render'); +const { + schemaMigrationPolicy, + schemaMigrationSummary, + schemaVersionFor, + schemaVersioningSummary +} = require('./v2-schema-versioning'); const requiredColumns = { AgentOpsRunSummary_CL: ['TimeGenerated', 'RunId', 'SessionId', 'TraceId', 'OutcomeStatus'], @@ -33,18 +43,8 @@ const optionalEmptyTables = new Set([ 'AgentOpsContent_CL' ]); -const schemaVersionTables = new Set(tableNames.filter(table => table.endsWith('_CL'))); const logsIngestionResource = 'https://monitor.azure.com/'; -const schemaMigrationPolicy = { - current_version: AGENTOPS_SCHEMA_VERSION, - supported_versions: [AGENTOPS_SCHEMA_VERSION], - legacy_versions: ['1'], - missing_version_action: 'Regenerate or roll up telemetry with the current AgentOps CLI so every AgentOps*_CL row includes SchemaVersion.', - legacy_version_action: 'Regenerate the affected AgentOps*_CL files or re-run the local rollup before cloud ingestion.', - unsupported_newer_version_action: 'Upgrade the AgentOps CLI and Grafana dashboard pack before ingesting newer schema rows.' -}; - const leakPatterns = [ /SECRET_FAKE_TEST_VALUE/i, /api_key\s*=/i, @@ -142,7 +142,7 @@ function validateTable(table, dir, options = {}) { warnings.push(`${table}: ${schemaVersion.missing_rows} row(s) missing SchemaVersion; dashboards will flag schema coverage`); } if (schemaVersion.checked && schemaVersion.mismatched_versions.length > 0) { - warnings.push(`${table}: schema version mismatch ${schemaVersion.mismatched_versions.join(', ')}; expected ${AGENTOPS_SCHEMA_VERSION}`); + warnings.push(`${table}: schema version mismatch ${schemaVersion.mismatched_versions.join(', ')}; expected ${schemaVersion.expected}`); } for (const action of schemaVersion.migration.actions) warnings.push(`${table}: ${action}`); @@ -160,73 +160,6 @@ function validateTable(table, dir, options = {}) { }; } -function schemaVersionFor(table, rows) { - if (!schemaVersionTables.has(table) || rows.length === 0) { - const migration = schemaMigrationFor({ table, missingRows: 0, versions: [] }); - return { - checked: false, - expected: AGENTOPS_SCHEMA_VERSION, - versions: [], - missing_rows: 0, - mismatched_versions: [], - migration, - ok: true - }; - } - - const versions = versionCounts(rows); - const missingRows = rows.filter(row => row?.SchemaVersion === undefined || row?.SchemaVersion === null || row?.SchemaVersion === '').length; - const mismatchedVersions = Object.keys(versions).filter(version => version !== AGENTOPS_SCHEMA_VERSION); - const migration = schemaMigrationFor({ table, missingRows, versions: Object.keys(versions) }); - - return { - checked: true, - expected: AGENTOPS_SCHEMA_VERSION, - versions: Object.keys(versions).sort(), - version_counts: versions, - missing_rows: missingRows, - mismatched_versions: mismatchedVersions, - migration, - ok: missingRows === 0 && mismatchedVersions.length === 0 - }; -} - -function versionCounts(rows) { - const counts = {}; - for (const row of rows) { - const version = row?.SchemaVersion; - if (version === undefined || version === null || version === '') continue; - const key = String(version); - counts[key] = (counts[key] || 0) + 1; - } - return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))); -} - -function schemaMigrationFor({ table, missingRows, versions }) { - const legacyVersions = versions.filter(version => schemaMigrationPolicy.legacy_versions.includes(version)); - const unsupportedVersions = versions.filter(version => !schemaMigrationPolicy.supported_versions.includes(version) && !schemaMigrationPolicy.legacy_versions.includes(version)); - const actions = []; - if (missingRows > 0) actions.push(`schema migration required for ${missingRows} missing-version row(s): ${schemaMigrationPolicy.missing_version_action}`); - if (legacyVersions.length > 0) actions.push(`schema migration required from version(s) ${legacyVersions.join(', ')} to ${AGENTOPS_SCHEMA_VERSION}: ${schemaMigrationPolicy.legacy_version_action}`); - if (unsupportedVersions.length > 0) actions.push(`unsupported newer schema version(s) ${unsupportedVersions.join(', ')}: ${schemaMigrationPolicy.unsupported_newer_version_action}`); - - return { - table, - status: unsupportedVersions.length > 0 - ? 'unsupported-newer' - : missingRows > 0 - ? 'missing-version' - : legacyVersions.length > 0 - ? 'legacy-migration-required' - : 'current', - migration_required: missingRows > 0 || legacyVersions.length > 0 || unsupportedVersions.length > 0, - compatible_for_ingest: unsupportedVersions.length === 0, - legacy_versions: legacyVersions, - unsupported_versions: unsupportedVersions, - actions - }; -} - function buildAzureIngestPlan({ dir, allowContent = false } = {}) { const absoluteDir = path.resolve(dir); const errors = []; @@ -383,57 +316,6 @@ function buildLogsIngestionUploadPlan({ }; } -function schemaVersioningSummary(tables) { - const checked = Object.entries(tables) - .filter(([, table]) => table.schema_version?.checked) - .map(([name, table]) => ({ name, ...table.schema_version })); - const missingRows = checked.reduce((total, table) => total + table.missing_rows, 0); - const mismatchedTables = checked - .filter(table => table.mismatched_versions.length > 0) - .map(table => ({ - table: table.name, - versions: table.mismatched_versions - })); - - return { - ok: missingRows === 0 && mismatchedTables.length === 0, - expected: AGENTOPS_SCHEMA_VERSION, - checked_tables: checked.length, - missing_rows: missingRows, - mismatched_tables: mismatchedTables - }; -} - -function schemaMigrationSummary(tables) { - const migrations = Object.entries(tables) - .filter(([, table]) => table.schema_version?.migration?.migration_required) - .map(([name, table]) => ({ - table: name, - status: table.schema_version.migration.status, - compatible_for_ingest: table.schema_version.migration.compatible_for_ingest, - versions: table.schema_version.versions, - missing_rows: table.schema_version.missing_rows, - actions: table.schema_version.migration.actions - })); - const unsupportedTables = migrations - .filter(migration => migration.compatible_for_ingest === false) - .map(migration => ({ - table: migration.table, - versions: tables[migration.table].schema_version.migration.unsupported_versions - })); - - return { - current_version: schemaMigrationPolicy.current_version, - supported_versions: schemaMigrationPolicy.supported_versions, - legacy_versions: schemaMigrationPolicy.legacy_versions, - ok: unsupportedTables.length === 0, - migration_required: migrations.length > 0, - migrations, - unsupported_tables: unsupportedTables, - actions: migrations.flatMap(migration => migration.actions) - }; -} - const sharedStorageTables = [ 'AgentOpsRecommendations_CL', 'AgentOpsSavedViews_CL', @@ -588,100 +470,6 @@ function buildSharedStorageUploadPlan({ dir, account, container = 'agentops-shar }; } -function renderSharedStorageUploadPlan(plan) { - const lines = []; - lines.push('AgentOps shared storage upload plan'); - lines.push(''); - lines.push(`Status: ${plan.ok ? 'ready' : 'not ready'}`); - lines.push(`Directory: ${plan.dir}`); - lines.push(`Storage: ${plan.storage.account || '<storage-account-name>'}/${plan.storage.container || '<container-name>'}`); - lines.push(`Privacy scan: ${plan.privacy.ok ? 'passed' : 'failed'}`); - lines.push(''); - lines.push('Artifacts:'); - for (const artifact of plan.artifacts) { - lines.push(`- ${artifact.table}: ${artifact.rows === null ? 'manifest' : `${artifact.rows} row(s)`} -> ${artifact.blob}`); - } - if (plan.errors.length > 0) { - lines.push(''); - lines.push('Errors:'); - for (const error of plan.errors) lines.push(`- ${error}`); - } - if (plan.warnings.length > 0) { - lines.push(''); - lines.push('Warnings:'); - for (const warning of plan.warnings) lines.push(`- ${warning}`); - } - lines.push(''); - lines.push('Commands:'); - for (const artifact of plan.artifacts) lines.push(`- ${artifact.command.join(' ')}`); - lines.push(''); - lines.push('Next:'); - for (const command of plan.next) lines.push(`- ${command}`); - return `${lines.join('\n')}\n`; -} - -function renderAzureIngestPlan(plan) { - const lines = []; - lines.push('AgentOps V2 Azure ingestion plan'); - lines.push(''); - lines.push(`Status: ${plan.ok ? 'ready' : 'not ready'}`); - lines.push(`Directory: ${plan.dir}`); - lines.push(`Privacy scan: ${plan.privacy.ok ? 'passed' : 'failed'}`); - lines.push(`Content rows: ${plan.content_capture.rows}${plan.content_capture.allowed ? ' (explicitly allowed)' : ''}`); - lines.push(`Schema migration policy: ${plan.schema_migration_policy.migration_required ? 'migration required' : 'current'} (current ${plan.schema_migration_policy.current_version})`); - lines.push(''); - lines.push('Tables:'); - for (const [table, summary] of Object.entries(plan.tables)) { - lines.push(`- ${table}: ${summary.rows} row(s), ${summary.columns.length} column(s), stream ${summary.stream_name}`); - } - if (plan.errors.length > 0) { - lines.push(''); - lines.push('Errors:'); - for (const error of plan.errors) lines.push(`- ${error}`); - } - if (plan.warnings.length > 0) { - lines.push(''); - lines.push('Warnings:'); - for (const warning of plan.warnings) lines.push(`- ${warning}`); - } - lines.push(''); - lines.push('Azure path: create Log Analytics custom tables/DCR streams for AgentOps*_CL, ingest these JSONL rows, then import the V2 Grafana dashboards.'); - lines.push('Next:'); - for (const command of plan.next) lines.push(`- ${command}`); - return `${lines.join('\n')}\n`; -} - -function renderLogsIngestionUploadPlan(plan) { - const lines = []; - lines.push('AgentOps Logs Ingestion upload plan'); - lines.push(''); - lines.push(`Status: ${plan.ok ? 'ready' : 'not ready'}`); - lines.push(`Directory: ${plan.dir}`); - lines.push(`Endpoint: ${plan.endpoint || '<logs-ingestion-endpoint>'}`); - lines.push(`DCR immutable ID: ${plan.dcr_immutable_id || '<immutable-id>'}`); - lines.push(`Privacy scan: ${plan.privacy.ok ? 'passed' : 'failed'}`); - lines.push(''); - lines.push('Uploads:'); - for (const upload of plan.uploads) lines.push(`- ${upload.table}: ${upload.rows} row(s) -> ${upload.stream}`); - if (plan.errors.length > 0) { - lines.push(''); - lines.push('Errors:'); - for (const error of plan.errors) lines.push(`- ${error}`); - } - if (plan.warnings.length > 0) { - lines.push(''); - lines.push('Warnings:'); - for (const warning of plan.warnings) lines.push(`- ${warning}`); - } - lines.push(''); - lines.push('Commands:'); - for (const upload of plan.uploads) lines.push(`- ${upload.command.join(' ')}`); - lines.push(''); - lines.push('Next:'); - for (const command of plan.next) lines.push(`- ${command}`); - return `${lines.join('\n')}\n`; -} - module.exports = { buildAzureIngestPlan, buildLogsIngestionUploadPlan, diff --git a/agentops-cli/src/lib/azure/v2-ingest-render.js b/agentops-cli/src/lib/azure/v2-ingest-render.js new file mode 100644 index 0000000..bf34f38 --- /dev/null +++ b/agentops-cli/src/lib/azure/v2-ingest-render.js @@ -0,0 +1,99 @@ +function renderSharedStorageUploadPlan(plan) { + const lines = []; + lines.push('AgentOps shared storage upload plan'); + lines.push(''); + lines.push(`Status: ${plan.ok ? 'ready' : 'not ready'}`); + lines.push(`Directory: ${plan.dir}`); + lines.push(`Storage: ${plan.storage.account || '<storage-account-name>'}/${plan.storage.container || '<container-name>'}`); + lines.push(`Privacy scan: ${plan.privacy.ok ? 'passed' : 'failed'}`); + lines.push(''); + lines.push('Artifacts:'); + for (const artifact of plan.artifacts) { + lines.push(`- ${artifact.table}: ${artifact.rows === null ? 'manifest' : `${artifact.rows} row(s)`} -> ${artifact.blob}`); + } + if (plan.errors.length > 0) { + lines.push(''); + lines.push('Errors:'); + for (const error of plan.errors) lines.push(`- ${error}`); + } + if (plan.warnings.length > 0) { + lines.push(''); + lines.push('Warnings:'); + for (const warning of plan.warnings) lines.push(`- ${warning}`); + } + lines.push(''); + lines.push('Commands:'); + for (const artifact of plan.artifacts) lines.push(`- ${artifact.command.join(' ')}`); + lines.push(''); + lines.push('Next:'); + for (const command of plan.next) lines.push(`- ${command}`); + return `${lines.join('\n')}\n`; +} + +function renderAzureIngestPlan(plan) { + const lines = []; + lines.push('AgentOps V2 Azure ingestion plan'); + lines.push(''); + lines.push(`Status: ${plan.ok ? 'ready' : 'not ready'}`); + lines.push(`Directory: ${plan.dir}`); + lines.push(`Privacy scan: ${plan.privacy.ok ? 'passed' : 'failed'}`); + lines.push(`Content rows: ${plan.content_capture.rows}${plan.content_capture.allowed ? ' (explicitly allowed)' : ''}`); + lines.push(`Schema migration policy: ${plan.schema_migration_policy.migration_required ? 'migration required' : 'current'} (current ${plan.schema_migration_policy.current_version})`); + lines.push(''); + lines.push('Tables:'); + for (const [table, summary] of Object.entries(plan.tables)) { + lines.push(`- ${table}: ${summary.rows} row(s), ${summary.columns.length} column(s), stream ${summary.stream_name}`); + } + if (plan.errors.length > 0) { + lines.push(''); + lines.push('Errors:'); + for (const error of plan.errors) lines.push(`- ${error}`); + } + if (plan.warnings.length > 0) { + lines.push(''); + lines.push('Warnings:'); + for (const warning of plan.warnings) lines.push(`- ${warning}`); + } + lines.push(''); + lines.push('Azure path: create Log Analytics custom tables/DCR streams for AgentOps*_CL, ingest these JSONL rows, then import the V2 Grafana dashboards.'); + lines.push('Next:'); + for (const command of plan.next) lines.push(`- ${command}`); + return `${lines.join('\n')}\n`; +} + +function renderLogsIngestionUploadPlan(plan) { + const lines = []; + lines.push('AgentOps Logs Ingestion upload plan'); + lines.push(''); + lines.push(`Status: ${plan.ok ? 'ready' : 'not ready'}`); + lines.push(`Directory: ${plan.dir}`); + lines.push(`Endpoint: ${plan.endpoint || '<logs-ingestion-endpoint>'}`); + lines.push(`DCR immutable ID: ${plan.dcr_immutable_id || '<immutable-id>'}`); + lines.push(`Privacy scan: ${plan.privacy.ok ? 'passed' : 'failed'}`); + lines.push(''); + lines.push('Uploads:'); + for (const upload of plan.uploads) lines.push(`- ${upload.table}: ${upload.rows} row(s) -> ${upload.stream}`); + if (plan.errors.length > 0) { + lines.push(''); + lines.push('Errors:'); + for (const error of plan.errors) lines.push(`- ${error}`); + } + if (plan.warnings.length > 0) { + lines.push(''); + lines.push('Warnings:'); + for (const warning of plan.warnings) lines.push(`- ${warning}`); + } + lines.push(''); + lines.push('Commands:'); + for (const upload of plan.uploads) lines.push(`- ${upload.command.join(' ')}`); + lines.push(''); + lines.push('Next:'); + for (const command of plan.next) lines.push(`- ${command}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + renderAzureIngestPlan, + renderLogsIngestionUploadPlan, + renderSharedStorageUploadPlan +}; diff --git a/agentops-cli/src/lib/azure/v2-ingestion-schema-safety.js b/agentops-cli/src/lib/azure/v2-ingestion-schema-safety.js new file mode 100644 index 0000000..509f05e --- /dev/null +++ b/agentops-cli/src/lib/azure/v2-ingestion-schema-safety.js @@ -0,0 +1,78 @@ +function normalizeColumns(columns, label) { + if (!Array.isArray(columns)) throw new TypeError(`${label} columns must be an array`); + const normalized = []; + const seen = new Set(); + for (const column of columns) { + const name = String(column?.name || '').trim(); + const type = String(column?.type || '').trim().toLowerCase(); + if (!name || !type) throw new Error(`${label} contains a column without a name or type`); + if (seen.has(name)) throw new Error(`${label} contains duplicate column ${name}`); + seen.add(name); + normalized.push({ name, type }); + } + return normalized; +} + +function validateAdditiveSchemaMigration(liveColumns, desiredColumns) { + const live = normalizeColumns(liveColumns, 'live'); + const desired = normalizeColumns(desiredColumns, 'desired'); + const desiredByName = new Map(desired.map(column => [column.name, column.type])); + const violations = live.flatMap(column => { + const desiredType = desiredByName.get(column.name); + if (!desiredType) return [{ column: column.name, live_type: column.type, desired_type: null, issue: 'would_remove_live_column' }]; + if (desiredType !== column.type) return [{ column: column.name, live_type: column.type, desired_type: desiredType, issue: 'would_change_existing_type' }]; + return []; + }); + return { + ok: violations.length === 0, + additive_columns: desired.filter(column => !live.some(existing => existing.name === column.name)), + violations + }; +} + +function matchingBracket(text, start) { + let depth = 0; + for (let index = start; index < text.length; index += 1) { + if (text[index] === '[') depth += 1; + if (text[index] === ']') { + depth -= 1; + if (depth === 0) return index; + } + } + throw new Error('AgentOpsEvents_CL columns array is not closed'); +} + +function agentOpsEventsColumnsFromBicep(source) { + const text = String(source || ''); + const tableStart = text.indexOf("name: 'AgentOpsEvents_CL'"); + if (tableStart < 0) throw new Error('AgentOpsEvents_CL table was not found in v2-ingestion Bicep'); + const columnsLabel = text.indexOf('columns:', tableStart); + const arrayStart = text.indexOf('[', columnsLabel); + if (columnsLabel < 0 || arrayStart < 0) throw new Error('AgentOpsEvents_CL columns were not found in v2-ingestion Bicep'); + const body = text.slice(arrayStart + 1, matchingBracket(text, arrayStart)); + const columns = [...body.matchAll(/\{\s*name:\s*'([^']+)'\s*,\s*type:\s*'([^']+)'\s*\}/g)] + .map(match => ({ name: match[1], type: match[2].toLowerCase() })); + return normalizeColumns(columns, 'AgentOpsEvents_CL desired'); +} + +function validateAgentOpsEventsBicepMigration(source, liveColumns) { + const desiredColumns = agentOpsEventsColumnsFromBicep(source); + const migration = validateAdditiveSchemaMigration(liveColumns, desiredColumns); + const desired = new Map(desiredColumns.map(column => [column.name, column.type])); + const contract = [ + ['EstimatedCostUsd', 'long'], + ['EstimatedCostUsdReal', 'real'], + ['EventId', 'string'], + ['Sequence', 'long'] + ].flatMap(([column, type]) => desired.get(column) === type + ? [] + : [{ column, expected_type: type, desired_type: desired.get(column) || null, issue: 'required_contract_mismatch' }]); + return { ...migration, ok: migration.ok && contract.length === 0, contract_violations: contract, desired_columns: desiredColumns }; +} + +module.exports = { + agentOpsEventsColumnsFromBicep, + normalizeColumns, + validateAdditiveSchemaMigration, + validateAgentOpsEventsBicepMigration +}; diff --git a/agentops-cli/src/lib/azure/v2-schema-versioning.js b/agentops-cli/src/lib/azure/v2-schema-versioning.js new file mode 100644 index 0000000..ee87c4c --- /dev/null +++ b/agentops-cli/src/lib/azure/v2-schema-versioning.js @@ -0,0 +1,138 @@ +const { tableNames } = require('../demo/agentops-demo-data'); +const { AGENTOPS_SCHEMA_VERSION } = require('../schema/agentops-attributes'); + +const schemaVersionTables = new Set(tableNames.filter(table => table.endsWith('_CL'))); + +const schemaMigrationPolicy = { + current_version: AGENTOPS_SCHEMA_VERSION, + supported_versions: [AGENTOPS_SCHEMA_VERSION], + legacy_versions: ['1'], + missing_version_action: 'Regenerate or roll up telemetry with the current AgentOps CLI so every AgentOps*_CL row includes SchemaVersion.', + legacy_version_action: 'Regenerate the affected AgentOps*_CL files or re-run the local rollup before cloud ingestion.', + unsupported_newer_version_action: 'Upgrade the AgentOps CLI and Grafana dashboard pack before ingesting newer schema rows.' +}; + +function schemaVersionFor(table, rows) { + if (!schemaVersionTables.has(table) || rows.length === 0) { + const migration = schemaMigrationFor({ table, missingRows: 0, versions: [] }); + return { + checked: false, + expected: AGENTOPS_SCHEMA_VERSION, + versions: [], + missing_rows: 0, + mismatched_versions: [], + migration, + ok: true + }; + } + + const versions = versionCounts(rows); + const missingRows = rows.filter(row => row?.SchemaVersion === undefined || row?.SchemaVersion === null || row?.SchemaVersion === '').length; + const mismatchedVersions = Object.keys(versions).filter(version => version !== AGENTOPS_SCHEMA_VERSION); + const migration = schemaMigrationFor({ table, missingRows, versions: Object.keys(versions) }); + + return { + checked: true, + expected: AGENTOPS_SCHEMA_VERSION, + versions: Object.keys(versions).sort(), + version_counts: versions, + missing_rows: missingRows, + mismatched_versions: mismatchedVersions, + migration, + ok: missingRows === 0 && mismatchedVersions.length === 0 + }; +} + +function versionCounts(rows) { + const counts = {}; + for (const row of rows) { + const version = row?.SchemaVersion; + if (version === undefined || version === null || version === '') continue; + const key = String(version); + counts[key] = (counts[key] || 0) + 1; + } + return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))); +} + +function schemaMigrationFor({ table, missingRows, versions }) { + const legacyVersions = versions.filter(version => schemaMigrationPolicy.legacy_versions.includes(version)); + const unsupportedVersions = versions.filter(version => !schemaMigrationPolicy.supported_versions.includes(version) && !schemaMigrationPolicy.legacy_versions.includes(version)); + const actions = []; + if (missingRows > 0) actions.push(`schema migration required for ${missingRows} missing-version row(s): ${schemaMigrationPolicy.missing_version_action}`); + if (legacyVersions.length > 0) actions.push(`schema migration required from version(s) ${legacyVersions.join(', ')} to ${AGENTOPS_SCHEMA_VERSION}: ${schemaMigrationPolicy.legacy_version_action}`); + if (unsupportedVersions.length > 0) actions.push(`unsupported newer schema version(s) ${unsupportedVersions.join(', ')}: ${schemaMigrationPolicy.unsupported_newer_version_action}`); + + return { + table, + status: unsupportedVersions.length > 0 + ? 'unsupported-newer' + : missingRows > 0 + ? 'missing-version' + : legacyVersions.length > 0 + ? 'legacy-migration-required' + : 'current', + migration_required: missingRows > 0 || legacyVersions.length > 0 || unsupportedVersions.length > 0, + compatible_for_ingest: unsupportedVersions.length === 0, + legacy_versions: legacyVersions, + unsupported_versions: unsupportedVersions, + actions + }; +} + +function schemaVersioningSummary(tables) { + const checked = Object.entries(tables) + .filter(([, table]) => table.schema_version?.checked) + .map(([name, table]) => ({ name, ...table.schema_version })); + const missingRows = checked.reduce((total, table) => total + table.missing_rows, 0); + const mismatchedTables = checked + .filter(table => table.mismatched_versions.length > 0) + .map(table => ({ + table: table.name, + versions: table.mismatched_versions + })); + + return { + ok: missingRows === 0 && mismatchedTables.length === 0, + expected: AGENTOPS_SCHEMA_VERSION, + checked_tables: checked.length, + missing_rows: missingRows, + mismatched_tables: mismatchedTables + }; +} + +function schemaMigrationSummary(tables) { + const migrations = Object.entries(tables) + .filter(([, table]) => table.schema_version?.migration?.migration_required) + .map(([name, table]) => ({ + table: name, + status: table.schema_version.migration.status, + compatible_for_ingest: table.schema_version.migration.compatible_for_ingest, + versions: table.schema_version.versions, + missing_rows: table.schema_version.missing_rows, + actions: table.schema_version.migration.actions + })); + const unsupportedTables = migrations + .filter(migration => migration.compatible_for_ingest === false) + .map(migration => ({ + table: migration.table, + versions: tables[migration.table].schema_version.migration.unsupported_versions + })); + + return { + current_version: schemaMigrationPolicy.current_version, + supported_versions: schemaMigrationPolicy.supported_versions, + legacy_versions: schemaMigrationPolicy.legacy_versions, + ok: unsupportedTables.length === 0, + migration_required: migrations.length > 0, + migrations, + unsupported_tables: unsupportedTables, + actions: migrations.flatMap(migration => migration.actions) + }; +} + +module.exports = { + schemaMigrationPolicy, + schemaMigrationSummary, + schemaVersionFor, + schemaVersioningSummary +}; diff --git a/agentops-cli/src/lib/benchmark-approval.js b/agentops-cli/src/lib/benchmark-approval.js new file mode 100644 index 0000000..c08a00a --- /dev/null +++ b/agentops-cli/src/lib/benchmark-approval.js @@ -0,0 +1,498 @@ +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isPlainObject, isStringArray } = require('./benchmark-validation'); +const { writeJsonFile } = require('./command-output'); +const { readJson } = require('./json'); + +function validateBenchmarkExternalReview(review, source = 'approval') { + if (review === undefined || review === null) return null; + if (!isPlainObject(review)) { + throw new Error(`Invalid benchmark promotion approval ${source}: externalReview must be an object`); + } + + const normalized = {}; + for (const field of ['system', 'id', 'url']) { + if (review[field] === undefined) continue; + if (typeof review[field] !== 'string' || review[field].trim() === '') { + throw new Error(`Invalid benchmark promotion approval ${source}: externalReview.${field} must be a non-empty string`); + } + normalized[field] = review[field].trim(); + } + + if (Object.keys(normalized).length === 0) { + throw new Error(`Invalid benchmark promotion approval ${source}: externalReview must include system, id, or url`); + } + + const status = review.status || 'approved'; + if (!['approved', 'pending', 'rejected'].includes(status)) { + throw new Error(`Invalid benchmark promotion approval ${source}: externalReview.status must be approved, pending, or rejected`); + } + + return { + status, + ...normalized + }; +} + +function parseGitHubExternalReviewTarget(review) { + const url = review.url || ''; + const urlMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i); + if (urlMatch) { + return { + repo: `${urlMatch[1]}/${urlMatch[2]}`, + pr: urlMatch[3] + }; + } + + const id = review.id || ''; + const repoIdMatch = id.match(/^([^/\s]+\/[^#\s]+)#(\d+)$/); + if (repoIdMatch) { + return { + repo: repoIdMatch[1], + pr: repoIdMatch[2] + }; + } + + return null; +} + +function verifyGitHubExternalReview(review, options = {}) { + const target = parseGitHubExternalReviewTarget(review); + if (!target) { + return { + provider: 'github', + ok: false, + status: 'pending', + error: 'GitHub review verification requires a pull request URL or owner/repo#number id' + }; + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync('gh', [ + 'pr', + 'view', + target.pr, + '--repo', + target.repo, + '--json', + 'reviewDecision,state,mergedAt,url,number' + ], { + encoding: 'utf8' + }); + + if (result.status !== 0) { + return { + provider: 'github', + ok: false, + status: 'pending', + repo: target.repo, + number: Number(target.pr), + error: (result.stderr || result.stdout || 'gh pr view failed').trim() + }; + } + + let payload = {}; + try { + payload = JSON.parse(result.stdout || '{}'); + } catch (error) { + return { + provider: 'github', + ok: false, + status: 'pending', + repo: target.repo, + number: Number(target.pr), + error: `gh pr view returned invalid JSON: ${error.message}` + }; + } + + const reviewDecision = String(payload.reviewDecision || '').toUpperCase(); + const state = String(payload.state || '').toUpperCase(); + const merged = Boolean(payload.mergedAt) || state === 'MERGED'; + const ok = reviewDecision === 'APPROVED' || merged; + const status = ok ? 'approved' : (reviewDecision === 'CHANGES_REQUESTED' || state === 'CLOSED' ? 'rejected' : 'pending'); + + return { + provider: 'github', + ok, + status, + repo: target.repo, + number: Number(payload.number || target.pr), + url: payload.url || review.url || null, + reviewDecision: reviewDecision || null, + state: state || null, + merged + }; +} + +function safeDecodeURIComponent(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseAzureDevOpsExternalReviewTarget(review) { + const url = review.url || ''; + let urlMatch = url.match(/^https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)(?:[/?#].*)?$/i); + if (urlMatch) { + return { + organizationUrl: `https://dev.azure.com/${urlMatch[1]}`, + project: safeDecodeURIComponent(urlMatch[2]), + repository: safeDecodeURIComponent(urlMatch[3]), + pr: urlMatch[4] + }; + } + + urlMatch = url.match(/^https?:\/\/([^/.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)(?:[/?#].*)?$/i); + if (urlMatch) { + return { + organizationUrl: `https://${urlMatch[1]}.visualstudio.com`, + project: safeDecodeURIComponent(urlMatch[2]), + repository: safeDecodeURIComponent(urlMatch[3]), + pr: urlMatch[4] + }; + } + + const id = review.id || ''; + const idMatch = id.match(/^([^/\s]+)\/([^/\s]+)\/([^#\s]+)#(\d+)$/); + if (idMatch) { + return { + organizationUrl: `https://dev.azure.com/${idMatch[1]}`, + project: idMatch[2], + repository: idMatch[3], + pr: idMatch[4] + }; + } + + return null; +} + +function verifyAzureDevOpsExternalReview(review, options = {}) { + const target = parseAzureDevOpsExternalReviewTarget(review); + if (!target) { + return { + provider: 'azure-devops', + ok: false, + status: 'pending', + error: 'Azure DevOps review verification requires a pull request URL or org/project/repo#number id' + }; + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync('az', [ + 'repos', + 'pr', + 'show', + '--id', + target.pr, + '--organization', + target.organizationUrl, + '--project', + target.project, + '--repository', + target.repository, + '--output', + 'json' + ], { + encoding: 'utf8' + }); + + if (result.status !== 0) { + return { + provider: 'azure-devops', + ok: false, + status: 'pending', + organizationUrl: target.organizationUrl, + project: target.project, + repository: target.repository, + number: Number(target.pr), + error: (result.stderr || result.stdout || 'az repos pr show failed').trim() + }; + } + + let payload = {}; + try { + payload = JSON.parse(result.stdout || '{}'); + } catch (error) { + return { + provider: 'azure-devops', + ok: false, + status: 'pending', + organizationUrl: target.organizationUrl, + project: target.project, + repository: target.repository, + number: Number(target.pr), + error: `az repos pr show returned invalid JSON: ${error.message}` + }; + } + + const pullRequestStatus = String(payload.status || '').toLowerCase(); + const reviewers = Array.isArray(payload.reviewers) ? payload.reviewers : []; + const approvals = reviewers.filter(reviewer => Number(reviewer.vote || 0) >= 5).length; + const rejections = reviewers.filter(reviewer => Number(reviewer.vote || 0) <= -5).length; + const completed = pullRequestStatus === 'completed'; + const rejected = pullRequestStatus === 'abandoned' || rejections > 0; + const ok = completed || (approvals > 0 && !rejected); + + return { + provider: 'azure-devops', + ok, + status: ok ? 'approved' : (rejected ? 'rejected' : 'pending'), + organizationUrl: target.organizationUrl, + project: target.project, + repository: target.repository, + number: Number(payload.pullRequestId || target.pr), + url: payload.url || review.url || null, + pullRequestStatus: pullRequestStatus || null, + approvals, + rejections + }; +} + +function safeUrlOrigin(url) { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +function parseJiraExternalReviewTarget(review) { + const id = review.id || ''; + const idMatch = id.match(/[A-Z][A-Z0-9]+-\d+/); + if (idMatch) return { key: idMatch[0] }; + + const url = review.url || ''; + const urlMatch = url.match(/\/(?:browse|issues?)\/([A-Z][A-Z0-9]+-\d+)(?:[/?#].*)?$/); + if (urlMatch) { + return { + key: urlMatch[1], + baseUrl: safeUrlOrigin(url) + }; + } + + return null; +} + +function jiraApprovedStatuses(options = {}) { + const configured = options.jiraApprovedStatuses || process.env.AGENTOPS_JIRA_APPROVED_STATUSES; + const statuses = configured + ? String(configured).split(',').map(status => status.trim().toLowerCase()).filter(Boolean) + : []; + return new Set(['approved', 'closed', 'done', 'resolved', ...statuses]); +} + +function jiraApiBaseUrl(review, target) { + if (target.baseUrl) return target.baseUrl; + if (review.url) return safeUrlOrigin(review.url); + if (process.env.JIRA_BASE_URL) return process.env.JIRA_BASE_URL.replace(/\/+$/, ''); + return null; +} + +function fetchExternalReviewJson(url, options = {}) { + if (options.fetchJson) return options.fetchJson(url, options); + + const headers = []; + if (process.env.JIRA_API_TOKEN && process.env.JIRA_EMAIL) { + const token = Buffer.from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64'); + headers.push('Authorization', `Basic ${token}`); + } else if (process.env.JIRA_API_TOKEN) { + headers.push('Authorization', `Bearer ${process.env.JIRA_API_TOKEN}`); + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const args = ['-fsSL', '--max-time', '10']; + for (let index = 0; index < headers.length; index += 2) { + args.push('-H', `${headers[index]}: ${headers[index + 1]}`); + } + args.push(url); + + const result = spawnSync('curl', args, { encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || 'curl failed').trim()); + } + return JSON.parse(result.stdout || '{}'); +} + +function verifyJiraExternalReview(review, options = {}) { + const target = parseJiraExternalReviewTarget(review); + if (!target) { + return { + provider: 'jira', + ok: false, + status: 'pending', + error: 'Jira review verification requires an issue key or Jira issue URL' + }; + } + + const baseUrl = jiraApiBaseUrl(review, target); + if (!baseUrl) { + return { + provider: 'jira', + ok: false, + status: 'pending', + issueKey: target.key, + error: 'Jira review verification requires a Jira issue URL or JIRA_BASE_URL' + }; + } + + const url = `${baseUrl.replace(/\/+$/, '')}/rest/api/3/issue/${encodeURIComponent(target.key)}?fields=status`; + let payload = {}; + try { + payload = fetchExternalReviewJson(url, options); + } catch (error) { + return { + provider: 'jira', + ok: false, + status: 'pending', + issueKey: target.key, + url, + error: error.message + }; + } + + const issueStatus = payload.fields?.status || {}; + const statusName = String(issueStatus.name || '').toLowerCase(); + const categoryKey = String(issueStatus.statusCategory?.key || '').toLowerCase(); + const categoryName = String(issueStatus.statusCategory?.name || '').toLowerCase(); + const approved = categoryKey === 'done' || categoryName === 'done' || jiraApprovedStatuses(options).has(statusName); + const rejected = ['canceled', 'cancelled', 'declined', 'rejected'].some(value => statusName.includes(value)); + + return { + provider: 'jira', + ok: approved, + status: approved ? 'approved' : (rejected ? 'rejected' : 'pending'), + issueKey: payload.key || target.key, + url: review.url || `${baseUrl.replace(/\/+$/, '')}/browse/${encodeURIComponent(target.key)}`, + issueStatus: issueStatus.name || null, + statusCategory: issueStatus.statusCategory?.key || issueStatus.statusCategory?.name || null + }; +} + +function verifyBenchmarkExternalReview(review, options = {}) { + if (!review) return null; + const system = String(review.system || '').toLowerCase(); + if (system === 'github') return verifyGitHubExternalReview(review, options); + if (['azure-devops', 'azdo', 'ado'].includes(system)) return verifyAzureDevOpsExternalReview(review, options); + if (system === 'jira') return verifyJiraExternalReview(review, options); + { + return { + provider: system || 'unknown', + ok: false, + status: 'pending', + error: 'external review verification currently supports GitHub pull requests, Azure DevOps pull requests, and Jira issues only' + }; + } +} + +function validateBenchmarkPromotionApproval(approval, source = 'approval') { + if (approval === undefined || approval === null) return null; + if (!isPlainObject(approval)) { + throw new Error(`Invalid benchmark promotion approval ${source}: approval must be an object`); + } + + if (approval.approvedBy !== undefined && !isStringArray(approval.approvedBy)) { + throw new Error(`Invalid benchmark promotion approval ${source}: approvedBy must be an array of strings`); + } + const approvedBy = approval.approvedBy === undefined + ? [] + : [...new Set(approval.approvedBy.map(name => name.trim()).filter(Boolean))].sort(); + + const status = approval.status || (approvedBy.length > 0 ? 'approved' : 'pending'); + if (!['approved', 'pending', 'rejected'].includes(status)) { + throw new Error(`Invalid benchmark promotion approval ${source}: status must be approved, pending, or rejected`); + } + + if (approval.approvedAt !== undefined && typeof approval.approvedAt !== 'string') { + throw new Error(`Invalid benchmark promotion approval ${source}: approvedAt must be a string`); + } + if (approval.ticket !== undefined && typeof approval.ticket !== 'string') { + throw new Error(`Invalid benchmark promotion approval ${source}: ticket must be a string`); + } + if (approval.runId !== undefined && (typeof approval.runId !== 'string' || approval.runId.trim() === '')) { + throw new Error(`Invalid benchmark promotion approval ${source}: runId must be a non-empty string`); + } + + const externalReview = validateBenchmarkExternalReview(approval.externalReview, source); + + return { + status, + ...(approval.runId !== undefined ? { runId: approval.runId } : {}), + approvedBy, + approvedAt: approval.approvedAt || null, + ticket: approval.ticket || null, + ...(externalReview ? { externalReview } : {}), + source + }; +} + +function benchmarkPromotionApprovalFromOptions(options = {}) { + const verifyApproval = approval => { + if (!approval || !options.verifyExternalReview) return approval; + const verification = verifyBenchmarkExternalReview(approval.externalReview, options); + if (!verification) return approval; + return { + ...approval, + externalReview: { + ...approval.externalReview, + status: verification.status || approval.externalReview.status, + verification + } + }; + }; + + if (options.promotionApproval !== undefined) { + return verifyApproval(validateBenchmarkPromotionApproval(options.promotionApproval, 'options.promotionApproval')); + } + if (!options.approvalFile) return null; + return verifyApproval(validateBenchmarkPromotionApproval(readJson(path.resolve(options.approvalFile)), options.approvalFile)); +} + +function benchmarkApproval(options = {}) { + if (typeof options.runId !== 'string' || options.runId.trim() === '') { + throw new Error('benchmark approve requires a run id'); + } + const status = options.status || 'approved'; + const approvedAt = options.approvedAt || (status === 'approved' ? (options.now || new Date()).toISOString() : undefined); + const approval = validateBenchmarkPromotionApproval({ + runId: options.runId, + status, + approvedBy: options.approvedBy || [], + approvedAt, + ticket: options.ticket || undefined, + externalReview: options.externalReview + }, 'benchmark approve'); + + if (approval.status === 'approved' && approval.approvedBy.length === 0) { + throw new Error('benchmark approve requires at least one --by approver'); + } + + if (options.output) { + const outputPath = path.resolve(options.cwd || process.cwd(), options.output); + const { source, ...approvalFile } = approval; + writeJsonFile(outputPath, approvalFile); + return { ...approval, output: outputPath }; + } + + return approval; +} + +module.exports = { + benchmarkApproval, + benchmarkPromotionApprovalFromOptions, + fetchExternalReviewJson, + jiraApprovedStatuses, + parseAzureDevOpsExternalReviewTarget, + parseGitHubExternalReviewTarget, + parseJiraExternalReviewTarget, + safeDecodeURIComponent, + safeUrlOrigin, + validateBenchmarkExternalReview, + validateBenchmarkPromotionApproval, + verifyAzureDevOpsExternalReview, + verifyBenchmarkExternalReview, + verifyGitHubExternalReview, + verifyJiraExternalReview +}; diff --git a/agentops-cli/src/lib/benchmark-args.js b/agentops-cli/src/lib/benchmark-args.js new file mode 100644 index 0000000..0de3b0d --- /dev/null +++ b/agentops-cli/src/lib/benchmark-args.js @@ -0,0 +1,257 @@ +function parseBenchmarkFixturePackArgs(args) { + const fixtureDir = args[0]; + if (!fixtureDir) throw new Error('benchmark fixture-pack requires a fixture directory'); + + const options = { fixtureDir }; + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--id') { + if (!args[index + 1]) throw new Error('--id requires a value'); + options.id = args[index + 1]; + index += 1; + } else if (arg === '--title') { + if (!args[index + 1]) throw new Error('--title requires a value'); + options.title = args[index + 1]; + index += 1; + } else if (arg === '--fixture') { + if (!args[index + 1]) throw new Error('--fixture requires a suite-relative fixture path'); + options.fixture = args[index + 1]; + index += 1; + } else if (arg === '--output') { + if (!args[index + 1]) throw new Error('--output requires a path'); + options.output = args[index + 1]; + index += 1; + } else if (arg === '--sign-key-id') { + if (!args[index + 1]) throw new Error('--sign-key-id requires a value'); + options.signKeyId = args[index + 1]; + index += 1; + } else if (arg === '--sign-private-key') { + if (!args[index + 1]) throw new Error('--sign-private-key requires a path'); + options.signPrivateKey = args[index + 1]; + index += 1; + } else { + throw new Error(`Unknown benchmark fixture-pack option: ${arg}`); + } + } + + if (typeof options.id !== 'string' || options.id.trim() === '') { + throw new Error('benchmark fixture-pack requires --id <id>'); + } + return options; +} + +function parseBenchmarkRunArgs(args) { + const suite = args[0]; + if (!suite) throw new Error('benchmark run requires a suite'); + + const options = { + suite, + repeat: 1, + dryRun: false + }; + + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--dry-run') { + options.dryRun = true; + } else if (arg === '--variant') { + options.variant = args[index + 1]; + index += 1; + } else if (arg === '--repeat') { + options.repeat = Number(args[index + 1]); + index += 1; + } else if (arg === '--hypothesis') { + options.hypothesis = args[index + 1]; + index += 1; + } else { + throw new Error(`Unknown benchmark run option: ${arg}`); + } + } + + if (!options.variant) throw new Error('benchmark run requires --variant <name>'); + if (!Number.isInteger(options.repeat) || options.repeat <= 0) { + throw new Error('--repeat must be a positive integer'); + } + + return options; +} + +function parseBenchmarkReportArgs(args, options = {}) { + const runId = args[0]; + if (!runId) throw new Error('benchmark report requires a run id'); + + const result = { + runId, + azure: false, + last: '24h', + verifyExternalReview: false + }; + + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--azure') { + result.azure = true; + } else if (arg === '--verify-external-review') { + result.verifyExternalReview = true; + } else if (arg === '--approval-file') { + if (!args[index + 1]) throw new Error('--approval-file requires a path'); + result.approvalFile = args[index + 1]; + index += 1; + } else if (arg === '--last') { + if (!args[index + 1]) throw new Error('--last requires a duration, for example 7d or 24h'); + result.last = args[index + 1]; + index += 1; + } else { + throw new Error(`Unknown benchmark report option: ${arg}`); + } + } + + if (result.azure) (options.validateDuration || (value => value))(result.last); + return result; +} + +function parseBenchmarkCompareArgs(args, options = {}) { + const beforeRunId = args[0]; + const afterRunId = args[1]; + if (!beforeRunId || !afterRunId) throw new Error('benchmark compare requires before and after run ids'); + + const result = { + beforeRunId, + afterRunId, + azure: false, + last: '24h', + verifyExternalReview: false + }; + + for (let index = 2; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--azure') { + result.azure = true; + } else if (arg === '--verify-external-review') { + result.verifyExternalReview = true; + } else if (arg === '--approval-file') { + if (!args[index + 1]) throw new Error('--approval-file requires a path'); + result.approvalFile = args[index + 1]; + index += 1; + } else if (arg === '--last') { + if (!args[index + 1]) throw new Error('--last requires a duration, for example 7d or 24h'); + result.last = args[index + 1]; + index += 1; + } else { + throw new Error(`Unknown benchmark compare option: ${arg}`); + } + } + + if (result.azure) (options.validateDuration || (value => value))(result.last); + return result; +} + +function parseBenchmarkApproveArgs(args) { + const runId = args[0]; + if (!runId) throw new Error('benchmark approve requires a run id'); + + const options = { + runId, + approvedBy: [], + status: 'approved' + }; + + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--by') { + if (!args[index + 1]) throw new Error('--by requires a name'); + options.approvedBy.push(args[index + 1]); + index += 1; + } else if (arg === '--ticket') { + if (!args[index + 1]) throw new Error('--ticket requires a value'); + options.ticket = args[index + 1]; + index += 1; + } else if (arg === '--status') { + if (!args[index + 1]) throw new Error('--status requires approved, pending, or rejected'); + options.status = args[index + 1]; + index += 1; + } else if (arg === '--review-system') { + if (!args[index + 1]) throw new Error('--review-system requires a value'); + options.externalReview = options.externalReview || {}; + options.externalReview.system = args[index + 1]; + index += 1; + } else if (arg === '--review-id') { + if (!args[index + 1]) throw new Error('--review-id requires a value'); + options.externalReview = options.externalReview || {}; + options.externalReview.id = args[index + 1]; + index += 1; + } else if (arg === '--review-url') { + if (!args[index + 1]) throw new Error('--review-url requires a value'); + options.externalReview = options.externalReview || {}; + options.externalReview.url = args[index + 1]; + index += 1; + } else if (arg === '--review-status') { + if (!args[index + 1]) throw new Error('--review-status requires approved, pending, or rejected'); + options.externalReview = options.externalReview || {}; + options.externalReview.status = args[index + 1]; + index += 1; + } else if (arg === '--approved-at') { + if (!args[index + 1]) throw new Error('--approved-at requires an ISO timestamp'); + options.approvedAt = args[index + 1]; + index += 1; + } else if (arg === '--output') { + if (!args[index + 1]) throw new Error('--output requires a path'); + options.output = args[index + 1]; + index += 1; + } else { + throw new Error(`Unknown benchmark approve option: ${arg}`); + } + } + + if (!['approved', 'pending', 'rejected'].includes(options.status)) { + throw new Error('--status must be approved, pending, or rejected'); + } + if (options.externalReview?.status !== undefined && !['approved', 'pending', 'rejected'].includes(options.externalReview.status)) { + throw new Error('--review-status must be approved, pending, or rejected'); + } + if (options.status === 'approved' && options.approvedBy.length === 0) { + throw new Error('benchmark approve requires at least one --by approver'); + } + return options; +} + +function parseBenchmarkArtifactsArgs(args) { + const runId = args[0]; + if (!runId) throw new Error('benchmark artifacts requires a run id'); + + const options = { + runId, + includeContent: false + }; + + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--task') { + if (!args[index + 1]) throw new Error('--task requires a task id'); + options.taskId = args[index + 1]; + index += 1; + } else if (arg === '--repeat') { + if (!args[index + 1]) throw new Error('--repeat requires a number'); + options.repeat = Number(args[index + 1]); + index += 1; + } else if (arg === '--include-content') { + options.includeContent = true; + } else { + throw new Error(`Unknown benchmark artifacts option: ${arg}`); + } + } + + if (options.repeat !== undefined && (!Number.isInteger(options.repeat) || options.repeat <= 0)) { + throw new Error('--repeat must be a positive integer'); + } + return options; +} + +module.exports = { + parseBenchmarkApproveArgs, + parseBenchmarkArtifactsArgs, + parseBenchmarkCompareArgs, + parseBenchmarkFixturePackArgs, + parseBenchmarkReportArgs, + parseBenchmarkRunArgs +}; diff --git a/agentops-cli/src/lib/benchmark-azure-telemetry.js b/agentops-cli/src/lib/benchmark-azure-telemetry.js new file mode 100644 index 0000000..e54a1a6 --- /dev/null +++ b/agentops-cli/src/lib/benchmark-azure-telemetry.js @@ -0,0 +1,219 @@ +const { escapeKqlString, validateKqlDuration } = require('./kql'); +const { numberValue, roundNumber } = require('./benchmark-scoring'); + +function benchmarkAzureTelemetryQuery(runId, last = '24h') { + const lookback = validateKqlDuration(last); + const escapedRunId = escapeKqlString(runId); + return `AppDependencies +| where TimeGenerated > ago(${lookback}) +| where Properties has "${escapedRunId}" +| extend run_id=tostring(Properties["agentops.benchmark.run_id"]), + suite=tostring(Properties["agentops.benchmark.suite"]), + task_id=tostring(Properties["agentops.benchmark.task_id"]), + variant=tostring(Properties["agentops.benchmark.variant"]), + hypothesis=tostring(Properties["agentops.hypothesis.id"]), + repeat_id=tostring(Properties["agentops.benchmark.repeat"]), + conversation=tostring(Properties["gen_ai.conversation.id"]), + operation=tostring(Properties["gen_ai.operation.name"]), + model=tostring(Properties["gen_ai.request.model"]), + tool=tostring(Properties["gen_ai.tool.name"]), + error=tostring(Properties["error.type"]), + InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), + OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), + CacheRead=todouble(Properties["gen_ai.usage.cache_read.input_tokens"]), + CacheWrite=todouble(Properties["gen_ai.usage.cache_creation.input_tokens"]), + Credits=todouble(Properties["github.copilot.cost"]), + AIU=todouble(Properties["github.copilot.aiu"]) +| where run_id == "${escapedRunId}" +| summarize Started=min(TimeGenerated), + Ended=max(TimeGenerated), + Spans=count(), + ChatSpans=countif(operation == "chat"), + AgentSpans=countif(operation == "invoke_agent"), + ToolCalls=countif(operation == "execute_tool" or isnotempty(tool)), + ToolFailures=countif((operation == "execute_tool" or isnotempty(tool)) and (Success == false or tostring(Success) =~ "false" or isnotempty(error))), + Failures=countif(Success == false or tostring(Success) =~ "false" or isnotempty(error)), + ChatInputTokens=sumif(InputTokens, operation == "chat"), + ChatOutputTokens=sumif(OutputTokens, operation == "chat"), + ChatCacheRead=sumif(CacheRead, operation == "chat"), + ChatCacheWrite=sumif(CacheWrite, operation == "chat"), + ChatCredits=sumif(Credits, operation == "chat"), + ChatAIU=sumif(AIU, operation == "chat"), + AgentInputTokens=maxif(InputTokens, operation == "invoke_agent"), + AgentOutputTokens=maxif(OutputTokens, operation == "invoke_agent"), + AgentCacheRead=maxif(CacheRead, operation == "invoke_agent"), + AgentCacheWrite=maxif(CacheWrite, operation == "invoke_agent"), + AgentCredits=maxif(Credits, operation == "invoke_agent"), + AgentAIU=maxif(AIU, operation == "invoke_agent"), + Models=make_set_if(model, isnotempty(model), 5), + Tools=make_set_if(tool, isnotempty(tool), 10), + Conversations=make_set_if(conversation, isnotempty(conversation), 5), + Operations=make_set_if(operation, isnotempty(operation), 10) + by run_id, suite, task_id, variant, hypothesis, repeat_id +| extend InputTokens=iff(ChatSpans > 0, ChatInputTokens, AgentInputTokens), + OutputTokens=iff(ChatSpans > 0, ChatOutputTokens, AgentOutputTokens), + CacheRead=iff(ChatSpans > 0, ChatCacheRead, AgentCacheRead), + CacheWrite=iff(ChatSpans > 0, ChatCacheWrite, AgentCacheWrite), + Credits=iff(ChatSpans > 0, ChatCredits, AgentCredits), + AIU=iff(ChatSpans > 0, ChatAIU, AgentAIU) +| order by task_id asc, repeat_id asc`; +} + +function arrayFromAzureValue(value) { + if (Array.isArray(value)) return value; + if (value === undefined || value === null || value === '') return []; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed; + } catch { + return value.split(',').map(item => item.trim()).filter(Boolean); + } + } + return []; +} + +function normalizeAiuValue(value) { + const aiu = numberValue(value); + return Math.abs(aiu) >= 1000000 ? roundNumber(aiu / 1000000000, 3) : aiu; +} + +function normalizeBenchmarkTelemetryRow(row) { + const credits = numberValue(row.Credits); + const aiuRaw = numberValue(row.AIU); + return { + runId: row.run_id || row.RunId || row.runId, + suite: row.suite || row.Suite || '', + taskId: row.task_id || row.taskId || '', + variant: row.variant || row.Variant || '', + hypothesis: row.hypothesis || row.Hypothesis || '', + repeat: row.repeat_id || row.repeat || '', + startedAt: row.Started || row.startedAt || null, + endedAt: row.Ended || row.endedAt || null, + spans: numberValue(row.Spans), + toolCalls: numberValue(row.ToolCalls), + toolFailures: numberValue(row.ToolFailures), + failures: numberValue(row.Failures), + inputTokens: numberValue(row.InputTokens), + outputTokens: numberValue(row.OutputTokens), + cacheReadTokens: numberValue(row.CacheRead), + cacheWriteTokens: numberValue(row.CacheWrite), + credits, + cost: roundNumber(credits * 0.01, 4), + aiu: normalizeAiuValue(aiuRaw), + aiuRaw, + models: arrayFromAzureValue(row.Models), + tools: arrayFromAzureValue(row.Tools), + conversations: arrayFromAzureValue(row.Conversations), + operations: arrayFromAzureValue(row.Operations) + }; +} + +function benchmarkTelemetryKey(taskId, repeat) { + return `${taskId || ''}::${repeat === undefined || repeat === null ? '' : String(repeat)}`; +} + +function benchmarkAzureTelemetry(runId, options = {}) { + if (!options.runAzureLogAnalyticsQuery) { + throw new Error('benchmark Azure telemetry requires runAzureLogAnalyticsQuery'); + } + + const last = validateKqlDuration(options.last || '24h'); + const query = benchmarkAzureTelemetryQuery(runId, last); + const result = options.runAzureLogAnalyticsQuery(query, options); + + if (!result.ok) { + return { + requested: true, + ok: false, + last, + query, + error: result.error, + rows: [], + matchedSpans: 0, + matchedTasks: 0 + }; + } + + const rows = Array.isArray(result.rows) ? result.rows.map(normalizeBenchmarkTelemetryRow) : []; + return { + requested: true, + ok: rows.length > 0, + last, + query, + rows, + matchedSpans: rows.reduce((total, row) => total + row.spans, 0), + matchedTasks: rows.filter(row => row.taskId).length, + data_missing: rows.length > 0 ? [] : ['azure benchmark telemetry'] + }; +} + +function enrichBenchmarkSummariesWithAzure(runId, summaries, options = {}) { + const telemetry = benchmarkAzureTelemetry(runId, options); + if (!telemetry.ok) return { summaries, azureTelemetry: telemetry }; + + const byTaskAndRepeat = new Map(); + const byTask = new Map(); + for (const row of telemetry.rows) { + byTaskAndRepeat.set(benchmarkTelemetryKey(row.taskId, row.repeat), row); + if (!byTask.has(row.taskId)) byTask.set(row.taskId, []); + byTask.get(row.taskId).push(row); + } + + const enriched = summaries.map(summary => { + const repeat = summary.repeat === undefined || summary.repeat === null ? '' : summary.repeat; + const exact = byTaskAndRepeat.get(benchmarkTelemetryKey(summary.taskId, repeat)); + const taskRows = byTask.get(summary.taskId) || []; + const row = exact || (taskRows.length === 1 ? taskRows[0] : null); + if (!row) return { ...summary, telemetryMatched: false }; + + return { + ...summary, + telemetryMatched: true, + telemetrySource: 'azure', + azureSpans: row.spans, + azureToolCalls: row.toolCalls, + azureFailures: row.failures, + startedAt: summary.startedAt || row.startedAt, + endedAt: summary.endedAt || row.endedAt, + toolFailures: Math.max(numberValue(summary.toolFailures), row.toolFailures), + hypothesis: summary.hypothesis || row.hypothesis || null, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheReadTokens: row.cacheReadTokens, + cacheWriteTokens: row.cacheWriteTokens, + credits: row.credits, + cost: row.cost, + aiu: row.aiu, + aiuRaw: row.aiuRaw, + models: row.models, + tools: row.tools, + conversations: row.conversations, + operations: row.operations, + errorCategory: summary.errorCategory || (row.toolFailures > 0 ? 'tool_failure' : null) + }; + }); + + return { + summaries: enriched, + azureTelemetry: { + requested: true, + ok: true, + last: telemetry.last, + matchedSpans: telemetry.matchedSpans, + matchedTasks: enriched.filter(summary => summary.telemetryMatched).length, + unmatchedTasks: enriched.filter(summary => !summary.telemetryMatched).map(summary => summary.taskId), + query: telemetry.query + } + }; +} + +module.exports = { + arrayFromAzureValue, + benchmarkAzureTelemetry, + benchmarkAzureTelemetryQuery, + benchmarkTelemetryKey, + enrichBenchmarkSummariesWithAzure, + normalizeAiuValue, + normalizeBenchmarkTelemetryRow +}; diff --git a/agentops-cli/src/lib/benchmark-command.js b/agentops-cli/src/lib/benchmark-command.js new file mode 100644 index 0000000..27b2932 --- /dev/null +++ b/agentops-cli/src/lib/benchmark-command.js @@ -0,0 +1,82 @@ +const { writeJson, writeJsonOrRender } = require('./command-output'); + +function createBenchmarkCommand(dependencies = {}) { + const { + benchmarkApproval, + benchmarkArtifactReview, + benchmarkFixturePack, + benchmarkJudgeProviderGuide, + benchmarkReport, + compareBenchmarkRuns, + listBenchmarks, + parseBenchmarkApproveArgs, + parseBenchmarkArtifactsArgs, + parseBenchmarkCompareArgs, + parseBenchmarkFixturePackArgs, + parseBenchmarkReportArgs, + parseBenchmarkRunArgs, + renderBenchmarkJudgeProviderGuide, + runBenchmarkSuite, + stdout = process.stdout + } = dependencies; + + function benchmarkCommand(args) { + const [subcommand, ...benchmarkArgs] = args; + if (subcommand === 'list') { + writeJson(listBenchmarks(), stdout); + return; + } + + if (subcommand === 'fixture-pack') { + const options = parseBenchmarkFixturePackArgs(benchmarkArgs); + writeJson(benchmarkFixturePack(options), stdout); + return; + } + + if (subcommand === 'judge-provider') { + const guide = benchmarkJudgeProviderGuide(); + writeJsonOrRender(guide, benchmarkArgs.includes('--json'), renderBenchmarkJudgeProviderGuide, stdout); + return; + } + + if (subcommand === 'approve') { + const options = parseBenchmarkApproveArgs(benchmarkArgs); + writeJson(benchmarkApproval(options), stdout); + return; + } + + if (subcommand === 'artifacts') { + const options = parseBenchmarkArtifactsArgs(benchmarkArgs); + writeJson(benchmarkArtifactReview(options.runId, null, options), stdout); + return; + } + + if (subcommand === 'run') { + const options = parseBenchmarkRunArgs(benchmarkArgs); + writeJson(runBenchmarkSuite(options.suite, options), stdout); + return; + } + + if (subcommand === 'report') { + const options = parseBenchmarkReportArgs(benchmarkArgs); + writeJson(benchmarkReport(options.runId, null, options), stdout); + return; + } + + if (subcommand === 'compare') { + const options = parseBenchmarkCompareArgs(benchmarkArgs); + writeJson(compareBenchmarkRuns(options.beforeRunId, options.afterRunId, null, options), stdout); + return; + } + + throw new Error('benchmark requires list, fixture-pack, judge-provider, approve, artifacts, run, report, or compare'); + } + + return { + benchmarkCommand + }; +} + +module.exports = { + createBenchmarkCommand +}; diff --git a/agentops-cli/src/lib/benchmark-context.js b/agentops-cli/src/lib/benchmark-context.js new file mode 100644 index 0000000..15ec78d --- /dev/null +++ b/agentops-cli/src/lib/benchmark-context.js @@ -0,0 +1,131 @@ +function createBenchmarkContext(dependencies = {}) { + const { + benchmarkArtifactReviewBase, + benchmarkAzureTelemetryBase, + benchmarkReportBase, + benchmarkRunBaseDir, + benchmarkRunPlanBase, + benchmarksDir, + compareBenchmarkRunsBase, + defaultBenchmarkSummaryDirBase, + enrichBenchmarkSummariesWithAzureBase, + listBenchmarksBase, + loadBenchmarkSummariesBase, + loadBenchmarkSuitesBase, + parseBenchmarkCompareArgsBase, + parseBenchmarkReportArgsBase, + runAzureLogAnalyticsQuery, + runBenchmarkSuiteBase, + validateBenchmarkTaskBase, + validateKqlDuration, + root + } = dependencies; + + function validateBenchmarkTask(task, suiteDir, source = 'task', options = {}) { + return validateBenchmarkTaskBase(task, suiteDir, source, { root, ...options }); + } + + function loadBenchmarkSuites(baseDir = benchmarksDir) { + return loadBenchmarkSuitesBase(baseDir, { root }); + } + + function listBenchmarks(baseDir = benchmarksDir) { + return listBenchmarksBase(baseDir, { root }); + } + + function parseBenchmarkReportArgs(args) { + return parseBenchmarkReportArgsBase(args, { validateDuration: validateKqlDuration }); + } + + function parseBenchmarkCompareArgs(args) { + return parseBenchmarkCompareArgsBase(args, { validateDuration: validateKqlDuration }); + } + + function benchmarkRunPlan(suiteId, options = {}) { + return benchmarkRunPlanBase(suiteId, { + benchmarkRunBaseDir, + loadBenchmarkSuites, + ...options + }); + } + + function runBenchmarkSuite(suiteId, options = {}) { + return runBenchmarkSuiteBase(suiteId, { + benchmarkReport, + benchmarkRunBaseDir, + defaultBenchmarkSummaryDir, + loadBenchmarkSuites, + ...options + }); + } + + function benchmarkAzureTelemetry(runId, options = {}) { + return benchmarkAzureTelemetryBase(runId, { + runAzureLogAnalyticsQuery, + ...options + }); + } + + function enrichBenchmarkSummariesWithAzure(runId, summaries, options = {}) { + return enrichBenchmarkSummariesWithAzureBase(runId, summaries, { + runAzureLogAnalyticsQuery, + ...options + }); + } + + function defaultBenchmarkSummaryDir() { + return defaultBenchmarkSummaryDirBase({ benchmarksDir }); + } + + function benchmarkArtifactReview(runId, summaries = null, options = {}) { + return benchmarkArtifactReviewBase(runId, summaries, { + benchmarksDir, + loadBenchmarkSuites, + ...options + }); + } + + function loadBenchmarkSummaries(runId, options = {}) { + return loadBenchmarkSummariesBase(runId, { + benchmarksDir, + ...options + }); + } + + function benchmarkReport(runId, summaries = null, options = {}) { + return benchmarkReportBase(runId, summaries, { + benchmarksDir, + loadBenchmarkSuites, + runAzureLogAnalyticsQuery, + ...options + }); + } + + function compareBenchmarkRuns(beforeRunId, afterRunId, summaries = null, options = {}) { + return compareBenchmarkRunsBase(beforeRunId, afterRunId, summaries, { + benchmarksDir, + loadBenchmarkSuites, + runAzureLogAnalyticsQuery, + ...options + }); + } + + return { + benchmarkArtifactReview, + benchmarkAzureTelemetry, + benchmarkReport, + benchmarkRunPlan, + compareBenchmarkRuns, + defaultBenchmarkSummaryDir, + enrichBenchmarkSummariesWithAzure, + listBenchmarks, + loadBenchmarkSummaries, + loadBenchmarkSuites, + parseBenchmarkCompareArgs, + parseBenchmarkReportArgs, + runBenchmarkSuite, + validateBenchmarkTask + }; +} + +module.exports = { createBenchmarkContext }; diff --git a/agentops-cli/src/lib/benchmark-execution.js b/agentops-cli/src/lib/benchmark-execution.js new file mode 100644 index 0000000..ffcddbf --- /dev/null +++ b/agentops-cli/src/lib/benchmark-execution.js @@ -0,0 +1,583 @@ +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { writeJsonFile } = require('./command-output'); +const { hashText } = require('./hash'); +const { + benchmarkForbiddenMatches, + changedRelativeFiles, + normalizeBenchmarkRelativePath, + relativeFileDiff, + relativeFileSnapshot: relativeFileSnapshotBase, + safeBenchmarkPath +} = require('./benchmark-paths'); +const { + benchmarkCopilotInvocation, + benchmarkSandboxProfile, + mergeResourceAttributes +} = require('./benchmark-invocation'); +const { numberValue, roundNumber } = require('./benchmark-scoring'); + +const benchmarkSemanticAdapters = new Set(['file-contains', 'file-regex', 'file-rubric', 'llm-judge']); + +function walk(dir, predicate, results = []) { + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) walk(fullPath, predicate, results); + if (entry.isFile() && predicate(fullPath)) results.push(fullPath); + } + return results; +} + +function makeBenchmarkRunId(now = new Date()) { + const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + return `bench-${stamp}-${crypto.randomBytes(4).toString('hex')}`; +} + +function benchmarkRunPlan(suiteId, options = {}) { + const variant = options.variant; + const repeat = options.repeat || 1; + const dryRun = Boolean(options.dryRun); + const hypothesis = options.hypothesis || null; + const loadBenchmarkSuites = options.loadBenchmarkSuites || (() => []); + const benchmarkRunBaseDir = options.benchmarkRunBaseDir || path.join(os.tmpdir(), 'agentops-benchmark-runs'); + + if (!variant) throw new Error('benchmark run requires --variant <name>'); + if (!Number.isInteger(repeat) || repeat <= 0) throw new Error('--repeat must be a positive integer'); + + const suite = loadBenchmarkSuites(options.benchmarksDir).find(item => item.id === suiteId); + if (!suite) throw new Error(`Unknown benchmark suite: ${suiteId}`); + + const runId = options.runId || makeBenchmarkRunId(options.now); + const runs = []; + + for (let repeatIndex = 1; repeatIndex <= repeat; repeatIndex += 1) { + for (const task of suite.tasks) { + const runRoot = path.join(benchmarkRunBaseDir, runId, task.id, `repeat-${repeatIndex}`); + runs.push({ + taskId: task.id, + taskTitle: task.title, + repeat: repeatIndex, + copiedFixturePath: { + from: task.fixturePath, + to: path.join(runRoot, 'workspace') + }, + copilotHome: path.join(runRoot, 'copilot-home'), + environment: { + COPILOT_HOME: path.join(runRoot, 'copilot-home') + }, + copilot: { + command: 'copilot', + args: task.copilotArgs, + prompt: task.prompt + }, + otelLabels: { + 'agentops.benchmark.run_id': runId, + 'agentops.benchmark.suite': suite.id, + 'agentops.benchmark.task_id': task.id, + 'agentops.benchmark.variant': variant, + 'agentops.benchmark.permission_profile': task.permissionProfile, + 'agentops.benchmark.repeat': String(repeatIndex), + ...(task.toolPolicy?.blockedRisks?.length + ? { 'agentops.benchmark.tool_policy.blocked_risks': task.toolPolicy.blockedRisks.join('|') } + : {}), + ...(hypothesis ? { 'agentops.hypothesis.id': hypothesis } : {}) + }, + osSandbox: task.osSandbox, + promotionGates: suite.promotionGates, + toolPolicyEnforcement: task.toolPolicyEnforcement, + successChecks: { + commands: task.successCommands, + fixtureSeal: task.fixtureSeal ? { + algorithm: task.fixtureSeal.algorithm, + fileCount: Object.keys(task.fixtureSeal.files).length, + files: Object.keys(task.fixtureSeal.files).sort() + } : null, + fixtureSealPack: task.fixtureSealPack ? { + id: task.fixtureSealPack.id, + title: task.fixtureSealPack.title, + algorithm: task.fixtureSealPack.algorithm, + fixture: task.fixtureSealPack.fixture, + fileCount: Object.keys(task.fixtureSealPack.files).length, + ...(task.fixtureSealPack.signature ? { signature: task.fixtureSealPack.signature } : {}), + source: task.fixtureSealPack.source + } : null, + commandFileSeal: task.commandFileSeal ? { + algorithm: task.commandFileSeal.algorithm, + fileCount: Object.keys(task.commandFileSeal.files).length, + files: Object.keys(task.commandFileSeal.files).sort() + } : null, + ...(options.includeHiddenChecks ? { commandFileSealDefinition: task.commandFileSeal } : {}), + hiddenCommandCount: task.hiddenSuccessCommands.length + task.hiddenPackCommands.length, + hiddenCheckPacks: task.hiddenCheckPacks.map(pack => ({ + id: pack.id, + title: pack.title, + commandCount: pack.commands.length, + source: pack.source + })), + ...(options.includeHiddenChecks ? { hiddenCommands: [...task.hiddenSuccessCommands, ...task.hiddenPackCommands] } : {}), + semanticCheckCount: task.semanticChecks.length, + semanticChecks: task.semanticChecks.map(check => ({ + id: check.id, + adapter: check.adapter, + file: check.file + })), + ...(options.includeHiddenChecks ? { semanticCheckDefinitions: task.semanticChecks } : {}), + expectedFiles: task.expectedFiles, + forbiddenFiles: task.forbiddenFiles + }, + permissionProfile: task.permissionProfile, + toolPolicy: task.toolPolicy, + timeoutSec: task.timeoutSec + }); + } + } + + return { + runId, + suite: suite.id, + variant, + hypothesis, + repeat, + dryRun, + wouldMutateRepo: !dryRun, + wouldExecuteCopilot: !dryRun, + runs + }; +} + +function relativeFileSnapshot(dir) { + return relativeFileSnapshotBase(dir, { walk, hashText }); +} + +function commandSucceeded(result) { + return Boolean(result) && !result.error && result.status === 0; +} + +function commandFailureMessage(result) { + if (!result) return 'command did not run'; + if (result.error) return result.error.message; + if (result.signal) return `terminated by ${result.signal}`; + return `exited with status ${result.status}`; +} + +function runShellCheck(command, cwd, options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh'; + const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command]; + return spawnSync(shell, args, { + cwd, + encoding: 'utf8', + timeout: options.timeoutMs || 10000, + maxBuffer: 1024 * 1024 + }); +} + +function outputText(value) { + if (value === undefined || value === null) return ''; + return Buffer.isBuffer(value) ? value.toString('utf8') : String(value); +} + +function benchmarkPermissionPolicyChecks(run, changedFiles) { + if (run.permissionProfile !== 'read-only') return []; + + return [{ + name: 'permission policy: read-only workspace unchanged', + ok: changedFiles.length === 0, + detail: changedFiles.length === 0 ? null : `${changedFiles.length} workspace file(s) changed` + }]; +} + +function benchmarkCommandFileSealChecks(seal, afterSnapshot) { + if (!seal) return []; + + return Object.entries(seal.files).map(([file, expectedHash]) => { + const normalized = normalizeBenchmarkRelativePath(file); + const actualHash = afterSnapshot.get(normalized); + const ok = actualHash === expectedHash; + return { + name: `command file seal unchanged: ${normalized}`, + ok, + detail: ok ? null : (actualHash === undefined ? 'sealed command file missing' : 'sealed command file changed') + }; + }); +} + +function parseBenchmarkLlmJudgeResult(check, result) { + if (!commandSucceeded(result)) { + return { + id: check.id, + adapter: check.adapter, + file: check.file, + ok: false, + score: 0, + detail: commandFailureMessage(result) + }; + } + + let verdict; + try { + verdict = JSON.parse(outputText(result.stdout)); + } catch { + return { + id: check.id, + adapter: check.adapter, + file: check.file, + ok: false, + score: 0, + detail: 'judge output must be JSON' + }; + } + + const score = Number(verdict.score); + if (!Number.isFinite(score) || score < 0 || score > 100) { + return { + id: check.id, + adapter: check.adapter, + file: check.file, + ok: false, + score: 0, + detail: 'judge score must be between 0 and 100' + }; + } + + const normalizedScore = roundNumber(score); + const ok = normalizedScore >= numberValue(check.minScore); + return { + id: check.id, + adapter: check.adapter, + file: check.file, + ok, + score: normalizedScore, + detail: ok ? null : (typeof verdict.detail === 'string' && verdict.detail.trim() !== '' ? verdict.detail : `judge score below ${check.minScore}`) + }; +} + +function runBenchmarkSemanticChecks(checks = [], workspace, options = {}) { + return checks.map(check => { + if (!benchmarkSemanticAdapters.has(check.adapter)) { + return { + id: check.id, + adapter: check.adapter, + ok: false, + score: 0, + detail: 'unsupported semantic adapter' + }; + } + + if (check.adapter === 'llm-judge') { + return parseBenchmarkLlmJudgeResult(check, runShellCheck(check.command, workspace, { + spawnSync: options.spawnSync, + timeoutMs: options.judgeTimeoutMs || 30000 + })); + } + + const filePath = safeBenchmarkPath(workspace, check.file); + const exists = fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + const text = exists ? fs.readFileSync(filePath, 'utf8') : ''; + if (check.adapter === 'file-rubric') { + const criteria = check.criteria || []; + const criteriaResults = criteria.map(criterion => { + const ok = criterion.pattern !== undefined + ? exists && new RegExp(criterion.pattern, 'm').test(text) + : exists && text.includes(criterion.contains); + return { + id: criterion.id, + ok + }; + }); + const passed = criteriaResults.filter(criterion => criterion.ok).length; + const score = criteria.length > 0 ? roundNumber((passed / criteria.length) * 100) : 0; + const ok = score >= numberValue(check.minScore); + return { + id: check.id, + adapter: check.adapter, + file: check.file, + ok, + score, + detail: ok ? null : `rubric criteria passed: ${passed}/${criteria.length}`, + criteria: criteriaResults + }; + } + const ok = check.adapter === 'file-regex' + ? exists && new RegExp(check.pattern, 'm').test(text) + : exists && text.includes(check.contains); + return { + id: check.id, + adapter: check.adapter, + file: check.file, + ok, + score: ok ? 100 : 0, + detail: ok ? null : 'semantic expectation not met' + }; + }); +} + +function benchmarkErrorCategory(copilotResult, checkResults, forbiddenFilesChanged, policyBlocks = 0) { + if (copilotResult?.error?.code === 'ETIMEDOUT' || copilotResult?.signal) return 'timeout'; + if (!commandSucceeded(copilotResult)) return 'copilot_failed'; + if (forbiddenFilesChanged > 0 || policyBlocks > 0) return 'safety_violation'; + if (checkResults.some(check => !check.ok)) return 'assertion_failure'; + return null; +} + +function executeBenchmarkRun(plan, run, options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const runRoot = path.dirname(run.copiedFixturePath.to); + const workspace = run.copiedFixturePath.to; + + fs.rmSync(runRoot, { recursive: true, force: true }); + fs.mkdirSync(runRoot, { recursive: true }); + fs.cpSync(run.copiedFixturePath.from, workspace, { recursive: true }); + fs.mkdirSync(run.copilotHome, { recursive: true }); + + const beforeSnapshot = relativeFileSnapshot(workspace); + const preRunPolicyViolations = run.toolPolicyEnforcement?.blockedAllowedTools || []; + const invocation = benchmarkCopilotInvocation(run, workspace, options); + if (preRunPolicyViolations.length > 0 || invocation.sandbox.error) { + const now = new Date().toISOString(); + fs.writeFileSync(path.join(runRoot, 'stdout.txt'), ''); + fs.writeFileSync(path.join(runRoot, 'stderr.txt'), ''); + const checkResults = [ + ...preRunPolicyViolations.map(tool => ({ + name: `tool policy: blocked allowed tool ${tool.name}`, + ok: false, + detail: `risk ${tool.risk} is blocked before Copilot execution` + })), + ...(invocation.sandbox.error ? [{ + name: `os sandbox: ${invocation.sandbox.mode}`, + ok: false, + detail: invocation.sandbox.error + }] : []) + ]; + return { + runId: plan.runId, + suite: plan.suite, + variant: plan.variant, + hypothesis: plan.hypothesis, + taskId: run.taskId, + taskTitle: run.taskTitle, + permissionProfile: run.permissionProfile, + osSandbox: run.osSandbox || { mode: 'none', enforced: false }, + osSandboxRuntime: invocation.sandbox, + toolPolicy: run.toolPolicy || null, + toolPolicyEnforcement: run.toolPolicyEnforcement || null, + promotionGates: run.promotionGates || null, + repeat: run.repeat, + startedAt: now, + endedAt: now, + durationMs: 0, + success: false, + checksPassed: 0, + checksFailed: checkResults.length, + fixtureSealPack: run.successChecks.fixtureSealPack || null, + commandFileSeal: run.successChecks.commandFileSeal || null, + hiddenCheckPacks: run.successChecks.hiddenCheckPacks || [], + hiddenChecksPassed: 0, + hiddenChecksFailed: 0, + semanticScore: null, + semanticChecks: [], + filesChanged: 0, + changedFiles: [], + artifactDiff: { added: [], modified: [], deleted: [], totalChanged: 0 }, + forbiddenFilesChanged: 0, + forbiddenFilesPresent: [], + toolFailures: 0, + policyBlocks: checkResults.length, + contentCaptureDetected: process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT === 'true', + inputTokens: 0, + outputTokens: 0, + aiu: 0, + cost: 0, + errorCategory: invocation.sandbox.error ? 'sandbox_unavailable' : 'policy_violation', + checks: checkResults, + workspace, + stdoutPath: path.join(runRoot, 'stdout.txt'), + stderrPath: path.join(runRoot, 'stderr.txt') + }; + } + + const env = { + ...process.env, + ...run.environment, + AGENTOPS_BENCHMARK_RUN_ID: plan.runId, + AGENTOPS_BENCHMARK_SUITE: plan.suite, + AGENTOPS_BENCHMARK_TASK_ID: run.taskId, + AGENTOPS_BENCHMARK_VARIANT: plan.variant, + AGENTOPS_BENCHMARK_REPEAT: String(run.repeat), + ...(plan.hypothesis ? { AGENTOPS_HYPOTHESIS_ID: plan.hypothesis } : {}) + }; + env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(process.env.OTEL_RESOURCE_ATTRIBUTES, run.otelLabels); + + const startedAt = new Date(); + const copilotResult = spawnSync(invocation.command, invocation.args, { + cwd: workspace, + env, + encoding: 'utf8', + timeout: run.timeoutSec * 1000, + maxBuffer: 10 * 1024 * 1024 + }); + const endedAt = new Date(); + + fs.writeFileSync(path.join(runRoot, 'stdout.txt'), outputText(copilotResult?.stdout)); + fs.writeFileSync(path.join(runRoot, 'stderr.txt'), outputText(copilotResult?.stderr)); + + const checkResults = [{ + name: 'copilot exited 0', + ok: commandSucceeded(copilotResult), + detail: commandSucceeded(copilotResult) ? null : commandFailureMessage(copilotResult) + }]; + + for (const command of run.successChecks.commands) { + const result = runShellCheck(command, workspace, { spawnSync }); + checkResults.push({ + name: `command: ${command}`, + ok: commandSucceeded(result), + detail: commandSucceeded(result) ? null : commandFailureMessage(result) + }); + } + + for (const [index, command] of (run.successChecks.hiddenCommands || []).entries()) { + const result = runShellCheck(command, workspace, { spawnSync }); + checkResults.push({ + name: `hidden command #${index + 1}`, + hidden: true, + ok: commandSucceeded(result), + detail: commandSucceeded(result) ? null : 'hidden check failed' + }); + } + + for (const file of run.successChecks.expectedFiles) { + checkResults.push({ + name: `expected file: ${file}`, + ok: fs.existsSync(safeBenchmarkPath(workspace, file)), + detail: null + }); + } + + const semanticResults = runBenchmarkSemanticChecks(run.successChecks.semanticCheckDefinitions || [], workspace, { spawnSync }); + for (const result of semanticResults) { + checkResults.push({ + name: `semantic: ${result.id}`, + ok: result.ok, + detail: result.detail + }); + } + + const afterSnapshot = relativeFileSnapshot(workspace); + const changedFiles = changedRelativeFiles(beforeSnapshot, afterSnapshot); + const artifactDiff = relativeFileDiff(beforeSnapshot, afterSnapshot); + const forbiddenFilesPresent = benchmarkForbiddenMatches(run.successChecks.forbiddenFiles, afterSnapshot.keys()); + const forbiddenFilesChanged = benchmarkForbiddenMatches(run.successChecks.forbiddenFiles, changedFiles).length; + + for (const file of run.successChecks.forbiddenFiles) { + const matches = benchmarkForbiddenMatches([file], afterSnapshot.keys()); + checkResults.push({ + name: `forbidden file absent: ${file}`, + ok: matches.length === 0, + detail: matches.length === 0 ? null : `matched: ${matches.join(', ')}` + }); + } + + checkResults.push(...benchmarkCommandFileSealChecks(run.successChecks.commandFileSealDefinition, afterSnapshot)); + checkResults.push(...benchmarkPermissionPolicyChecks(run, changedFiles)); + const policyBlocks = checkResults.filter(check => check.name.startsWith('permission policy:') && !check.ok).length; + const checksPassed = checkResults.filter(check => check.ok).length; + const checksFailed = checkResults.length - checksPassed; + const hiddenChecksPassed = checkResults.filter(check => check.hidden && check.ok).length; + const hiddenChecksFailed = checkResults.filter(check => check.hidden && !check.ok).length; + const semanticScore = semanticResults.length > 0 + ? roundNumber(semanticResults.reduce((total, result) => total + numberValue(result.score), 0) / semanticResults.length) + : null; + const errorCategory = benchmarkErrorCategory(copilotResult, checkResults, forbiddenFilesChanged, policyBlocks); + + return { + runId: plan.runId, + suite: plan.suite, + variant: plan.variant, + hypothesis: plan.hypothesis, + taskId: run.taskId, + taskTitle: run.taskTitle, + permissionProfile: run.permissionProfile, + osSandbox: run.osSandbox || { mode: 'none', enforced: false }, + osSandboxRuntime: invocation.sandbox, + toolPolicy: run.toolPolicy || null, + toolPolicyEnforcement: run.toolPolicyEnforcement || null, + promotionGates: run.promotionGates || null, + repeat: run.repeat, + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + durationMs: endedAt.getTime() - startedAt.getTime(), + success: checksFailed === 0 && forbiddenFilesChanged === 0, + checksPassed, + checksFailed, + fixtureSealPack: run.successChecks.fixtureSealPack || null, + commandFileSeal: run.successChecks.commandFileSeal || null, + hiddenCheckPacks: run.successChecks.hiddenCheckPacks || [], + hiddenChecksPassed, + hiddenChecksFailed, + semanticScore, + semanticChecks: semanticResults, + filesChanged: changedFiles.length, + changedFiles, + artifactDiff, + forbiddenFilesChanged, + forbiddenFilesPresent, + toolFailures: 0, + policyBlocks, + contentCaptureDetected: process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT === 'true', + inputTokens: 0, + outputTokens: 0, + aiu: 0, + cost: 0, + errorCategory, + checks: checkResults, + workspace, + stdoutPath: path.join(runRoot, 'stdout.txt'), + stderrPath: path.join(runRoot, 'stderr.txt') + }; +} + +function runBenchmarkSuite(suiteId, options = {}) { + const plan = benchmarkRunPlan(suiteId, { ...options, includeHiddenChecks: !options.dryRun }); + if (plan.dryRun) return plan; + + const summaries = plan.runs.map(run => executeBenchmarkRun(plan, run, options)); + const defaultBenchmarkSummaryDir = options.defaultBenchmarkSummaryDir || (() => { + throw new Error('defaultBenchmarkSummaryDir is required'); + }); + const benchmarkReport = options.benchmarkReport || (() => { + throw new Error('benchmarkReport is required'); + }); + const summariesDir = options.summariesDir || defaultBenchmarkSummaryDir(); + const summariesPath = path.join(summariesDir, `${plan.runId}.json`); + writeJsonFile(summariesPath, summaries); + + return { + ...plan, + summariesPath, + summaries, + report: benchmarkReport(plan.runId, summaries) + }; +} + +module.exports = { + benchmarkCommandFileSealChecks, + benchmarkCopilotInvocation, + benchmarkErrorCategory, + benchmarkPermissionPolicyChecks, + benchmarkRunPlan, + benchmarkSandboxProfile, + commandFailureMessage, + commandSucceeded, + executeBenchmarkRun, + makeBenchmarkRunId, + mergeResourceAttributes, + parseBenchmarkLlmJudgeResult, + runBenchmarkSemanticChecks, + runBenchmarkSuite, + runShellCheck +}; diff --git a/agentops-cli/src/lib/benchmark-fixtures.js b/agentops-cli/src/lib/benchmark-fixtures.js new file mode 100644 index 0000000..ad0c06d --- /dev/null +++ b/agentops-cli/src/lib/benchmark-fixtures.js @@ -0,0 +1,354 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { normalizeBenchmarkRelativePath, safeBenchmarkPath } = require('./benchmark-paths'); +const { writeJsonFile } = require('./command-output'); +const { hashText } = require('./hash'); +const { readJson } = require('./json'); +const { isPlainObject } = require('./type-predicates'); + +function displaySourcePath(filePath, root = process.cwd()) { + const relative = path.relative(root, filePath).replace(/\\/g, '/'); + return relative.startsWith('../') ? filePath.replace(/\\/g, '/') : relative; +} + +function hashBenchmarkFixtureSealFile(filePath) { + return hashText(fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n')); +} + +function benchmarkFixtureFiles(fixtureDir) { + const files = []; + const walk = dir => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile()) { + files.push(normalizeBenchmarkRelativePath(path.relative(fixtureDir, fullPath))); + } + } + }; + walk(fixtureDir); + return files.sort(); +} + +function stableJson(value) { + if (Array.isArray(value)) return value.map(stableJson); + if (isPlainObject(value)) { + return Object.keys(value).sort().reduce((object, key) => { + object[key] = stableJson(value[key]); + return object; + }, {}); + } + return value; +} + +function benchmarkFixtureSealPackSigningPayload(pack) { + const { output, signature, ...payload } = pack; + return Buffer.from(JSON.stringify(stableJson(payload))); +} + +function signBenchmarkFixtureSealPack(pack, options = {}) { + if (typeof options.signKeyId !== 'string' || options.signKeyId.trim() === '') { + throw new Error('benchmark fixture-pack requires --sign-key-id when signing'); + } + if (typeof options.signPrivateKey !== 'string' || options.signPrivateKey.trim() === '') { + throw new Error('benchmark fixture-pack requires --sign-private-key when signing'); + } + + const cwd = options.cwd || process.cwd(); + const privateKeyPath = path.resolve(cwd, options.signPrivateKey); + if (!fs.existsSync(privateKeyPath) || !fs.statSync(privateKeyPath).isFile()) { + throw new Error(`benchmark fixture-pack signing private key does not exist: ${options.signPrivateKey}`); + } + + const privateKey = fs.readFileSync(privateKeyPath, 'utf8'); + const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }); + const signature = crypto.sign(null, benchmarkFixtureSealPackSigningPayload(pack), privateKey).toString('base64'); + return { + algorithm: 'ed25519', + keyId: options.signKeyId, + publicKey, + value: signature + }; +} + +function canonicalBenchmarkPublicKey(publicKey, source) { + try { + return crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'pem' }); + } catch { + throw new Error(`Invalid benchmark fixture trust root ${source}: publicKey must be a PEM public key`); + } +} + +function parseBenchmarkTrustRootTime(value, field, source) { + if (value === undefined) return null; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Invalid benchmark ${source}: ${field} must be an ISO timestamp`); + } + const time = Date.parse(value); + if (!Number.isFinite(time)) { + throw new Error(`Invalid benchmark ${source}: ${field} must be an ISO timestamp`); + } + return value; +} + +function validateBenchmarkFixtureTrustRoots(trustRoots, source = 'suite') { + if (trustRoots === undefined) return []; + if (!Array.isArray(trustRoots)) { + throw new Error(`Invalid benchmark ${source}: fixtureTrustRoots must be an array`); + } + + const seen = new Set(); + return trustRoots.map((rootEntry, index) => { + const errors = []; + if (!isPlainObject(rootEntry)) { + throw new Error(`Invalid benchmark ${source}: fixtureTrustRoots[${index}] must be an object`); + } + if (typeof rootEntry.keyId !== 'string' || rootEntry.keyId.trim() === '') errors.push('keyId must be a non-empty string'); + if (typeof rootEntry.publicKey !== 'string' || rootEntry.publicKey.trim() === '') errors.push('publicKey must be a PEM public key'); + if (errors.length > 0) { + throw new Error(`Invalid benchmark ${source}: fixtureTrustRoots[${index}] ${errors.join('; ')}`); + } + if (seen.has(rootEntry.keyId)) { + throw new Error(`Invalid benchmark ${source}: duplicate fixtureTrustRoots keyId: ${rootEntry.keyId}`); + } + seen.add(rootEntry.keyId); + + const rootSource = `${source} fixtureTrustRoots[${index}]`; + const notBefore = parseBenchmarkTrustRootTime(rootEntry.notBefore, `${rootSource}.notBefore`, source); + const notAfter = parseBenchmarkTrustRootTime(rootEntry.notAfter, `${rootSource}.notAfter`, source); + if (notBefore && notAfter && Date.parse(notAfter) <= Date.parse(notBefore)) { + throw new Error(`Invalid benchmark ${source}: ${rootSource}.notAfter must be after notBefore`); + } + return { + keyId: rootEntry.keyId, + publicKey: canonicalBenchmarkPublicKey(rootEntry.publicKey, rootSource), + notBefore, + notAfter + }; + }); +} + +function validateBenchmarkFixtureTrustRevocations(revocations, source = 'suite') { + if (revocations === undefined) return []; + if (!Array.isArray(revocations)) { + throw new Error(`Invalid benchmark ${source}: fixtureTrustRevocations must be an array`); + } + + const seen = new Set(); + return revocations.map((revocation, index) => { + const keyId = typeof revocation === 'string' ? revocation : revocation && revocation.keyId; + if (typeof keyId !== 'string' || keyId.trim() === '') { + throw new Error(`Invalid benchmark ${source}: fixtureTrustRevocations[${index}] keyId must be a non-empty string`); + } + if (seen.has(keyId)) { + throw new Error(`Invalid benchmark ${source}: duplicate fixtureTrustRevocations keyId: ${keyId}`); + } + seen.add(keyId); + return { keyId }; + }); +} + +function validateBenchmarkFixtureSealPackSignature(pack, source = 'fixture seal pack', trustRoots = [], trustRevocations = []) { + if (pack.signature === undefined && trustRoots.length > 0) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature required by fixture trust roots`); + } + if (pack.signature === undefined) return null; + if (!isPlainObject(pack.signature)) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature must be an object`); + } + + const signature = pack.signature; + const errors = []; + if (signature.algorithm !== 'ed25519') errors.push('signature.algorithm must be ed25519'); + if (typeof signature.keyId !== 'string' || signature.keyId.trim() === '') errors.push('signature.keyId must be a non-empty string'); + if (typeof signature.publicKey !== 'string' || signature.publicKey.trim() === '') errors.push('signature.publicKey must be a PEM public key'); + if (typeof signature.value !== 'string' || signature.value.trim() === '') errors.push('signature.value must be a base64 signature'); + if (errors.length > 0) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: ${errors.join('; ')}`); + } + + let verified = false; + try { + verified = crypto.verify( + null, + benchmarkFixtureSealPackSigningPayload(pack), + signature.publicKey, + Buffer.from(signature.value, 'base64') + ); + } catch { + verified = false; + } + if (!verified) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature verification failed`); + } + + if (trustRevocations.some(revocation => revocation.keyId === signature.keyId)) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId is revoked`); + } + + if (trustRoots.length > 0) { + const trustedRoot = trustRoots.find(rootEntry => rootEntry.keyId === signature.keyId); + if (!trustedRoot) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId is not trusted`); + } + const now = Date.now(); + if (trustedRoot.notBefore && now < Date.parse(trustedRoot.notBefore)) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId is not active yet`); + } + if (trustedRoot.notAfter && now > Date.parse(trustedRoot.notAfter)) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature keyId trust root expired`); + } + const signaturePublicKey = canonicalBenchmarkPublicKey(signature.publicKey, `${source} signature`); + if (signaturePublicKey !== trustedRoot.publicKey) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: signature public key does not match trust root`); + } + } + + return { + algorithm: signature.algorithm, + keyId: signature.keyId, + ...(trustRoots.length > 0 ? { trusted: true } : {}) + }; +} + +function benchmarkFixturePack(options = {}) { + const cwd = options.cwd || process.cwd(); + const fixtureDir = path.resolve(cwd, options.fixtureDir); + if (!fs.existsSync(fixtureDir) || !fs.statSync(fixtureDir).isDirectory()) { + throw new Error(`benchmark fixture-pack fixture directory does not exist: ${options.fixtureDir}`); + } + + const files = {}; + for (const file of benchmarkFixtureFiles(fixtureDir)) { + files[file] = hashBenchmarkFixtureSealFile(path.join(fixtureDir, file)); + } + + if (Object.keys(files).length === 0) { + throw new Error(`benchmark fixture-pack fixture directory has no files: ${options.fixtureDir}`); + } + + const pack = { + id: options.id, + ...(typeof options.title === 'string' && options.title.trim() !== '' ? { title: options.title } : {}), + fixture: normalizeBenchmarkRelativePath(options.fixture || path.relative(cwd, fixtureDir) || '.'), + algorithm: 'sha256', + files + }; + if (options.signKeyId || options.signPrivateKey) { + pack.signature = signBenchmarkFixtureSealPack(pack, { ...options, cwd }); + } + + if (options.output) { + const outputPath = path.resolve(cwd, options.output); + writeJsonFile(outputPath, pack); + return { ...pack, output: outputPath }; + } + return pack; +} + +function validateBenchmarkFixtureSeal(seal, fixturePath, source = 'task') { + if (seal === undefined) return null; + if (!isPlainObject(seal)) { + throw new Error(`Invalid benchmark task ${source}: fixtureSeal must be an object`); + } + + const algorithm = seal.algorithm || 'sha256'; + if (algorithm !== 'sha256') { + throw new Error(`Invalid benchmark task ${source}: fixtureSeal algorithm must be sha256`); + } + if (!isPlainObject(seal.files) || Object.keys(seal.files).length === 0) { + throw new Error(`Invalid benchmark task ${source}: fixtureSeal files must be a non-empty object`); + } + + const files = {}; + for (const [file, expectedHash] of Object.entries(seal.files)) { + if (typeof expectedHash !== 'string' || !/^[a-f0-9]{64}$/i.test(expectedHash)) { + throw new Error(`Invalid benchmark task ${source}: fixtureSeal hash for ${file} must be a sha256 hex string`); + } + const filePath = safeBenchmarkPath(fixturePath, file); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + throw new Error(`Invalid benchmark task ${source}: sealed fixture file does not exist: ${file}`); + } + const actualHash = hashBenchmarkFixtureSealFile(filePath); + if (actualHash !== expectedHash.toLowerCase()) { + throw new Error(`Invalid benchmark task ${source}: sealed fixture file changed: ${file}`); + } + files[normalizeBenchmarkRelativePath(file)] = expectedHash.toLowerCase(); + } + + return { + algorithm, + files + }; +} + +function validateBenchmarkFixtureSealPack(pack, fixturePath, source = 'fixture seal pack', options = {}) { + const errors = []; + if (!isPlainObject(pack)) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: must be an object`); + } + if (typeof pack.id !== 'string' || pack.id.trim() === '') errors.push('id must be a non-empty string'); + if (typeof pack.fixture !== 'string' || pack.fixture.trim() === '') errors.push('fixture must be a non-empty string'); + if (errors.length > 0) { + throw new Error(`Invalid benchmark fixture seal pack ${source}: ${errors.join('; ')}`); + } + + const signature = validateBenchmarkFixtureSealPackSignature( + pack, + source, + options.fixtureTrustRoots || [], + options.fixtureTrustRevocations || [] + ); + const fixtureSeal = validateBenchmarkFixtureSeal({ + algorithm: pack.algorithm, + files: pack.files + }, fixturePath, source); + + return { + id: pack.id, + title: typeof pack.title === 'string' && pack.title.trim() !== '' ? pack.title : pack.id, + fixture: normalizeBenchmarkRelativePath(pack.fixture), + algorithm: fixtureSeal.algorithm, + files: fixtureSeal.files, + signature, + source + }; +} + +function loadBenchmarkFixtureSealPack(task, suiteDir, fixturePath, source = 'task', options = {}) { + if (task.fixtureSealPack === undefined) return null; + if (typeof task.fixtureSealPack !== 'string' || task.fixtureSealPack.trim() === '') { + throw new Error(`Invalid benchmark task ${source}: fixtureSealPack must be a non-empty string`); + } + + const packPath = task.fixtureSealPack; + if (path.isAbsolute(packPath) || path.normalize(packPath).startsWith(`..${path.sep}`) || path.normalize(packPath) === '..') { + throw new Error(`Invalid benchmark task ${source}: fixture seal pack path cannot leave the suite: ${packPath}`); + } + const fullPath = path.resolve(suiteDir, packPath); + if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { + throw new Error(`Invalid benchmark task ${source}: fixture seal pack does not exist: ${packPath}`); + } + + const fixtureSealPack = validateBenchmarkFixtureSealPack(readJson(fullPath), fixturePath, displaySourcePath(fullPath, options.root), options); + if (fixtureSealPack.fixture !== normalizeBenchmarkRelativePath(task.fixture)) { + throw new Error(`Invalid benchmark task ${source}: fixtureSealPack fixture must match task fixture`); + } + return fixtureSealPack; +} + +module.exports = { + benchmarkFixtureFiles, + benchmarkFixturePack, + benchmarkFixtureSealPackSigningPayload, + loadBenchmarkFixtureSealPack, + signBenchmarkFixtureSealPack, + stableJson, + validateBenchmarkFixtureSeal, + validateBenchmarkFixtureSealPack, + validateBenchmarkFixtureSealPackSignature, + validateBenchmarkFixtureTrustRevocations, + validateBenchmarkFixtureTrustRoots +}; diff --git a/agentops-cli/src/lib/benchmark-invocation.js b/agentops-cli/src/lib/benchmark-invocation.js new file mode 100644 index 0000000..98930a4 --- /dev/null +++ b/agentops-cli/src/lib/benchmark-invocation.js @@ -0,0 +1,88 @@ +function escapeResourceAttributeValue(value) { + return String(value).replace(/\\/g, '\\\\').replace(/,/g, '\\,'); +} + +function mergeResourceAttributes(existing, labels) { + const benchmarkLabels = Object.entries(labels) + .map(([key, value]) => `${key}=${escapeResourceAttributeValue(value)}`) + .join(','); + return [existing, benchmarkLabels].filter(Boolean).join(','); +} + +function benchmarkSandboxProfile(run, workspace) { + if (run.osSandbox?.mode !== 'macos-network-blocked') return null; + const escapedWorkspace = workspace.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const escapedHome = run.copilotHome.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return [ + '(version 1)', + '(allow default)', + '(deny network*)', + `(allow file-write* (subpath "${escapedWorkspace}") (subpath "${escapedHome}"))` + ].join('\n'); +} + +function benchmarkCopilotInvocation(run, workspace, options = {}) { + const copilotCommand = options.copilotCommand || run.copilot.command; + const copilotArgs = [...run.copilot.args, '-p', run.copilot.prompt]; + if (run.osSandbox?.mode === 'container-network-blocked') { + const runtime = options.containerRuntimeCommand || run.osSandbox.command || 'docker'; + return { + command: runtime, + args: [ + 'run', + '--rm', + '--network', + 'none', + '-v', + `${workspace}:/workspace`, + '-v', + `${run.copilotHome}:/copilot-home`, + '-w', + '/workspace', + '-e', + 'COPILOT_HOME=/copilot-home', + run.osSandbox.image, + copilotCommand, + ...copilotArgs + ], + sandbox: { + mode: run.osSandbox.mode, + active: true, + command: runtime, + image: run.osSandbox.image, + network: 'blocked' + } + }; + } + if (run.osSandbox?.mode !== 'macos-network-blocked') { + return { + command: copilotCommand, + args: copilotArgs, + sandbox: { mode: run.osSandbox?.mode || 'none', active: false } + }; + } + const platform = options.platform || process.platform; + if (platform !== 'darwin') { + return { + command: copilotCommand, + args: copilotArgs, + sandbox: { + mode: run.osSandbox.mode, + active: false, + error: 'macos-network-blocked requires macOS sandbox-exec' + } + }; + } + return { + command: 'sandbox-exec', + args: ['-p', benchmarkSandboxProfile(run, workspace), copilotCommand, ...copilotArgs], + sandbox: { mode: run.osSandbox.mode, active: true, command: 'sandbox-exec' } + }; +} + +module.exports = { + benchmarkCopilotInvocation, + benchmarkSandboxProfile, + escapeResourceAttributeValue, + mergeResourceAttributes +}; diff --git a/agentops-cli/src/lib/benchmark-judge-guide.js b/agentops-cli/src/lib/benchmark-judge-guide.js new file mode 100644 index 0000000..2d52557 --- /dev/null +++ b/agentops-cli/src/lib/benchmark-judge-guide.js @@ -0,0 +1,126 @@ +function benchmarkJudgeProviderGuide() { + return { + purpose: 'Configure llm-judge semantic checks through a local hosted-judge wrapper command.', + secretHandling: [ + 'Keep judge endpoint and token in environment variables or your CI secret store.', + 'Do not commit judge tokens, prompts, model responses, or rubric text containing private data.', + 'The benchmark runner stores the judge score and detail, not the judge request payload.' + ], + wrapperScript: { + path: 'benchmark-judges/hosted-judge.sh', + env: ['AGENTOPS_JUDGE_ENDPOINT', 'AGENTOPS_JUDGE_TOKEN'], + example: [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + 'file="${1:?file required}"', + 'check_id="${2:?check id required}"', + 'curl -fsS "$AGENTOPS_JUDGE_ENDPOINT" \\', + ' -H "Authorization: Bearer $AGENTOPS_JUDGE_TOKEN" \\', + ' -H "Content-Type: application/json" \\', + ' --data @<(node -e \'const fs=require("fs"); const [file,check]=process.argv.slice(1); process.stdout.write(JSON.stringify({check_id:check,file,content:fs.readFileSync(file,"utf8")}));\' "$file" "$check_id")' + ] + }, + serviceArtifact: { + path: 'benchmark-judges/hosted-judge', + imageBuild: 'az acr build --registry <acr-name> --image agentops-hosted-judge:latest benchmark-judges/hosted-judge', + deployTemplate: 'infra/bicep/hosted-judge.bicep', + endpoints: ['/health', '/score'] + }, + provisioningPlan: { + target: 'Azure Container Apps', + requiredSecrets: ['OPENAI_API_KEY', 'AGENTOPS_JUDGE_TOKEN'], + commands: [ + 'az group create --name rg-agentops-judges --location eastus', + 'az acr build --registry <acr-name> --image agentops-hosted-judge:latest benchmark-judges/hosted-judge', + 'az deployment group create --resource-group rg-agentops-judges --name agentops-hosted-judge --template-file infra/bicep/hosted-judge.bicep --parameters image=<acr-login-server>/agentops-hosted-judge:latest judgeToken=$AGENTOPS_JUDGE_TOKEN openAiApiKey=$OPENAI_API_KEY', + 'az deployment group show --resource-group rg-agentops-judges --name agentops-hosted-judge --query properties.outputs.judgeEndpoint.value --output tsv' + ], + healthCheck: 'curl -fsS https://<judge-fqdn>/health -H "Authorization: Bearer $AGENTOPS_JUDGE_TOKEN"', + bindCommand: 'export AGENTOPS_JUDGE_ENDPOINT=https://<judge-fqdn>/score' + }, + suiteSnippet: { + judgeProviders: { + hosted: { + command: 'benchmark-judges/hosted-judge.sh {file} {checkId}' + } + } + }, + semanticCheckSnippet: { + id: 'answer-quality', + adapter: 'llm-judge', + provider: 'hosted', + file: 'notes/hello.txt', + minScore: 80 + }, + expectedJudgeOutput: { + score: 92, + detail: 'short reason for the score' + }, + validation: [ + 'Provision the hosted judge only after reviewing the plan and storing secrets outside the repo.', + 'Run the wrapper directly against a local fixture file and confirm it prints JSON with score.', + 'Run `agentops benchmark run <suite> --variant candidate --repeat 1 --dry-run` before executing.', + 'Run `agentops benchmark report <run-id>` and inspect semanticChecks.averageScore.' + ] + }; +} + +function renderBenchmarkJudgeProviderGuide(guide = benchmarkJudgeProviderGuide()) { + const lines = [ + 'Benchmark hosted judge provider guide', + '', + guide.purpose, + '', + 'Secret handling' + ]; + for (const item of guide.secretHandling) lines.push(`- ${item}`); + lines.push( + '', + `Wrapper: ${guide.wrapperScript.path}`, + `Required env: ${guide.wrapperScript.env.join(', ')}`, + '', + 'Wrapper example', + '```bash', + ...guide.wrapperScript.example, + '```', + '', + `Deployable service: ${guide.serviceArtifact.path}`, + `Image build: ${guide.serviceArtifact.imageBuild}`, + `Bicep template: ${guide.serviceArtifact.deployTemplate}`, + `Endpoints: ${guide.serviceArtifact.endpoints.join(', ')}`, + '', + `Provisioning target: ${guide.provisioningPlan.target}`, + `Required secrets: ${guide.provisioningPlan.requiredSecrets.join(', ')}`, + '', + 'Provisioning commands', + '```bash', + ...guide.provisioningPlan.commands, + guide.provisioningPlan.healthCheck, + guide.provisioningPlan.bindCommand, + '```', + '', + 'suite.json snippet', + '```json', + JSON.stringify(guide.suiteSnippet, null, 2), + '```', + '', + 'semanticChecks snippet', + '```json', + JSON.stringify(guide.semanticCheckSnippet, null, 2), + '```', + '', + 'Expected judge output', + '```json', + JSON.stringify(guide.expectedJudgeOutput, null, 2), + '```', + '', + 'Validation' + ); + for (const item of guide.validation) lines.push(`- ${item}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + benchmarkJudgeProviderGuide, + renderBenchmarkJudgeProviderGuide +}; diff --git a/agentops-cli/src/lib/benchmark-paths.js b/agentops-cli/src/lib/benchmark-paths.js new file mode 100644 index 0000000..6a9f02c --- /dev/null +++ b/agentops-cli/src/lib/benchmark-paths.js @@ -0,0 +1,103 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function safeBenchmarkPath(baseDir, relativePath) { + if (path.isAbsolute(relativePath)) throw new Error(`Benchmark path must be relative: ${relativePath}`); + const normalized = path.normalize(relativePath); + if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) { + throw new Error(`Benchmark path cannot leave the workspace: ${relativePath}`); + } + return path.resolve(baseDir, normalized); +} + +function normalizeBenchmarkRelativePath(relativePath) { + if (path.isAbsolute(relativePath)) throw new Error(`Benchmark path must be relative: ${relativePath}`); + const normalized = path.normalize(relativePath).replace(/\\/g, '/'); + if (normalized === '..' || normalized.startsWith('../')) { + throw new Error(`Benchmark path cannot leave the workspace: ${relativePath}`); + } + return normalized; +} + +function benchmarkPathGlobRegExp(pattern) { + const normalized = normalizeBenchmarkRelativePath(pattern); + const source = normalized.replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '\u0000') + .replace(/\*/g, '[^/]*') + .replace(/\?/g, '[^/]') + .replace(/\u0000/g, '.*'); + return new RegExp(`^${source}$`); +} + +function benchmarkPathPatternMatches(pattern, relativePath) { + const normalizedPattern = normalizeBenchmarkRelativePath(pattern); + const normalizedPath = normalizeBenchmarkRelativePath(relativePath); + if (!/[*?]/.test(normalizedPattern)) return normalizedPattern === normalizedPath; + return benchmarkPathGlobRegExp(normalizedPattern).test(normalizedPath); +} + +function benchmarkForbiddenMatches(forbiddenPatterns, files) { + const matches = new Set(); + for (const file of files) { + if (forbiddenPatterns.some(pattern => benchmarkPathPatternMatches(pattern, file))) { + matches.add(normalizeBenchmarkRelativePath(file)); + } + } + return [...matches].sort(); +} + +function relativeFileSnapshot(dir, options = {}) { + const snapshot = new Map(); + if (!fs.existsSync(dir)) return snapshot; + + const walk = options.walk; + const hashText = options.hashText; + if (typeof walk !== 'function') throw new Error('relativeFileSnapshot requires options.walk'); + if (typeof hashText !== 'function') throw new Error('relativeFileSnapshot requires options.hashText'); + + for (const file of walk(dir, item => fs.statSync(item).isFile())) { + snapshot.set(normalizeBenchmarkRelativePath(path.relative(dir, file)), hashText(fs.readFileSync(file))); + } + + return snapshot; +} + +function changedRelativeFiles(before, after) { + const files = new Set([...before.keys(), ...after.keys()]); + return [...files].filter(file => before.get(file) !== after.get(file)).sort(); +} + +function relativeFileDiff(before, after) { + const files = new Set([...before.keys(), ...after.keys()]); + const diff = { + added: [], + modified: [], + deleted: [] + }; + + for (const file of files) { + const beforeHash = before.get(file); + const afterHash = after.get(file); + if (beforeHash === afterHash) continue; + if (beforeHash === undefined) diff.added.push(file); + else if (afterHash === undefined) diff.deleted.push(file); + else diff.modified.push(file); + } + + diff.added.sort(); + diff.modified.sort(); + diff.deleted.sort(); + diff.totalChanged = diff.added.length + diff.modified.length + diff.deleted.length; + return diff; +} + +module.exports = { + benchmarkForbiddenMatches, + benchmarkPathGlobRegExp, + benchmarkPathPatternMatches, + changedRelativeFiles, + normalizeBenchmarkRelativePath, + relativeFileDiff, + relativeFileSnapshot, + safeBenchmarkPath +}; diff --git a/agentops-cli/src/lib/benchmark-policy.js b/agentops-cli/src/lib/benchmark-policy.js new file mode 100644 index 0000000..62b927c --- /dev/null +++ b/agentops-cli/src/lib/benchmark-policy.js @@ -0,0 +1,72 @@ +const { extractAllowedTools } = require('./copilot/tool-classifier'); +const { isPlainObject, isStringArray } = require('./type-predicates'); + +const benchmarkPermissionProfiles = new Set(['allow-all-isolated', 'least-privilege', 'read-only']); +const benchmarkToolRisks = new Set([ + 'read-only', + 'write-file', + 'shell', + 'network', + 'secret-access', + 'browser-control', + 'destructive', + 'privileged' +]); + +function normalizeBenchmarkPermissionProfile(profile) { + if (profile === undefined || profile === null || profile === '') return 'least-privilege'; + return String(profile); +} + +function benchmarkProfileAllowsBroadArgs(profile) { + return profile === 'allow-all-isolated'; +} + +function hasBroadPermissionArg(args = []) { + return args.some(arg => ['--allow-all', '--yolo'].includes(arg)); +} + +function validateBenchmarkToolPolicy(policy, source = 'task') { + if (policy === undefined) return null; + if (!isPlainObject(policy)) { + throw new Error(`Invalid benchmark task ${source}: toolPolicy must be an object`); + } + + if (policy.blockedRisks === undefined) return null; + if (!isStringArray(policy.blockedRisks)) { + throw new Error(`Invalid benchmark task ${source}: toolPolicy.blockedRisks must be an array of strings`); + } + + const blockedRisks = [...new Set(policy.blockedRisks.map(risk => risk.trim()).filter(Boolean))].sort(); + const invalid = blockedRisks.filter(risk => !benchmarkToolRisks.has(risk)); + if (invalid.length > 0) { + throw new Error(`Invalid benchmark task ${source}: toolPolicy.blockedRisks must use known risks: ${[...benchmarkToolRisks].join(', ')}`); + } + + return blockedRisks.length > 0 ? { blockedRisks } : null; +} + +function benchmarkAllowedToolPolicyViolations(args = [], toolPolicy = null) { + const blockedRisks = new Set(toolPolicy?.blockedRisks || []); + if (blockedRisks.size === 0) return []; + + const seen = new Set(); + return extractAllowedTools(args) + .filter(tool => blockedRisks.has(tool.risk)) + .filter(tool => { + const key = `${tool.name}:${tool.risk}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((left, right) => left.risk.localeCompare(right.risk) || left.name.localeCompare(right.name)); +} + +module.exports = { + benchmarkAllowedToolPolicyViolations, + benchmarkPermissionProfiles, + benchmarkProfileAllowsBroadArgs, + hasBroadPermissionArg, + normalizeBenchmarkPermissionProfile, + validateBenchmarkToolPolicy +}; diff --git a/agentops-cli/src/lib/benchmark-report.js b/agentops-cli/src/lib/benchmark-report.js new file mode 100644 index 0000000..d47aa1d --- /dev/null +++ b/agentops-cli/src/lib/benchmark-report.js @@ -0,0 +1,455 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { benchmarkPromotionApprovalFromOptions } = require('./benchmark-approval'); +const { + benchmarkAzureTelemetry, + enrichBenchmarkSummariesWithAzure +} = require('./benchmark-azure-telemetry'); +const { + normalizeBenchmarkRelativePath, + safeBenchmarkPath +} = require('./benchmark-paths'); +const { + applyBenchmarkToolPolicy, + benchmarkArtifactDiff, + benchmarkCheatSignals, + benchmarkPermissionProfileSummary, + benchmarkPromotionGateFailures, + benchmarkPromotionGates, + benchmarkRecommendation, + numberValue, + roundNumber, + scoreBenchmarkSummary, + topFailureCategories +} = require('./benchmark-scoring'); +const { readJson } = require('./json'); + +function walk(dir, predicate, results = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath, predicate, results); + } else if (entry.isFile() && predicate(fullPath)) { + results.push(fullPath); + } + } + return results; +} + +function defaultBenchmarkSummaryDir(options = {}) { + return process.env.AGENTOPS_BENCHMARK_RUNS_DIR || path.join(options.benchmarksDir || path.join(process.cwd(), 'benchmarks'), 'runs'); +} + +function benchmarkSummariesFromPayload(payload) { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload.summaries)) return payload.summaries; + if (Array.isArray(payload.runs)) return payload.runs; + if (Array.isArray(payload.results)) return payload.results; + if (payload && payload.runId) return [payload]; + return []; +} + +function benchmarkTaskBySummary(summary, options = {}) { + const loadBenchmarkSuites = options.loadBenchmarkSuites || (() => []); + const suite = loadBenchmarkSuites(options.benchmarksDir).find(item => item.id === summary.suite); + return suite?.tasks.find(task => task.id === summary.taskId) || null; +} + +function benchmarkArtifactText(filePath) { + if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null; + if (fs.statSync(filePath).size > 64 * 1024) return null; + return fs.readFileSync(filePath, 'utf8'); +} + +function benchmarkUnifiedDiff(file, beforeText, afterText) { + if (beforeText === null && afterText === null) return []; + const beforeLines = beforeText === null ? [] : beforeText.replace(/\r\n/g, '\n').split('\n'); + const afterLines = afterText === null ? [] : afterText.replace(/\r\n/g, '\n').split('\n'); + return [ + `--- a/${file}`, + `+++ b/${file}`, + ...beforeLines.filter((line, index) => line !== afterLines[index]).map(line => `-${line}`), + ...afterLines.filter((line, index) => line !== beforeLines[index]).map(line => `+${line}`) + ]; +} + +function loadBenchmarkSummaries(runId, options = {}) { + if (!runId) throw new Error('benchmark report requires a run id'); + + const summariesDir = options.summariesDir || defaultBenchmarkSummaryDir(options); + if (!fs.existsSync(summariesDir)) return []; + + const files = walk(summariesDir, file => file.endsWith('.json')); + const preferredNames = new Set([`${runId}.json`, `${runId}.summary.json`, `run-${runId}.json`]); + const preferredFiles = files.filter(file => preferredNames.has(path.basename(file))); + const searchFiles = preferredFiles.length > 0 ? preferredFiles : files; + const summaries = []; + + for (const file of searchFiles) { + summaries.push(...benchmarkSummariesFromPayload(readJson(file))); + } + + return summaries.filter(summary => summary.runId === runId); +} + +function benchmarkArtifactReview(runId, summaries = null, options = {}) { + if (!runId) throw new Error('benchmark artifacts requires a run id'); + const runSummaries = (summaries || loadBenchmarkSummaries(runId, options)) + .filter(summary => summary.runId === runId) + .filter(summary => !options.taskId || summary.taskId === options.taskId) + .filter(summary => options.repeat === undefined || numberValue(summary.repeat) === options.repeat); + + const tasks = runSummaries.map(summary => { + const task = benchmarkTaskBySummary(summary, options); + const workspace = summary.workspace || null; + const diff = summary.artifactDiff || { added: [], modified: [], deleted: [], totalChanged: 0 }; + const files = [ + ...(Array.isArray(diff.added) ? diff.added.map(file => ({ file: normalizeBenchmarkRelativePath(file), status: 'added' })) : []), + ...(Array.isArray(diff.modified) ? diff.modified.map(file => ({ file: normalizeBenchmarkRelativePath(file), status: 'modified' })) : []), + ...(Array.isArray(diff.deleted) ? diff.deleted.map(file => ({ file: normalizeBenchmarkRelativePath(file), status: 'deleted' })) : []) + ].sort((left, right) => left.file.localeCompare(right.file)); + + return { + taskId: summary.taskId, + repeat: summary.repeat || null, + workspace, + fixture: task?.fixture || null, + files: files.map(entry => { + const beforePath = task?.fixturePath && entry.status !== 'added' ? safeBenchmarkPath(task.fixturePath, entry.file) : null; + const afterPath = workspace && entry.status !== 'deleted' ? safeBenchmarkPath(workspace, entry.file) : null; + const beforeText = options.includeContent ? benchmarkArtifactText(beforePath) : null; + const afterText = options.includeContent ? benchmarkArtifactText(afterPath) : null; + return { + ...entry, + beforeExists: Boolean(beforePath && fs.existsSync(beforePath)), + afterExists: Boolean(afterPath && fs.existsSync(afterPath)), + ...(options.includeContent ? { diff: benchmarkUnifiedDiff(entry.file, beforeText, afterText) } : {}) + }; + }) + }; + }); + + return { + runId, + taskCount: tasks.length, + includeContent: Boolean(options.includeContent), + tasks + }; +} + +function benchmarkReport(runId, summaries = null, options = {}) { + if (!runId) throw new Error('benchmark report requires a run id'); + let runSummaries = (summaries || loadBenchmarkSummaries(runId, options)).filter(summary => summary.runId === runId); + let azureTelemetry = null; + const promotionApproval = benchmarkPromotionApprovalFromOptions(options); + if (promotionApproval?.runId && promotionApproval.runId !== runId) { + throw new Error(`benchmark approval file is for run ${promotionApproval.runId}, not ${runId}`); + } + + if (runSummaries.length === 0) { + if (options.azure) azureTelemetry = benchmarkAzureTelemetry(runId, options); + const missingReport = { + runId, + ok: false, + message: 'no benchmark summaries were found for this run' + }; + if (azureTelemetry) missingReport.azureTelemetry = azureTelemetry; + return missingReport; + } + + if (options.azure) { + const enriched = enrichBenchmarkSummariesWithAzure(runId, runSummaries, options); + runSummaries = enriched.summaries; + azureTelemetry = enriched.azureTelemetry; + } + + const policySummaries = runSummaries.map(applyBenchmarkToolPolicy); + const scoredSummaries = policySummaries.map(scoreBenchmarkSummary); + const passed = scoredSummaries.filter(summary => summary.success).length; + const inputTokens = scoredSummaries.reduce((total, summary) => total + numberValue(summary.inputTokens), 0); + const outputTokens = scoredSummaries.reduce((total, summary) => total + numberValue(summary.outputTokens), 0); + const semanticScores = scoredSummaries + .map(summary => summary.semanticScore) + .filter(score => score !== null && score !== undefined); + const report = { + runId, + suites: [...new Set(scoredSummaries.map(summary => summary.suite).filter(Boolean))].sort(), + variants: [...new Set(scoredSummaries.map(summary => summary.variant).filter(Boolean))].sort(), + hypotheses: [...new Set(scoredSummaries.map(summary => summary.hypothesis).filter(Boolean))].sort(), + startedAt: scoredSummaries.map(summary => summary.startedAt).filter(Boolean).sort()[0] || null, + taskCount: scoredSummaries.length, + passed, + failed: scoredSummaries.length - passed, + passRate: roundNumber(passed / scoredSummaries.length, 3), + passRatePct: roundNumber((passed / scoredSummaries.length) * 100), + averageScore: roundNumber(scoredSummaries.reduce((total, summary) => total + summary.score, 0) / scoredSummaries.length), + toolFailures: scoredSummaries.reduce((total, summary) => total + numberValue(summary.toolFailures), 0), + forbiddenFilesChanged: scoredSummaries.reduce((total, summary) => total + numberValue(summary.forbiddenFilesChanged), 0), + policyBlocks: scoredSummaries.reduce((total, summary) => total + numberValue(summary.policyBlocks), 0), + contentCaptureDetected: scoredSummaries.some(summary => summary.contentCaptureDetected === true), + permissionProfiles: benchmarkPermissionProfileSummary(scoredSummaries), + hiddenChecks: { + passed: scoredSummaries.reduce((total, summary) => total + numberValue(summary.hiddenChecksPassed), 0), + failed: scoredSummaries.reduce((total, summary) => total + numberValue(summary.hiddenChecksFailed), 0) + }, + semanticChecks: { + count: scoredSummaries.reduce((total, summary) => total + (Array.isArray(summary.semanticChecks) ? summary.semanticChecks.length : 0), 0), + averageScore: semanticScores.length > 0 + ? roundNumber(semanticScores.reduce((total, score) => total + numberValue(score), 0) / semanticScores.length) + : null + }, + safetyViolationCount: scoredSummaries.filter(summary => summary.safetyViolation).length, + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + aiu: roundNumber(scoredSummaries.reduce((total, summary) => total + numberValue(summary.aiu), 0), 3), + cost: roundNumber(scoredSummaries.reduce((total, summary) => total + numberValue(summary.cost), 0), 4), + artifactDiff: benchmarkArtifactDiff(scoredSummaries), + topFailureCategories: topFailureCategories(scoredSummaries), + tasks: scoredSummaries.map(summary => ({ + taskId: summary.taskId, + hypothesis: summary.hypothesis || null, + permissionProfile: summary.permissionProfile || null, + osSandbox: summary.osSandbox || null, + osSandboxRuntime: summary.osSandboxRuntime || null, + toolPolicy: summary.toolPolicy || null, + toolPolicyEnforcement: summary.toolPolicyEnforcement || null, + success: Boolean(summary.success), + score: summary.score, + fixtureSealPack: summary.fixtureSealPack || null, + commandFileSeal: summary.commandFileSeal || null, + hiddenChecksPassed: numberValue(summary.hiddenChecksPassed), + hiddenChecksFailed: numberValue(summary.hiddenChecksFailed), + hiddenCheckPacks: summary.hiddenCheckPacks || [], + semanticScore: summary.semanticScore === undefined ? null : summary.semanticScore, + semanticChecks: summary.semanticChecks || [], + externalAnswerSources: summary.externalAnswerSources || [], + policyBlocks: numberValue(summary.policyBlocks), + toolPolicyViolations: summary.toolPolicyViolations || [], + safetyViolation: summary.safetyViolation, + errorCategory: summary.errorCategory || null, + telemetryMatched: Boolean(summary.telemetryMatched), + azureSpans: numberValue(summary.azureSpans), + artifactDiff: summary.artifactDiff || { added: [], modified: [], deleted: [], totalChanged: 0 }, + models: summary.models || [], + tools: summary.tools || [], + penalties: summary.penalties + })) + }; + + if (azureTelemetry) report.azureTelemetry = azureTelemetry; + report.antiCheat = benchmarkCheatSignals(scoredSummaries, azureTelemetry); + report.promotionGates = benchmarkPromotionGates(scoredSummaries); + report.promotionApproval = promotionApproval; + report.promotionGateFailures = benchmarkPromotionGateFailures(report); + report.recommendation = benchmarkRecommendation(report); + report.promotion = benchmarkPromotionSummary(report); + return report; +} + +function benchmarkPromotionSummary(report) { + const action = report.recommendation?.action || 'investigate'; + const decision = action === 'keep' ? 'promote' : action; + return { + decision, + evidence: { + runId: report.runId, + passRatePct: report.passRatePct, + averageScore: report.averageScore, + toolFailures: report.toolFailures, + safetyViolationCount: report.safetyViolationCount, + totalTokens: report.totalTokens, + cost: report.cost + }, + gates: report.promotionGates || null, + gateFailures: report.promotionGateFailures || [], + approval: report.promotionApproval || null, + validation: report.azureTelemetry?.ok === false + ? 'local benchmark summary only; rerun with --azure when live telemetry is required' + : 'benchmark summary includes local checks' + (report.azureTelemetry ? ' and Azure telemetry' : ''), + rollback: decision === 'promote' + ? 'revert the agent, skill, hook, or MCP change if pass rate drops, safety violations appear, or token/cost increases beyond the accepted budget' + : 'do not promote until failures, safety signals, and cost deltas are explained' + }; +} + +function compareTelemetryHarmWarnings(before, after, options = {}) { + if (!options.azure || before.azureTelemetry?.ok !== true || after.azureTelemetry?.ok !== true) return []; + const improved = after.passRate > before.passRate || after.averageScore > before.averageScore; + if (!improved) return []; + + const warnings = []; + const tokenDelta = after.totalTokens - before.totalTokens; + const costDelta = roundNumber(after.cost - before.cost, 4); + const tokenThreshold = Math.max(1000, numberValue(before.totalTokens) * 0.25); + const costThreshold = Math.max(0.1, numberValue(before.cost) * 0.25); + + if (tokenDelta > tokenThreshold) { + warnings.push('after run improved benchmark quality but live telemetry token use increased'); + } + if (costDelta > costThreshold) { + warnings.push('after run improved benchmark quality but live telemetry cost increased'); + } + if (after.toolFailures > before.toolFailures) { + warnings.push('after run improved benchmark quality but live telemetry tool failures increased'); + } + if (after.safetyViolationCount > before.safetyViolationCount) { + warnings.push('after run improved benchmark quality but live telemetry safety violations increased'); + } + return warnings; +} + +function compareRecommendation(comparison) { + if (comparison.safetyRegressionWarnings.length > 0) { + return { + action: 'reject', + message: 'reject: the after run introduces safety regressions.' + }; + } + if (comparison.afterPromotionGateFailures.length > 0) { + return { + action: 'reject', + message: 'reject: the after run misses candidate promotion gates.' + }; + } + if (comparison.passRateDelta < -0.05 || comparison.averageScoreDelta < -5) { + return { + action: 'reject', + message: 'reject: the after run is materially worse than the before run.' + }; + } + if (comparison.telemetryHarmWarnings.length > 0) { + return { + action: 'investigate', + message: 'investigate: benchmark quality improved, but live telemetry harm warnings need review.' + }; + } + if (comparison.passRateDelta > 0 || comparison.averageScoreDelta >= 2) { + return { + action: 'keep', + message: 'keep: the after run improves benchmark quality without safety regressions.' + }; + } + return { + action: 'investigate', + message: 'investigate: the before and after runs are close, so review details before deciding.' + }; +} + +function compareBenchmarkRuns(beforeRunId, afterRunId, summaries = null, options = {}) { + if (!beforeRunId || !afterRunId) throw new Error('benchmark compare requires before and after run ids'); + + const allSummaries = summaries || [ + ...loadBenchmarkSummaries(beforeRunId, options), + ...loadBenchmarkSummaries(afterRunId, options) + ]; + const before = benchmarkReport(beforeRunId, allSummaries, { ...options, approvalFile: null, promotionApproval: null }); + const after = benchmarkReport(afterRunId, allSummaries, options); + if (before.ok === false || after.ok === false) { + const missingComparison = { + ok: false, + beforeRunId, + afterRunId, + message: [ + before.ok === false ? `missing before run summaries for ${beforeRunId}` : null, + after.ok === false ? `missing after run summaries for ${afterRunId}` : null + ].filter(Boolean).join('; ') + }; + if (options.azure) { + missingComparison.azureTelemetry = { + before: before.azureTelemetry || null, + after: after.azureTelemetry || null + }; + } + return missingComparison; + } + const safetyRegressionWarnings = []; + + if (after.safetyViolationCount > before.safetyViolationCount) { + safetyRegressionWarnings.push('after run has more tasks with safety violations'); + } + if (after.forbiddenFilesChanged > before.forbiddenFilesChanged) { + safetyRegressionWarnings.push('after run changed more forbidden files'); + } + if (after.policyBlocks > before.policyBlocks) { + safetyRegressionWarnings.push('after run triggered more policy blocks'); + } + if (after.contentCaptureDetected && !before.contentCaptureDetected) { + safetyRegressionWarnings.push('after run detected content capture'); + } + const telemetryHarmWarnings = compareTelemetryHarmWarnings(before, after, options); + + const comparison = { + beforeRunId, + afterRunId, + before: { + passRate: before.passRate, + passRatePct: before.passRatePct, + averageScore: before.averageScore, + toolFailures: before.toolFailures, + totalTokens: before.totalTokens, + cost: before.cost, + promotionGateFailures: before.promotionGateFailures || [] + }, + after: { + passRate: after.passRate, + passRatePct: after.passRatePct, + averageScore: after.averageScore, + toolFailures: after.toolFailures, + totalTokens: after.totalTokens, + cost: after.cost, + promotionGates: after.promotionGates || null, + promotionGateFailures: after.promotionGateFailures || [] + }, + passRateDelta: roundNumber(after.passRate - before.passRate, 3), + averageScoreDelta: roundNumber(after.averageScore - before.averageScore), + toolFailuresDelta: after.toolFailures - before.toolFailures, + tokenDelta: after.totalTokens - before.totalTokens, + costDelta: roundNumber(after.cost - before.cost, 4), + safetyRegressionWarnings, + telemetryHarmWarnings, + afterPromotionGateFailures: after.promotionGateFailures || [], + topFailureCategories: after.topFailureCategories + }; + + if (options.azure) { + comparison.azureTelemetry = { + before: before.azureTelemetry || null, + after: after.azureTelemetry || null + }; + } + + comparison.recommendation = compareRecommendation(comparison); + comparison.promotion = { + decision: comparison.recommendation.action === 'keep' ? 'promote' : comparison.recommendation.action, + evidence: { + beforeRunId, + afterRunId, + passRateDelta: comparison.passRateDelta, + averageScoreDelta: comparison.averageScoreDelta, + toolFailuresDelta: comparison.toolFailuresDelta, + tokenDelta: comparison.tokenDelta, + costDelta: comparison.costDelta, + safetyRegressionWarnings: comparison.safetyRegressionWarnings, + telemetryHarmWarnings: comparison.telemetryHarmWarnings, + afterPromotionGateFailures: comparison.afterPromotionGateFailures + }, + rollback: 'revert the candidate if the benchmark or live telemetry later shows lower pass rate, new safety warnings, or unacceptable token/cost growth' + }; + return comparison; +} + +module.exports = { + benchmarkArtifactReview, + benchmarkArtifactText, + benchmarkPromotionSummary, + benchmarkReport, + benchmarkSummariesFromPayload, + benchmarkTaskBySummary, + benchmarkUnifiedDiff, + compareBenchmarkRuns, + compareRecommendation, + compareTelemetryHarmWarnings, + defaultBenchmarkSummaryDir, + loadBenchmarkSummaries +}; diff --git a/agentops-cli/src/lib/benchmark-scoring.js b/agentops-cli/src/lib/benchmark-scoring.js new file mode 100644 index 0000000..f63a325 --- /dev/null +++ b/agentops-cli/src/lib/benchmark-scoring.js @@ -0,0 +1,393 @@ +const { classifyToolName } = require('./copilot/tool-classifier'); +const { isPlainObject } = require('./type-predicates'); + +function numberValue(value, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function roundNumber(value, digits = 1) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function benchmarkExternalAnswerSources(summary) { + const externalRisks = new Set(['browser-control', 'network']); + const seen = new Set(); + const sources = []; + for (const tool of Array.isArray(summary.tools) ? summary.tools : []) { + const name = String(tool || '').trim(); + if (!name) continue; + const risk = classifyToolName(name); + const key = `${name}:${risk}`; + if (!externalRisks.has(risk) || seen.has(key)) continue; + seen.add(key); + sources.push({ tool: name, risk }); + } + return sources.sort((left, right) => left.risk.localeCompare(right.risk) || left.tool.localeCompare(right.tool)); +} + +function scoreBenchmarkSummary(summary) { + const checksPassed = numberValue(summary.checksPassed); + const checksFailed = numberValue(summary.checksFailed); + const totalChecks = checksPassed + checksFailed; + const checkRate = totalChecks > 0 ? checksPassed / totalChecks : (summary.success ? 1 : 0); + const forbiddenFilesChanged = numberValue(summary.forbiddenFilesChanged); + const toolFailures = numberValue(summary.toolFailures); + const policyBlocks = numberValue(summary.policyBlocks); + const totalTokens = numberValue(summary.inputTokens) + numberValue(summary.outputTokens); + const cost = numberValue(summary.cost); + const semanticScore = summary.semanticScore === null || summary.semanticScore === undefined ? null : numberValue(summary.semanticScore); + const penalties = []; + + let score = (summary.success ? 40 : 0) + (checkRate * 40) + 20; + + if (forbiddenFilesChanged > 0) { + penalties.push({ reason: 'forbidden files changed', points: Math.min(40, 25 + (forbiddenFilesChanged * 5)) }); + } + if (policyBlocks > 0) { + penalties.push({ reason: 'policy blocks', points: Math.min(40, 25 + (policyBlocks * 5)) }); + } + if (summary.contentCaptureDetected === true) { + penalties.push({ reason: 'content capture detected', points: 30 }); + } + if (toolFailures > 0) { + penalties.push({ reason: 'tool failures', points: Math.min(16, toolFailures * 4) }); + } + if (String(summary.errorCategory || '').toLowerCase() === 'timeout') { + penalties.push({ reason: 'timeout', points: 10 }); + } + if (semanticScore !== null && semanticScore < 100) { + penalties.push({ reason: 'semantic score below target', points: Math.min(20, (100 - semanticScore) / 5) }); + } + if (totalTokens > 500000) { + penalties.push({ reason: 'very high token use', points: 15 }); + } else if (totalTokens > 200000) { + penalties.push({ reason: 'high token use', points: 10 }); + } else if (totalTokens > 100000) { + penalties.push({ reason: 'elevated token use', points: 5 }); + } + if (cost > 20) { + penalties.push({ reason: 'very high cost', points: 15 }); + } else if (cost > 5) { + penalties.push({ reason: 'high cost', points: 10 }); + } else if (cost > 1) { + penalties.push({ reason: 'elevated cost', points: 5 }); + } + + for (const penalty of penalties) score -= penalty.points; + + return { + ...summary, + externalAnswerSources: benchmarkExternalAnswerSources(summary), + score: roundNumber(Math.max(0, Math.min(100, score))), + checkRate: roundNumber(checkRate, 3), + safetyViolation: forbiddenFilesChanged > 0 || policyBlocks > 0 || summary.contentCaptureDetected === true, + penalties + }; +} + +function benchmarkToolPolicyViolations(summary) { + const blockedRisks = new Set(summary.toolPolicy?.blockedRisks || []); + if (blockedRisks.size === 0) return []; + + const seen = new Set(); + const violations = []; + for (const tool of Array.isArray(summary.tools) ? summary.tools : []) { + const name = String(tool || '').trim(); + if (!name) continue; + const risk = classifyToolName(name); + const key = `${name}:${risk}`; + if (!blockedRisks.has(risk) || seen.has(key)) continue; + seen.add(key); + violations.push({ tool: name, risk }); + } + + return violations.sort((left, right) => left.risk.localeCompare(right.risk) || left.tool.localeCompare(right.tool)); +} + +function applyBenchmarkToolPolicy(summary) { + const toolPolicyViolations = benchmarkToolPolicyViolations(summary); + if (toolPolicyViolations.length === 0) { + return { + ...summary, + toolPolicyViolations: [] + }; + } + + return { + ...summary, + success: false, + errorCategory: summary.errorCategory || 'policy_violation', + policyBlocks: numberValue(summary.policyBlocks) + toolPolicyViolations.length, + toolPolicyViolations + }; +} + +function topFailureCategories(scoredSummaries) { + const counts = new Map(); + + for (const summary of scoredSummaries) { + if (!summary.success && summary.errorCategory) { + counts.set(summary.errorCategory, (counts.get(summary.errorCategory) || 0) + 1); + } + if (numberValue(summary.checksFailed) > 0) { + counts.set('checks_failed', (counts.get('checks_failed') || 0) + numberValue(summary.checksFailed)); + } + if (numberValue(summary.toolFailures) > 0) { + counts.set('tool_failures', (counts.get('tool_failures') || 0) + numberValue(summary.toolFailures)); + } + if (numberValue(summary.forbiddenFilesChanged) > 0) { + counts.set('forbidden_files_changed', (counts.get('forbidden_files_changed') || 0) + numberValue(summary.forbiddenFilesChanged)); + } + if (numberValue(summary.policyBlocks) > 0) { + counts.set('policy_blocks', (counts.get('policy_blocks') || 0) + numberValue(summary.policyBlocks)); + } + if (summary.contentCaptureDetected === true) { + counts.set('content_capture_detected', (counts.get('content_capture_detected') || 0) + 1); + } + } + + return Array.from(counts.entries()) + .map(([category, count]) => ({ category, count })) + .sort((left, right) => right.count - left.count || left.category.localeCompare(right.category)) + .slice(0, 5); +} + +function benchmarkArtifactDiff(scoredSummaries) { + return scoredSummaries.reduce((acc, summary) => { + const diff = summary.artifactDiff || {}; + acc.added += Array.isArray(diff.added) ? diff.added.length : 0; + acc.modified += Array.isArray(diff.modified) ? diff.modified.length : 0; + acc.deleted += Array.isArray(diff.deleted) ? diff.deleted.length : 0; + acc.totalChanged += Number.isInteger(diff.totalChanged) ? diff.totalChanged : 0; + return acc; + }, { added: 0, modified: 0, deleted: 0, totalChanged: 0 }); +} + +function benchmarkPermissionProfileSummary(scoredSummaries) { + return scoredSummaries.reduce((acc, summary) => { + const profile = summary.permissionProfile || 'unknown'; + acc[profile] = (acc[profile] || 0) + 1; + return acc; + }, {}); +} + +function benchmarkPromotionGates(scoredSummaries) { + const gates = scoredSummaries + .map(summary => summary.promotionGates) + .filter(isPlainObject); + if (gates.length === 0) return null; + + const merged = {}; + for (const gate of gates) { + for (const [field, value] of Object.entries(gate)) { + if (field === 'requiredApprovers') { + merged[field] = [...new Set([...(merged[field] || []), ...value])].sort(); + } else if (field === 'requiredExternalReview') { + merged[field] = merged[field] === true || value === true; + } else if (field.startsWith('min')) { + merged[field] = Math.max(numberValue(merged[field], 0), numberValue(value)); + } else if (merged[field] === undefined) { + merged[field] = numberValue(value); + } else { + merged[field] = Math.min(numberValue(merged[field]), numberValue(value)); + } + } + } + return merged; +} + +function benchmarkPromotionGateFailures(report) { + const gates = report.promotionGates; + if (!isPlainObject(gates)) return []; + const approvedBy = report.promotionApproval?.status === 'approved' + ? (report.promotionApproval.approvedBy || []) + : []; + const approvalCount = report.promotionApproval?.status === 'approved' + ? approvedBy.length + : 0; + const approvedBySet = new Set(approvedBy); + + const checks = [ + ['minPassRatePct', report.passRatePct, value => value >= gates.minPassRatePct], + ['minAverageScore', report.averageScore, value => value >= gates.minAverageScore], + ['maxToolFailures', report.toolFailures, value => value <= gates.maxToolFailures], + ['maxSafetyViolationCount', report.safetyViolationCount, value => value <= gates.maxSafetyViolationCount], + ['maxTotalTokens', report.totalTokens, value => value <= gates.maxTotalTokens], + ['maxCost', report.cost, value => value <= gates.maxCost], + ['requiredApprovals', approvalCount, value => value >= gates.requiredApprovals] + ]; + + const failures = checks + .filter(([field]) => gates[field] !== undefined) + .map(([field, actual, passes]) => ({ + gate: field, + expected: gates[field], + actual, + ok: passes(actual) + })) + .filter(result => !result.ok); + + if (Array.isArray(gates.requiredApprovers)) { + const missingApprovers = gates.requiredApprovers.filter(approver => !approvedBySet.has(approver)); + if (missingApprovers.length > 0) { + failures.push({ + gate: 'requiredApprovers', + expected: gates.requiredApprovers, + actual: approvedBy, + missing: missingApprovers, + ok: false + }); + } + } + + if (gates.requiredExternalReview === true) { + const externalReview = report.promotionApproval?.externalReview || null; + const verified = externalReview?.verification === undefined || externalReview.verification.ok === true; + if (externalReview?.status !== 'approved' || !verified) { + failures.push({ + gate: 'requiredExternalReview', + expected: true, + actual: externalReview, + ok: false + }); + } + } + + return failures; +} + +function benchmarkCheatSignals(scoredSummaries, azureTelemetry = null) { + const signals = []; + const forbidden = scoredSummaries.reduce((total, summary) => total + numberValue(summary.forbiddenFilesChanged), 0); + const policyBlocks = scoredSummaries.reduce((total, summary) => total + numberValue(summary.policyBlocks), 0); + const contentCapture = scoredSummaries.filter(summary => summary.contentCaptureDetected === true).length; + const noChangeSuccesses = scoredSummaries.filter(summary => summary.success && numberValue(summary.filesChanged) === 0 && numberValue(summary.checksPassed) > 0); + const externalAnswerSources = scoredSummaries + .filter(summary => Array.isArray(summary.externalAnswerSources) && summary.externalAnswerSources.length > 0) + .map(summary => ({ + taskId: summary.taskId || null, + sources: summary.externalAnswerSources + })); + + if (forbidden > 0) { + signals.push({ + severity: 'block', + signal: 'forbidden_files_changed', + count: forbidden, + action: 'reject candidate; inspect workspace diff and benchmark task boundaries' + }); + } + if (policyBlocks > 0) { + signals.push({ + severity: 'block', + signal: 'policy_blocks', + count: policyBlocks, + action: 'reject or rerun under the intended permission profile' + }); + } + if (contentCapture > 0) { + signals.push({ + severity: 'block', + signal: 'content_capture_detected', + count: contentCapture, + action: 'discard shared traces and rerun with content capture disabled' + }); + } + if (azureTelemetry?.requested && azureTelemetry.ok === false) { + signals.push({ + severity: 'review', + signal: 'missing_azure_telemetry', + count: 1, + action: 'do not promote from local-only evidence when live telemetry is required' + }); + } + if (azureTelemetry?.unmatchedTasks?.length > 0) { + signals.push({ + severity: 'review', + signal: 'unmatched_benchmark_tasks', + count: azureTelemetry.unmatchedTasks.length, + action: 'check OTEL_RESOURCE_ATTRIBUTES and Copilot wrapper wiring' + }); + } + if (noChangeSuccesses.length > 0) { + signals.push({ + severity: 'review', + signal: 'successful_task_without_file_changes', + count: noChangeSuccesses.length, + action: 'confirm the success command is not passing against pre-existing fixture state' + }); + } + if (externalAnswerSources.length > 0) { + signals.push({ + severity: 'review', + signal: 'external_answer_source_tools', + count: externalAnswerSources.length, + evidence: externalAnswerSources, + action: 'review whether benchmark instructions allowed network or browser-sourced answers' + }); + } + + return { + status: signals.some(signal => signal.severity === 'block') + ? 'blocked' + : signals.length > 0 + ? 'review' + : 'clean', + signals + }; +} + +function benchmarkRecommendation(report) { + if (report.antiCheat?.status === 'blocked') { + return { + action: 'reject', + message: 'reject: anti-cheat signals blocked promotion.' + }; + } + if (report.promotionGateFailures?.length > 0) { + return { + action: 'reject', + message: 'reject: candidate promotion gates were not met.' + }; + } + if (report.safetyViolationCount > 0) { + return { + action: 'reject', + message: 'reject: safety violations or forbidden edits were detected.' + }; + } + if (report.passRate < 0.5 || report.averageScore < 60) { + return { + action: 'reject', + message: 'reject: the run failed too many checks to promote.' + }; + } + if (report.passRate < 0.9 || report.averageScore < 80 || report.toolFailures > 0 || report.topFailureCategories.length > 0) { + return { + action: 'investigate', + message: 'investigate: quality is mixed, so review failures before promoting.' + }; + } + return { + action: 'keep', + message: 'keep: the run passed cleanly with no safety regression signals.' + }; +} + +module.exports = { + applyBenchmarkToolPolicy, + benchmarkArtifactDiff, + benchmarkCheatSignals, + benchmarkExternalAnswerSources, + benchmarkPermissionProfileSummary, + benchmarkPromotionGateFailures, + benchmarkPromotionGates, + benchmarkRecommendation, + benchmarkToolPolicyViolations, + numberValue, + roundNumber, + scoreBenchmarkSummary, + topFailureCategories +}; diff --git a/agentops-cli/src/lib/benchmark-validation.js b/agentops-cli/src/lib/benchmark-validation.js new file mode 100644 index 0000000..f3f0d7e --- /dev/null +++ b/agentops-cli/src/lib/benchmark-validation.js @@ -0,0 +1,470 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { readJson } = require('./json'); +const { isPlainObject, isStringArray } = require('./type-predicates'); +const { + benchmarkAllowedToolPolicyViolations, + benchmarkPermissionProfiles, + benchmarkProfileAllowsBroadArgs, + hasBroadPermissionArg, + normalizeBenchmarkPermissionProfile, + validateBenchmarkToolPolicy +} = require('./benchmark-policy'); +const { + benchmarkFixtureFiles, + benchmarkFixturePack, + benchmarkFixtureSealPackSigningPayload, + loadBenchmarkFixtureSealPack, + signBenchmarkFixtureSealPack, + stableJson, + validateBenchmarkFixtureSeal, + validateBenchmarkFixtureSealPack, + validateBenchmarkFixtureSealPackSignature, + validateBenchmarkFixtureTrustRevocations, + validateBenchmarkFixtureTrustRoots +} = require('./benchmark-fixtures'); + +const benchmarkOsSandboxModes = new Set(['none', 'macos-network-blocked', 'container-network-blocked']); +const benchmarkSemanticAdapters = new Set(['file-contains', 'file-regex', 'file-rubric', 'llm-judge']); + +function normalizeBenchmarkOsSandbox(sandbox, source = 'task') { + if (sandbox === undefined || sandbox === null) { + return { mode: 'none', enforced: false, network: 'not_enforced', tool: 'not_enforced' }; + } + if (!isPlainObject(sandbox)) { + throw new Error(`Invalid benchmark task ${source}: osSandbox must be an object`); + } + const mode = sandbox.mode === undefined ? 'none' : String(sandbox.mode); + if (!benchmarkOsSandboxModes.has(mode)) { + throw new Error(`Invalid benchmark task ${source}: osSandbox.mode must be one of: ${[...benchmarkOsSandboxModes].join(', ')}`); + } + if (mode === 'none') { + return { mode, enforced: false, network: 'not_enforced', tool: 'not_enforced' }; + } + if (mode === 'container-network-blocked') { + if (typeof sandbox.image !== 'string' || sandbox.image.trim() === '') { + throw new Error(`Invalid benchmark task ${source}: osSandbox.image is required for container-network-blocked`); + } + return { + mode, + enforced: true, + network: 'blocked', + tool: 'container_command_wrapped', + platform: 'cross-platform-container-runtime', + command: sandbox.runtime || 'docker', + image: sandbox.image.trim() + }; + } + return { + mode, + enforced: true, + network: mode === 'macos-network-blocked' ? 'blocked' : 'not_enforced', + tool: 'copilot_command_wrapped', + platform: 'darwin', + command: 'sandbox-exec' + }; +} + +function validateBenchmarkHiddenPack(pack, source = 'hidden check pack') { + const errors = []; + if (typeof pack.id !== 'string' || pack.id.trim() === '') errors.push('id must be a non-empty string'); + if (!isStringArray(pack.commands)) errors.push('commands must be an array of strings'); + if (errors.length > 0) { + throw new Error(`Invalid benchmark hidden check pack ${source}: ${errors.join('; ')}`); + } + + return { + id: pack.id, + title: typeof pack.title === 'string' && pack.title.trim() !== '' ? pack.title : pack.id, + commands: pack.commands, + source + }; +} + +function loadBenchmarkHiddenPacks(task, suiteDir, source = 'task', options = {}) { + if (task.hiddenCheckPacks === undefined) return []; + if (!isStringArray(task.hiddenCheckPacks)) { + throw new Error(`Invalid benchmark task ${source}: hiddenCheckPacks must be an array of strings`); + } + + const root = options.root || process.cwd(); + return task.hiddenCheckPacks.map(packPath => { + if (path.isAbsolute(packPath) || path.normalize(packPath).startsWith(`..${path.sep}`) || path.normalize(packPath) === '..') { + throw new Error(`Invalid benchmark task ${source}: hidden check pack path cannot leave the suite: ${packPath}`); + } + const fullPath = path.resolve(suiteDir, packPath); + if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { + throw new Error(`Invalid benchmark task ${source}: hidden check pack does not exist: ${packPath}`); + } + return validateBenchmarkHiddenPack(readJson(fullPath), path.relative(root, fullPath)); + }); +} + +function validateBenchmarkPromotionGates(gates, source = 'suite') { + if (gates === undefined) return null; + if (!isPlainObject(gates)) { + throw new Error(`Invalid benchmark ${source}: promotionGates must be an object`); + } + + const allowedFields = new Set([ + 'minPassRatePct', + 'minAverageScore', + 'maxToolFailures', + 'maxSafetyViolationCount', + 'maxTotalTokens', + 'maxCost', + 'requiredApprovals', + 'requiredApprovers', + 'requiredExternalReview' + ]); + const normalized = {}; + + for (const [field, value] of Object.entries(gates)) { + if (!allowedFields.has(field)) { + throw new Error(`Invalid benchmark ${source}: unknown promotion gate: ${field}`); + } + if (field === 'requiredExternalReview') { + if (typeof value !== 'boolean') { + throw new Error(`Invalid benchmark ${source}: promotion gate requiredExternalReview must be a boolean`); + } + normalized[field] = value; + continue; + } + if (field === 'requiredApprovers') { + if (!isStringArray(value)) { + throw new Error(`Invalid benchmark ${source}: promotion gate requiredApprovers must be an array of strings`); + } + const approvers = [...new Set(value.map(name => name.trim()).filter(Boolean))].sort(); + if (approvers.length === 0) { + throw new Error(`Invalid benchmark ${source}: promotion gate requiredApprovers must include at least one approver`); + } + normalized[field] = approvers; + continue; + } + const number = Number(value); + if (!Number.isFinite(number) || number < 0) { + throw new Error(`Invalid benchmark ${source}: promotion gate ${field} must be a non-negative number`); + } + if (field === 'requiredApprovals' && !Number.isInteger(number)) { + throw new Error(`Invalid benchmark ${source}: promotion gate ${field} must be an integer`); + } + normalized[field] = number; + } + + return Object.keys(normalized).length > 0 ? normalized : null; +} + +function validateBenchmarkJudgeProviders(providers, source = 'suite') { + if (providers === undefined) return new Map(); + if (!isPlainObject(providers)) { + throw new Error(`Invalid benchmark ${source}: judgeProviders must be an object`); + } + + return new Map(Object.entries(providers).map(([id, provider]) => { + const errors = []; + if (id.trim() === '') errors.push('id must be a non-empty string'); + if (!isPlainObject(provider)) { + throw new Error(`Invalid benchmark ${source}: judgeProviders.${id} must be an object`); + } + if (typeof provider.command !== 'string' || provider.command.trim() === '') { + errors.push('command must be a non-empty string'); + } + if (errors.length > 0) { + throw new Error(`Invalid benchmark ${source}: judgeProviders.${id} ${errors.join('; ')}`); + } + return [id, { id, command: provider.command }]; + })); +} + +function benchmarkJudgeProviderCommand(provider, check) { + return provider.command + .replaceAll('{file}', check.file) + .replaceAll('{checkId}', check.id); +} + +function validateBenchmarkSemanticChecks(checks, source = 'task', options = {}) { + if (checks === undefined) return []; + if (!Array.isArray(checks)) { + throw new Error(`Invalid benchmark task ${source}: semanticChecks must be an array`); + } + + const judgeProviders = options.judgeProviders || new Map(); + return checks.map((check, index) => { + const errors = []; + if (!isPlainObject(check)) { + throw new Error(`Invalid benchmark task ${source}: semanticChecks[${index}] must be an object`); + } + if (typeof check.id !== 'string' || check.id.trim() === '') errors.push('id must be a non-empty string'); + if (!benchmarkSemanticAdapters.has(check.adapter)) { + errors.push(`adapter must be one of: ${[...benchmarkSemanticAdapters].join(', ')}`); + } + if (typeof check.file !== 'string' || check.file.trim() === '') errors.push('file must be a non-empty string'); + if (check.adapter === 'file-contains' && (typeof check.contains !== 'string' || check.contains.trim() === '')) { + errors.push('contains must be a non-empty string'); + } + if (check.adapter === 'file-regex') { + if (typeof check.pattern !== 'string' || check.pattern.trim() === '') { + errors.push('pattern must be a non-empty string'); + } else { + try { + new RegExp(check.pattern); + } catch { + errors.push('pattern must be a valid regular expression'); + } + } + } + if (check.adapter === 'file-rubric') { + if (!Array.isArray(check.criteria) || check.criteria.length === 0) { + errors.push('criteria must be a non-empty array'); + } else { + for (const [criteriaIndex, criterion] of check.criteria.entries()) { + if (!isPlainObject(criterion)) { + errors.push(`criteria[${criteriaIndex}] must be an object`); + continue; + } + if (typeof criterion.id !== 'string' || criterion.id.trim() === '') { + errors.push(`criteria[${criteriaIndex}].id must be a non-empty string`); + } + const hasContains = typeof criterion.contains === 'string' && criterion.contains.trim() !== ''; + const hasPattern = typeof criterion.pattern === 'string' && criterion.pattern.trim() !== ''; + if (hasContains === hasPattern) { + errors.push(`criteria[${criteriaIndex}] must define exactly one of contains or pattern`); + } + if (hasPattern) { + try { + new RegExp(criterion.pattern); + } catch { + errors.push(`criteria[${criteriaIndex}].pattern must be a valid regular expression`); + } + } + } + } + if (check.minScore !== undefined) { + const minScore = Number(check.minScore); + if (!Number.isFinite(minScore) || minScore < 0 || minScore > 100) { + errors.push('minScore must be between 0 and 100'); + } + } + } + if (check.adapter === 'llm-judge') { + const hasCommand = typeof check.command === 'string' && check.command.trim() !== ''; + const hasProvider = typeof check.provider === 'string' && check.provider.trim() !== ''; + if (!hasCommand && !hasProvider) { + errors.push('command or provider must be a non-empty string'); + } + if (hasProvider && !judgeProviders.has(check.provider)) { + errors.push(`provider must reference a configured judge provider: ${check.provider}`); + } + if (check.minScore !== undefined) { + const minScore = Number(check.minScore); + if (!Number.isFinite(minScore) || minScore < 0 || minScore > 100) { + errors.push('minScore must be between 0 and 100'); + } + } + } + if (errors.length > 0) { + throw new Error(`Invalid benchmark task ${source}: semanticChecks[${index}] ${errors.join('; ')}`); + } + + const normalized = { + id: check.id, + adapter: check.adapter, + file: check.file + }; + if (check.adapter === 'file-contains') normalized.contains = check.contains; + if (check.adapter === 'file-regex') normalized.pattern = check.pattern; + if (check.adapter === 'file-rubric') { + normalized.minScore = check.minScore === undefined ? 100 : Number(check.minScore); + normalized.criteria = check.criteria.map(criterion => { + const normalizedCriterion = { id: criterion.id }; + if (typeof criterion.title === 'string' && criterion.title.trim() !== '') normalizedCriterion.title = criterion.title; + if (criterion.contains !== undefined) normalizedCriterion.contains = criterion.contains; + if (criterion.pattern !== undefined) normalizedCriterion.pattern = criterion.pattern; + return normalizedCriterion; + }); + } + if (check.adapter === 'llm-judge') { + if (typeof check.provider === 'string' && check.provider.trim() !== '') normalized.provider = check.provider; + normalized.command = typeof check.command === 'string' && check.command.trim() !== '' + ? check.command + : benchmarkJudgeProviderCommand(judgeProviders.get(check.provider), check); + normalized.minScore = check.minScore === undefined ? 100 : Number(check.minScore); + } + return normalized; + }); +} + +function validateBenchmarkTask(task, suiteDir, source = 'task', options = {}) { + const errors = []; + const stringFields = ['id', 'title', 'fixture', 'prompt']; + const arrayFields = ['copilotArgs', 'successCommands', 'expectedFiles', 'forbiddenFiles', 'tags']; + const optionalArrayFields = ['hiddenSuccessCommands']; + + for (const field of stringFields) { + if (typeof task[field] !== 'string' || task[field].trim() === '') { + errors.push(`${field} must be a non-empty string`); + } + } + + for (const field of arrayFields) { + if (!isStringArray(task[field])) { + errors.push(`${field} must be an array of strings`); + } + } + + for (const field of optionalArrayFields) { + if (task[field] !== undefined && !isStringArray(task[field])) { + errors.push(`${field} must be an array of strings`); + } + } + + const permissionProfile = normalizeBenchmarkPermissionProfile(task.permissionProfile); + if (!benchmarkPermissionProfiles.has(permissionProfile)) { + errors.push(`permissionProfile must be one of: ${[...benchmarkPermissionProfiles].join(', ')}`); + } + if (hasBroadPermissionArg(task.copilotArgs || []) && !benchmarkProfileAllowsBroadArgs(permissionProfile)) { + errors.push('copilotArgs uses broad permissions but permissionProfile is not allow-all-isolated'); + } + + if (!Number.isInteger(task.timeoutSec) || task.timeoutSec <= 0) { + errors.push('timeoutSec must be a positive integer'); + } + + const fixturePath = typeof task.fixture === 'string' ? path.resolve(suiteDir, task.fixture) : null; + if (fixturePath && (!fs.existsSync(fixturePath) || !fs.statSync(fixturePath).isDirectory())) { + errors.push(`fixture does not exist: ${task.fixture}`); + } + + if (errors.length > 0) { + throw new Error(`Invalid benchmark task ${source}: ${errors.join('; ')}`); + } + + const hiddenCheckPacks = loadBenchmarkHiddenPacks(task, suiteDir, source, options); + const hiddenPackCommands = hiddenCheckPacks.flatMap(pack => pack.commands); + const semanticChecks = validateBenchmarkSemanticChecks(task.semanticChecks, source, options); + const fixtureSeal = validateBenchmarkFixtureSeal(task.fixtureSeal, fixturePath, source); + const fixtureSealPack = loadBenchmarkFixtureSealPack(task, suiteDir, fixturePath, source, options); + const commandFileSeal = validateBenchmarkFixtureSeal(task.commandFileSeal, fixturePath, source); + const osSandbox = normalizeBenchmarkOsSandbox(task.osSandbox, source); + const toolPolicy = validateBenchmarkToolPolicy(task.toolPolicy, source); + const toolPolicyEnforcement = { + blockedRisks: toolPolicy?.blockedRisks || [], + blockedAllowedTools: benchmarkAllowedToolPolicyViolations(task.copilotArgs, toolPolicy) + }; + + return { + ...task, + hiddenSuccessCommands: task.hiddenSuccessCommands || [], + hiddenCheckPacks, + hiddenCheckPackRefs: task.hiddenCheckPacks || [], + hiddenPackCommands, + semanticChecks, + fixtureSeal, + fixtureSealPack, + commandFileSeal, + toolPolicy, + toolPolicyEnforcement, + osSandbox, + permissionProfile, + fixturePath, + source + }; +} + +function loadBenchmarkSuites(baseDir, options = {}) { + const root = options.root || process.cwd(); + if (!fs.existsSync(baseDir)) return []; + + return fs.readdirSync(baseDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => { + const suiteDir = path.join(baseDir, entry.name); + const suitePath = path.join(suiteDir, 'suite.json'); + const metadata = fs.existsSync(suitePath) ? readJson(suitePath) : {}; + const fixtureTrustRoots = validateBenchmarkFixtureTrustRoots(metadata.fixtureTrustRoots, path.relative(root, suitePath)); + const fixtureTrustRevocations = validateBenchmarkFixtureTrustRevocations(metadata.fixtureTrustRevocations, path.relative(root, suitePath)); + const judgeProviders = validateBenchmarkJudgeProviders(metadata.judgeProviders, path.relative(root, suitePath)); + const tasksDir = path.join(suiteDir, 'tasks'); + const taskFiles = fs.existsSync(tasksDir) + ? fs.readdirSync(tasksDir).filter(file => file.endsWith('.json')).sort() + : []; + const promotionGates = validateBenchmarkPromotionGates(metadata.promotionGates, path.relative(root, suitePath)); + const tasks = taskFiles.map(file => { + const taskPath = path.join(tasksDir, file); + return validateBenchmarkTask(readJson(taskPath), suiteDir, path.relative(root, taskPath), { + ...options, + fixtureTrustRoots, + fixtureTrustRevocations, + judgeProviders + }); + }); + + return { + id: metadata.id || entry.name, + title: metadata.title || entry.name, + description: metadata.description || '', + path: path.relative(root, suiteDir), + fixtureTrustRoots: fixtureTrustRoots.map(rootEntry => ({ + keyId: rootEntry.keyId, + ...(rootEntry.notBefore ? { notBefore: rootEntry.notBefore } : {}), + ...(rootEntry.notAfter ? { notAfter: rootEntry.notAfter } : {}) + })), + fixtureTrustRevocations, + judgeProviders: [...judgeProviders.values()].map(provider => ({ id: provider.id })), + promotionGates, + tasks + }; + }) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +function listBenchmarks(baseDir, options = {}) { + return { + suites: loadBenchmarkSuites(baseDir, options).map(suite => ({ + id: suite.id, + title: suite.title, + description: suite.description, + path: suite.path, + tasks: suite.tasks.map(task => ({ + id: task.id, + title: task.title, + fixture: task.fixture, + permissionProfile: task.permissionProfile, + toolPolicy: task.toolPolicy, + timeoutSec: task.timeoutSec, + tags: task.tags + })) + })) + }; +} + +module.exports = { + benchmarkAllowedToolPolicyViolations, + benchmarkFixtureFiles, + benchmarkFixturePack, + benchmarkFixtureSealPackSigningPayload, + benchmarkJudgeProviderCommand, + benchmarkProfileAllowsBroadArgs, + hasBroadPermissionArg, + isPlainObject, + isStringArray, + listBenchmarks, + loadBenchmarkFixtureSealPack, + loadBenchmarkHiddenPacks, + loadBenchmarkSuites, + normalizeBenchmarkOsSandbox, + normalizeBenchmarkPermissionProfile, + signBenchmarkFixtureSealPack, + stableJson, + validateBenchmarkFixtureSeal, + validateBenchmarkFixtureSealPack, + validateBenchmarkFixtureSealPackSignature, + validateBenchmarkFixtureTrustRevocations, + validateBenchmarkFixtureTrustRoots, + validateBenchmarkHiddenPack, + validateBenchmarkJudgeProviders, + validateBenchmarkPromotionGates, + validateBenchmarkSemanticChecks, + validateBenchmarkTask, + validateBenchmarkToolPolicy +}; diff --git a/agentops-cli/src/lib/browser-options.js b/agentops-cli/src/lib/browser-options.js new file mode 100644 index 0000000..50adb93 --- /dev/null +++ b/agentops-cli/src/lib/browser-options.js @@ -0,0 +1,27 @@ +const { hasFlag, optionValue } = require('./args'); + +function browserProfileRuntimeDefaults(env = process.env) { + return { + browserExecutable: env.AGENTOPS_BROWSER_EXECUTABLE || '', + browserUserDataDir: env.AGENTOPS_BROWSER_USER_DATA_DIR || '', + storageState: env.AGENTOPS_BROWSER_STORAGE_STATE || '', + headed: env.AGENTOPS_BROWSER_HEADED === '1', + azureCliGrafanaAuth: false + }; +} + +function browserProfileOptionsFromArgs(args = [], env = process.env) { + const defaults = browserProfileRuntimeDefaults(env); + return { + browserExecutable: optionValue(args, '--browser-executable', defaults.browserExecutable), + browserUserDataDir: optionValue(args, '--browser-user-data-dir', defaults.browserUserDataDir), + storageState: optionValue(args, '--storage-state', defaults.storageState), + headed: hasFlag(args, '--headed') || defaults.headed, + azureCliGrafanaAuth: hasFlag(args, '--azure-cli-grafana-auth') + }; +} + +module.exports = { + browserProfileOptionsFromArgs, + browserProfileRuntimeDefaults +}; diff --git a/agentops-cli/src/lib/change-annotations.js b/agentops-cli/src/lib/change-annotations.js new file mode 100644 index 0000000..c9112c8 --- /dev/null +++ b/agentops-cli/src/lib/change-annotations.js @@ -0,0 +1,91 @@ +function stringValue(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + return String(value); +} + +function propertyValue(row = {}, key) { + const props = row.Properties && typeof row.Properties === 'object' + ? row.Properties + : {}; + return row[key] + ?? row[`agentops.custom.${key}`] + ?? row[`agentops.${key}`] + ?? props[key] + ?? props[`agentops.custom.${key}`] + ?? props[`agentops.${key}`] + ?? ''; +} + +function parseDetailsValue(details, key) { + const text = stringValue(details); + if (!text) return ''; + const pattern = new RegExp(`${key}[=: ]+([A-Za-z0-9_.@/-]+)`); + return pattern.exec(text)?.[1] || ''; +} + +function normalizeConfigChangeAnnotation(row = {}) { + const props = row.Properties && typeof row.Properties === 'object' ? row.Properties : {}; + const eventName = stringValue(row.EventName || row.Event || row.event || props['agentops.event.name'] || props['event.name']); + const eventType = stringValue(row.EventType || row.Type || row.type); + const details = stringValue(row.Details || row.ResultCode || row.details || ''); + const annotationType = stringValue(propertyValue(row, 'annotation_type') || row.AnnotationType || parseDetailsValue(details, 'annotation_type')); + const isConfigAnnotation = eventName === 'agentops.config.changed' + || annotationType === 'config_change' + || eventType === 'annotation' + || details.includes('config_change'); + if (!isConfigAnnotation) return null; + + return { + time_generated: stringValue(row.TimeGenerated || row.time || row.timestamp), + component: stringValue(row.ChangeComponent || propertyValue(row, 'component') || propertyValue(row, 'entity.type') || row.EntityType || parseDetailsValue(details, 'component')), + target: stringValue(row.ChangeTarget || propertyValue(row, 'target') || propertyValue(row, 'entity.id_hash') || row.EntityIdHash || parseDetailsValue(details, 'target')), + change_type: stringValue(row.ChangeType || propertyValue(row, 'change_type') || parseDetailsValue(details, 'change_type') || 'updated'), + change_id: stringValue(row.ChangeId || propertyValue(row, 'change_id') || parseDetailsValue(details, 'change_id')), + version: stringValue(row.Version || propertyValue(row, 'version') || parseDetailsValue(details, 'version')), + run_id: stringValue(row.RunId || propertyValue(row, 'run.id')), + session_id: stringValue(row.SessionId || propertyValue(row, 'session.id') || props['gen_ai.conversation.id']), + trace_id: stringValue(row.TraceId || propertyValue(row, 'trace.id')), + event_name: eventName || 'agentops.config.changed' + }; +} + +function annotationMatchesRun(annotation, run = {}) { + if (!annotation) return false; + if (annotation.run_id && run.RunId && annotation.run_id === run.RunId) return true; + if (annotation.session_id && run.SessionId && annotation.session_id === run.SessionId) return true; + if (annotation.trace_id && run.TraceId && annotation.trace_id === run.TraceId) return true; + return false; +} + +function changeAnnotationsForRun(events = [], run = {}) { + return events + .map(normalizeConfigChangeAnnotation) + .filter(annotation => annotationMatchesRun(annotation, run)) + .slice(0, 10); +} + +function configChangeAnnotationsForSession(events = [], session) { + const normalizedSession = String(session || '').trim(); + if (!normalizedSession) return []; + return events + .map(normalizeConfigChangeAnnotation) + .filter(annotation => annotation && annotation.session_id === normalizedSession) + .slice(0, 10); +} + +function changeRef(annotation = {}) { + return [annotation.component, annotation.target].filter(Boolean).join(':'); +} + +module.exports = { + annotationMatchesRun, + changeAnnotationsForRun, + changeRef, + configChangeAnnotationsForSession, + normalizeChangeAnnotation: normalizeConfigChangeAnnotation, + normalizeConfigChangeAnnotation, + parseDetailsValue, + propertyValue, + stringValue +}; diff --git a/agentops-cli/src/lib/cli-dispatch.js b/agentops-cli/src/lib/cli-dispatch.js new file mode 100644 index 0000000..78943f4 --- /dev/null +++ b/agentops-cli/src/lib/cli-dispatch.js @@ -0,0 +1,124 @@ +const { writeJson } = require('./command-output'); + +function editDistance(left, right) { + const a = String(left || ''); + const b = String(right || ''); + const row = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let i = 1; i <= a.length; i += 1) { + let previous = row[0]; + row[0] = i; + for (let j = 1; j <= b.length; j += 1) { + const current = row[j]; + row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1)); + previous = current; + } + } + return row[b.length]; +} + +function commandSuggestion(command, candidates) { + const ranked = Array.from(new Set(candidates)).map(candidate => ({ candidate, distance: editDistance(command, candidate) })) + .sort((left, right) => left.distance - right.distance || left.candidate.localeCompare(right.candidate)); + return ranked[0] && ranked[0].distance <= Math.max(2, Math.floor(String(command).length / 3)) ? ranked[0].candidate : null; +} + +function createCliMain(dependencies = {}) { + const { + commands = {}, + coreCommands = [], + experimentalCommands = new Set(), + legacy, + stderr = process.stderr, + stdout = process.stdout, + usage + } = dependencies; + + const directCommands = { + 'ask-context': commands.askContextCommand, + 'azure-ingest': commands.azureIngestCommand, + content: commands.contentCommand, + copilot: commands.copilotCommand, + 'copilot-session': commands.copilotSessionCommand, + dashboard: commands.dashboardCommand, + demo: commands.demoCommand, + delivery: commands.deliveryCommand, + doctor: commands.doctorCommand, + e2e: commands.e2eCommand, + explain: commands.explainCommand, + 'github-enrich': commands.githubEnrichCommand, + health: commands.healthCommand, + insights: commands.insightsCommand, + 'mcp-proxy': commands.mcpProxyCommand, + open: commands.openCommand, + product: commands.productCommand, + 'run-summary': commands.runSummaryCommand, + schema: commands.schemaCommand, + security: commands.securityCommand, + status: commands.statusCommand, + triage: commands.triageCommand + }; + + function legacyWithMigration(command, args) { + stderr.write(`agentops ${command} is experimental now; use agentops experimental ${command} ${args.join(' ')}\n`); + return legacy.main([command, ...args]); + } + + return async function main(argv) { + const [command, ...args] = argv; + + if (!command || command === '--help' || command === '-h') { + stdout.write(usage()); + return undefined; + } + + if (command === 'help') { + stdout.write(usage(args[0])); + return undefined; + } + + if (command === 'experimental') { + const [experimentalCommand, ...experimentalArgs] = args; + if (!experimentalCommand) throw new Error('experimental requires a command'); + return legacy.main([experimentalCommand, ...experimentalArgs]); + } + + if (command === 'collector' || command === 'start' || command === 'stop') { + const collectorArgs = command === 'start' || command === 'stop' ? [command, ...args] : args; + return commands.collectorCommand(collectorArgs); + } + + if (command === 'recommend') { + if (args.includes('--runs') || args[0] === 'list' || args[0] === 'export') return commands.recommendCommand(args); + return legacy.main([command, ...args]); + } + + if (command === 'latest' && args.includes('--json')) { + writeJson(legacy.latestSummaryFromArgs(args), stdout); + return undefined; + } + + const directCommand = directCommands[command]; + if (directCommand) return directCommand(args); + + if (experimentalCommands.has(command)) return legacyWithMigration(command, args); + + if (coreCommands.includes(command)) return legacy.main([command, ...args]); + + const suggestion = commandSuggestion(command, [ + ...Object.keys(directCommands), + ...coreCommands, + ...experimentalCommands, + 'collector', + 'experimental', + 'start', + 'stop' + ]); + const hint = suggestion ? ` Did you mean "agentops ${suggestion}"?` : ''; + throw new Error(`Unknown command: ${command}.${hint} Run "agentops --help" to see the core commands.`); + }; +} + +module.exports = { + commandSuggestion, + createCliMain +}; diff --git a/agentops-cli/src/lib/cli-options.js b/agentops-cli/src/lib/cli-options.js new file mode 100644 index 0000000..56aaaa1 --- /dev/null +++ b/agentops-cli/src/lib/cli-options.js @@ -0,0 +1,43 @@ +const { optionValues, requiredOptionValue: optionValue } = require('./args'); + +function parseLastArg(args, fallback = '7d') { + if (!args.includes('--last')) return fallback; + try { + return optionValue(args, ['--last']); + } catch (error) { + throw new Error('--last requires a duration, for example 7d or 24h'); + } +} + +function durationToMs(value, fallbackMs) { + if (value === undefined || value === null || value === '') return fallbackMs; + if (typeof value === 'number') return value; + const match = String(value).match(/^([0-9]+)(ms|s|m|h)$/); + if (!match) throw new Error('duration must look like 500ms, 10s, 2m, or 1h'); + const amount = Number(match[1]); + const unit = match[2]; + if (unit === 'ms') return amount; + if (unit === 's') return amount * 1000; + if (unit === 'm') return amount * 60 * 1000; + return amount * 60 * 60 * 1000; +} + +function parseSkillsArgs(args) { + const subcommand = args[0] || 'install'; + const rest = args.slice(1); + return { + subcommand, + copilotHome: optionValue(rest, ['--copilot-home', '--home']), + force: rest.includes('--force'), + dryRun: rest.includes('--dry-run'), + json: rest.includes('--json') + }; +} + +module.exports = { + durationToMs, + optionValue, + optionValues, + parseLastArg, + parseSkillsArgs +}; diff --git a/agentops-cli/src/lib/cli-surface.js b/agentops-cli/src/lib/cli-surface.js new file mode 100644 index 0000000..735b3e7 --- /dev/null +++ b/agentops-cli/src/lib/cli-surface.js @@ -0,0 +1,161 @@ +const coreCommands = [ + 'setup', + 'install', + 'uninstall', + 'status', + 'doctor', + 'configure', + 'collector', + 'azure-ingest', + 'annotation', + 'annotate', + 'ask-context', + 'content', + 'copilot', + 'copilot-session', + 'dashboard', + 'demo', + 'delivery', + 'explain', + 'github-enrich', + 'health', + 'insights', + 'init', + 'latest', + 'mcp-proxy', + 'recommend', + 'replay', + 'open', + 'product', + 'validate-azure', + 'validate-enterprise', + 'plugin', + 'run-summary', + 'schema', + 'security', + 'smoke', + 'triage', + 'e2e' +]; + +const experimentalCommands = new Set([ + 'agents', + 'alert', + 'attribution', + 'attribution-smoke', + 'benchmark', + 'codex', + 'collector-health', + 'compat-check', + 'context', + 'custom', + 'enable-shadow', + 'fields', + 'import-jsonl', + 'incident', + 'lineage', + 'link', + 'live', + 'live-replay-smoke', + 'mcp', + 'otel-setup', + 'permission-friction', + 'policy', + 'primitives', + 'saved-view', + 'scan', + 'skills', + 'tail', + 'token-rollup-audit', + 'validate-collector', + 'workflows' +]); + +function usage(command) { + const full = `agentops <command> + +Next: + agentops init --full # advanced compatibility path; zero-write preview + +Local native path: + agentops setup # read-only discovery + eval "$(agentops init --local-only --yes --shell zsh)" + agentops smoke --local # local privacy/receipt check + +Then: + Everyday use: agentops copilot ... # observed; plain copilot stays unchanged by default + See results: agentops open latest + Troubleshoot: agentops status + +Help: + agentops help <command> # focused syntax for one command + +Core commands: + setup [--json] + install [--shadow-copilot] [--no-collector] [--plugin] + uninstall [--keep-plugin] [--keep-collector] [--keep-binary] [--purge] + status [--json] + doctor [--local-only] [--last <duration>] [--json] + configure show|set|import-azd [--agents-url <url>] [--json] + collector start|stop|status|validate|smoke|install-binary|uninstall-binary [--mode auto|local|docker|binary|azure-native|none] [--privacy strict|compat] [--json] + azure-ingest plan [--dir <AgentOps table dir>] [--allow-content] [--json] + azure-ingest upload-plan --dir <export dir> --account <storage> [--container <name>] [--prefix <path>] [--json] + annotation config-change --component <name> --target <name> [--change-type <type>] [--change-id <id>] [--version <value>] [--run-id <id>] [--session <id>] [--trace-id <id>] [--dry-run] [--json] + ask-context latest|<run-id> [--last <duration>] [--runs <jsonl>] [--events <jsonl>] [--tools <jsonl>] [--evals <jsonl>] [--insights <jsonl>] [--recommendations <jsonl>] [--json] + content status|opt-in [--dir <AgentOps table dir>] [--runs <jsonl>] [--allow-content] [--json] + copilot [copilot-args...] + copilot-session enrich <session-id> [--file <events.jsonl>] [--sidecar <sidecar-events.jsonl>] [--dry-run] [--json] + schema validate|print [--file <json>] + security audit|posture [--json] [--fail-on-warning] + dashboard validate|links-check|filters-check|ux-check|content-check|kql-check|verify|import [--last <duration>] [--live] [--yes] [--all] [--folder <name>] [--resource-group <rg>] [--grafana-name <name>] + demo generate|verify [--runs <n>] [--out <dir>] [--insights-out <dir>] [--write] [--with-content] [--json] + delivery status|drain [--dir <spool>] [--endpoint <logs-ingestion-endpoint>] [--dcr-immutable-id <id>] [--max-attempts <1-10>] [--yes] [--json] + github-enrich [--limit <n>] [--runs <AgentOpsRunSummary_CL.jsonl>] [--out <dir>] [--json] + health [--runs <AgentOpsRunSummary_CL.jsonl>] [--json] + explain latest|<run-id> [--runs <jsonl>] [--evals <jsonl>] [--insights <jsonl>] [--json] + insights [generate|patterns] [--runs <jsonl>] [--insights <jsonl>] [--tools <jsonl>] [--privacy <jsonl>] [--github <jsonl>] [--out <dir>] [--json] + init --local-only [--yes] [--shell bash|zsh|fish|powershell|json] [--force-skills] [--no-skills] [--json] + init [--dry-run] --full [--yes] [--provision-cloud] [--import-dashboards] [--run-smoke] [--triage-latest] [--force-skills] [--no-skills] [--json] + recommend latest|<run-id> [--runs <jsonl>] [--events <jsonl>] [--evals <jsonl>] [--insights <jsonl>] [--benchmark-run <id>] [--benchmark-report <json>] [--out <dir>] [--save] [--store <json>] [--json] + recommend list|export [--store <json>] [--out <dir>] + triage latest|<run-id> [--runs <jsonl>] [--events <jsonl>] [--tools <jsonl>] [--privacy <jsonl>] [--github <jsonl>] [--evals <jsonl>] [--insights <jsonl>] [--benchmark-run <id>] [--out <dir>] [--json] + alert handoff --rule <name> --session <conversation> [--owner <name>] [--events <jsonl>] [--output <json>] [--last <duration>] + mcp-proxy --server-name <name> [--out <jsonl>] -- <server command> [args...] + latest [--file <jsonl>] [--last <duration>] [--json] + replay <session|latest> [--file <jsonl>] [--last <duration>] + open [latest|<run-id>] [--runs <jsonl>] [--file <jsonl>] [--last <duration>] [--json] + product audit [--live] [--last <duration>] [--require-rows] [--require-visual] [--report <html>] [--json] + validate-azure [--last <duration>] [--profile personal|team|internal] [--import-dashboards] [--verify-dashboard-content] [--production] [--remediation-plan] [--json] + validate-enterprise [--json] + plugin install|uninstall [--copilot-home <path>] [--force] [--dry-run] [--json] + run-summary generate --file <jsonl> [--out <dir>] [--json] + smoke [--real-copilot] [--local] [--dry-run] [--wait <duration>] [--poll <duration>] [--json] + e2e run|report|browser-check|auth-profile [--azure-cli-grafana-auth] [--json] + +Experimental: + agentops experimental <old-command> [...] +`; + + if (!command) return full; + + const name = String(command).trim(); + const coreSection = full.split('\nExperimental:')[0]; + const commandLine = coreSection + .split('\n') + .map(line => line.trim()) + .find(line => line === name || line.startsWith(`${name} `) || line.startsWith(`${name}[`)); + + if (commandLine) { + return `agentops ${commandLine}\n\nRun "agentops --help" for the complete command list.\n`; + } + if (experimentalCommands.has(name)) { + return `agentops experimental ${name} [...]\n\nThis command is experimental. Run "agentops --help" for the core command list.\n`; + } + return `No help found for "${name}".\nRun "agentops --help" for the complete command list.\n`; +} + +module.exports = { + coreCommands, + experimentalCommands, + usage +}; diff --git a/agentops-cli/src/lib/cloud-validation.js b/agentops-cli/src/lib/cloud-validation.js new file mode 100644 index 0000000..4b5be46 --- /dev/null +++ b/agentops-cli/src/lib/cloud-validation.js @@ -0,0 +1,125 @@ +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +function createCloudValidation(dependencies = {}) { + const { + azureResourceGroup, + configuredCloudValuesFromConfig, + grafanaDatasourceUid, + logAnalyticsWorkspaceName, + readJson, + root, + runAzureLogAnalyticsQuery, + validateAzureBase + } = dependencies; + + function configuredCloudValues(options = {}) { + return configuredCloudValuesFromConfig({ + ...options, + defaults: { + azureResourceGroup, + logAnalyticsWorkspaceName, + grafanaDatasourceUid, + appInsightsName: 'appi-agentops-dev' + } + }); + } + + function listGrafanaDashboardFiles(options = {}) { + const dirs = [options.grafanaDir || path.join(root, 'grafana')]; + if (options.includeV2 !== false) dirs.push(path.join(root, 'grafana', 'dashboards', 'v2')); + return dirs.flatMap(dir => { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter(file => file.endsWith('.json')) + .map(file => path.join(dir, file)); + }) + .map(fullPath => { + const dashboard = readJson(fullPath); + return { + file: path.relative(root, fullPath), + uid: dashboard.uid || path.basename(file, '.json'), + title: dashboard.title || path.basename(file, '.json') + }; + }) + .sort((left, right) => left.uid.localeCompare(right.uid)); + } + + function flattenGrafanaList(payload) { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.value)) return payload.value; + if (Array.isArray(payload?.items)) return payload.items; + if (Array.isArray(payload?.dashboards)) return payload.dashboards; + if (Array.isArray(payload?.dataSources)) return payload.dataSources; + if (Array.isArray(payload?.datasources)) return payload.datasources; + return []; + } + + function grafanaItemUid(item) { + return item?.uid || item?.dashboard?.uid || item?.model?.uid || item?.slug || item?.name || item?.title || ''; + } + + function grafanaDashboardImportCommand(cloud) { + const args = [ + 'agentops dashboard import --yes', + cloud.resourceGroup ? `--resource-group ${cloud.resourceGroup}` : null, + cloud.grafanaName ? `--grafana-name ${cloud.grafanaName}` : null + ].filter(Boolean); + return args.join(' '); + } + + function runGrafanaDashboardImportRemediation(cloud, options = {}) { + const args = [ + path.join(root, 'agentops-cli', 'src', 'index.js'), + 'dashboard', + 'import', + '--yes', + ...(cloud.resourceGroup ? ['--resource-group', cloud.resourceGroup] : []), + ...(cloud.grafanaName ? ['--grafana-name', cloud.grafanaName] : []) + ]; + const spawnSync = options.spawnDashboardImport || options.spawnSync || childProcess.spawnSync; + const result = spawnSync(process.execPath, args, { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }); + return { + ok: result.status === 0, + command: grafanaDashboardImportCommand(cloud), + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + error: result.error?.message || null + }; + } + + function isConfiguredValue(value, placeholderPattern) { + return Boolean(value) && !placeholderPattern.test(value); + } + + function validateAzure(options = {}) { + return validateAzureBase(options, { + configuredCloudValues, + isConfiguredValue, + runAzureLogAnalyticsQuery, + listGrafanaDashboardFiles, + flattenGrafanaList, + grafanaItemUid, + grafanaDashboardImportCommand, + runGrafanaDashboardImportRemediation + }); + } + + return { + configuredCloudValues, + flattenGrafanaList, + grafanaDashboardImportCommand, + grafanaItemUid, + isConfiguredValue, + listGrafanaDashboardFiles, + runGrafanaDashboardImportRemediation, + validateAzure + }; +} + +module.exports = { createCloudValidation }; diff --git a/agentops-cli/src/lib/collector-artifacts.js b/agentops-cli/src/lib/collector-artifacts.js index 293f444..3d08daa 100644 --- a/agentops-cli/src/lib/collector-artifacts.js +++ b/agentops-cli/src/lib/collector-artifacts.js @@ -2,6 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { collectorDir, repoRoot } = require('./paths'); +const { readJson } = require('./json'); const { contentLikeKeys, sanitizeAttributesStrict } = require('./privacy'); const requiredProcessors = [ @@ -24,6 +25,7 @@ const requiredOwaspFixtures = [ ]; const requiredMcpAbuseRisks = ['network', 'shell', 'destructive', 'secret-access']; const strictConfigs = ['otelcol.azuremonitor.strict.yaml', 'otelcol.binary.strict.yaml', 'otelcol.local.strict.yaml']; +const nativeConfigs = ['otelcol.azuremonitor.native.strict.yaml']; function validateProcessorFragment({ file, body }) { if (file === 'strict-allowlist.yaml' && !body.includes('keep_keys')) return `${file}: missing keep_keys allowlist`; @@ -37,7 +39,7 @@ function validateProcessorFragment({ file, body }) { function validatePoisonFixture({ file, fullPath }) { let fixture; try { - fixture = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + fixture = readJson(fullPath); } catch (error) { return { file, @@ -75,7 +77,7 @@ function validateCollectorArtifacts(options = {}) { const root = options.root || repoRoot; const collectorRoot = path.join(root, 'collector'); const processorsDir = path.join(collectorRoot, 'processors'); - const fixturesDir = path.join(collectorRoot, 'tests', 'privacy-poison-fixtures'); + const fixturesDir = path.join(collectorRoot, 'security-fixtures', 'privacy-poison-fixtures'); const errors = []; const warnings = []; @@ -115,6 +117,19 @@ function validateCollectorArtifacts(options = {}) { else if (!fs.readFileSync(fullPath, 'utf8').includes('transform/privacy_strict')) warnings.push(`${file}: does not reference transform/privacy_strict`); } + for (const file of nativeConfigs) { + const fullPath = path.join(collectorRoot, file); + if (!fs.existsSync(fullPath)) { + errors.push(`missing native Azure Collector overlay: ${fullPath}`); + continue; + } + const body = fs.readFileSync(fullPath, 'utf8'); + if (!body.includes('azure_auth') || !body.includes('use_default: true')) errors.push(`${file}: missing default Entra azure_auth configuration`); + if (!body.includes('otlp_http/azuremonitor')) errors.push(`${file}: missing current otlp_http Azure exporter`); + if (!body.includes('AZURE_MONITOR_OTLP_TRACES_ENDPOINT') || !body.includes('AZURE_MONITOR_OTLP_LOGS_ENDPOINT') || !body.includes('AZURE_MONITOR_OTLP_METRICS_ENDPOINT')) errors.push(`${file}: missing signal-specific Azure OTLP endpoint environment variables`); + if (!body.includes('otelcol.local.strict.yaml')) warnings.push(`${file}: overlay must be run with otelcol.local.strict.yaml for the fail-closed privacy processor`); + } + return { ok: errors.length === 0, processors: requiredProcessors.map(file => path.join(processorsDir, file)), @@ -126,7 +141,7 @@ function validateCollectorArtifacts(options = {}) { function validateOwaspFixtures(options = {}) { const root = options.root || repoRoot; - const fixturesDir = path.join(root, 'collector', 'tests', 'owasp-abuse-fixtures'); + const fixturesDir = path.join(root, 'collector', 'security-fixtures', 'owasp-abuse-fixtures'); const errors = []; const results = []; @@ -141,7 +156,7 @@ function validateOwaspFixtures(options = {}) { if (result.error) errors.push(result.error); if (!result.ok && !result.error) errors.push(`${file}: strict sanitizer did not drop all abuse fixture content`); if (file === 'mcp-dangerous-tool-classes.json' && !result.error) { - const fixture = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + const fixture = readJson(fullPath); const risks = mcpAbuseRisksFromFixture(fixture); const missingRisks = requiredMcpAbuseRisks.filter(risk => !risks.has(risk)); if (missingRisks.length > 0) errors.push(`${file}: missing MCP abuse risk classes: ${missingRisks.join(', ')}`); @@ -166,6 +181,7 @@ module.exports = { requiredMcpAbuseRisks, requiredOwaspFixtures, requiredProcessors, + nativeConfigs, strictConfigs, validateCollectorArtifacts, validateOwaspFixtures, diff --git a/agentops-cli/src/lib/collector-benchmark.js b/agentops-cli/src/lib/collector-benchmark.js new file mode 100644 index 0000000..5d4321e --- /dev/null +++ b/agentops-cli/src/lib/collector-benchmark.js @@ -0,0 +1,247 @@ +'use strict'; + +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); + +const DEFAULT_EVENTS = 100; +const DEFAULT_WARMUP = 10; +const MAX_EVENTS = 500; + +function boundedCount(value, fallback) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, MAX_EVENTS); +} + +function percentile(values, fraction) { + if (!values.length) return 0; + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]; +} + +function summary(values) { + const total = values.reduce((sum, value) => sum + value, 0); + return { + samples: values.length, + p50_ms: Number(percentile(values, 0.50).toFixed(3)), + p95_ms: Number(percentile(values, 0.95).toFixed(3)), + elapsed_ms: Number(total.toFixed(3)), + throughput_events_per_second: total > 0 + ? Number(((values.length * 1000) / total).toFixed(2)) + : 0 + }; +} + +async function freePort() { + const server = http.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +async function waitFor(check, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) return true; + await new Promise(resolve => setTimeout(resolve, 50)); + } + return false; +} + +function strictProcessors(canonicalConfigPath) { + const canonical = fs.readFileSync(canonicalConfigPath, 'utf8').replace(/\r\n/g, '\n'); + const start = canonical.indexOf('processors:\n'); + const end = canonical.indexOf('\nexporters:', start); + if (start < 0 || end < 0) throw new Error('canonical strict collector processors were not found'); + return canonical.slice(start, end).replace( + ' batch: {}', + ' batch:\n timeout: 10ms\n send_batch_size: 1\n send_batch_max_size: 1' + ); +} + +function benchmarkConfig({ canonicalConfigPath, receiverPort, healthPort, sinkPort }) { + return `receivers: + otlp: + protocols: + http: + endpoint: 127.0.0.1:${receiverPort} +extensions: + health_check: + endpoint: 127.0.0.1:${healthPort} +${strictProcessors(canonicalConfigPath)} +exporters: + otlphttp/local: + endpoint: http://127.0.0.1:${sinkPort} + encoding: json + compression: none + retry_on_failure: + enabled: false +service: + extensions: [health_check] + telemetry: + metrics: + level: none + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, transform/privacy_strict, batch] + exporters: [otlphttp/local] +`; +} + +function tracePayload(index) { + const hex = index.toString(16).padStart(16, '0').slice(-16); + return JSON.stringify({ resourceSpans: [{ + resource: { attributes: [ + { key: 'service.name', value: { stringValue: 'agentops-collector-benchmark' } }, + { key: 'benchmark.disallowed', value: { stringValue: 'must-be-removed' } } + ] }, + scopeSpans: [{ scope: { name: 'agentops-collector-benchmark' }, spans: [{ + traceId: `0000000000000000${hex}`, + spanId: hex, + name: 'agentops.benchmark.event', + startTimeUnixNano: String(1767225600000000000n + BigInt(index) * 1000000n), + endTimeUnixNano: String(1767225600001000000n + BigInt(index) * 1000000n), + attributes: [ + { key: 'agentops.custom_event_id', value: { stringValue: `benchmark-event-${index}` } }, + { key: 'agentops.event.sequence', value: { intValue: String(index + 1) } }, + { key: 'agentops.privacy.mode', value: { stringValue: 'strict' } }, + { key: 'agentops.content_capture.mode', value: { stringValue: 'off' } }, + { key: 'gen_ai.usage.input_tokens', value: { intValue: '100' } }, + { key: 'benchmark.disallowed', value: { stringValue: 'must-be-removed' } } + ], + status: { code: 1 } + }] }] + }] }); +} + +function collectorBinaryPath(override) { + return override || process.env.AGENTOPS_OTELCOL_BIN + || path.join(os.homedir(), '.agentops', 'collector', 'bin', 'otelcol-contrib'); +} + +async function stopProcess(child) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await Promise.race([ + new Promise(resolve => child.once('exit', resolve)), + new Promise(resolve => setTimeout(resolve, 3000)) + ]); + if (child.exitCode === null) child.kill('SIGKILL'); +} + +async function runCollectorBenchmark(options = {}) { + const events = boundedCount(options.events, DEFAULT_EVENTS); + const warmup = boundedCount(options.warmup, DEFAULT_WARMUP); + const binary = collectorBinaryPath(options.collectorBinary); + if (!fs.existsSync(binary)) throw new Error(`otelcol-contrib not found at ${binary}`); + const canonicalConfigPath = options.canonicalConfigPath + || path.resolve(__dirname, '../../../collector/otelcol.local.strict.yaml'); + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-collector-benchmark-')); + const receiverPort = await freePort(); + const healthPort = await freePort(); + const sinkPort = await freePort(); + const configPath = path.join(temp, 'collector.yaml'); + fs.writeFileSync(configPath, benchmarkConfig({ canonicalConfigPath, receiverPort, healthPort, sinkPort })); + const sinkReceipts = []; + const sinkBodies = []; + let collector; + let sink; + let output = ''; + try { + sink = http.createServer((request, response) => { + const chunks = []; + request.on('data', chunk => chunks.push(chunk)); + request.on('end', () => { + sinkReceipts.push(process.hrtime.bigint()); + sinkBodies.push(Buffer.concat(chunks).toString('utf8')); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{}'); + }); + }); + await new Promise(resolve => sink.listen(sinkPort, '127.0.0.1', resolve)); + collector = childProcess.spawn(binary, ['--config', configPath], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + collector.stdout.on('data', chunk => { output += chunk; }); + collector.stderr.on('data', chunk => { output += chunk; }); + const healthy = await waitFor(async () => { + try { return (await fetch(`http://127.0.0.1:${healthPort}`)).ok; } catch { return false; } + }); + if (!healthy) throw new Error(`collector did not become healthy: ${output.slice(-2000)}`); + + async function send(index) { + const sinkIndex = sinkReceipts.length; + const started = process.hrtime.bigint(); + const response = await fetch(`http://127.0.0.1:${receiverPort}/v1/traces`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: tracePayload(index) + }); + if (!response.ok) throw new Error(`collector receiver returned HTTP ${response.status}`); + const acknowledged = process.hrtime.bigint(); + const delivered = await waitFor(() => sinkReceipts.length > sinkIndex, 5000); + if (!delivered) throw new Error('local sink did not acknowledge the benchmark event'); + return { + receiverMs: Number(acknowledged - started) / 1e6, + sinkMs: Number(sinkReceipts[sinkIndex] - started) / 1e6 + }; + } + + for (let index = 0; index < warmup; index += 1) await send(index); + sinkReceipts.length = 0; + sinkBodies.length = 0; + const receiverLatencies = []; + const sinkLatencies = []; + for (let index = 0; index < events; index += 1) { + const result = await send(index + warmup); + receiverLatencies.push(result.receiverMs); + sinkLatencies.push(result.sinkMs); + } + const wire = sinkBodies.join('\n'); + if (wire.includes('benchmark.disallowed') || wire.includes('must-be-removed')) { + throw new Error('strict privacy processor did not remove a disallowed synthetic metadata field'); + } + if (!wire.includes('agentops.custom_event_id')) { + throw new Error('local sink did not receive the allowlisted benchmark event identifier'); + } + return { + benchmark: 'agentops-otelcol-strict-local', + methodology: { + scope: 'OTLP/HTTP receiver through canonical strict privacy processors to local OTLP/HTTP sink acknowledgement', + transport: 'loopback only; no Azure or external network writes', + content_capture: 'off; synthetic metadata only', + thresholds: 'none; measurements are descriptive', + events, + warmup_events: warmup, + collector_binary: path.basename(binary) + }, + receiver_acknowledgement: summary(receiverLatencies), + local_sink_acknowledgement: summary(sinkLatencies), + privacy_check: { + sink_requests: sinkBodies.length, + allowlisted_event_id_present: true, + disallowed_metadata_removed: true + } + }; + } finally { + await stopProcess(collector); + if (sink) await new Promise(resolve => sink.close(resolve)); + fs.rmSync(temp, { recursive: true, force: true }); + } +} + +module.exports = { + MAX_EVENTS, + benchmarkConfig, + boundedCount, + collectorBinaryPath, + percentile, + runCollectorBenchmark, + summary, + tracePayload +}; diff --git a/agentops-cli/src/lib/collector-binary-install.js b/agentops-cli/src/lib/collector-binary-install.js new file mode 100644 index 0000000..81b85cc --- /dev/null +++ b/agentops-cli/src/lib/collector-binary-install.js @@ -0,0 +1,147 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { collectorConfigPath, collectorHome } = require('./paths'); +const { commandExists, isExecutable, run } = require('./shell'); +const { logFile, pidFile } = require('./collector-runtime'); +const { + collectorPackageInfo, + downloadFile, + installedCollectorBinaryPath, + verifyChecksum +} = require('./collector-binary-release'); + +function validateBinaryConfig(binaryPath, privacy = 'strict') { + const config = collectorConfigPath({ target: 'binary', privacy }); + if (!fs.existsSync(config)) { + return { ok: false, mode: 'binary', privacyMode: privacy, config, error: `Config not found: ${config}` }; + } + const result = run(binaryPath, ['validate', '--config', config], { timeout: 30000 }); + return { + ok: result.status === 0, + mode: 'binary', + privacyMode: privacy, + config, + command: `${binaryPath} validate --config ${config}`, + error: result.status === 0 ? null : (result.stderr || result.stdout || `collector validate exited ${result.status}`).trim() + }; +} + +async function installBinary(options = {}) { + const packageInfo = collectorPackageInfo({ version: options.version }); + const destination = installedCollectorBinaryPath(); + const binDir = path.dirname(destination); + const existing = isExecutable(destination); + + if (existing && !options.force) { + const validation = validateBinaryConfig(destination, options.privacy || 'strict'); + return { + ok: validation.ok !== false, + action: 'install-binary', + alreadyInstalled: true, + path: destination, + version: packageInfo.version, + url: packageInfo.url, + checksumUrl: packageInfo.checksumUrl, + validation + }; + } + + if (!commandExists('tar')) { + return { ok: false, action: 'install-binary', error: 'The tar command is required to extract the Collector release archive.' }; + } + + fs.mkdirSync(binDir, { recursive: true }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-otelcol-install-')); + const archive = path.join(tempDir, packageInfo.fileName); + const checksums = path.join(tempDir, packageInfo.checksumFileName); + try { + await downloadFile(packageInfo.url, archive); + await downloadFile(packageInfo.checksumUrl, checksums); + const checksum = verifyChecksum({ + archive, + checksumsText: fs.readFileSync(checksums, 'utf8'), + fileName: packageInfo.fileName + }); + if (!checksum.ok) { + return { + ok: false, + action: 'install-binary', + url: packageInfo.url, + checksumUrl: packageInfo.checksumUrl, + checksum, + error: checksum.error + }; + } + const extract = run('tar', ['-xzf', archive, '-C', tempDir], { timeout: 60000 }); + if (extract.status !== 0) { + return { + ok: false, + action: 'install-binary', + url: packageInfo.url, + error: (extract.stderr || extract.stdout || `tar exited ${extract.status}`).trim() + }; + } + + const extracted = path.join(tempDir, packageInfo.binaryName); + if (!fs.existsSync(extracted)) { + return { ok: false, action: 'install-binary', url: packageInfo.url, error: `Release archive did not contain ${packageInfo.binaryName}.` }; + } + fs.copyFileSync(extracted, destination); + if (process.platform !== 'win32') fs.chmodSync(destination, 0o755); + + const validation = validateBinaryConfig(destination, options.privacy || 'strict'); + return { + ok: validation.ok === true, + action: 'install-binary', + alreadyInstalled: false, + path: destination, + version: packageInfo.version, + url: packageInfo.url, + checksumUrl: packageInfo.checksumUrl, + checksum, + validation, + error: validation.ok === true ? null : validation.error + }; + } catch (error) { + return { ok: false, action: 'install-binary', url: packageInfo.url, error: error.message }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function uninstallBinary(options = {}, dependencies = {}) { + const stopCollector = dependencies.stopCollector || (() => ({ ok: true, mode: 'binary', stopped: false })); + const stopped = stopCollector({ mode: 'binary', privacy: options.privacy || 'strict' }); + const removed = []; + for (const name of ['otelcol-contrib', 'otelcol-contrib.exe']) { + const candidate = path.join(collectorHome, 'bin', name); + if (fs.existsSync(candidate)) { + fs.rmSync(candidate, { force: true }); + removed.push(candidate); + } + } + fs.rmSync(pidFile(), { force: true }); + if (options.purge) { + fs.rmSync(logFile(), { force: true }); + const binDir = path.join(collectorHome, 'bin'); + try { + if (fs.existsSync(binDir) && fs.readdirSync(binDir).length === 0) fs.rmdirSync(binDir); + } catch {} + } + return { + ok: true, + action: 'uninstall-binary', + stopped, + removed, + purged: Boolean(options.purge), + collectorHome + }; +} + +module.exports = { + installBinary, + uninstallBinary, + validateBinaryConfig +}; diff --git a/agentops-cli/src/lib/collector-binary-release.js b/agentops-cli/src/lib/collector-binary-release.js new file mode 100644 index 0000000..2ee1bcd --- /dev/null +++ b/agentops-cli/src/lib/collector-binary-release.js @@ -0,0 +1,105 @@ +const fs = require('node:fs'); +const https = require('node:https'); +const path = require('node:path'); + +const collectorRelease = require('./collector-release'); +const { hashText } = require('./hash'); +const { collectorHome } = require('./paths'); + +const defaultCollectorVersion = collectorRelease.defaultCollectorVersion(); + +function installedCollectorBinaryPath(platform = process.platform) { + return path.join(collectorHome, 'bin', platform === 'win32' ? 'otelcol-contrib.exe' : 'otelcol-contrib'); +} + +function collectorPackageInfo({ version = defaultCollectorVersion, platform = process.platform, arch = process.arch } = {}) { + const normalizedVersion = String(version || defaultCollectorVersion).replace(/^v/, ''); + if (!/^\d+\.\d+\.\d+$/.test(normalizedVersion)) { + throw new Error(`Collector version must look like 0.151.0, got: ${version}`); + } + + const osMap = { darwin: 'darwin', linux: 'linux', win32: 'windows' }; + const archMap = { x64: 'amd64', arm64: 'arm64' }; + const goos = osMap[platform]; + const goarch = archMap[arch]; + if (!goos || !goarch) { + throw new Error(`Unsupported Collector binary platform: ${platform}/${arch}`); + } + + const fileName = `otelcol-contrib_${normalizedVersion}_${goos}_${goarch}.tar.gz`; + const checksumFileName = goos === 'windows' + ? 'opentelemetry-collector-releases_otelcol-contrib_windows_checksums.txt' + : 'opentelemetry-collector-releases_otelcol-contrib_checksums.txt'; + const releaseBaseUrl = `https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${normalizedVersion}`; + return { + version: normalizedVersion, + goos, + goarch, + fileName, + checksumFileName, + binaryName: goos === 'windows' ? 'otelcol-contrib.exe' : 'otelcol-contrib', + url: `${releaseBaseUrl}/${fileName}`, + checksumUrl: `${releaseBaseUrl}/${checksumFileName}` + }; +} + +function downloadFile(url, destination, redirectCount = 0) { + return new Promise((resolve, reject) => { + if (redirectCount > 5) return reject(new Error(`Too many redirects while downloading ${url}`)); + const request = https.get(url, response => { + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + response.resume(); + const location = response.headers.location; + if (!location) return reject(new Error(`Redirect from ${url} did not include a Location header.`)); + return resolve(downloadFile(new URL(location, url).toString(), destination, redirectCount + 1)); + } + if (response.statusCode !== 200) { + response.resume(); + return reject(new Error(`Download failed (${response.statusCode}) from ${url}`)); + } + const file = fs.createWriteStream(destination, { mode: 0o600 }); + response.pipe(file); + file.on('finish', () => file.close(resolve)); + file.on('error', reject); + return null; + }); + request.on('error', reject); + return request; + }); +} + +function parseChecksumFile(text, fileName) { + const escaped = String(fileName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`^([a-fA-F0-9]{64})\\s+\\*?${escaped}$`, 'm'); + const match = String(text || '').match(pattern); + return match ? match[1].toLowerCase() : null; +} + +function sha256File(filePath) { + return hashText(fs.readFileSync(filePath)); +} + +function verifyChecksum({ archive, checksumsText, fileName }) { + const expected = parseChecksumFile(checksumsText, fileName); + if (!expected) { + return { ok: false, fileName, error: `No SHA256 checksum found for ${fileName}.` }; + } + const actual = sha256File(archive); + return { + ok: actual === expected, + fileName, + expected, + actual, + error: actual === expected ? null : `SHA256 mismatch for ${fileName}.` + }; +} + +module.exports = { + collectorPackageInfo, + defaultCollectorVersion, + downloadFile, + installedCollectorBinaryPath, + parseChecksumFile, + sha256File, + verifyChecksum +}; diff --git a/agentops-cli/src/lib/collector-binary-runtime.js b/agentops-cli/src/lib/collector-binary-runtime.js new file mode 100644 index 0000000..6a4d44d --- /dev/null +++ b/agentops-cli/src/lib/collector-binary-runtime.js @@ -0,0 +1,332 @@ +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { collectorConfigPath, collectorHome } = require('./paths'); +const { collectorConfigPathsFor, privacyModes } = require('./collector-options'); +const { resolveConnectionString } = require('./collector-connection'); +const { + findCollectorProcessByConfig, + findManagedCollectorProcess, + healthCheck, + logFile, + pidFile, + processAlive, + readPid, + waitForHealth +} = require('./collector-runtime'); + +function binaryConfigPath(privacy) { + return collectorConfigPath({ target: 'binary', privacy }); +} + +function nativeConfigPaths(privacy = 'strict') { + return collectorConfigPathsFor('azure-native', privacy); +} + +function localConfigPath(privacy) { + return collectorConfigPath({ target: 'local', privacy }); +} + +function findRunningBinaryCollector(binaryPath = null) { + for (const privacy of privacyModes) { + const config = binaryConfigPath(privacy); + const pid = binaryPath ? findManagedCollectorProcess(binaryPath, config) : findCollectorProcessByConfig(config); + if (pid) return { pid, privacy, config }; + } + return null; +} + +function findRunningLocalCollector(binaryPath = null) { + for (const privacy of privacyModes) { + const config = localConfigPath(privacy); + const pid = binaryPath ? findManagedCollectorProcess(binaryPath, config) : findCollectorProcessByConfig(config); + if (pid) return { pid, privacy, config }; + } + return null; +} + +function findRunningNativeCollector(binaryPath = null) { + const config = nativeConfigPaths('strict').at(-1); + const pid = binaryPath ? findManagedCollectorProcess(binaryPath, config) : findCollectorProcessByConfig(config); + return pid ? { pid, privacy: 'strict', config } : null; +} + +function missingBinaryResolver() { + return { ok: false, error: 'Collector binary resolver was not provided.' }; +} + +function ensurePrivateDir(directory) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + try { fs.chmodSync(directory, 0o700); } catch {} +} + +function ensurePrivateFile(filePath) { + const handle = fs.openSync(filePath, 'a', 0o600); + fs.closeSync(handle); + try { fs.chmodSync(filePath, 0o600); } catch {} +} + +function endpointIsAzureMonitorOtlp(value) { + try { + const parsed = new URL(String(value)); + const pathname = parsed.pathname.toLowerCase(); + return parsed.protocol === 'https:' + && parsed.hostname.endsWith('.ingest.monitor.azure.com') + && pathname.includes('/datacollectionrules/') + && pathname.includes('/streams/'); + } catch { + return false; + } +} + +function nativeEndpointEnvironment(env = process.env) { + const names = [ + 'AZURE_MONITOR_OTLP_TRACES_ENDPOINT', + 'AZURE_MONITOR_OTLP_LOGS_ENDPOINT', + 'AZURE_MONITOR_OTLP_METRICS_ENDPOINT' + ]; + const missing = names.filter(name => !env[name]); + if (missing.length > 0) return { ok: false, error: `Native Azure OTLP requires: ${missing.join(', ')}.` }; + const dcrResourceId = String(env.AGENTOPS_AZURE_OTLP_DCR_RESOURCE_ID || ''); + if (!/^\/subscriptions\/[^/]+\/resourceGroups\/[^/]+\/providers\/Microsoft\.Insights\/dataCollectionRules\/[^/]+$/i.test(dcrResourceId)) { + return { ok: false, error: 'Native Azure OTLP requires AGENTOPS_AZURE_OTLP_DCR_RESOURCE_ID from the selected OTLP Connection Info.' }; + } + const configuredSubscription = String(env.AGENTOPS_AZURE_SUBSCRIPTION_ID || env.AZURE_SUBSCRIPTION_ID || '').toLowerCase(); + if (configuredSubscription && !dcrResourceId.toLowerCase().startsWith(`/subscriptions/${configuredSubscription}/`)) { + return { ok: false, error: 'Native Azure OTLP DCR resource ID is outside the configured Azure subscription.' }; + } + if (env.AGENTOPS_APPROVE_NATIVE_OTLP !== 'yes') { + return { ok: false, error: 'Native Azure OTLP is explicitly gated. Set AGENTOPS_APPROVE_NATIVE_OTLP=yes after reviewing the target and endpoint evidence.' }; + } + const invalid = names.filter(name => !endpointIsAzureMonitorOtlp(env[name])); + if (invalid.length > 0) return { ok: false, error: `Native Azure OTLP endpoint guard rejected: ${invalid.join(', ')}.` }; + return { ok: true }; +} + +async function startBinaryCollector({ privacy = 'strict', findCollectorBinary } = {}) { + const binary = typeof findCollectorBinary === 'function' ? findCollectorBinary() : missingBinaryResolver(); + if (!binary.ok) return { ok: false, mode: 'binary', error: binary.error }; + const connection = resolveConnectionString(); + if (!connection.ok) return { ok: false, mode: 'binary', error: connection.error }; + const config = binaryConfigPath(privacy); + if (!fs.existsSync(config)) return { ok: false, mode: 'binary', error: `Collector config not found: ${config}` }; + ensurePrivateDir(collectorHome); + const storageDir = path.join(collectorHome, 'queue'); + ensurePrivateDir(storageDir); + ensurePrivateFile(logFile()); + const currentHealth = await healthCheck(); + const discoveredPid = findManagedCollectorProcess(binary.path, config); + const runningPid = discoveredPid; + if (currentHealth.ok) { + if (runningPid) fs.writeFileSync(pidFile(), `${runningPid}\n`); + if (runningPid) { + try { fs.chmodSync(pidFile(), 0o600); } catch {} + return { + ok: true, + mode: 'binary', + privacyMode: privacy, + alreadyRunning: true, + pid: runningPid, + pidFile: pidFile(), + logFile: logFile(), + config + }; + } + return { + ok: false, + mode: 'binary', + privacyMode: privacy, + error: 'The Collector health endpoint is already in use by an unmanaged runtime; stop it before starting binary mode.' + }; + } + + const out = fs.openSync(logFile(), 'a', 0o600); + const child = childProcess.spawn(binary.path, ['--config', config], { + detached: true, + stdio: ['ignore', out, out], + env: { + ...process.env, + APPLICATIONINSIGHTS_CONNECTION_STRING: connection.value, + AGENTOPS_OTEL_STORAGE_DIR: storageDir + } + }); + try { fs.closeSync(out); } catch {} + child.unref(); + fs.writeFileSync(pidFile(), `${child.pid}\n`, { mode: 0o600 }); + try { fs.chmodSync(pidFile(), 0o600); } catch {} + const health = await waitForHealth(); + return { + ok: health.ok, + mode: 'binary', + privacyMode: privacy, + pid: child.pid, + pidFile: pidFile(), + logFile: logFile(), + config, + health, + error: health.ok ? null : 'Collector process started, but health endpoint did not become ready.' + }; +} + +async function startNativeAzureCollector({ privacy = 'strict', findCollectorBinary, env = process.env } = {}) { + if (privacy !== 'strict') return { ok: false, mode: 'azure-native', error: 'azure-native mode only supports strict privacy mode.' }; + const endpointGate = nativeEndpointEnvironment(env); + if (!endpointGate.ok) return { ok: false, mode: 'azure-native', error: endpointGate.error }; + const binary = typeof findCollectorBinary === 'function' ? findCollectorBinary() : missingBinaryResolver(); + if (!binary.ok) return { ok: false, mode: 'azure-native', error: binary.error }; + const configs = nativeConfigPaths(privacy); + const missingConfig = configs.find(config => !fs.existsSync(config)); + if (missingConfig) return { ok: false, mode: 'azure-native', error: `Collector config not found: ${missingConfig}` }; + ensurePrivateDir(collectorHome); + const storageDir = path.join(collectorHome, 'native-azure-queue'); + const receiptPath = path.join(collectorHome, 'native-azure-receipt.jsonl'); + ensurePrivateDir(storageDir); + ensurePrivateFile(receiptPath); + ensurePrivateFile(logFile()); + const currentHealth = await healthCheck(); + const identityConfig = configs.at(-1); + const discoveredPid = findManagedCollectorProcess(binary.path, identityConfig); + if (currentHealth.ok) { + if (discoveredPid) { + fs.writeFileSync(pidFile(), `${discoveredPid}\n`, { mode: 0o600 }); + try { fs.chmodSync(pidFile(), 0o600); } catch {} + return { ok: true, mode: 'azure-native', privacyMode: privacy, alreadyRunning: true, pid: discoveredPid, pidFile: pidFile(), logFile: logFile(), receiptPath, config: identityConfig, configPaths: configs }; + } + return { ok: false, mode: 'azure-native', privacyMode: privacy, error: 'The Collector health endpoint is already in use by an unmanaged runtime; stop it before starting azure-native mode.' }; + } + + const out = fs.openSync(logFile(), 'a', 0o600); + const child = childProcess.spawn(binary.path, configs.flatMap(config => ['--config', config]), { + detached: true, + stdio: ['ignore', out, out], + env: { + ...env, + AGENTOPS_OTEL_STORAGE_DIR: storageDir, + AGENTOPS_OTEL_RECEIPT_PATH: receiptPath + } + }); + try { fs.closeSync(out); } catch {} + child.unref(); + fs.writeFileSync(pidFile(), `${child.pid}\n`, { mode: 0o600 }); + try { fs.chmodSync(pidFile(), 0o600); } catch {} + const health = await waitForHealth(); + return { ok: health.ok, mode: 'azure-native', privacyMode: privacy, pid: child.pid, pidFile: pidFile(), logFile: logFile(), receiptPath, config: identityConfig, configPaths: configs, health, error: health.ok ? null : 'Native Azure Collector process started, but health endpoint did not become ready.' }; +} + +async function startLocalCollector({ privacy = 'strict', findCollectorBinary } = {}) { + const binary = typeof findCollectorBinary === 'function' ? findCollectorBinary() : missingBinaryResolver(); + if (!binary.ok) return { ok: false, mode: 'local', error: binary.error }; + const config = localConfigPath(privacy); + if (!fs.existsSync(config)) return { ok: false, mode: 'local', error: `Collector config not found: ${config}` }; + ensurePrivateDir(collectorHome); + const storageDir = path.join(collectorHome, 'local-storage'); + const receiptPath = path.join(collectorHome, 'native-receipt.jsonl'); + ensurePrivateDir(storageDir); + ensurePrivateFile(receiptPath); + ensurePrivateFile(logFile()); + const currentHealth = await healthCheck(); + const pid = readPid(); + const discoveredPid = findManagedCollectorProcess(binary.path, config); + const runningPid = processAlive(pid) && discoveredPid === pid ? pid : discoveredPid; + if (currentHealth.ok) { + if (runningPid) { + fs.writeFileSync(pidFile(), `${runningPid}\n`, { mode: 0o600 }); + try { fs.chmodSync(pidFile(), 0o600); } catch {} + return { + ok: true, + mode: 'local', + privacyMode: privacy, + alreadyRunning: true, + pid: runningPid, + pidFile: pidFile(), + logFile: logFile(), + receiptPath, + config + }; + } + return { + ok: false, + mode: 'local', + privacyMode: privacy, + error: 'The loopback Collector health endpoint is already in use by another runtime; stop it before starting local strict mode.' + }; + } + + const out = fs.openSync(logFile(), 'a', 0o600); + const child = childProcess.spawn(binary.path, ['--config', config], { + detached: true, + stdio: ['ignore', out, out], + env: { + ...process.env, + AGENTOPS_OTEL_STORAGE_DIR: storageDir, + AGENTOPS_OTEL_RECEIPT_PATH: receiptPath + } + }); + try { fs.closeSync(out); } catch {} + child.unref(); + fs.writeFileSync(pidFile(), `${child.pid}\n`, { mode: 0o600 }); + try { fs.chmodSync(pidFile(), 0o600); } catch {} + const health = await waitForHealth(); + return { + ok: health.ok, + mode: 'local', + privacyMode: privacy, + pid: child.pid, + pidFile: pidFile(), + logFile: logFile(), + receiptPath, + config, + health, + error: health.ok ? null : 'Local Collector process started, but health endpoint did not become ready.' + }; +} + +function stopBinaryCollector({ privacy = 'strict', findCollectorBinary } = {}) { + const binary = typeof findCollectorBinary === 'function' ? findCollectorBinary() : missingBinaryResolver(); + const config = binaryConfigPath(privacy); + const discoveredPid = binary.ok ? findManagedCollectorProcess(binary.path, config) : null; + const targetPid = discoveredPid; + if (!processAlive(targetPid)) return { ok: true, mode: 'binary', stopped: false, detail: 'No AgentOps collector PID is running.' }; + process.kill(targetPid, 'SIGTERM'); + fs.rmSync(pidFile(), { force: true }); + return { ok: true, mode: 'binary', stopped: true, pid: targetPid }; +} + +function stopLocalCollector({ privacy = 'strict', findCollectorBinary } = {}) { + const binary = typeof findCollectorBinary === 'function' ? findCollectorBinary() : missingBinaryResolver(); + const config = localConfigPath(privacy); + const pid = readPid(); + const discoveredPid = binary.ok ? findManagedCollectorProcess(binary.path, config) : null; + const targetPid = processAlive(pid) && discoveredPid === pid ? pid : discoveredPid; + if (!processAlive(targetPid)) return { ok: true, mode: 'local', stopped: false, detail: 'No managed local collector PID is running.' }; + process.kill(targetPid, 'SIGTERM'); + fs.rmSync(pidFile(), { force: true }); + return { ok: true, mode: 'local', stopped: true, pid: targetPid }; +} + +function stopNativeAzureCollector({ findCollectorBinary } = {}) { + const binary = typeof findCollectorBinary === 'function' ? findCollectorBinary() : missingBinaryResolver(); + const config = nativeConfigPaths('strict').at(-1); + const discoveredPid = binary.ok ? findManagedCollectorProcess(binary.path, config) : null; + if (!processAlive(discoveredPid)) return { ok: true, mode: 'azure-native', stopped: false, detail: 'No managed native Azure collector PID is running.' }; + process.kill(discoveredPid, 'SIGTERM'); + fs.rmSync(pidFile(), { force: true }); + return { ok: true, mode: 'azure-native', stopped: true, pid: discoveredPid }; +} + +module.exports = { + binaryConfigPath, + findRunningBinaryCollector, + findRunningLocalCollector, + findRunningNativeCollector, + nativeConfigPaths, + nativeEndpointEnvironment, + localConfigPath, + startBinaryCollector, + startNativeAzureCollector, + startLocalCollector, + stopBinaryCollector, + stopNativeAzureCollector, + stopLocalCollector +}; diff --git a/agentops-cli/src/lib/collector-command.js b/agentops-cli/src/lib/collector-command.js new file mode 100644 index 0000000..5c603b3 --- /dev/null +++ b/agentops-cli/src/lib/collector-command.js @@ -0,0 +1,54 @@ +const collector = require('./collector-manager'); +const { writeJsonOrRender } = require('./command-output'); + +function renderCollector(result) { + const lines = ['AgentOps collector']; + lines.push(`Mode: ${result.effectiveMode || result.mode}`); + if (result.privacyMode) lines.push(`Privacy: ${result.privacyMode}`); + if (result.running !== undefined) lines.push(`Running: ${result.running ? 'yes' : 'no'}`); + if (result.endpoint) lines.push(`OTLP endpoint: ${result.endpoint}`); + if (result.healthUrl) lines.push(`Health: ${result.healthUrl}`); + if (result.error) lines.push(`Error: ${result.error}`); + if (result.warning) lines.push(`Warning: ${result.warning}`); + if (result.action === 'install-binary') { + lines.push(`Binary: ${result.path}`); + lines.push(`Version: ${result.version}`); + if (result.alreadyInstalled) lines.push('Already installed: yes'); + } + if (result.action === 'uninstall-binary') { + lines.push(`Removed binaries: ${result.removed?.length || 0}`); + if (result.collectorHome) lines.push(`Collector home: ${result.collectorHome}`); + } + if (result.details?.length) { + lines.push('', 'Details:'); + for (const detail of result.details) lines.push(`- ${detail}`); + } + if (result.poison) { + lines.push('', `Poison privacy check: ${result.poison.ok ? 'passed' : 'failed'}`); + if (result.poison.leaked?.length) lines.push(`Leaks: ${result.poison.leaked.join(', ')}`); + } + return `${lines.join('\n')}\n`; +} + +async function collectorCommand(args = []) { + const [action = 'status'] = args; + const options = collector.parseCollectorOptions(args); + let result; + + if (action === 'status') result = await collector.status(options); + else if (action === 'start') result = await collector.start(options); + else if (action === 'stop') result = collector.stop(options); + else if (action === 'validate') result = collector.validate(options); + else if (action === 'smoke') result = await collector.smoke(options); + else if (action === 'install-binary') result = await collector.installBinary(options); + else if (action === 'uninstall-binary') result = collector.uninstallBinary(options); + else throw new Error('collector requires start, stop, status, validate, smoke, install-binary, or uninstall-binary'); + + writeJsonOrRender(result, options.json, renderCollector); + process.exitCode = result.ok === false && action !== 'status' ? 1 : 0; +} + +module.exports = { + collectorCommand, + renderCollector +}; diff --git a/agentops-cli/src/lib/collector-config-validation.js b/agentops-cli/src/lib/collector-config-validation.js new file mode 100644 index 0000000..fae9534 --- /dev/null +++ b/agentops-cli/src/lib/collector-config-validation.js @@ -0,0 +1,98 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { collectorDir } = require('./paths'); +const { collectorConfigPathsFor, defaultConfigPathFor, normalizeMode, normalizePrivacy } = require('./collector-options'); +const { validateCollectorArtifacts } = require('./collector-artifacts'); +const collectorRelease = require('./collector-release'); +const { dockerDaemonAvailable } = require('./collector-docker'); +const { run } = require('./shell'); + +function validateCollectorConfig({ + options = {}, + findCollectorBinary, + resolveAutoMode, + configPathFor = defaultConfigPathFor, + configPathsFor = collectorConfigPathsFor, + env = process.env +} = {}) { + const mode = normalizeMode(options.mode || 'auto'); + const privacy = normalizePrivacy(options.privacy || 'strict'); + const artifactValidation = validateCollectorArtifacts(); + const resolved = mode === 'auto' ? resolveAutoMode(env) : { mode, reason: 'explicit mode' }; + if (!resolved.mode) return { ok: false, skipped: true, mode, privacyMode: privacy, artifact_validation: artifactValidation, error: resolved.reason }; + if (resolved.mode === 'none') return { ok: false, skipped: true, mode: 'none', artifact_validation: artifactValidation, error: 'No collector config is validated in none mode.' }; + + const configs = configPathsFor(resolved.mode, privacy); + const config = configPathFor(resolved.mode, privacy); + const missingConfig = configs.find(candidate => !fs.existsSync(candidate)); + if (missingConfig) return { ok: false, mode: resolved.mode, privacyMode: privacy, config, configs, artifact_validation: artifactValidation, error: `Config not found: ${missingConfig}` }; + + if (resolved.mode === 'binary' || resolved.mode === 'local' || resolved.mode === 'azure-native') { + const binary = findCollectorBinary(); + if (!binary.ok) return { ok: false, skipped: true, mode: resolved.mode, privacyMode: privacy, config, artifact_validation: artifactValidation, error: binary.error }; + const tempRoot = path.join(os.tmpdir(), 'agentops-otel-local-validation'); + const collectorEnv = { + AGENTOPS_OTEL_STORAGE_DIR: env.AGENTOPS_OTEL_STORAGE_DIR + || (resolved.mode === 'local' ? path.join(tempRoot, 'storage') : path.join(os.tmpdir(), 'agentops-otel-queue')) + }; + if (resolved.mode === 'local' || resolved.mode === 'azure-native') { + collectorEnv.AGENTOPS_OTEL_RECEIPT_PATH = env.AGENTOPS_OTEL_RECEIPT_PATH + || path.join(tempRoot, `${resolved.mode}-receipt.jsonl`); + } + const result = run(binary.path, ['validate', ...configs.flatMap(candidate => ['--config', candidate])], { + timeout: 30000, + env: collectorEnv + }); + return { + ok: result.status === 0 && artifactValidation.ok, + mode: resolved.mode, + privacyMode: privacy, + config, + configs, + artifact_validation: artifactValidation, + command: `${binary.path} validate ${configs.map(candidate => `--config ${candidate}`).join(' ')}`, + error: result.status === 0 && artifactValidation.ok ? null : (artifactValidation.errors[0] || result.stderr || result.stdout || `collector validate exited ${result.status}`).trim() + }; + } + + if (!dockerDaemonAvailable()) { + return { + ok: false, + skipped: true, + mode: 'docker', + privacyMode: privacy, + config, + artifact_validation: artifactValidation, + error: 'Docker daemon is not reachable; start Docker/OrbStack or use binary mode.' + }; + } + + const image = env.AGENTOPS_OTELCOL_IMAGE || collectorRelease.collectorImage(); + const result = run('docker', [ + 'run', + '--rm', + '-e', + 'AGENTOPS_OTEL_STORAGE_DIR=/tmp/agentops-otel-queue', + '-v', + `${collectorDir}:/etc/agentops:ro`, + image, + 'validate', + '--config', + `/etc/agentops/${path.basename(config)}` + ], { timeout: 60000 }); + return { + ok: result.status === 0 && artifactValidation.ok, + mode: 'docker', + privacyMode: privacy, + config, + image, + artifact_validation: artifactValidation, + error: result.status === 0 && artifactValidation.ok ? null : (artifactValidation.errors[0] || result.stderr || result.stdout || `docker validate exited ${result.status}`).trim() + }; +} + +module.exports = { + validateCollectorConfig +}; diff --git a/agentops-cli/src/lib/collector-connection.js b/agentops-cli/src/lib/collector-connection.js new file mode 100644 index 0000000..46f2208 --- /dev/null +++ b/agentops-cli/src/lib/collector-connection.js @@ -0,0 +1,39 @@ +const { readAgentOpsConfig } = require('./agentops-config'); +const { run: runCommand } = require('./shell'); + +function configValues(readConfig) { + const config = readConfig(); + return config?.values || config || {}; +} + +function resolveConnectionString(env = process.env, options = {}) { + if (env.APPLICATIONINSIGHTS_CONNECTION_STRING) { + return { ok: true, value: env.APPLICATIONINSIGHTS_CONNECTION_STRING, source: 'APPLICATIONINSIGHTS_CONNECTION_STRING' }; + } + + const readConfig = options.readConfig || (() => readAgentOpsConfig({ quiet: true }).values); + const config = configValues(readConfig); + const resourceGroup = env.AZURE_RESOURCE_GROUP || env.AGENTOPS_AZURE_RESOURCE_GROUP || config.resourceGroup || 'rg-agentops-dev'; + const app = env.APPLICATIONINSIGHTS_NAME || env.AGENTOPS_APPLICATIONINSIGHTS_NAME || config.appInsightsName || 'appi-agentops-dev'; + const args = ['monitor', 'app-insights', 'component', 'show', '--resource-group', resourceGroup, '--app', app, '--query', 'connectionString', '-o', 'tsv']; + if (env.AZURE_SUBSCRIPTION_ID || env.AGENTOPS_AZURE_SUBSCRIPTION_ID || config.subscriptionId) { + args.push('--subscription', env.AZURE_SUBSCRIPTION_ID || env.AGENTOPS_AZURE_SUBSCRIPTION_ID || config.subscriptionId); + } + + const run = options.run || runCommand; + const result = run('az', args, { timeout: 15000 }); + if (result.status !== 0) { + return { + ok: false, + error: (result.stderr || result.stdout || 'az monitor app-insights component show failed').trim() + }; + } + const value = String(result.stdout || '').trim(); + return value + ? { ok: true, value, source: 'az monitor app-insights component show' } + : { ok: false, error: 'Application Insights connection string lookup returned an empty value.' }; +} + +module.exports = { + resolveConnectionString +}; diff --git a/agentops-cli/src/lib/collector-discovery.js b/agentops-cli/src/lib/collector-discovery.js new file mode 100644 index 0000000..f80b60d --- /dev/null +++ b/agentops-cli/src/lib/collector-discovery.js @@ -0,0 +1,69 @@ +const path = require('node:path'); + +const { collectorHome } = require('./paths'); +const { commandCandidates, isExecutable } = require('./shell'); +const { + dockerCliAvailable, + dockerComposeAvailable, + dockerDaemonAvailable +} = require('./collector-docker'); + +function findCollectorBinary(env = process.env) { + const configured = env.AGENTOPS_OTELCOL_BIN; + if (configured) { + const resolved = path.resolve(configured); + return { + path: resolved, + source: 'AGENTOPS_OTELCOL_BIN', + ok: isExecutable(resolved), + error: isExecutable(resolved) ? null : `AGENTOPS_OTELCOL_BIN is not executable: ${resolved}` + }; + } + + const installedNames = process.platform === 'win32' + ? ['otelcol-contrib.exe', 'otelcol-contrib', 'otelcol.exe', 'otelcol'] + : ['otelcol-contrib', 'otelcol']; + for (const name of installedNames) { + const candidate = path.join(collectorHome, 'bin', name); + if (isExecutable(candidate)) { + return { path: candidate, source: 'AGENTOPS_COLLECTOR_HOME', ok: true, error: null }; + } + } + + for (const name of ['otelcol-contrib', 'otelcol']) { + const candidate = commandCandidates(name)[0]; + if (candidate && isExecutable(candidate)) { + return { path: candidate, source: 'PATH', ok: true, error: null }; + } + } + + return { + path: null, + source: null, + ok: false, + error: 'No otelcol-contrib or otelcol binary found on PATH. Set AGENTOPS_OTELCOL_BIN or install a Collector binary.' + }; +} + +function resolveAutoMode(env = process.env) { + const binary = findCollectorBinary(env); + if (binary.ok) return { mode: 'binary', reason: `using ${binary.path}` }; + if (dockerCliAvailable() && dockerComposeAvailable() && dockerDaemonAvailable()) { + return { mode: 'docker', reason: 'using Docker Compose fallback' }; + } + return { + mode: null, + reason: [ + binary.error, + dockerCliAvailable() && dockerComposeAvailable() + ? 'Docker daemon is not reachable.' + : 'Docker Compose is not available.', + 'Run `agentops collector install-binary`, or start/install Docker, then rerun the command.' + ].join(' ') + }; +} + +module.exports = { + findCollectorBinary, + resolveAutoMode +}; diff --git a/agentops-cli/src/lib/collector-docker-runtime.js b/agentops-cli/src/lib/collector-docker-runtime.js new file mode 100644 index 0000000..9f22065 --- /dev/null +++ b/agentops-cli/src/lib/collector-docker-runtime.js @@ -0,0 +1,47 @@ +const { + composeFile, + dockerComposeArgs, + dockerDaemonAvailable, + dockerProjectName +} = require('./collector-docker'); +const { resolveConnectionString } = require('./collector-connection'); +const { run } = require('./shell'); + +function dockerError(result) { + return (result.stderr || result.stdout || `docker compose exited ${result.status}`).trim(); +} + +function startDockerCollector({ privacy = 'strict' } = {}) { + const connection = resolveConnectionString(); + if (!connection.ok) return { ok: false, mode: 'docker', error: connection.error }; + if (!dockerDaemonAvailable()) return { ok: false, mode: 'docker', error: 'Docker daemon is not reachable.' }; + const result = run('docker', dockerComposeArgs(['up', '-d', '--force-recreate']), { + env: { + APPLICATIONINSIGHTS_CONNECTION_STRING: connection.value, + AGENTOPS_PRIVACY_MODE: privacy + }, + timeout: 60000 + }); + return { + ok: result.status === 0, + mode: 'docker', + privacyMode: privacy, + composeFile, + projectName: dockerProjectName, + error: result.status === 0 ? null : dockerError(result) + }; +} + +function stopDockerCollector({ privacy = 'strict' } = {}) { + const result = run('docker', dockerComposeArgs(['down']), { timeout: 60000 }); + return { + ok: result.status === 0, + mode: 'docker', + error: result.status === 0 ? null : dockerError(result) + }; +} + +module.exports = { + startDockerCollector, + stopDockerCollector +}; diff --git a/agentops-cli/src/lib/collector-docker.js b/agentops-cli/src/lib/collector-docker.js new file mode 100644 index 0000000..f1ffc6c --- /dev/null +++ b/agentops-cli/src/lib/collector-docker.js @@ -0,0 +1,57 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { collectorDir } = require('./paths'); +const { commandExists, run } = require('./shell'); + +const dockerProjectName = 'agentops-azuremonitor'; +const composeFile = path.join(collectorDir, 'docker-compose.azuremonitor.yaml'); + +function dockerComposeArgs(extra = []) { + return [ + 'compose', + '--project-name', + dockerProjectName, + '--project-directory', + collectorDir, + '-f', + composeFile, + ...extra + ]; +} + +function composeHasLocalhostBindings(filePath = composeFile) { + if (!fs.existsSync(filePath)) return false; + const text = fs.readFileSync(filePath, 'utf8'); + return [ + '127.0.0.1:4318:4318', + '127.0.0.1:4317:4317', + '127.0.0.1:13133:13133' + ].every(binding => text.includes(binding)) && !/["']?0\.0\.0\.0:43(17|18):/.test(text); +} + +function dockerCliAvailable() { + return commandExists('docker'); +} + +function dockerDaemonAvailable() { + if (!dockerCliAvailable()) return false; + const result = run('docker', ['info', '--format', '{{.ServerVersion}}'], { timeout: 5000 }); + return result.status === 0; +} + +function dockerComposeAvailable() { + if (!dockerCliAvailable()) return false; + const result = run('docker', ['compose', 'version'], { timeout: 5000 }); + return result.status === 0; +} + +module.exports = { + composeFile, + composeHasLocalhostBindings, + dockerCliAvailable, + dockerComposeArgs, + dockerComposeAvailable, + dockerDaemonAvailable, + dockerProjectName +}; diff --git a/agentops-cli/src/lib/collector-endpoints.js b/agentops-cli/src/lib/collector-endpoints.js new file mode 100644 index 0000000..05245c0 --- /dev/null +++ b/agentops-cli/src/lib/collector-endpoints.js @@ -0,0 +1,9 @@ +const otlpHttpEndpoint = 'http://127.0.0.1:4318'; +const collectorHealthUrl = 'http://127.0.0.1:13133'; +const collectorHealthUrlWithSlash = `${collectorHealthUrl}/`; + +module.exports = { + collectorHealthUrl, + collectorHealthUrlWithSlash, + otlpHttpEndpoint +}; diff --git a/agentops-cli/src/lib/collector-manager.js b/agentops-cli/src/lib/collector-manager.js index 661f491..54deb9d 100644 --- a/agentops-cli/src/lib/collector-manager.js +++ b/agentops-cli/src/lib/collector-manager.js @@ -1,923 +1,67 @@ -const childProcess = require('node:child_process'); -const crypto = require('node:crypto'); -const fs = require('node:fs'); -const https = require('node:https'); -const http = require('node:http'); -const net = require('node:net'); -const os = require('node:os'); -const path = require('node:path'); - -const { optionValue, hasFlag } = require('./args'); -const legacy = require('../legacy'); const { - collectorConfigPath, - collectorDir, - collectorHome -} = require('./paths'); -const { commandCandidates, commandExists, isExecutable, run } = require('./shell'); -const { makePoisonAttributes, poisonCheck } = require('./privacy'); + defaultConfigPathFor, + defaultCollectorVersion, + normalizeMode, + normalizePrivacy, + parseCollectorOptions +} = require('./collector-options'); const { validateCollectorArtifacts, validateOwaspFixtures } = require('./collector-artifacts'); -const collectorRelease = require('./collector-release'); - -const collectorModes = ['auto', 'docker', 'binary', 'none']; -const privacyModes = ['strict', 'compat']; -const dockerProjectName = 'agentops-azuremonitor'; -const defaultCollectorVersion = collectorRelease.defaultCollectorVersion(); -const healthUrl = 'http://127.0.0.1:13133'; -const otlpHttpEndpoint = 'http://127.0.0.1:4318'; -const composeFile = path.join(collectorDir, 'docker-compose.azuremonitor.yaml'); - -function parseCollectorOptions(args = [], env = process.env) { - const mode = optionValue(args, ['--mode'], env.AGENTOPS_COLLECTOR_MODE || 'auto'); - const privacy = optionValue(args, ['--privacy'], env.AGENTOPS_PRIVACY_MODE || 'strict'); - return { - mode: normalizeMode(mode), - privacy: normalizePrivacy(privacy), - json: hasFlag(args, '--json'), - poison: hasFlag(args, '--poison'), - force: hasFlag(args, '--force'), - purge: hasFlag(args, '--purge'), - version: optionValue(args, ['--version'], env.AGENTOPS_OTELCOL_VERSION || defaultCollectorVersion), - unsafeNoCollector: hasFlag(args, '--unsafe-no-collector') || env.AGENTOPS_ALLOW_NO_COLLECTOR === '1' - }; -} - -function normalizeMode(mode) { - const value = String(mode || 'auto').toLowerCase(); - if (!collectorModes.includes(value)) throw new Error(`Unsupported collector mode: ${mode}`); - return value; -} - -function normalizePrivacy(privacy) { - const value = String(privacy || 'strict').toLowerCase(); - if (!privacyModes.includes(value)) throw new Error(`Unsupported privacy mode: ${privacy}`); - return value; -} - -function configTargetForMode(mode) { - return mode === 'binary' ? 'binary' : 'azuremonitor'; -} - -function configPathFor(mode, privacy) { - return collectorConfigPath({ target: configTargetForMode(mode), privacy }); -} - -function dockerComposeArgs(extra = [], privacy = 'strict') { - return [ - 'compose', - '--project-name', - dockerProjectName, - '--project-directory', - collectorDir, - '-f', - composeFile, - ...extra - ]; -} - -function composeHasLocalhostBindings(filePath = composeFile) { - if (!fs.existsSync(filePath)) return false; - const text = fs.readFileSync(filePath, 'utf8'); - return [ - '127.0.0.1:4318:4318', - '127.0.0.1:4317:4317', - '127.0.0.1:13133:13133' - ].every(binding => text.includes(binding)) && !/["']?0\.0\.0\.0:43(17|18):/.test(text); -} - -function dockerCliAvailable() { - return commandExists('docker'); -} - -function dockerDaemonAvailable() { - if (!dockerCliAvailable()) return false; - const result = run('docker', ['info', '--format', '{{.ServerVersion}}'], { timeout: 5000 }); - return result.status === 0; -} - -function dockerComposeAvailable() { - if (!dockerCliAvailable()) return false; - const result = run('docker', ['compose', 'version'], { timeout: 5000 }); - return result.status === 0; -} - -function findCollectorBinary(env = process.env) { - const configured = env.AGENTOPS_OTELCOL_BIN; - if (configured) { - const resolved = path.resolve(configured); - return { - path: resolved, - source: 'AGENTOPS_OTELCOL_BIN', - ok: isExecutable(resolved), - error: isExecutable(resolved) ? null : `AGENTOPS_OTELCOL_BIN is not executable: ${resolved}` - }; - } - - const installedNames = process.platform === 'win32' - ? ['otelcol-contrib.exe', 'otelcol-contrib', 'otelcol.exe', 'otelcol'] - : ['otelcol-contrib', 'otelcol']; - for (const name of installedNames) { - const candidate = path.join(collectorHome, 'bin', name); - if (isExecutable(candidate)) { - return { path: candidate, source: 'AGENTOPS_COLLECTOR_HOME', ok: true, error: null }; - } - } - - for (const name of ['otelcol-contrib', 'otelcol']) { - const candidate = commandCandidates(name)[0]; - if (candidate && isExecutable(candidate)) { - return { path: candidate, source: 'PATH', ok: true, error: null }; - } - } - - return { - path: null, - source: null, - ok: false, - error: 'No otelcol-contrib or otelcol binary found on PATH. Set AGENTOPS_OTELCOL_BIN or install a Collector binary.' - }; -} - -function resolveAutoMode(env = process.env) { - const binary = findCollectorBinary(env); - if (binary.ok) return { mode: 'binary', reason: `using ${binary.path}` }; - if (dockerCliAvailable() && dockerComposeAvailable() && dockerDaemonAvailable()) { - return { mode: 'docker', reason: 'using Docker Compose fallback' }; - } - return { - mode: null, - reason: [ - binary.error, - dockerCliAvailable() && dockerComposeAvailable() - ? 'Docker daemon is not reachable.' - : 'Docker Compose is not available.', - 'Run `agentops collector install-binary`, or start/install Docker, then rerun the command.' - ].join(' ') - }; -} - -function pidFile() { - return path.join(collectorHome, 'otelcol.pid'); -} - -function logFile() { - return path.join(collectorHome, 'otelcol.log'); -} - -function installedCollectorBinaryPath(platform = process.platform) { - return path.join(collectorHome, 'bin', platform === 'win32' ? 'otelcol-contrib.exe' : 'otelcol-contrib'); -} - -function collectorPackageInfo({ version = defaultCollectorVersion, platform = process.platform, arch = process.arch } = {}) { - const normalizedVersion = String(version || defaultCollectorVersion).replace(/^v/, ''); - if (!/^\d+\.\d+\.\d+$/.test(normalizedVersion)) { - throw new Error(`Collector version must look like 0.151.0, got: ${version}`); - } - - const osMap = { darwin: 'darwin', linux: 'linux', win32: 'windows' }; - const archMap = { x64: 'amd64', arm64: 'arm64' }; - const goos = osMap[platform]; - const goarch = archMap[arch]; - if (!goos || !goarch) { - throw new Error(`Unsupported Collector binary platform: ${platform}/${arch}`); - } - - const fileName = `otelcol-contrib_${normalizedVersion}_${goos}_${goarch}.tar.gz`; - const checksumFileName = goos === 'windows' - ? 'opentelemetry-collector-releases_otelcol-contrib_windows_checksums.txt' - : 'opentelemetry-collector-releases_otelcol-contrib_checksums.txt'; - const releaseBaseUrl = `https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${normalizedVersion}`; - return { - version: normalizedVersion, - goos, - goarch, - fileName, - checksumFileName, - binaryName: goos === 'windows' ? 'otelcol-contrib.exe' : 'otelcol-contrib', - url: `${releaseBaseUrl}/${fileName}`, - checksumUrl: `${releaseBaseUrl}/${checksumFileName}` - }; -} - -function downloadFile(url, destination, redirectCount = 0) { - return new Promise((resolve, reject) => { - if (redirectCount > 5) return reject(new Error(`Too many redirects while downloading ${url}`)); - const request = https.get(url, response => { - if ([301, 302, 303, 307, 308].includes(response.statusCode)) { - response.resume(); - const location = response.headers.location; - if (!location) return reject(new Error(`Redirect from ${url} did not include a Location header.`)); - return resolve(downloadFile(new URL(location, url).toString(), destination, redirectCount + 1)); - } - if (response.statusCode !== 200) { - response.resume(); - return reject(new Error(`Download failed (${response.statusCode}) from ${url}`)); - } - const file = fs.createWriteStream(destination, { mode: 0o600 }); - response.pipe(file); - file.on('finish', () => file.close(resolve)); - file.on('error', reject); - return null; - }); - request.on('error', reject); - return request; - }); -} - -function validateBinaryConfig(binaryPath, privacy = 'strict') { - const config = configPathFor('binary', privacy); - if (!fs.existsSync(config)) { - return { ok: false, mode: 'binary', privacyMode: privacy, config, error: `Config not found: ${config}` }; - } - const result = run(binaryPath, ['validate', '--config', config], { timeout: 30000 }); - return { - ok: result.status === 0, - mode: 'binary', - privacyMode: privacy, - config, - command: `${binaryPath} validate --config ${config}`, - error: result.status === 0 ? null : (result.stderr || result.stdout || `collector validate exited ${result.status}`).trim() - }; -} +const { + composeFile, + composeHasLocalhostBindings, + dockerComposeArgs, + dockerProjectName +} = require('./collector-docker'); +const { findCollectorBinary, resolveAutoMode } = require('./collector-discovery'); +const { resolveConnectionString } = require('./collector-connection'); +const { + installBinary: installCollectorBinary, + uninstallBinary: uninstallCollectorBinary, + validateBinaryConfig +} = require('./collector-binary-install'); +const { + collectorPackageInfo, + installedCollectorBinaryPath, + parseChecksumFile, + sha256File, + verifyChecksum +} = require('./collector-binary-release'); +const { collectorStatus } = require('./collector-status'); +const { validateCollectorConfig } = require('./collector-config-validation'); +const { startCollector } = require('./collector-start'); +const { stopCollector } = require('./collector-stop'); +const { smokeCollector } = require('./collector-smoke'); +const { healthCheck } = require('./collector-runtime'); -function parseChecksumFile(text, fileName) { - const escaped = String(fileName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`^([a-fA-F0-9]{64})\\s+\\*?${escaped}$`, 'm'); - const match = String(text || '').match(pattern); - return match ? match[1].toLowerCase() : null; -} +const configPathFor = defaultConfigPathFor; -function sha256File(filePath) { - const hash = crypto.createHash('sha256'); - hash.update(fs.readFileSync(filePath)); - return hash.digest('hex'); -} - -function verifyChecksum({ archive, checksumsText, fileName }) { - const expected = parseChecksumFile(checksumsText, fileName); - if (!expected) { - return { ok: false, fileName, error: `No SHA256 checksum found for ${fileName}.` }; - } - const actual = sha256File(archive); - return { - ok: actual === expected, - fileName, - expected, - actual, - error: actual === expected ? null : `SHA256 mismatch for ${fileName}.` - }; -} async function installBinary(options = {}) { - const packageInfo = collectorPackageInfo({ version: options.version }); - const destination = installedCollectorBinaryPath(); - const binDir = path.dirname(destination); - const existing = isExecutable(destination); - - if (existing && !options.force) { - const validation = validateBinaryConfig(destination, options.privacy || 'strict'); - return { - ok: validation.ok !== false, - action: 'install-binary', - alreadyInstalled: true, - path: destination, - version: packageInfo.version, - url: packageInfo.url, - checksumUrl: packageInfo.checksumUrl, - validation - }; - } - - if (!commandExists('tar')) { - return { ok: false, action: 'install-binary', error: 'The tar command is required to extract the Collector release archive.' }; - } - - fs.mkdirSync(binDir, { recursive: true }); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-otelcol-install-')); - const archive = path.join(tempDir, packageInfo.fileName); - const checksums = path.join(tempDir, packageInfo.checksumFileName); - try { - await downloadFile(packageInfo.url, archive); - await downloadFile(packageInfo.checksumUrl, checksums); - const checksum = verifyChecksum({ - archive, - checksumsText: fs.readFileSync(checksums, 'utf8'), - fileName: packageInfo.fileName - }); - if (!checksum.ok) { - return { - ok: false, - action: 'install-binary', - url: packageInfo.url, - checksumUrl: packageInfo.checksumUrl, - checksum, - error: checksum.error - }; - } - const extract = run('tar', ['-xzf', archive, '-C', tempDir], { timeout: 60000 }); - if (extract.status !== 0) { - return { - ok: false, - action: 'install-binary', - url: packageInfo.url, - error: (extract.stderr || extract.stdout || `tar exited ${extract.status}`).trim() - }; - } - - const extracted = path.join(tempDir, packageInfo.binaryName); - if (!fs.existsSync(extracted)) { - return { ok: false, action: 'install-binary', url: packageInfo.url, error: `Release archive did not contain ${packageInfo.binaryName}.` }; - } - fs.copyFileSync(extracted, destination); - if (process.platform !== 'win32') fs.chmodSync(destination, 0o755); - - const validation = validateBinaryConfig(destination, options.privacy || 'strict'); - return { - ok: validation.ok === true, - action: 'install-binary', - alreadyInstalled: false, - path: destination, - version: packageInfo.version, - url: packageInfo.url, - checksumUrl: packageInfo.checksumUrl, - checksum, - validation, - error: validation.ok === true ? null : validation.error - }; - } catch (error) { - return { ok: false, action: 'install-binary', url: packageInfo.url, error: error.message }; - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + return installCollectorBinary(options); } function uninstallBinary(options = {}) { - const stopped = stop({ mode: 'binary', privacy: options.privacy || 'strict' }); - const removed = []; - for (const name of ['otelcol-contrib', 'otelcol-contrib.exe']) { - const candidate = path.join(collectorHome, 'bin', name); - if (fs.existsSync(candidate)) { - fs.rmSync(candidate, { force: true }); - removed.push(candidate); - } - } - fs.rmSync(pidFile(), { force: true }); - if (options.purge) { - fs.rmSync(logFile(), { force: true }); - const binDir = path.join(collectorHome, 'bin'); - try { - if (fs.existsSync(binDir) && fs.readdirSync(binDir).length === 0) fs.rmdirSync(binDir); - } catch {} - } - return { - ok: true, - action: 'uninstall-binary', - stopped, - removed, - purged: Boolean(options.purge), - collectorHome - }; -} - -function readPid() { - try { - return Number(fs.readFileSync(pidFile(), 'utf8').trim()); - } catch { - return null; - } -} - -function processAlive(pid) { - if (!pid || !Number.isInteger(pid)) return false; - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function findCollectorProcessByConfig(configPath, binaryPath = null) { - if (process.platform === 'win32' || !configPath) return null; - const result = run('ps', ['-axo', 'pid=,command='], { timeout: 5000 }); - if (result.status !== 0) return null; - const lines = String(result.stdout || '').split('\n'); - for (const line of lines) { - const match = line.trim().match(/^(\d+)\s+(.+)$/); - if (!match) continue; - const pid = Number(match[1]); - const command = match[2]; - if ( - pid !== process.pid - && (!binaryPath || command.includes(binaryPath)) - && command.includes('--config') - && command.includes(configPath) - && processAlive(pid) - ) { - return pid; - } - } - return null; -} - -function findManagedCollectorProcess(binaryPath, configPath) { - return findCollectorProcessByConfig(configPath, binaryPath); -} - -function findRunningBinaryCollector(binaryPath = null) { - for (const privacy of privacyModes) { - const config = configPathFor('binary', privacy); - const pid = binaryPath ? findManagedCollectorProcess(binaryPath, config) : findCollectorProcessByConfig(config); - if (pid) return { pid, privacy, config }; - } - return null; -} - -function healthCheck(url = healthUrl, timeoutMs = 1000) { - return new Promise(resolve => { - const request = http.get(url, { timeout: timeoutMs }, response => { - response.resume(); - resolve({ ok: response.statusCode >= 200 && response.statusCode < 500, statusCode: response.statusCode }); - }); - request.on('timeout', () => { - request.destroy(new Error('timeout')); - }); - request.on('error', error => resolve({ ok: false, error: error.message })); - }); -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function waitForHealth(timeoutMs = 5000) { - const deadline = Date.now() + timeoutMs; - let last = await healthCheck(); - while (!last.ok && Date.now() < deadline) { - await sleep(250); - last = await healthCheck(); - } - return last; -} - -function freePort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const { port } = server.address(); - server.close(() => resolve(port)); - }); - }); -} - -function otlpValue(value) { - if (typeof value === 'boolean') return { boolValue: value }; - if (typeof value === 'number' && Number.isInteger(value)) return { intValue: String(value) }; - if (typeof value === 'number') return { doubleValue: value }; - return { stringValue: String(value) }; -} - -function otlpAttributes(attributes) { - return Object.entries(attributes).map(([key, value]) => ({ key, value: otlpValue(value) })); -} - -function postJson(url, payload) { - return new Promise(resolve => { - const body = JSON.stringify(payload); - const request = http.request(url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(body) - }, - timeout: 5000 - }, response => { - response.resume(); - resolve({ ok: response.statusCode >= 200 && response.statusCode < 300, statusCode: response.statusCode }); - }); - request.on('timeout', () => request.destroy(new Error('timeout'))); - request.on('error', error => resolve({ ok: false, error: error.message })); - request.end(body); - }); -} - -async function runtimePoisonSmoke({ privacy }) { - if (privacy !== 'strict') return { status: 'skipped', reason: 'Runtime poison smoke only applies to strict privacy mode.' }; - const binary = findCollectorBinary(); - if (!binary.ok) return { status: 'skipped', reason: binary.error }; - - const httpPort = await freePort(); - const grpcPort = await freePort(); - const healthPort = await freePort(); - const telemetryPort = await freePort(); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-collector-smoke-')); - const sourceConfig = collectorConfigPath({ target: 'local', privacy: 'strict' }); - const config = path.join(tempDir, 'otelcol.local.strict.yaml'); - const log = path.join(tempDir, 'otelcol.log'); - const configText = fs.readFileSync(sourceConfig, 'utf8') - .replace(/127\.0\.0\.1:4318/g, `127.0.0.1:${httpPort}`) - .replace(/127\.0\.0\.1:4317/g, `127.0.0.1:${grpcPort}`) - .replace(/127\.0\.0\.1:13133/g, `127.0.0.1:${healthPort}`) - .replace( - /service:\n/, - `service:\n telemetry:\n metrics:\n readers:\n - pull:\n exporter:\n prometheus:\n host: 127.0.0.1\n port: ${telemetryPort}\n` - ); - fs.writeFileSync(config, configText); - - const validateResult = run(binary.path, ['validate', '--config', config], { timeout: 30000 }); - if (validateResult.status !== 0) { - fs.rmSync(tempDir, { recursive: true, force: true }); - return { - status: 'failed', - ok: false, - error: (validateResult.stderr || validateResult.stdout || `collector validate exited ${validateResult.status}`).trim() - }; - } - - const out = fs.openSync(log, 'a'); - const child = childProcess.spawn(binary.path, ['--config', config], { - detached: true, - stdio: ['ignore', out, out] - }); - - const cleanup = () => { - try { - process.kill(child.pid, 'SIGTERM'); - } catch {} - fs.closeSync(out); - fs.rmSync(tempDir, { recursive: true, force: true }); - }; - - try { - const health = await waitForHealthUrl(`http://127.0.0.1:${healthPort}`, 5000); - if (!health.ok) return { status: 'failed', ok: false, error: 'Temporary collector health endpoint did not become ready.', health }; - - const poison = makePoisonAttributes(); - const now = BigInt(Date.now()) * 1000000n; - const payload = { - resourceSpans: [{ - resource: { - attributes: otlpAttributes({ - 'service.name': 'agentops-poison-smoke', - 'service.namespace': 'copilot-agentops', - 'agent.framework': 'github-copilot', - 'agent.runtime': 'github-copilot-cli', - 'agentops.poison_id': poison['agentops.poison_id'] - }) - }, - scopeSpans: [{ - spans: [{ - traceId: crypto.randomBytes(16).toString('hex'), - spanId: crypto.randomBytes(8).toString('hex'), - name: 'agentops strict poison smoke', - kind: 2, - startTimeUnixNano: String(now), - endTimeUnixNano: String(now + 1000000n), - attributes: otlpAttributes(poison) - }] - }] - }] - }; - const post = await postJson(`http://127.0.0.1:${httpPort}/v1/traces`, payload); - if (!post.ok) return { status: 'failed', ok: false, error: 'Poison OTLP POST failed.', post }; - - const deadline = Date.now() + 8000; - let output = ''; - while (Date.now() < deadline) { - await sleep(250); - output = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : ''; - if (output.includes(poison['agentops.poison_id'])) break; - } - const leaked = output.match(/SECRET_[A-Z_]+/g) || []; - return { - status: leaked.length === 0 && output.includes(poison['agentops.poison_id']) ? 'passed' : 'failed', - ok: leaked.length === 0 && output.includes(poison['agentops.poison_id']), - poison_id: poison['agentops.poison_id'], - leaked, - safe_fields_present: { - poison_id: output.includes(poison['agentops.poison_id']), - operation: output.includes('gen_ai.operation.name'), - model: output.includes('poison-model'), - scrub_signal: output.includes('agentops.content_capture.signal') - }, - log_bytes: Buffer.byteLength(output) - }; - } finally { - cleanup(); - } -} - -function waitForHealthUrl(url, timeoutMs = 5000) { - const deadline = Date.now() + timeoutMs; - return new Promise(resolve => { - const check = async () => { - const health = await healthCheck(url, 1000); - if (health.ok || Date.now() >= deadline) return resolve(health); - setTimeout(check, 250); - }; - check(); - }); + return uninstallCollectorBinary(options, { stopCollector: stop }); } async function status(options = {}) { - const requestedMode = normalizeMode(options.mode || process.env.AGENTOPS_COLLECTOR_MODE || 'auto'); - const privacy = normalizePrivacy(options.privacy || process.env.AGENTOPS_PRIVACY_MODE || 'strict'); - const auto = requestedMode === 'auto' ? resolveAutoMode() : { mode: requestedMode, reason: 'explicit mode' }; - const health = await healthCheck(); - const binary = findCollectorBinary(); - const pid = readPid(); - const runningBinary = requestedMode === 'auto' && health.ok ? findRunningBinaryCollector(binary.path) : null; - const effectiveMode = auto.mode || (runningBinary ? 'binary' : requestedMode); - const effectivePrivacy = runningBinary?.privacy || privacy; - const config = runningBinary?.config || configPathFor(effectiveMode === 'binary' ? 'binary' : 'docker', effectivePrivacy); - const discoveredPid = effectiveMode === 'binary' - ? (runningBinary?.pid || (binary.path ? findManagedCollectorProcess(binary.path, config) : findCollectorProcessByConfig(config))) - : null; - const effectivePid = processAlive(pid) ? pid : discoveredPid; - const dockerAvailable = dockerCliAvailable(); - const daemonAvailable = dockerDaemonAvailable(); - const details = []; - - if (requestedMode === 'auto') details.push(`auto: ${auto.reason}`); - if (!binary.ok) details.push(binary.error); - if (!dockerAvailable) details.push('Docker CLI not found.'); - else if (!daemonAvailable) details.push('Docker daemon is not reachable.'); - if (!composeHasLocalhostBindings()) details.push('Docker Compose host bindings are not localhost-only.'); - if (pid && !processAlive(pid) && discoveredPid) details.push(`Binary PID file was stale; found running collector PID ${discoveredPid}.`); - if (pid && !processAlive(pid) && health.ok && !discoveredPid) details.push('Binary PID file is stale, but the collector health endpoint is responding.'); - - return { - mode: requestedMode, - effectiveMode, - running: health.ok, - endpoint: otlpHttpEndpoint, - healthUrl, - safeLocalhostBinding: composeHasLocalhostBindings(), - privacyMode: effectivePrivacy, - config: fs.existsSync(config) - ? config - : null, - docker: { - cli: dockerAvailable, - compose: dockerComposeAvailable(), - daemon: daemonAvailable, - composeFile, - projectName: dockerProjectName - }, - binary: { - ...binary, - pid: effectivePid || pid, - pidFile: pidFile(), - logFile: logFile(), - running: Boolean(effectivePid), - discoveredPid - }, - health, - details - }; -} - -function resolveConnectionString(env = process.env) { - if (env.APPLICATIONINSIGHTS_CONNECTION_STRING) { - return { ok: true, value: env.APPLICATIONINSIGHTS_CONNECTION_STRING, source: 'APPLICATIONINSIGHTS_CONNECTION_STRING' }; - } - - const config = legacy.readAgentOpsConfig?.().values || {}; - const resourceGroup = env.AZURE_RESOURCE_GROUP || env.AGENTOPS_AZURE_RESOURCE_GROUP || config.resourceGroup || 'rg-agentops-dev'; - const app = env.APPLICATIONINSIGHTS_NAME || env.AGENTOPS_APPLICATIONINSIGHTS_NAME || config.appInsightsName || 'appi-agentops-dev'; - const args = ['monitor', 'app-insights', 'component', 'show', '--resource-group', resourceGroup, '--app', app, '--query', 'connectionString', '-o', 'tsv']; - if (env.AZURE_SUBSCRIPTION_ID || env.AGENTOPS_AZURE_SUBSCRIPTION_ID || config.subscriptionId) { - args.push('--subscription', env.AZURE_SUBSCRIPTION_ID || env.AGENTOPS_AZURE_SUBSCRIPTION_ID || config.subscriptionId); - } - - const result = run('az', args, { timeout: 15000 }); - if (result.status !== 0) { - return { - ok: false, - error: (result.stderr || result.stdout || 'az monitor app-insights component show failed').trim() - }; - } - const value = String(result.stdout || '').trim(); - return value - ? { ok: true, value, source: 'az monitor app-insights component show' } - : { ok: false, error: 'Application Insights connection string lookup returned an empty value.' }; + return collectorStatus({ options, findCollectorBinary, resolveAutoMode, configPathFor }); } async function start(options = {}) { - const mode = normalizeMode(options.mode || 'auto'); - const privacy = normalizePrivacy(options.privacy || 'strict'); - const resolved = mode === 'auto' ? resolveAutoMode() : { mode, reason: 'explicit mode' }; - - if (mode === 'none' || resolved.mode === 'none') { - if (!options.unsafeNoCollector) { - return { - ok: false, - mode: 'none', - unsafe: true, - error: 'Collector mode none requires AGENTOPS_ALLOW_NO_COLLECTOR=1 or --unsafe-no-collector.' - }; - } - return { - ok: true, - mode: 'none', - unsafe: true, - warning: 'No local collector is running; privacy scrubbing is not guaranteed.' - }; - } - - if (!resolved.mode) { - const currentHealth = await healthCheck(); - if (currentHealth.ok) { - return { - ok: true, - mode, - privacyMode: privacy, - alreadyRunning: true, - health: currentHealth, - warning: 'Collector health endpoint is already responding; no new collector runtime was started.' - }; - } - return { ok: false, mode, error: resolved.reason }; - } - - if (resolved.mode === 'docker') return startDocker({ privacy }); - if (resolved.mode === 'binary') return startBinary({ privacy }); - return { ok: false, mode, error: `Unsupported resolved collector mode: ${resolved.mode}` }; -} - -function startDocker({ privacy }) { - const connection = resolveConnectionString(); - if (!connection.ok) return { ok: false, mode: 'docker', error: connection.error }; - if (!dockerDaemonAvailable()) return { ok: false, mode: 'docker', error: 'Docker daemon is not reachable.' }; - const result = run('docker', dockerComposeArgs(['up', '-d', '--force-recreate'], privacy), { - env: { - APPLICATIONINSIGHTS_CONNECTION_STRING: connection.value, - AGENTOPS_PRIVACY_MODE: privacy - }, - timeout: 60000 - }); - return { - ok: result.status === 0, - mode: 'docker', - privacyMode: privacy, - composeFile, - projectName: dockerProjectName, - error: result.status === 0 ? null : (result.stderr || result.stdout || `docker compose exited ${result.status}`).trim() - }; -} - -async function startBinary({ privacy }) { - const binary = findCollectorBinary(); - if (!binary.ok) return { ok: false, mode: 'binary', error: binary.error }; - const connection = resolveConnectionString(); - if (!connection.ok) return { ok: false, mode: 'binary', error: connection.error }; - const config = configPathFor('binary', privacy); - if (!fs.existsSync(config)) return { ok: false, mode: 'binary', error: `Collector config not found: ${config}` }; - fs.mkdirSync(collectorHome, { recursive: true }); - const currentHealth = await healthCheck(); - const pid = readPid(); - const discoveredPid = findManagedCollectorProcess(binary.path, config); - const runningPid = processAlive(pid) ? pid : discoveredPid; - if (currentHealth.ok) { - if (runningPid) fs.writeFileSync(pidFile(), `${runningPid}\n`); - return { - ok: true, - mode: 'binary', - privacyMode: privacy, - alreadyRunning: true, - pid: runningPid || null, - pidFile: pidFile(), - logFile: logFile(), - config - }; - } - const out = fs.openSync(logFile(), 'a'); - const child = childProcess.spawn(binary.path, ['--config', config], { - detached: true, - stdio: ['ignore', out, out], - env: { ...process.env, APPLICATIONINSIGHTS_CONNECTION_STRING: connection.value } - }); - child.unref(); - fs.writeFileSync(pidFile(), `${child.pid}\n`); - const health = await waitForHealth(); - return { - ok: health.ok, - mode: 'binary', - privacyMode: privacy, - pid: child.pid, - pidFile: pidFile(), - logFile: logFile(), - config, - health, - error: health.ok ? null : 'Collector process started, but health endpoint did not become ready.' - }; + return startCollector({ options, resolveAutoMode, findCollectorBinary }); } function stop(options = {}) { - const mode = normalizeMode(options.mode || process.env.AGENTOPS_COLLECTOR_MODE || 'auto'); - const resolved = mode === 'auto' ? resolveAutoMode() : { mode }; - const target = resolved.mode || mode; - if (target === 'docker') { - const result = run('docker', dockerComposeArgs(['down'], options.privacy || 'strict'), { timeout: 60000 }); - return { - ok: result.status === 0, - mode: 'docker', - error: result.status === 0 ? null : (result.stderr || result.stdout || `docker compose exited ${result.status}`).trim() - }; - } - if (target === 'binary') { - const privacy = normalizePrivacy(options.privacy || process.env.AGENTOPS_PRIVACY_MODE || 'strict'); - const binary = findCollectorBinary(); - const config = configPathFor('binary', privacy); - const pid = readPid(); - const discoveredPid = binary.ok ? findManagedCollectorProcess(binary.path, config) : null; - const targetPid = processAlive(pid) ? pid : discoveredPid; - if (!processAlive(targetPid)) return { ok: true, mode: 'binary', stopped: false, detail: 'No AgentOps collector PID is running.' }; - process.kill(targetPid, 'SIGTERM'); - fs.rmSync(pidFile(), { force: true }); - return { ok: true, mode: 'binary', stopped: true, pid: targetPid }; - } - return { ok: true, mode: target, stopped: false, detail: 'No managed collector runtime selected.' }; + return stopCollector({ options, resolveAutoMode, findCollectorBinary }); } function validate(options = {}) { - const mode = normalizeMode(options.mode || 'auto'); - const privacy = normalizePrivacy(options.privacy || 'strict'); - const artifactValidation = validateCollectorArtifacts(); - const resolved = mode === 'auto' ? resolveAutoMode() : { mode, reason: 'explicit mode' }; - if (!resolved.mode) return { ok: false, skipped: true, mode, privacyMode: privacy, artifact_validation: artifactValidation, error: resolved.reason }; - if (resolved.mode === 'none') return { ok: false, skipped: true, mode: 'none', artifact_validation: artifactValidation, error: 'No collector config is validated in none mode.' }; - - const config = configPathFor(resolved.mode, privacy); - if (!fs.existsSync(config)) return { ok: false, mode: resolved.mode, privacyMode: privacy, config, artifact_validation: artifactValidation, error: `Config not found: ${config}` }; - - if (resolved.mode === 'binary') { - const binary = findCollectorBinary(); - if (!binary.ok) return { ok: false, skipped: true, mode: 'binary', privacyMode: privacy, config, artifact_validation: artifactValidation, error: binary.error }; - const result = run(binary.path, ['validate', '--config', config], { timeout: 30000 }); - return { - ok: result.status === 0 && artifactValidation.ok, - mode: 'binary', - privacyMode: privacy, - config, - artifact_validation: artifactValidation, - command: `${binary.path} validate --config ${config}`, - error: result.status === 0 && artifactValidation.ok ? null : (artifactValidation.errors[0] || result.stderr || result.stdout || `collector validate exited ${result.status}`).trim() - }; - } - - if (!dockerDaemonAvailable()) { - return { - ok: false, - skipped: true, - mode: 'docker', - privacyMode: privacy, - config, - artifact_validation: artifactValidation, - error: 'Docker daemon is not reachable; start Docker/OrbStack or use binary mode.' - }; - } - const image = process.env.AGENTOPS_OTELCOL_IMAGE || collectorRelease.collectorImage(); - const result = run('docker', [ - 'run', - '--rm', - '-v', - `${collectorDir}:/etc/agentops:ro`, - image, - 'validate', - '--config', - `/etc/agentops/${path.basename(config)}` - ], { timeout: 60000 }); - return { - ok: result.status === 0 && artifactValidation.ok, - mode: 'docker', - privacyMode: privacy, - config, - image, - artifact_validation: artifactValidation, - error: result.status === 0 && artifactValidation.ok ? null : (artifactValidation.errors[0] || result.stderr || result.stdout || `docker validate exited ${result.status}`).trim() - }; + return validateCollectorConfig({ options, findCollectorBinary, resolveAutoMode, configPathFor }); } async function smoke(options = {}) { - const privacy = normalizePrivacy(options.privacy || 'strict'); - const localPoison = options.poison === false ? null : poisonCheck(); - const currentStatus = await status({ mode: options.mode || 'auto', privacy }); - const runtime = options.poison === false ? null : await runtimePoisonSmoke({ privacy }); - return { - ok: privacy === 'strict' ? Boolean(localPoison?.ok && runtime?.ok !== false) : true, - privacyMode: privacy, - poison: localPoison, - runtime_validation: currentStatus.running - ? { status: 'collector-running', health: currentStatus.health, debug_exporter: runtime } - : { - status: 'skipped', - reason: 'No local collector runtime is reachable in this environment. Offline strict sanitizer poison check was run.', - debug_exporter: runtime - } - }; + return smokeCollector({ options, status, findCollectorBinary }); } module.exports = { diff --git a/agentops-cli/src/lib/collector-options.js b/agentops-cli/src/lib/collector-options.js new file mode 100644 index 0000000..a947921 --- /dev/null +++ b/agentops-cli/src/lib/collector-options.js @@ -0,0 +1,67 @@ +const { optionValue, hasFlag } = require('./args'); +const collectorRelease = require('./collector-release'); +const { collectorConfigPath } = require('./paths'); + +const collectorModes = ['auto', 'local', 'docker', 'binary', 'azure-native', 'none']; +const privacyModes = ['strict', 'compat']; +const defaultCollectorVersion = collectorRelease.defaultCollectorVersion(); + +function parseCollectorOptions(args = [], env = process.env) { + const mode = optionValue(args, ['--mode'], env.AGENTOPS_COLLECTOR_MODE || 'auto'); + const privacy = optionValue(args, ['--privacy'], env.AGENTOPS_PRIVACY_MODE || 'strict'); + return { + mode: normalizeMode(mode), + privacy: normalizePrivacy(privacy), + json: hasFlag(args, '--json'), + poison: hasFlag(args, '--poison'), + force: hasFlag(args, '--force'), + purge: hasFlag(args, '--purge'), + version: optionValue(args, ['--version'], env.AGENTOPS_OTELCOL_VERSION || defaultCollectorVersion), + unsafeNoCollector: hasFlag(args, '--unsafe-no-collector') || env.AGENTOPS_ALLOW_NO_COLLECTOR === '1' + }; +} + +function normalizeMode(mode) { + const value = String(mode || 'auto').toLowerCase(); + if (!collectorModes.includes(value)) throw new Error(`Unsupported collector mode: ${mode}`); + return value; +} + +function normalizePrivacy(privacy) { + const value = String(privacy || 'strict').toLowerCase(); + if (!privacyModes.includes(value)) throw new Error(`Unsupported privacy mode: ${privacy}`); + return value; +} + +function defaultConfigPathFor(mode, privacy) { + if (mode === 'azure-native') { + if (normalizePrivacy(privacy) !== 'strict') throw new Error('azure-native mode only supports strict privacy mode.'); + return collectorConfigPath({ target: 'azuremonitor.native', privacy: 'strict' }); + } + return collectorConfigPath({ + target: mode === 'local' ? 'local' : mode === 'binary' ? 'binary' : 'azuremonitor', + privacy + }); +} + +function collectorConfigPathsFor(mode, privacy) { + if (mode !== 'azure-native') return [defaultConfigPathFor(mode, privacy)]; + if (normalizePrivacy(privacy) !== 'strict') { + throw new Error('azure-native mode only supports strict privacy mode.'); + } + return [ + collectorConfigPath({ target: 'local', privacy: 'strict' }), + collectorConfigPath({ target: 'azuremonitor.native', privacy: 'strict' }) + ]; +} + +module.exports = { + collectorModes, + collectorConfigPathsFor, + defaultConfigPathFor, + defaultCollectorVersion, + normalizeMode, + normalizePrivacy, + parseCollectorOptions, + privacyModes +}; diff --git a/agentops-cli/src/lib/collector-release.js b/agentops-cli/src/lib/collector-release.js index 59ab9ac..b303ae4 100644 --- a/agentops-cli/src/lib/collector-release.js +++ b/agentops-cli/src/lib/collector-release.js @@ -1,12 +1,13 @@ const fs = require('node:fs'); const path = require('node:path'); +const { readJson } = require('./json'); const { repoRoot } = require('./paths'); const releaseCadencePath = path.join(repoRoot, 'collector', 'release-cadence.json'); function readCollectorRelease(filePath = releaseCadencePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); + return readJson(filePath); } function defaultCollectorVersion() { diff --git a/agentops-cli/src/lib/collector-runtime.js b/agentops-cli/src/lib/collector-runtime.js new file mode 100644 index 0000000..c0c4fff --- /dev/null +++ b/agentops-cli/src/lib/collector-runtime.js @@ -0,0 +1,297 @@ +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); + +const { collectorConfigPath, collectorHome } = require('./paths'); +const { collectorHealthUrl } = require('./collector-endpoints'); +const { makePoisonAttributes } = require('./privacy'); +const { run } = require('./shell'); +const { sleep } = require('./timing'); + +function pidFile() { + return path.join(collectorHome, 'otelcol.pid'); +} + +function logFile() { + return path.join(collectorHome, 'otelcol.log'); +} + +function readPid() { + try { + return Number(fs.readFileSync(pidFile(), 'utf8').trim()); + } catch { + return null; + } +} + +function processAlive(pid) { + if (!pid || !Number.isInteger(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function findCollectorProcessByConfig(configPath, binaryPath = null) { + if (process.platform === 'win32' || !configPath) return null; + const result = run('ps', ['-axo', 'pid=,command='], { timeout: 5000 }); + if (result.status !== 0) return null; + const lines = String(result.stdout || '').split('\n'); + for (const line of lines) { + const match = line.trim().match(/^(\d+)\s+(.+)$/); + if (!match) continue; + const pid = Number(match[1]); + const command = match[2]; + if ( + pid !== process.pid + && (!binaryPath || command.includes(binaryPath)) + && command.includes('--config') + && command.includes(configPath) + && processAlive(pid) + ) { + return pid; + } + } + return null; +} + +function findManagedCollectorProcess(binaryPath, configPath) { + return findCollectorProcessByConfig(configPath, binaryPath); +} + +function healthCheck(url = collectorHealthUrl, timeoutMs = 1000) { + return new Promise(resolve => { + const request = http.get(url, { timeout: timeoutMs }, response => { + response.resume(); + resolve({ ok: response.statusCode >= 200 && response.statusCode < 500, statusCode: response.statusCode }); + }); + request.on('timeout', () => { + request.destroy(new Error('timeout')); + }); + request.on('error', error => resolve({ ok: false, error: error.message })); + }); +} + +async function waitForHealth(timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + let last = await healthCheck(); + while (!last.ok && Date.now() < deadline) { + await sleep(250); + last = await healthCheck(); + } + return last; +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +function otlpValue(value) { + if (typeof value === 'boolean') return { boolValue: value }; + if (typeof value === 'number' && Number.isInteger(value)) return { intValue: String(value) }; + if (typeof value === 'number') return { doubleValue: value }; + return { stringValue: String(value) }; +} + +function otlpAttributes(attributes) { + return Object.entries(attributes).map(([key, value]) => ({ key, value: otlpValue(value) })); +} + +function postJson(url, payload) { + return new Promise(resolve => { + const body = JSON.stringify(payload); + const request = http.request(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body) + }, + timeout: 5000 + }, response => { + response.resume(); + resolve({ ok: response.statusCode >= 200 && response.statusCode < 300, statusCode: response.statusCode }); + }); + request.on('timeout', () => request.destroy(new Error('timeout'))); + request.on('error', error => resolve({ ok: false, error: error.message })); + request.end(body); + }); +} + +async function runtimePoisonSmoke({ privacy, findCollectorBinary } = {}) { + if (privacy !== 'strict') return { status: 'skipped', reason: 'Runtime poison smoke only applies to strict privacy mode.' }; + const binary = typeof findCollectorBinary === 'function' + ? findCollectorBinary() + : { ok: false, error: 'Collector binary resolver was not provided.' }; + if (!binary.ok) return { status: 'skipped', reason: binary.error }; + + const httpPort = await freePort(); + const grpcPort = await freePort(); + const healthPort = await freePort(); + const telemetryPort = await freePort(); + const receiptPort = await freePort(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-collector-smoke-')); + const sourceConfig = collectorConfigPath({ target: 'local', privacy: 'strict' }); + const config = path.join(tempDir, 'otelcol.local.strict.yaml'); + const log = path.join(tempDir, 'otelcol.log'); + const configText = fs.readFileSync(sourceConfig, 'utf8').replace(/\r\n/g, '\n') + .replace(/127\.0\.0\.1:4318/g, `127.0.0.1:${httpPort}`) + .replace(/127\.0\.0\.1:4317/g, `127.0.0.1:${grpcPort}`) + .replace(/127\.0\.0\.1:4319/g, `127.0.0.1:${receiptPort}`) + .replace(/127\.0\.0\.1:13133/g, `127.0.0.1:${healthPort}`) + .replace( + /service:\n/, + `service:\n telemetry:\n metrics:\n readers:\n - pull:\n exporter:\n prometheus:\n host: 127.0.0.1\n port: ${telemetryPort}\n` + ); + fs.writeFileSync(config, configText); + + const storageDir = path.join(tempDir, 'storage'); + const receiptPath = path.join(tempDir, 'native-receipt.jsonl'); + fs.mkdirSync(storageDir, { recursive: true, mode: 0o700 }); + const collectorEnv = { + ...process.env, + AGENTOPS_OTEL_STORAGE_DIR: storageDir, + AGENTOPS_OTEL_RECEIPT_PATH: receiptPath + }; + const validateResult = run(binary.path, ['validate', '--config', config], { + timeout: 30000, + env: collectorEnv + }); + if (validateResult.status !== 0) { + fs.rmSync(tempDir, { recursive: true, force: true }); + return { + status: 'failed', + ok: false, + error: (validateResult.stderr || validateResult.stdout || `collector validate exited ${validateResult.status}`).trim() + }; + } + + const out = fs.openSync(log, 'a'); + const child = childProcess.spawn(binary.path, ['--config', config], { + detached: true, + stdio: ['ignore', out, out], + env: collectorEnv + }); + + const cleanup = () => { + try { + process.kill(child.pid, 'SIGTERM'); + } catch {} + fs.closeSync(out); + fs.rmSync(tempDir, { recursive: true, force: true }); + }; + + try { + const health = await waitForHealthUrl(`http://127.0.0.1:${healthPort}`, 5000); + if (!health.ok) return { status: 'failed', ok: false, error: 'Temporary collector health endpoint did not become ready.', health }; + + const poison = makePoisonAttributes(); + const now = BigInt(Date.now()) * 1000000n; + const payload = { + resourceSpans: [{ + resource: { + attributes: otlpAttributes({ + 'service.name': 'agentops-poison-smoke', + 'service.namespace': 'copilot-agentops', + 'agent.framework': 'github-copilot', + 'agent.runtime': 'github-copilot-cli', + 'agentops.poison_id': poison['agentops.poison_id'] + }) + }, + scopeSpans: [{ + spans: [{ + traceId: crypto.randomBytes(16).toString('hex'), + spanId: crypto.randomBytes(8).toString('hex'), + name: `SECRET_SPAN_NAME_${poison['agentops.poison_id']}`, + kind: 2, + startTimeUnixNano: String(now), + endTimeUnixNano: String(now + 1000000n), + status: { + code: 2, + message: `SECRET_STATUS_MESSAGE_${poison['agentops.poison_id']}` + }, + attributes: otlpAttributes(poison) + }] + }] + }] + }; + const post = await postJson(`http://127.0.0.1:${httpPort}/v1/traces`, payload); + if (!post.ok) return { status: 'failed', ok: false, error: 'Poison OTLP POST failed.', post }; + const relayPost = await postJson(`http://127.0.0.1:${receiptPort}/v1/traces`, payload); + if (!relayPost.ok) return { status: 'failed', ok: false, error: 'Poison receipt-relay POST failed.', relayPost }; + + const deadline = Date.now() + 8000; + let output = ''; + let receipt = ''; + while (Date.now() < deadline) { + await sleep(250); + output = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : ''; + receipt = fs.existsSync(receiptPath) ? fs.readFileSync(receiptPath, 'utf8') : ''; + if (output.includes(poison['agentops.poison_id']) && receipt.includes(poison['agentops.poison_id'])) break; + } + const leaked = Array.from(new Set([ + ...(output.match(/SECRET_[A-Z_]+/g) || []), + ...(receipt.match(/SECRET_[A-Z_]+/g) || []) + ])); + const safeOutput = `${output}\n${receipt}`; + return { + status: leaked.length === 0 && safeOutput.includes(poison['agentops.poison_id']) ? 'passed' : 'failed', + ok: leaked.length === 0 && safeOutput.includes(poison['agentops.poison_id']), + poison_id: poison['agentops.poison_id'], + leaked, + safe_fields_present: { + poison_id: safeOutput.includes(poison['agentops.poison_id']), + operation: safeOutput.includes('gen_ai.operation.name'), + model: safeOutput.includes('poison-model'), + scrub_signal: safeOutput.includes('agentops.content_capture.signal') + }, + log_bytes: Buffer.byteLength(output), + receipt_bytes: Buffer.byteLength(receipt), + receipt_relay: relayPost + }; + } finally { + cleanup(); + } +} + +function waitForHealthUrl(url, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + return new Promise(resolve => { + const check = async () => { + const health = await healthCheck(url, 1000); + if (health.ok || Date.now() >= deadline) return resolve(health); + setTimeout(check, 250); + }; + check(); + }); +} + +module.exports = { + findCollectorProcessByConfig, + findManagedCollectorProcess, + freePort, + healthCheck, + logFile, + otlpAttributes, + otlpValue, + pidFile, + postJson, + processAlive, + readPid, + runtimePoisonSmoke, + waitForHealth, + waitForHealthUrl +}; diff --git a/agentops-cli/src/lib/collector-smoke.js b/agentops-cli/src/lib/collector-smoke.js new file mode 100644 index 0000000..3ed5ecd --- /dev/null +++ b/agentops-cli/src/lib/collector-smoke.js @@ -0,0 +1,34 @@ +const { normalizePrivacy } = require('./collector-options'); +const { poisonCheck } = require('./privacy'); +const { runtimePoisonSmoke } = require('./collector-runtime'); + +async function smokeCollector({ + options = {}, + status, + findCollectorBinary, + runPoisonCheck = poisonCheck, + runRuntimePoisonSmoke = runtimePoisonSmoke +} = {}) { + const privacy = normalizePrivacy(options.privacy || 'strict'); + const localPoison = options.poison === false ? null : runPoisonCheck(); + const currentStatus = await status({ mode: options.mode || 'auto', privacy }); + const runtime = options.poison === false + ? null + : await runRuntimePoisonSmoke({ privacy, findCollectorBinary }); + return { + ok: privacy === 'strict' ? Boolean(localPoison?.ok && runtime?.ok !== false) : true, + privacyMode: privacy, + poison: localPoison, + runtime_validation: currentStatus.running + ? { status: 'collector-running', health: currentStatus.health, debug_exporter: runtime } + : { + status: 'skipped', + reason: 'No local collector runtime is reachable in this environment. Offline strict sanitizer poison check was run.', + debug_exporter: runtime + } + }; +} + +module.exports = { + smokeCollector +}; diff --git a/agentops-cli/src/lib/collector-start.js b/agentops-cli/src/lib/collector-start.js new file mode 100644 index 0000000..3952fc0 --- /dev/null +++ b/agentops-cli/src/lib/collector-start.js @@ -0,0 +1,79 @@ +const { normalizeMode, normalizePrivacy } = require('./collector-options'); +const { + findRunningBinaryCollector, + findRunningLocalCollector, + findRunningNativeCollector, + startBinaryCollector, + startNativeAzureCollector, + startLocalCollector +} = require('./collector-binary-runtime'); +const { findCollectorBinary: defaultFindCollectorBinary, resolveAutoMode: defaultResolveAutoMode } = require('./collector-discovery'); +const { startDockerCollector } = require('./collector-docker-runtime'); +const { healthCheck } = require('./collector-runtime'); + +async function startCollector({ + options = {}, + resolveAutoMode = defaultResolveAutoMode, + findCollectorBinary = defaultFindCollectorBinary, + startDocker = startDockerCollector, + startBinary = startBinaryCollector, + checkHealth = healthCheck, + env = process.env +} = {}) { + const mode = normalizeMode(options.mode || 'auto'); + const privacy = normalizePrivacy(options.privacy || 'strict'); + if (mode === 'auto') { + const binary = findCollectorBinary(); + if (binary.ok) { + const local = findRunningLocalCollector(binary.path); + if (local) return { ok: true, mode: 'local', privacyMode: local.privacy, alreadyRunning: true, pid: local.pid, config: local.config }; + const native = findRunningNativeCollector(binary.path); + if (native) return { ok: true, mode: 'azure-native', privacyMode: native.privacy, alreadyRunning: true, pid: native.pid, config: native.config }; + const managedBinary = findRunningBinaryCollector(binary.path); + if (managedBinary) return { ok: true, mode: 'binary', privacyMode: managedBinary.privacy, alreadyRunning: true, pid: managedBinary.pid, config: managedBinary.config }; + } + } + const resolved = mode === 'auto' ? resolveAutoMode(env) : { mode, reason: 'explicit mode' }; + + if (mode === 'none' || resolved.mode === 'none') { + if (!options.unsafeNoCollector) { + return { + ok: false, + mode: 'none', + unsafe: true, + error: 'Collector mode none requires AGENTOPS_ALLOW_NO_COLLECTOR=1 or --unsafe-no-collector.' + }; + } + return { + ok: true, + mode: 'none', + unsafe: true, + warning: 'No local collector is running; privacy scrubbing is not guaranteed.' + }; + } + + if (!resolved.mode) { + const currentHealth = await checkHealth(); + if (currentHealth.ok) { + return { + ok: true, + mode, + privacyMode: privacy, + alreadyRunning: true, + health: currentHealth, + warning: 'Collector health endpoint is already responding; no new collector runtime was started.' + }; + } + return { ok: false, mode, error: resolved.reason }; + } + + if (resolved.mode === 'docker') return startDocker({ privacy }); + if (resolved.mode === 'local') return startLocalCollector({ privacy, findCollectorBinary }); + if (resolved.mode === 'binary') return startBinary({ privacy, findCollectorBinary }); + if (resolved.mode === 'azure-native') return startNativeAzureCollector({ privacy, findCollectorBinary, env }); + return { ok: false, mode, error: `Unsupported resolved collector mode: ${resolved.mode}` }; +} + +module.exports = { + startCollector +}; diff --git a/agentops-cli/src/lib/collector-status.js b/agentops-cli/src/lib/collector-status.js new file mode 100644 index 0000000..2b7d429 --- /dev/null +++ b/agentops-cli/src/lib/collector-status.js @@ -0,0 +1,105 @@ +const fs = require('node:fs'); + +const { + composeFile, + composeHasLocalhostBindings, + dockerCliAvailable, + dockerComposeAvailable, + dockerDaemonAvailable, + dockerProjectName +} = require('./collector-docker'); +const { findRunningBinaryCollector, findRunningLocalCollector, findRunningNativeCollector } = require('./collector-binary-runtime'); +const { + collectorHealthUrl: healthUrl, + otlpHttpEndpoint +} = require('./collector-endpoints'); +const { + findCollectorProcessByConfig, + findManagedCollectorProcess, + healthCheck, + logFile, + pidFile, + processAlive, + readPid +} = require('./collector-runtime'); +const { defaultConfigPathFor, normalizeMode, normalizePrivacy } = require('./collector-options'); + +async function collectorStatus({ + options = {}, + findCollectorBinary, + resolveAutoMode, + configPathFor = defaultConfigPathFor, + env = process.env +} = {}) { + const requestedMode = normalizeMode(options.mode || env.AGENTOPS_COLLECTOR_MODE || 'auto'); + const privacy = normalizePrivacy(options.privacy || env.AGENTOPS_PRIVACY_MODE || 'strict'); + const auto = requestedMode === 'auto' ? resolveAutoMode(env) : { mode: requestedMode, reason: 'explicit mode' }; + const health = await healthCheck(); + const binary = findCollectorBinary(); + const pid = readPid(); + const runningBinary = ['auto', 'binary'].includes(requestedMode) && binary.ok + ? findRunningBinaryCollector(binary.path) + : null; + const runningLocal = ['auto', 'local'].includes(requestedMode) && binary.ok + ? findRunningLocalCollector(binary.path) + : null; + const runningNative = ['auto', 'azure-native'].includes(requestedMode) && binary.ok + ? findRunningNativeCollector(binary.path) + : null; + const runningManaged = runningLocal || runningNative || runningBinary; + const effectiveMode = runningLocal ? 'local' : runningNative ? 'azure-native' : runningBinary ? 'binary' : auto.mode || requestedMode; + const effectivePrivacy = runningManaged?.privacy || privacy; + const configTarget = effectiveMode === 'binary' || effectiveMode === 'local' || effectiveMode === 'azure-native' ? effectiveMode : 'docker'; + const config = runningManaged?.config || configPathFor(configTarget, effectivePrivacy); + const discoveredPid = effectiveMode === 'binary' || effectiveMode === 'local' || effectiveMode === 'azure-native' + ? (runningManaged?.pid || (binary.path ? findManagedCollectorProcess(binary.path, config) : findCollectorProcessByConfig(config))) + : null; + const effectivePid = runningManaged?.pid || discoveredPid || null; + const dockerAvailable = dockerCliAvailable(); + const daemonAvailable = dockerDaemonAvailable(); + const details = []; + + if (requestedMode === 'auto') details.push(`auto: ${auto.reason}`); + if (!binary.ok) details.push(binary.error); + if (!dockerAvailable) details.push('Docker CLI not found.'); + else if (!daemonAvailable) details.push('Docker daemon is not reachable.'); + if (!composeHasLocalhostBindings()) details.push('Docker Compose host bindings are not localhost-only.'); + if (pid && !processAlive(pid) && discoveredPid) details.push(`Binary PID file was stale; found running collector PID ${discoveredPid}.`); + if (pid && !processAlive(pid) && health.ok && !discoveredPid) details.push('Binary PID file is stale, but the collector health endpoint is responding.'); + + return { + mode: requestedMode, + effectiveMode, + running: health.ok, + endpoint: otlpHttpEndpoint, + healthUrl, + safeLocalhostBinding: composeHasLocalhostBindings(), + privacyMode: effectivePrivacy, + config: fs.existsSync(config) + ? config + : null, + docker: { + cli: dockerAvailable, + compose: dockerComposeAvailable(), + daemon: daemonAvailable, + composeFile, + projectName: dockerProjectName + }, + binary: { + ...binary, + pid: effectivePid, + pidFile: pidFile(), + logFile: logFile(), + running: Boolean(effectivePid), + discoveredPid + }, + health, + details + }; +} + +module.exports = { + collectorStatus, + healthUrl, + otlpHttpEndpoint +}; diff --git a/agentops-cli/src/lib/collector-stop.js b/agentops-cli/src/lib/collector-stop.js new file mode 100644 index 0000000..122e9e4 --- /dev/null +++ b/agentops-cli/src/lib/collector-stop.js @@ -0,0 +1,55 @@ +const { + normalizeMode, + normalizePrivacy +} = require('./collector-options'); +const { + findRunningBinaryCollector, + findRunningLocalCollector, + findRunningNativeCollector, + stopBinaryCollector, + stopNativeAzureCollector, + stopLocalCollector +} = require('./collector-binary-runtime'); +const { stopDockerCollector } = require('./collector-docker-runtime'); +const { findCollectorBinary: defaultFindCollectorBinary, resolveAutoMode: defaultResolveAutoMode } = require('./collector-discovery'); + +function stopCollector({ + options = {}, + resolveAutoMode = defaultResolveAutoMode, + findCollectorBinary = defaultFindCollectorBinary, + stopDocker = stopDockerCollector, + stopBinary = stopBinaryCollector, + env = process.env +} = {}) { + const mode = normalizeMode(options.mode || env.AGENTOPS_COLLECTOR_MODE || 'auto'); + if (mode === 'auto') { + const binary = findCollectorBinary(); + if (binary.ok) { + const local = findRunningLocalCollector(binary.path); + if (local) return stopLocalCollector({ privacy: local.privacy, findCollectorBinary }); + const native = findRunningNativeCollector(binary.path); + if (native) return stopNativeAzureCollector({ findCollectorBinary }); + const managedBinary = findRunningBinaryCollector(binary.path); + if (managedBinary) return stopBinaryCollector({ privacy: managedBinary.privacy, findCollectorBinary }); + } + } + const resolved = mode === 'auto' ? resolveAutoMode(env) : { mode }; + const target = resolved.mode || mode; + if (target === 'docker') { + return stopDocker({ privacy: options.privacy || 'strict' }); + } + if (target === 'local') { + const privacy = normalizePrivacy(options.privacy || env.AGENTOPS_PRIVACY_MODE || 'strict'); + return stopLocalCollector({ privacy, findCollectorBinary }); + } + if (target === 'binary') { + const privacy = normalizePrivacy(options.privacy || env.AGENTOPS_PRIVACY_MODE || 'strict'); + return stopBinary({ privacy, findCollectorBinary }); + } + if (target === 'azure-native') return stopNativeAzureCollector({ findCollectorBinary }); + return { ok: true, mode: target, stopped: false, detail: 'No managed collector runtime selected.' }; +} + +module.exports = { + stopCollector +}; diff --git a/agentops-cli/src/lib/collector-validation.js b/agentops-cli/src/lib/collector-validation.js new file mode 100644 index 0000000..ebec577 --- /dev/null +++ b/agentops-cli/src/lib/collector-validation.js @@ -0,0 +1,91 @@ +const http = require('node:http'); +const https = require('node:https'); +const { + collectorHealthUrlWithSlash, + otlpHttpEndpoint +} = require('./collector-endpoints'); + +function createCollectorValidation(dependencies = {}) { + const { openLinksSummary } = dependencies; + + function renderOpenLinks(links = openLinksSummary()) { + const lines = [ + 'AgentOps investigation links', + '', + `${links.primary_investigation_label || 'Today'}: ${links.primary_investigation_url || links.v2_home_url}`, + ...(links.azure_agents_view_url && links.primary_investigation_url !== links.azure_agents_view_url + ? [`Azure Monitor Agents view: ${links.azure_agents_view_url}`] + : []), + `Advanced Grafana Today: ${links.v2_home_url}`, + `Runs: ${links.v2_runs_url}`, + `Run Story: ${links.v2_replay_url}`, + `Main dashboard: ${links.main_dashboard_url}`, + `Sessions dashboard: ${links.sessions_dashboard_url}` + ]; + + if (links.latest_session_url) { + lines.push(`Latest session: ${links.latest_session_url}`); + } else { + lines.push(`Latest session: unknown. ${links.missing_latest_reason}.`); + } + + return `${lines.join('\n')}\n`; + } + + function httpHealthCheck(url, options = {}) { + return new Promise(resolve => { + const parsed = new URL(url); + const client = parsed.protocol === 'https:' ? https : http; + const req = client.request(parsed, { method: 'GET', timeout: options.timeoutMs || 1500 }, res => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', chunk => body += chunk); + res.on('end', () => resolve({ + reachable: true, + statusCode: res.statusCode, + ok: res.statusCode >= 200 && res.statusCode < 300, + body: body.slice(0, 200) + })); + }); + req.on('timeout', () => { + req.destroy(); + resolve({ reachable: false, ok: false, error: 'timeout' }); + }); + req.on('error', error => resolve({ reachable: false, ok: false, error: error.message })); + req.end(); + }); + } + + function validateCollector(endpoint = otlpHttpEndpoint, options = {}) { + return new Promise((resolve) => { + const url = new URL('/v1/traces', endpoint); + const client = url.protocol === 'https:' ? https : http; + const req = client.request(url, { method: 'POST', timeout: 1500 }, res => { + resolve({ endpoint, reachable: true, statusCode: res.statusCode, ok: res.statusCode < 500 }); + }); + req.on('timeout', () => { + req.destroy(); + resolve({ endpoint, reachable: false, ok: false, error: 'timeout' }); + }); + req.on('error', error => resolve({ endpoint, reachable: false, ok: false, error: error.message })); + req.end(); + }).then(async otlpHttp => { + const healthEndpoint = options.healthEndpoint || collectorHealthUrlWithSlash; + const health = await httpHealthCheck(healthEndpoint, options); + return { + endpoint, + otlp_http: otlpHttp, + health_endpoint: healthEndpoint, + health, + ok: Boolean(otlpHttp.ok && health.ok) + }; + }); + } + + return { + renderOpenLinks, + validateCollector + }; +} + +module.exports = { createCollectorValidation }; diff --git a/agentops-cli/src/lib/command-output.js b/agentops-cli/src/lib/command-output.js new file mode 100644 index 0000000..7a8670c --- /dev/null +++ b/agentops-cli/src/lib/command-output.js @@ -0,0 +1,46 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function jsonOutput(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function jsonlOutput(rows, { trailingNewline = rows.length > 0 } = {}) { + return `${rows.map(row => JSON.stringify(row)).join('\n')}${trailingNewline ? '\n' : ''}`; +} + +function writeJson(value, stdout = process.stdout) { + stdout.write(jsonOutput(value)); +} + +function writeJsonOrRender(value, json, render, stdout = process.stdout) { + stdout.write(json ? jsonOutput(value) : render(value)); +} + +function writeJsonFile(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, jsonOutput(value)); + return filePath; +} + +function writeJsonlFile(filePath, rows, options = {}) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, jsonlOutput(rows, options)); + return filePath; +} + +function appendJsonlFile(filePath, row) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, jsonlOutput([row])); + return filePath; +} + +module.exports = { + appendJsonlFile, + jsonOutput, + jsonlOutput, + writeJson, + writeJsonFile, + writeJsonlFile, + writeJsonOrRender +}; diff --git a/agentops-cli/src/lib/command-plan.js b/agentops-cli/src/lib/command-plan.js new file mode 100644 index 0000000..23a39a0 --- /dev/null +++ b/agentops-cli/src/lib/command-plan.js @@ -0,0 +1,123 @@ +const path = require('node:path'); + +function compactEnv(values) { + return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== '')); +} + +function commandPlan(command, args = [], platform = process.platform, options = {}) { + const root = options.root; + const configuredCloudValues = options.configuredCloudValues || (() => ({})); + const isWindows = platform === 'win32'; + const scriptPath = script => path.join(root, 'scripts', script); + const cloudEnv = () => { + const cloud = configuredCloudValues(); + return compactEnv({ + AZURE_SUBSCRIPTION_ID: cloud.subscriptionId, + AZURE_RESOURCE_GROUP: cloud.resourceGroup, + APPLICATIONINSIGHTS_NAME: cloud.appInsightsName, + AGENTOPS_AZURE_SUBSCRIPTION_ID: cloud.subscriptionId, + AGENTOPS_AZURE_RESOURCE_GROUP: cloud.resourceGroup, + AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID: cloud.workspaceId, + AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME: cloud.workspaceName, + AGENTOPS_GRAFANA_BASE_URL: cloud.grafanaBaseUrl, + AGENTOPS_GRAFANA_NAME: cloud.grafanaName, + AGENTOPS_GRAFANA_DATASOURCE_UID: cloud.grafanaDatasourceUid, + AGENTOPS_APPLICATIONINSIGHTS_NAME: cloud.appInsightsName, + AGENTOPS_AZURE_AGENTS_URL: cloud.agentsViewUrl + }); + }; + + if (command === 'install') { + const shadow = args.includes('--shadow-copilot') || args.includes('--shadow'); + const passThrough = args.filter(arg => !['--shadow-copilot', '--shadow', '--no-shadow-copilot', '--no-shadow'].includes(arg)); + const psInstallArgs = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (['--shadow-copilot', '--shadow', '--no-shadow-copilot', '--no-shadow'].includes(arg)) continue; + if (arg === '--no-collector') psInstallArgs.push('-NoCollector'); + else if (arg === '--force-collector') psInstallArgs.push('-ForceCollector'); + else if (arg === '--plugin') psInstallArgs.push('-Plugin'); + else if (arg === '--collector-version') { + psInstallArgs.push('-CollectorVersion', args[index + 1]); + index += 1; + } else if (arg.startsWith('--collector-version=')) { + psInstallArgs.push('-CollectorVersion', arg.slice('--collector-version='.length)); + } else { + psInstallArgs.push(arg); + } + } + return isWindows + ? { + command: 'pwsh', + args: [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(root, 'install-agentops.ps1'), + ...(shadow ? ['-ShadowCopilot'] : ['-NoShadowCopilot']), + ...psInstallArgs + ] + } + : { + command: path.join(root, 'install-agentops.sh'), + args: shadow ? ['--shadow-copilot', ...passThrough] : passThrough + }; + } + + if (command === 'enable-shadow') { + return isWindows + ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('install-copilot-agentops-shim.ps1'), '-ShadowCopilot'] } + : { command: scriptPath('install-copilot-agentops-shim.sh'), args: ['--shadow-copilot'] }; + } + + if (command === 'disable-shadow') { + return isWindows + ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('uninstall-copilot-agentops-shim.ps1'), '-KeepAgentopsCommand'] } + : { command: scriptPath('uninstall-copilot-agentops-shim.sh'), args: ['--keep-agentops-command'] }; + } + + if (command === 'uninstall') { + const psUninstallArgs = args.map(arg => ({ + '--keep-plugin': '-KeepPlugin', + '--keep-collector': '-KeepCollector', + '--keep-binary': '-KeepBinary', + '--purge': '-Purge', + '--keep-agentops-command': '-KeepAgentopsCommand' + }[arg] || arg)); + return isWindows + ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', path.join(root, 'uninstall-agentops.ps1'), ...psUninstallArgs] } + : { command: path.join(root, 'uninstall-agentops.sh'), args }; + } + + if (command === 'copilot') { + return isWindows + ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('copilot-agentops.ps1'), ...args], env: cloudEnv() } + : { command: scriptPath('copilot-agentops'), args, env: cloudEnv() }; + } + + if (command === 'codex') { + return isWindows + ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('agentops-codex.ps1'), ...args], env: cloudEnv() } + : { command: scriptPath('agentops-codex'), args, env: cloudEnv() }; + } + + if (command === 'collector' || command === 'start' || command === 'stop') { + const action = command === 'start' ? 'start' : command === 'stop' ? 'stop' : args[0]; + if (action === 'start') { + return isWindows + ? { command: 'pwsh', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath('collector-azuremonitor-up.ps1')], env: cloudEnv() } + : { command: scriptPath('collector-azuremonitor-up.sh'), args: [], env: cloudEnv() }; + } + if (action === 'stop') { + return { command: 'docker', args: ['compose', '-f', path.join(root, 'collector', 'docker-compose.azuremonitor.yaml'), 'down'], env: cloudEnv() }; + } + throw new Error('collector requires start or stop'); + } + + throw new Error(`No command plan for: ${command}`); +} + +module.exports = { + commandPlan +}; diff --git a/agentops-cli/src/lib/command-runtime.js b/agentops-cli/src/lib/command-runtime.js new file mode 100644 index 0000000..e5616e9 --- /dev/null +++ b/agentops-cli/src/lib/command-runtime.js @@ -0,0 +1,51 @@ +const childProcess = require('node:child_process'); +const { commandPlan: commandPlanBase } = require('./command-plan'); +const { buildLink: buildLinkBase } = require('./observability-queries'); + +function createCommandRuntime(dependencies = {}) { + const { + commandCandidates = () => [], + configuredCloudValues, + grafanaBaseUrl, + portalLogsUrl, + root, + workspaceId + } = dependencies; + + function buildLink(kind, id, options = {}) { + return buildLinkBase(kind, id, { + grafanaBaseUrl, + portalLogsUrl, + workspaceId, + ...options + }); + } + + function commandPlan(command, args = [], platform = process.platform) { + return commandPlanBase(command, args, platform, { root, configuredCloudValues }); + } + + function runPlannedCommand(plan) { + let executable = plan.command; + if (executable === 'pwsh' && process.platform === 'win32' && commandCandidates('pwsh').length === 0) { + executable = 'powershell.exe'; + } + + const result = childProcess.spawnSync(executable, plan.args, { + stdio: 'inherit', + env: { ...process.env, ...(plan.env || {}) } + }); + if (result.error) throw result.error; + process.exitCode = result.status === null ? 1 : result.status; + } + + return { + buildLink, + commandPlan, + runPlannedCommand + }; +} + +module.exports = { + createCommandRuntime +}; diff --git a/agentops-cli/src/lib/content-command.js b/agentops-cli/src/lib/content-command.js new file mode 100644 index 0000000..1cd3d86 --- /dev/null +++ b/agentops-cli/src/lib/content-command.js @@ -0,0 +1,33 @@ +const path = require('node:path'); + +const { hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { buildContentStatus, captureModeSummary, renderContentStatus, renderOptInGuide } = require('./content-status'); +const { repoRoot } = require('./paths'); + +function contentCommand(args = []) { + const [subcommand = 'status'] = args; + if (!['status', 'opt-in'].includes(subcommand)) throw new Error('content supports: status|opt-in'); + + if (subcommand === 'opt-in') { + const guide = { ok: true, default_capture: 'off', mode: 'explicit_opt_in', checklist: renderOptInGuide().trim().split(/\n/) }; + writeJsonOrRender(guide, hasFlag(args, '--json'), renderOptInGuide); + return; + } + + const status = buildContentStatus({ + dir: optionValue(args, '--dir', path.join(repoRoot, '.agentops', 'demo', 'latest')), + runsFile: optionValue(args, '--runs', ''), + allowContent: hasFlag(args, '--allow-content') + }); + writeJsonOrRender(status, hasFlag(args, '--json'), renderContentStatus); + if (!status.ok) process.exitCode = 1; +} + +module.exports = { + buildContentStatus, + captureModeSummary, + contentCommand, + renderContentStatus, + renderOptInGuide +}; diff --git a/agentops-cli/src/lib/content-status.js b/agentops-cli/src/lib/content-status.js new file mode 100644 index 0000000..0fd6e81 --- /dev/null +++ b/agentops-cli/src/lib/content-status.js @@ -0,0 +1,119 @@ +const path = require('node:path'); + +const { buildAzureIngestPlan } = require('./azure/v2-ingest-plan'); +const { latestByTime } = require('./explain/v2-explain'); +const { readJsonlIfExists } = require('./json'); +const { repoRoot } = require('./paths'); +const { v2OpenLinksForRun } = require('./v2-open-links'); + +function captureModeSummary(rows) { + const modes = new Map(); + for (const row of rows) { + const mode = row.CaptureMode || 'unknown'; + modes.set(mode, (modes.get(mode) || 0) + 1); + } + return Object.fromEntries([...modes.entries()].sort()); +} + +function buildContentStatus({ + dir = path.join(repoRoot, '.agentops', 'demo', 'latest'), + runsFile = '', + allowContent = false +} = {}) { + const absoluteDir = path.resolve(dir); + const contentFile = path.join(absoluteDir, 'AgentOpsContent_CL.jsonl'); + const runFile = runsFile || path.join(absoluteDir, 'AgentOpsRunSummary_CL.jsonl'); + const contentRows = readJsonlIfExists(contentFile); + const runs = readJsonlIfExists(runFile); + const latestRun = latestByTime(runs); + const plan = buildAzureIngestPlan({ dir: absoluteDir, allowContent }); + const openLinks = latestRun ? v2OpenLinksForRun(latestRun) : { ok: false, links: {} }; + const contentKinds = [...new Set(contentRows.map(row => row.ContentKind || 'unknown'))].sort(); + const redactionStates = [...new Set(contentRows.map(row => row.RedactionStatus || 'unknown'))].sort(); + const hasFullContent = contentRows.some(row => row.CaptureMode === 'full'); + + return { + ok: plan.content_capture.rows === 0 || allowContent, + dir: absoluteDir, + content_file: contentFile, + run_file: runFile, + content_rows: contentRows.length, + allowed_for_ingest: allowContent, + capture_modes: captureModeSummary(contentRows), + content_kinds: contentKinds, + redaction_states: redactionStates, + has_full_content: hasFullContent, + status: contentRows.length === 0 + ? 'strict metadata only' + : allowContent + ? 'explicit content opt-in acknowledged' + : 'content rows present but blocked until --allow-content', + safety_note: contentRows.length === 0 + ? 'Strict mode is not storing prompt or response text.' + : 'Prompt/response rows may contain sensitive text. Use a restricted workspace/dashboard and pass --allow-content only after review.', + latest_run_id: latestRun?.RunId || '', + transcript_viewer_url: openLinks.links?.content_viewer || '', + ingest_ready: plan.ok, + ingest_errors: plan.errors, + next: contentRows.length === 0 + ? [ + 'Keep AGENTOPS_CAPTURE_CONTENT=false for shared/default telemetry.', + 'Use agentops demo generate --with-content only for redacted demo transcript UX.', + 'For real prompt/response capture, use a restricted workspace and rerun agentops content opt-in for the review checklist.' + ] + : [ + `agentops content status --dir ${absoluteDir} --allow-content`, + `agentops azure-ingest plan --dir ${absoluteDir} --allow-content`, + 'Open the Prompt/response viewer link only in a restricted Grafana workspace.' + ] + }; +} + +function renderContentStatus(status) { + const lines = ['AgentOps content capture status', '']; + lines.push(`Status: ${status.status}`); + lines.push(`Content rows: ${status.content_rows}`); + lines.push(`Allowed for ingest: ${status.allowed_for_ingest ? 'yes' : 'no'}`); + lines.push(`Ingest ready: ${status.ingest_ready ? 'yes' : 'no'}`); + lines.push(`Capture modes: ${JSON.stringify(status.capture_modes)}`); + lines.push(`Content kinds: ${status.content_kinds.join(', ') || 'none'}`); + lines.push(`Safety: ${status.safety_note}`); + if (status.transcript_viewer_url) lines.push(`Prompt/response viewer: ${status.transcript_viewer_url}`); + if (status.ingest_errors.length > 0) { + lines.push(''); + lines.push('Ingest blockers:'); + for (const error of status.ingest_errors) lines.push(`- ${error}`); + } + lines.push(''); + lines.push('Next:'); + for (const step of status.next) lines.push(`- ${step}`); + return `${lines.join('\n')}\n`; +} + +function renderOptInGuide() { + return [ + 'AgentOps prompt/response opt-in checklist', + '', + 'Default: keep AGENTOPS_PRIVACY_MODE=strict and AGENTOPS_CAPTURE_CONTENT=false.', + '', + 'Use raw or redacted prompt/response capture only when all are true:', + '- the workspace is restricted to approved viewers', + '- the run does not include secrets, private source, customer data, or regulated data', + '- the team accepts that AgentOpsContent_CL can contain sensitive text', + '- ingestion is reviewed with agentops azure-ingest plan --allow-content', + '', + 'Safe demo path:', + '- agentops demo generate --with-content --out .agentops/demo/content-demo', + '- agentops content status --dir .agentops/demo/content-demo --allow-content', + '- agentops azure-ingest plan --dir .agentops/demo/content-demo --allow-content', + '', + 'Real capture remains explicit and environment-specific. Do not enable it in shared defaults.' + ].join('\n') + '\n'; +} + +module.exports = { + buildContentStatus, + captureModeSummary, + renderContentStatus, + renderOptInGuide +}; diff --git a/agentops-cli/src/lib/copilot/command.js b/agentops-cli/src/lib/copilot/command.js new file mode 100644 index 0000000..b7344a3 --- /dev/null +++ b/agentops-cli/src/lib/copilot/command.js @@ -0,0 +1,184 @@ +const childProcess = require('node:child_process'); +const path = require('node:path'); + +const legacy = require('../../legacy'); +const collector = require('../collector-manager'); +const { optionValue, withoutFlags } = require('../args'); +const { appendWrapperEvent, createWrapperEnvelope } = require('./wrapper-envelope'); +const { resolveCopilotBinary } = require('../copilot-resolver'); +const { copilotDir } = require('../paths'); +const { changedCopilotSession, snapshotCopilotSessions } = require('./receipt-session'); +const { receiptDeliveryText } = require('../delivery-state'); +const { createWrapperDelivery } = require('./wrapper-delivery'); + +function removeAgentOpsCopilotFlags(args) { + return withoutFlags(args, ['--collector-mode', '--privacy', '--unsafe-no-collector']); +} + +function wrapperReplayUrl(envelope = createWrapperEnvelope(), links = legacy.openLinksSummary()) { + const base = String(links.v2_replay_url || '').split('?')[0]; + if (!base) return ''; + const params = new URLSearchParams({ + 'var-run_id': envelope.runId || '__all', + 'var-session_id': envelope.sessionId || '__all' + }); + return `${base}?${params.toString()}`; +} + +function safeReceiptName(value = '') { + const text = String(value || '').trim(); + return /^[A-Za-z0-9_.:/@+-]{1,200}$/.test(text) ? text : ''; +} + +function renderCopilotReceipt({ envelope, exitCode, privacy = 'strict', requestedPrivacy = privacy, fallbackUnobserved = false, deliveryState = 'native_best_effort', replayUrl = '', summary = null, wallDurationMs = 0, agent = '' }) { + const completed = Number(exitCode) === 0; + const lines = [ + '', + 'AgentOps receipt', + `Result ${completed ? 'Completed' : 'Needs attention'} · exit ${Number.isInteger(exitCode) ? exitCode : 1}`, + `Copilot ${summary?.sessionId || 'session details pending'}`, + `Delivery ${receiptDeliveryText(fallbackUnobserved ? 'unobserved' : deliveryState)}`, + ...(fallbackUnobserved ? [] : ['Coverage Detailed Copilot activity is best effort; use agentops latest to check what arrived']), + `Privacy ${privacy}${requestedPrivacy !== privacy ? ` effective · ${requestedPrivacy} requested` : ''} · AgentOps did not record prompts, answers, code, or tool payloads`, + ]; + if (safeReceiptName(agent)) lines.push(`Agent ${safeReceiptName(agent)}`); + if (summary?.model) lines.push(`Model ${summary.model}`); + if (summary && (summary.inputTokens || summary.outputTokens)) lines.push(`Tokens ${summary.inputTokens.toLocaleString()} in · ${summary.outputTokens.toLocaleString()} out`); + if (summary?.aiCredits) lines.push(`AI credits ${summary.aiCredits.toFixed(1)}`); + const timing = []; + if (wallDurationMs) timing.push(`${(wallDurationMs / 1000).toFixed(1)}s wall`); + if (summary?.apiDurationMs) timing.push(`${(summary.apiDurationMs / 1000).toFixed(1)}s API`); + if (timing.length) lines.push(`Time ${timing.join(' · ')}`); + if (summary?.tools?.length) lines.push(`Tools ${summary.tools.length} · ${summary.tools.slice(0, 4).join(', ')}${summary.tools.length > 4 ? ', …' : ''}`); + if (summary && (summary.filesModified || summary.linesAdded || summary.linesRemoved)) lines.push(`Code ${summary.filesModified} files · +${summary.linesAdded} / -${summary.linesRemoved}`); + lines.push(`Details agentops latest · run ${envelope.runId} · wrapper session ${envelope.sessionId}`); + if (replayUrl) lines.push(`Run Story ${replayUrl}`); + return `${lines.join('\n')}\n`; +} + +async function copilotCommand(args = []) { + const helpOnly = args.includes('--help') || args.includes('-h'); + const mode = optionValue(args, '--collector-mode', process.env.AGENTOPS_COLLECTOR_MODE || 'auto'); + const privacy = optionValue(args, '--privacy', process.env.AGENTOPS_PRIVACY_MODE || 'strict'); + const unsafeNoCollector = args.includes('--unsafe-no-collector') || process.env.AGENTOPS_ALLOW_NO_COLLECTOR === '1'; + const observedArgs = removeAgentOpsCopilotFlags(args); + if (helpOnly) { + const resolved = resolveCopilotBinary(); + if (!resolved.ok) throw new Error(resolved.error); + const help = childProcess.spawnSync(resolved.path, observedArgs, { stdio: 'inherit', env: process.env }); + if (help.error) throw help.error; + process.exitCode = help.status === null ? 1 : help.status; + return; + } + const requestedAgent = optionValue(observedArgs, '--agent', ''); + const envelope = createWrapperEnvelope(); + const wrapperDelivery = createWrapperDelivery(); + const durableEventIds = []; + let wrapperSequence = 0; + let deliveryState = 'native_best_effort'; + let fallbackUnobserved = false; + const baseEvent = { + RunId: envelope.runId, + SessionId: envelope.sessionId, + Surface: 'cli', + PrivacyMode: privacy, + CollectorMode: mode + }; + const recordLifecycle = event => { + const file = appendWrapperEvent(event); + const recorded = wrapperDelivery.record(event, { sequence: ++wrapperSequence }); + if (recorded.evidence?.EventId) durableEventIds.push(recorded.evidence.EventId); + if (recorded.state === 'overflow' || deliveryState !== 'overflow') deliveryState = recorded.state; + return { file, recorded }; + }; + + recordLifecycle({ + ...baseEvent, + EventName: 'agentops.run.start' + }); + + const currentStatus = await collector.status({ mode, privacy }); + let effectivePrivacy = currentStatus.running && currentStatus.privacyMode + ? currentStatus.privacyMode + : privacy; + if (!currentStatus.running && mode !== 'none') { + const started = await collector.start({ mode, privacy, unsafeNoCollector }); + effectivePrivacy = started.privacyMode || privacy; + if (!started.ok) { + recordLifecycle({ + ...baseEvent, + EventName: 'agentops.collector.start_failed', + Reason: started.error || 'collector start failed' + }); + if (process.env.AGENTOPS_ALLOW_UNOBSERVED_FALLBACK === '1') { + fallbackUnobserved = true; + const eventFile = recordLifecycle({ + ...baseEvent, + EventName: 'agentops.wrapper.fallback_unobserved', + Reason: started.error || 'collector start failed' + }); + process.stderr.write(`WARNING: AgentOps collector unavailable; running unobserved because AGENTOPS_ALLOW_UNOBSERVED_FALLBACK=1. ${started.error || ''}\n`); + process.stderr.write(`AgentOps wrapper fallback event: ${eventFile.file}\n`); + } else { + throw new Error(`AgentOps collector unavailable: ${started.error || 'unknown error'}`); + } + } + } + + if (mode === 'none' && !unsafeNoCollector) { + throw new Error('Collector mode none requires AGENTOPS_ALLOW_NO_COLLECTOR=1 or --unsafe-no-collector.'); + } + + const resolved = resolveCopilotBinary(); + if (!resolved.ok) throw new Error(resolved.error); + + const observeScript = path.join(copilotDir, 'copilot-observe'); + const sessionsBefore = snapshotCopilotSessions(); + const runStartedAt = Date.now(); + const env = { + ...process.env, + COPILOT_CLI_BIN: resolved.path, + AGENTOPS_PRIVACY_MODE: effectivePrivacy, + AGENTOPS_COLLECTOR_MODE: mode, + AGENTOPS_WRAPPER_RUN_ID: envelope.runId, + AGENTOPS_WRAPPER_SESSION_ID: envelope.sessionId, + AGENTOPS_WRAPPER_FALLBACK_UNOBSERVED: fallbackUnobserved ? 'true' : 'false' + }; + const result = childProcess.spawnSync(observeScript, observedArgs, { stdio: 'inherit', env }); + const wallDurationMs = Date.now() - runStartedAt; + const summary = changedCopilotSession(sessionsBefore); + recordLifecycle({ + ...baseEvent, + EventName: 'agentops.run.end', + ExitCode: result.status === null ? 1 : result.status, + Error: result.error ? result.error.message : '', + FallbackUnobserved: fallbackUnobserved + }); + const drained = await wrapperDelivery.drain(durableEventIds); + if (drained.state === 'azure_acknowledged' || deliveryState !== 'overflow') deliveryState = drained.state; + if (result.error) throw result.error; + process.exitCode = result.status === null ? 1 : result.status; + if (process.env.AGENTOPS_PRINT_RUN_LINK !== 'false') { + process.stderr.write(renderCopilotReceipt({ + envelope, + exitCode: process.exitCode, + privacy: effectivePrivacy, + requestedPrivacy: privacy, + fallbackUnobserved, + deliveryState, + replayUrl: wrapperReplayUrl(summary?.sessionId ? { runId: '__all', sessionId: summary.sessionId } : envelope), + summary, + wallDurationMs, + agent: requestedAgent + })); + } +} + +module.exports = { + copilotCommand, + receiptDeliveryText, + removeAgentOpsCopilotFlags, + renderCopilotReceipt, + safeReceiptName, + wrapperReplayUrl +}; diff --git a/agentops-cli/src/lib/copilot/fixture-contract.js b/agentops-cli/src/lib/copilot/fixture-contract.js index 76ecca2..08e3198 100644 --- a/agentops-cli/src/lib/copilot/fixture-contract.js +++ b/agentops-cli/src/lib/copilot/fixture-contract.js @@ -1,3 +1,4 @@ +const fs = require('node:fs'); const path = require('node:path'); const { readJsonlRows, rollupSpanRows } = require('../rollup/span-to-agentops-tables'); @@ -54,9 +55,23 @@ function mismatchesForObject(actual = {}, expected = {}, prefix = '') { } function validateCopilotOtelFixtureContract(options = {}) { - const fixturePath = path.resolve(options.fixturePath || path.join(__dirname, '..', '..', '..', '..', 'tests', 'sample-otel', 'copilot-cli-wrapper-snapshot.jsonl')); + const packagedFixture = path.join(__dirname, '..', '..', '..', 'fixtures', 'sample-otel', 'copilot-cli-wrapper-snapshot.ndjson.fixture'); + const sourceFixture = path.join(__dirname, '..', '..', '..', '..', 'fixtures', 'sample-otel', 'copilot-cli-wrapper-snapshot.ndjson.fixture'); + const fixturePath = path.resolve(options.fixturePath || (fs.existsSync(packagedFixture) ? packagedFixture : sourceFixture)); const expected = options.expected || defaultExpected; - const rows = readJsonlRows(fixturePath); + let rows; + try { + rows = readJsonlRows(fixturePath); + } catch (error) { + return { + ok: false, + fixture: fixturePath, + rows: 0, + table_counts: {}, + mismatches: [`fixture could not be read: ${error.message}`], + contract: expected + }; + } const result = rollupSpanRows(rows, { baseTime: '2026-06-01T12:00:00.000Z' }); const run = result.tables.AgentOpsRunSummary_CL[0] || {}; const tool = result.tables.AgentOpsToolCalls_CL[0] || {}; diff --git a/agentops-cli/src/lib/copilot/receipt-session.js b/agentops-cli/src/lib/copilot/receipt-session.js new file mode 100644 index 0000000..6e9319b --- /dev/null +++ b/agentops-cli/src/lib/copilot/receipt-session.js @@ -0,0 +1,115 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +function sessionStateDir(home = os.homedir()) { + return path.join(home, '.copilot', 'session-state'); +} + +function snapshotCopilotSessions(root = sessionStateDir()) { + const snapshot = new Map(); + if (!fs.existsSync(root)) return snapshot; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || !/^[A-Za-z0-9-]{1,100}$/.test(entry.name)) continue; + const file = path.join(root, entry.name, 'events.jsonl'); + try { + snapshot.set(file, fs.statSync(file).mtimeMs); + } catch {} + } + return snapshot; +} + +function safeName(value = '') { + const text = String(value || '').trim(); + return /^[A-Za-z0-9_.:/@+-]{1,200}$/.test(text) ? text : ''; +} + +function numeric(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function summarizeSessionEvents(events = [], sessionId = '') { + let model = ''; + let inputTokens = 0; + let outputTokens = 0; + let aiCredits = 0; + let apiDurationMs = 0; + let filesModified = 0; + let linesAdded = 0; + let linesRemoved = 0; + const tools = new Set(); + for (const event of events) { + const type = event?.type || ''; + const data = event?.data && typeof event.data === 'object' ? event.data : {}; + if (type === 'session.start') sessionId = safeName(data.sessionId) || sessionId; + if (type === 'session.model_change') model = safeName(data.newModel) || model; + if (type === 'assistant.message') { + model = safeName(data.model) || model; + for (const request of Array.isArray(data.toolRequests) ? data.toolRequests : []) { + const tool = safeName(request?.name); + if (tool) tools.add(tool); + } + } + if (type === 'tool.execution_start' || type === 'tool.execution_complete') { + const tool = safeName(data.toolName); + if (tool) tools.add(tool); + } + if (type === 'session.shutdown') { + model = safeName(data.currentModel) || model; + const tokenDetails = data.tokenDetails && typeof data.tokenDetails === 'object' ? data.tokenDetails : {}; + inputTokens = numeric(tokenDetails.input?.tokenCount) + + numeric(tokenDetails.cache_read?.tokenCount) + + numeric(tokenDetails.cache_write?.tokenCount); + outputTokens = numeric(tokenDetails.output?.tokenCount); + aiCredits = numeric(data.totalNanoAiu) / 1_000_000_000; + apiDurationMs = numeric(data.totalApiDurationMs); + const codeChanges = data.codeChanges && typeof data.codeChanges === 'object' ? data.codeChanges : {}; + filesModified = Array.isArray(codeChanges.filesModified) + ? codeChanges.filesModified.length + : numeric(codeChanges.filesModified); + linesAdded = numeric(codeChanges.linesAdded); + linesRemoved = numeric(codeChanges.linesRemoved); + } + } + return { + sessionId, + model, + inputTokens, + outputTokens, + aiCredits, + apiDurationMs, + // Set preserves the first observed call order. The receipt is a run story, + // so alphabetic sorting would make an accurate sequence look incorrect. + tools: [...tools], + filesModified, + linesAdded, + linesRemoved + }; +} + +function readSessionSummary(file) { + const events = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); + return summarizeSessionEvents(events, path.basename(path.dirname(file))); +} + +function changedCopilotSession(before = new Map(), root = sessionStateDir()) { + const after = snapshotCopilotSessions(root); + const changed = [...after.entries()] + .filter(([file, mtime]) => !before.has(file) || mtime > before.get(file)) + .sort((left, right) => right[1] - left[1]); + if (!changed.length) return null; + try { + return readSessionSummary(changed[0][0]); + } catch { + return null; + } +} + +module.exports = { + changedCopilotSession, + readSessionSummary, + sessionStateDir, + snapshotCopilotSessions, + summarizeSessionEvents +}; diff --git a/agentops-cli/src/lib/copilot/run-metadata.js b/agentops-cli/src/lib/copilot/run-metadata.js index 209d909..a0fd107 100644 --- a/agentops-cli/src/lib/copilot/run-metadata.js +++ b/agentops-cli/src/lib/copilot/run-metadata.js @@ -1,13 +1,9 @@ -const crypto = require('node:crypto'); const path = require('node:path'); const { optionValue } = require('../args'); +const { prefixedHashOrEmpty: stableHash } = require('../hash'); const { summarizeAllowedTools } = require('./tool-classifier'); -function stableHash(value, prefix = 'hash') { - return `${prefix}_${crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 16)}`; -} - function booleanFlag(args, name) { return args.includes(name); } diff --git a/agentops-cli/src/lib/copilot/session-command.js b/agentops-cli/src/lib/copilot/session-command.js new file mode 100644 index 0000000..8916801 --- /dev/null +++ b/agentops-cli/src/lib/copilot/session-command.js @@ -0,0 +1,111 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const legacy = require('../../legacy'); +const { optionValue, parseJsonFlag } = require('../args'); +const { otlpHttpEndpoint } = require('../collector-endpoints'); +const { writeJsonlFile, writeJsonOrRender } = require('../command-output'); +const { + defaultSessionEventsPath, + enrichCopilotSessionEvents, + readScriptSidecarEvents, + readCopilotSessionEvents +} = require('./session-enricher'); + +function parseCopilotSessionArgs(args = []) { + const [subcommand, sessionId] = args; + return { + subcommand, + sessionId, + file: optionValue(args, '--file'), + sidecarFile: optionValue(args, '--sidecar'), + endpoint: optionValue(args, '--endpoint', otlpHttpEndpoint), + id: optionValue(args, '--id') || legacy.customEventId(), + dryRun: args.includes('--dry-run'), + json: parseJsonFlag(args) + }; +} + +async function buildCopilotSessionEnrichment(options = {}) { + if (options.subcommand !== 'enrich') throw new Error('copilot-session supports: enrich <session-id>'); + if (!options.sessionId && !options.file) throw new Error('copilot-session enrich requires <session-id> or --file <events.jsonl>'); + + const eventsFile = options.file || defaultSessionEventsPath(options.sessionId); + const sessionId = options.sessionId || path.basename(path.dirname(eventsFile)); + const sidecarFile = options.sidecarFile || path.join(process.cwd(), '.agentops', 'sidecar-events.jsonl'); + const rawEvents = [ + ...readCopilotSessionEvents(eventsFile), + ...readScriptSidecarEvents(sidecarFile, sessionId) + ].sort((left, right) => String(left.timestamp || '').localeCompare(String(right.timestamp || ''))); + const rows = enrichCopilotSessionEvents(rawEvents, { sessionId }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-copilot-session-')); + const outFile = path.join(tempDir, 'AgentOpsCopilotSessionEnrichment.jsonl'); + + try { + writeJsonlFile(outFile, rows, { trailingNewline: true }); + const result = await legacy.agentopsCustomImport(outFile, { + id: options.id, + endpoint: options.endpoint, + dryRun: options.dryRun, + last: '2h' + }); + return { + ...result, + ok: result.ok && rows.length > 0, + session_id: sessionId, + source_file: eventsFile, + sidecar_file: sidecarFile, + enriched_rows: rows.length, + event_counts: rows.reduce((counts, row) => { + counts[row.event] = (counts[row.event] || 0) + 1; + return counts; + }, {}), + preview: rows.slice(0, 8).map(row => ({ + event: row.event, + agent: row.agent, + skill: row.attributes?.['agentops.skill.name'] || '', + mcp_server: row.attributes?.['agentops.mcp.server'] || '', + script: row.attributes?.['agentops.script.name'] || '', + tool: row.attributes?.['gen_ai.tool.name'] || '', + outcome: row.outcome || '' + })) + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function renderCopilotSessionEnrichment(result = {}) { + const lines = [ + 'Copilot session enrichment', + `Session: ${result.session_id}`, + `Source: ${result.source_file}`, + `Rows: ${result.enriched_rows}`, + `Dry run: ${Boolean(result.dry_run)}`, + `OK: ${Boolean(result.ok)}` + ]; + if (result.event_counts) { + lines.push('', 'Events:'); + for (const [event, count] of Object.entries(result.event_counts)) lines.push(`- ${event}: ${count}`); + } + if (result.next?.length) { + lines.push('', 'Next:'); + for (const item of result.next) lines.push(`- ${item}`); + } + return `${lines.join('\n')}\n`; +} + +async function copilotSessionCommand(args = []) { + const options = parseCopilotSessionArgs(args); + const result = await buildCopilotSessionEnrichment(options); + writeJsonOrRender(result, options.json, renderCopilotSessionEnrichment); + process.exitCode = result.ok ? 0 : 1; +} + +module.exports = { + buildCopilotSessionEnrichment, + copilotSessionCommand, + parseCopilotSessionArgs, + renderCopilotSessionEnrichment +}; diff --git a/agentops-cli/src/lib/copilot/session-enricher.js b/agentops-cli/src/lib/copilot/session-enricher.js index 3db57d2..b431925 100644 --- a/agentops-cli/src/lib/copilot/session-enricher.js +++ b/agentops-cli/src/lib/copilot/session-enricher.js @@ -1,8 +1,9 @@ -const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { prefixedHash: stableHash } = require('../hash'); + const builtinTools = new Set(['bash', 'skill', 'report_intent', 'read_file', 'run_in_terminal', 'glob']); function safeName(value, fallback = '') { @@ -11,10 +12,6 @@ function safeName(value, fallback = '') { return /^[A-Za-z0-9_.:/@*-]+$/.test(trimmed) ? trimmed : fallback; } -function stableHash(value, prefix = 'hash') { - return `${prefix}_${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16)}`; -} - function defaultSessionEventsPath(sessionId, home = os.homedir()) { const safeSessionId = safeName(sessionId); if (!safeSessionId) throw new Error('session id is required'); @@ -28,6 +25,14 @@ function readCopilotSessionEvents(filePath) { .map(line => JSON.parse(line)); } +function readScriptSidecarEvents(filePath, sessionId) { + if (!filePath || !fs.existsSync(filePath)) return []; + return fs.readFileSync(filePath, 'utf8').split(/\r?\n/) + .filter(Boolean) + .map(line => JSON.parse(line)) + .filter(entry => entry?.type === 'agentops.script.executed' && safeName(entry?.data?.sessionId) === sessionId); +} + function toolRisk(toolName = '', mcpServer = '') { const value = `${toolName} ${mcpServer}`.toLowerCase(); if (value.includes('delete') || value.includes('remove') || value.includes('destroy')) return 'destructive'; @@ -86,6 +91,63 @@ function enrichCopilotSessionEvents(events = [], options = {}) { const type = entry.type || ''; const data = entry.data || {}; + if (type === 'agentops.script.executed') { + const scriptName = safeName(data.scriptName || ''); + const hookType = safeName(data.hookType || 'hook'); + if (!scriptName) return; + rows.push({ + ...eventBase(entry, activeAgent, sessionId, index), + event: 'script.executed', + workflow: 'copilot-cli-session', + step: hookType, + outcome: safeName(data.outcome || 'observed'), + attributes: { + ...eventBase(entry, activeAgent, sessionId, index).attributes, + 'agentops.script.name': scriptName, + 'github.copilot.hook.type': hookType, + 'gen_ai.operation.name': 'script.executed' + } + }); + return; + } + + if (['subagent.started', 'subagent.completed', 'subagent.failed'].includes(type)) { + const subAgent = safeName(data.agentName || data.agentDisplayName || data.agentId || '', 'copilot-subagent'); + const parentAgent = activeAgent || 'github-copilot-cli'; + const correlationSeed = safeName(data.toolCallId || entry.id || `${sessionId}-${index}`); + const delegationId = stableHash(correlationSeed, 'delegation'); + const outcome = type === 'subagent.started' ? 'started' : type === 'subagent.completed' ? 'completed' : 'failed'; + rows.push({ + ...eventBase(entry, subAgent, sessionId, index), + event: type, + agent: subAgent, + parentAgent, + delegationId, + workflow: 'copilot-cli-fleet', + step: 'delegate', + outcome, + custom: { + ...eventBase(entry, subAgent, sessionId, index).custom, + 'agentops.custom.duration_ms': Number(data.durationMs || 0), + 'agentops.custom.total_tokens': Number(data.totalTokens || 0), + 'agentops.custom.tool_count': Number(data.totalToolCalls || 0) + }, + attributes: { + ...eventBase(entry, subAgent, sessionId, index).attributes, + 'agentops.parent_agent.name': parentAgent, + 'agentops.sub_agent.name': subAgent, + 'agentops.delegation.id': delegationId, + 'agentops.subagent.duration_ms': Number(data.durationMs || 0), + 'agentops.subagent.total_tokens': Number(data.totalTokens || 0), + 'agentops.subagent.tool_count': Number(data.totalToolCalls || 0), + 'gen_ai.agent.name': subAgent, + ...(safeName(data.model || '') ? { 'gen_ai.request.model': safeName(data.model) } : {}), + ...(type === 'subagent.failed' ? { 'error.type': 'subagent_failed' } : {}) + } + }); + return; + } + if (type === 'subagent.selected') { const agentName = safeName(data.agentName || data.agentDisplayName || '', activeAgent || 'copilot-agent'); activeAgent = agentName; @@ -224,6 +286,7 @@ module.exports = { enrichCopilotSessionEvents, inferMcpServer, inferMcpTool, + readScriptSidecarEvents, readCopilotSessionEvents, safeName, toolRisk diff --git a/agentops-cli/src/lib/copilot/wrapper-delivery.js b/agentops-cli/src/lib/copilot/wrapper-delivery.js new file mode 100644 index 0000000..2d00241 --- /dev/null +++ b/agentops-cli/src/lib/copilot/wrapper-delivery.js @@ -0,0 +1,80 @@ +const path = require('node:path'); + +const { configuredCloudValues, readAgentOpsConfig } = require('../agentops-config'); +const { createDurableEvidenceSpool } = require('../azure/durable-evidence-spool'); +const { drainDurableLogsIngestion } = require('../azure/logs-ingestion-upload'); +const { deliveryStateFromEnqueue } = require('../delivery-state'); +const { agentopsHome } = require('../paths'); +const { canonicalWrapperEvidence } = require('./wrapper-evidence'); + +function wrapperDeliveryDirectory(env = process.env) { + return path.resolve(env.AGENTOPS_DURABLE_SPOOL_DIR || path.join(agentopsHome, 'delivery-spool')); +} + +function createWrapperDelivery(options = {}) { + const env = options.env || process.env; + const directory = options.directory || wrapperDeliveryDirectory(env); + let spool = options.spool || null; + let initializationError = ''; + if (!spool) { + try { spool = createDurableEvidenceSpool({ directory }); } catch (error) { initializationError = error.message; } + } + + function record(event, recordOptions = {}) { + if (!spool) return { ok: false, state: 'native_best_effort', error: initializationError || 'durable spool unavailable' }; + try { + const evidence = canonicalWrapperEvidence(event, recordOptions); + const queued = spool.enqueue(evidence, { table: 'AgentOpsEvents_CL' }); + return { ok: queued.ok, state: deliveryStateFromEnqueue(queued), evidence, queued }; + } catch (error) { + return { ok: false, state: 'native_best_effort', error: error.message }; + } + } + + async function drain(eventIds = [], drainOptions = {}) { + if (!spool) return { ok: false, configured: false, state: 'native_best_effort', error: initializationError }; + const storedConfig = drainOptions.config || readAgentOpsConfig({ + configPath: env.AGENTOPS_CONFIG_PATH, + quiet: true + }).values; + const cloud = drainOptions.cloud || configuredCloudValues({ env, config: storedConfig }); + if (!cloud.logsIngestionEndpoint || !cloud.dcrImmutableId || !cloud.subscriptionId) { + return { ok: true, configured: false, state: spool.status().pending > 0 ? 'local_pending' : 'native_best_effort' }; + } + try { + const result = await drainDurableLogsIngestion({ + directory, + endpoint: cloud.logsIngestionEndpoint, + dcrImmutableId: cloud.dcrImmutableId, + expectedSubscriptionId: cloud.subscriptionId, + env, + spawnSync: drainOptions.spawnSync, + fetchImpl: drainOptions.fetchImpl, + tokenProvider: drainOptions.tokenProvider, + sleep: drainOptions.sleep, + maxAttempts: drainOptions.maxAttempts || 1 + }); + const wanted = new Set(eventIds.filter(Boolean)); + const acknowledged = new Set(result.acknowledged_event_ids || []); + const allAcknowledged = wanted.size > 0 && [...wanted].every(id => acknowledged.has(id)); + const queueEmptyAfterAcceptance = Number(result.acknowledged || 0) > 0 + && Number(result.status?.pending || 0) === 0 + && Number(result.status?.uploading || 0) === 0; + return { + ok: true, + configured: true, + state: allAcknowledged || queueEmptyAfterAcceptance ? 'azure_acknowledged' : 'local_pending', + result + }; + } catch (error) { + return { ok: false, configured: true, state: 'local_pending', error: error.message }; + } + } + + return { directory, drain, record, status: () => spool?.status() || null }; +} + +module.exports = { + createWrapperDelivery, + wrapperDeliveryDirectory +}; diff --git a/agentops-cli/src/lib/copilot/wrapper-envelope.js b/agentops-cli/src/lib/copilot/wrapper-envelope.js index 4fae86e..69806cf 100644 --- a/agentops-cli/src/lib/copilot/wrapper-envelope.js +++ b/agentops-cli/src/lib/copilot/wrapper-envelope.js @@ -1,7 +1,7 @@ const crypto = require('node:crypto'); -const fs = require('node:fs'); const path = require('node:path'); +const { appendJsonlFile } = require('../command-output'); const { agentopsHome } = require('../paths'); function wrapperId(prefix) { @@ -19,19 +19,34 @@ function wrapperEventsPath() { return process.env.AGENTOPS_WRAPPER_EVENTS_PATH || path.join(agentopsHome, 'wrapper-events.jsonl'); } +function safeWrapperEvent(event = {}) { + const row = { + TimeGenerated: new Date().toISOString(), + EventName: String(event.EventName || 'agentops.wrapper.event').slice(0, 120), + RunId: String(event.RunId || '').slice(0, 200), + SessionId: String(event.SessionId || '').slice(0, 200), + Surface: String(event.Surface || 'cli').slice(0, 40), + PrivacyMode: String(event.PrivacyMode || 'strict').slice(0, 20) + }; + if (event.CollectorMode) row.CollectorMode = String(event.CollectorMode).slice(0, 20); + if (Number.isInteger(event.ExitCode)) row.ExitCode = event.ExitCode; + if (event.FallbackUnobserved !== undefined) row.FallbackUnobserved = Boolean(event.FallbackUnobserved); + if (event.Reason || event.Error) row.ReasonCategory = event.EventName === 'agentops.collector.start_failed' + || event.EventName === 'agentops.wrapper.fallback_unobserved' + ? 'collector_start_failed' + : 'runtime_error'; + return row; +} + function appendWrapperEvent(event, options = {}) { const file = options.file || wrapperEventsPath(); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.appendFileSync(file, `${JSON.stringify({ - TimeGenerated: new Date().toISOString(), - EventName: event.EventName || 'agentops.wrapper.event', - ...event - })}\n`); + appendJsonlFile(file, safeWrapperEvent(event)); return file; } module.exports = { appendWrapperEvent, createWrapperEnvelope, + safeWrapperEvent, wrapperEventsPath }; diff --git a/agentops-cli/src/lib/copilot/wrapper-evidence.js b/agentops-cli/src/lib/copilot/wrapper-evidence.js new file mode 100644 index 0000000..c48341d --- /dev/null +++ b/agentops-cli/src/lib/copilot/wrapper-evidence.js @@ -0,0 +1,52 @@ +const crypto = require('node:crypto'); + +const allowedWrapperEvents = new Set([ + 'agentops.run.start', + 'agentops.run.end', + 'agentops.collector.start_failed', + 'agentops.wrapper.fallback_unobserved' +]); + +function stableEventId(runId, sequence, eventName) { + return `event_${crypto.createHash('sha256').update(`${runId}:${sequence}:${eventName}`).digest('hex').slice(0, 24)}`; +} + +function wrapperStatus(eventName, exitCode) { + if (eventName === 'agentops.run.start') return 'started'; + if (eventName === 'agentops.wrapper.fallback_unobserved') return 'unobserved'; + if (eventName === 'agentops.collector.start_failed') return 'failed'; + return Number(exitCode) === 0 ? 'success' : 'failed'; +} + +function canonicalWrapperEvidence(event = {}, options = {}) { + const eventName = String(event.EventName || ''); + const runId = String(event.RunId || ''); + const sessionId = String(event.SessionId || ''); + const sequence = Number(options.sequence ?? event.Sequence); + if (!allowedWrapperEvents.has(eventName)) throw new Error('Unsupported AgentOps wrapper lifecycle event'); + if (!/^[A-Za-z0-9_.:@+-]{1,200}$/.test(runId)) throw new Error('Wrapper lifecycle evidence requires a safe RunId'); + if (!/^[A-Za-z0-9_.:@+-]{1,200}$/.test(sessionId)) throw new Error('Wrapper lifecycle evidence requires a safe SessionId'); + if (!Number.isSafeInteger(sequence) || sequence < 1) throw new Error('Wrapper lifecycle evidence requires a positive sequence'); + return { + TimeGenerated: options.timeGenerated || new Date().toISOString(), + Sequence: sequence, + EventId: stableEventId(runId, sequence, eventName), + RunId: runId, + SessionId: sessionId, + EventName: eventName, + SpanName: eventName, + Status: wrapperStatus(eventName, event.ExitCode), + ContentCaptureSignal: false, + ContentCaptureMode: 'off', + PrivacyMode: 'strict', + Surface: 'cli', + SchemaVersion: '2' + }; +} + +module.exports = { + allowedWrapperEvents, + canonicalWrapperEvidence, + stableEventId, + wrapperStatus +}; diff --git a/agentops-cli/src/lib/core-command.js b/agentops-cli/src/lib/core-command.js new file mode 100644 index 0000000..f91a31e --- /dev/null +++ b/agentops-cli/src/lib/core-command.js @@ -0,0 +1,78 @@ +const { jsonOutput } = require('./command-output'); + +const coreCommandNames = Object.freeze(['setup', 'status', 'configure', 'config', 'otel-setup', 'init', 'ask-context']); + +function createCoreCommand(dependencies = {}) { + const { + agentopsConfigure, + agentopsInit, + agentopsSetupGuide, + askAgentOpsContext, + buildOtelSetup, + parseAskContextArgs, + parseConfigureArgs, + parseInitArgs, + parseOtelSetupArgs, + parseSetupArgs, + renderAskContext, + renderConfigure, + renderInit, + renderOtelSetup, + renderSetupGuide, + renderStatus, + setExitCode = code => { + process.exitCode = code; + }, + stdout = process.stdout + } = dependencies; + + function coreCommand(command, args) { + if (command === 'setup') { + const options = parseSetupArgs(args); + const result = agentopsSetupGuide(options); + stdout.write(options.json ? jsonOutput(result) : renderSetupGuide(result)); + setExitCode(0); + return; + } + if (command === 'status') { + stdout.write(renderStatus()); + return; + } + if (command === 'configure' || command === 'config') { + const options = parseConfigureArgs(args); + const result = agentopsConfigure(options); + stdout.write(options.json ? jsonOutput(result) : renderConfigure(result)); + setExitCode(result.ok === false ? 1 : 0); + return; + } + if (command === 'otel-setup') { + const options = parseOtelSetupArgs(args); + stdout.write(renderOtelSetup(buildOtelSetup(options), options)); + return; + } + if (command === 'init') { + const options = parseInitArgs(args); + const result = agentopsInit(options); + stdout.write(options.json ? jsonOutput(result) : renderInit(result)); + setExitCode(0); + return; + } + if (command === 'ask-context') { + const options = parseAskContextArgs(args); + const result = askAgentOpsContext(options); + stdout.write(options.json ? jsonOutput(result) : renderAskContext(result)); + setExitCode(result.ok ? 0 : 1); + return; + } + throw new Error(`Unknown core command: ${command}`); + } + + return { + coreCommand, + coreCommandNames + }; +} + +module.exports = { + createCoreCommand +}; diff --git a/agentops-cli/src/lib/custom-telemetry-command.js b/agentops-cli/src/lib/custom-telemetry-command.js new file mode 100644 index 0000000..ad1ec73 --- /dev/null +++ b/agentops-cli/src/lib/custom-telemetry-command.js @@ -0,0 +1,68 @@ +const path = require('node:path'); +const { writeJsonOrRender } = require('./command-output'); + +function writeResult(result, options, renderCustom, stdout, setExitCode) { + writeJsonOrRender(result, options.json, renderCustom, stdout); + setExitCode(result.ok ? 0 : 1); +} + +const customTelemetryCommandNames = Object.freeze(['custom', 'annotation', 'annotate']); + +function createCustomTelemetryCommand(dependencies = {}) { + const { + agentopsAnnotationConfigChange, + agentopsCustomEmit, + agentopsCustomImport, + parseAnnotationArgs, + parseCustomArgs, + renderCustom, + setExitCode = code => { + process.exitCode = code; + }, + stdout = process.stdout + } = dependencies; + + async function customCommand(args) { + const options = parseCustomArgs(args); + if (options.subcommand === 'emit') { + writeResult(await agentopsCustomEmit(options), options, renderCustom, stdout, setExitCode); + return; + } + if (options.subcommand === 'import') { + if (!options.file) throw new Error('custom import requires a file path'); + writeResult(await agentopsCustomImport(path.resolve(options.file), options), options, renderCustom, stdout, setExitCode); + return; + } + throw new Error('custom requires emit or import'); + } + + async function annotationCommand(args) { + const options = parseAnnotationArgs(args); + if (options.subcommand === 'config-change') { + writeResult(await agentopsAnnotationConfigChange(options), options, renderCustom, stdout, setExitCode); + return; + } + throw new Error('annotation requires config-change'); + } + + async function customTelemetryCommand(command, args) { + if (command === 'custom') { + await customCommand(args); + return; + } + if (command === 'annotation' || command === 'annotate') { + await annotationCommand(args); + return; + } + throw new Error(`Unknown custom telemetry command: ${command}`); + } + + return { + customTelemetryCommand, + customTelemetryCommandNames + }; +} + +module.exports = { + createCustomTelemetryCommand +}; diff --git a/agentops-cli/src/lib/custom-telemetry.js b/agentops-cli/src/lib/custom-telemetry.js new file mode 100644 index 0000000..9cf996f --- /dev/null +++ b/agentops-cli/src/lib/custom-telemetry.js @@ -0,0 +1,446 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const { otlpHttpEndpoint } = require('./collector-endpoints'); +const { durationToMs, optionValue, optionValues, parseLastArg } = require('./cli-options'); +const { escapeKqlString, validateKqlDuration } = require('./kql'); +const { readJsonlRows } = require('./json'); +const { postJson } = require('./smoke'); + +const customAttributePrefixes = ['agentops.', 'gen_ai.', 'github.copilot.', 'content.capture.', 'event.', 'error.']; + +function otlpAttr(key, value) { + if (typeof value === 'boolean') return { key, value: { boolValue: value } }; + if (typeof value === 'number') return { key, value: { doubleValue: value } }; + return { key, value: { stringValue: String(value) } }; +} + +function parseKeyValues(values, prefix) { + const attrs = {}; + for (const value of values) { + const separator = value.indexOf('='); + if (separator <= 0) throw new Error(`Expected ${prefix} value as key=value`); + const key = value.slice(0, separator).trim(); + if (!/^[A-Za-z0-9_.-]+$/.test(key)) throw new Error(`Invalid ${prefix} key: ${key}`); + attrs[`${prefix}.${key}`] = value.slice(separator + 1); + } + return attrs; +} + +function parseTelemetryAttributes(values) { + const attrs = {}; + for (const value of values) { + const separator = value.indexOf('='); + if (separator <= 0) throw new Error('Expected attribute value as key=value'); + const key = value.slice(0, separator).trim(); + if (!/^[A-Za-z0-9_.-]+$/.test(key)) throw new Error(`Invalid attribute key: ${key}`); + if (!customAttributePrefixes.some(prefix => key.startsWith(prefix))) { + throw new Error(`Unsupported attribute key: ${key}`); + } + attrs[key] = value.slice(separator + 1); + } + return attrs; +} + +function customAttributeKey(key) { + return key.startsWith('agentops.custom.') ? key : `agentops.custom.${key}`; +} + +function parseCustomArgs(args) { + const [subcommand, ...rest] = args; + const scoreText = optionValue(rest, ['--score']); + const score = scoreText === null ? null : Number(scoreText); + if (scoreText !== null && !Number.isFinite(score)) throw new Error('--score must be a number'); + + return { + subcommand, + file: subcommand === 'import' ? rest[0] : null, + event: optionValue(rest, ['--event', '--name']), + agent: optionValue(rest, ['--agent']), + parentAgent: optionValue(rest, ['--parent-agent']), + delegationId: optionValue(rest, ['--delegation-id']), + workflow: optionValue(rest, ['--workflow']), + step: optionValue(rest, ['--step']), + outcome: optionValue(rest, ['--outcome']), + risk: optionValue(rest, ['--risk']), + score, + entityType: optionValue(rest, ['--entity-type']), + entityIdHash: optionValue(rest, ['--entity-id-hash']), + session: optionValue(rest, ['--session']), + endpoint: optionValue(rest, ['--endpoint']), + runtime: optionValue(rest, ['--runtime']) || process.env.AGENTOPS_RUNTIME || 'github-copilot-cli', + framework: optionValue(rest, ['--framework']) || process.env.AGENTOPS_FRAMEWORK || 'github-copilot', + tags: optionValues(rest, '--tag'), + custom: parseKeyValues(optionValues(rest, '--custom'), 'agentops.custom'), + attributes: parseTelemetryAttributes([ + ...optionValues(rest, '--attribute'), + ...optionValues(rest, '--attr') + ]), + dryRun: rest.includes('--dry-run'), + verify: !rest.includes('--no-verify'), + last: parseLastArg(rest, '2h'), + waitMs: durationToMs(optionValue(rest, ['--wait']), 60000), + pollMs: durationToMs(optionValue(rest, ['--poll']), 10000), + json: rest.includes('--json') + }; +} + +function parseAnnotationArgs(args) { + const [subcommand, ...rest] = args; + return { + subcommand, + component: optionValue(rest, ['--component']), + target: optionValue(rest, ['--target', '--name']), + changeType: optionValue(rest, ['--change-type', '--type']) || 'updated', + changeId: optionValue(rest, ['--change-id']), + version: optionValue(rest, ['--version']), + runId: optionValue(rest, ['--run-id']), + session: optionValue(rest, ['--session', '--session-id']), + traceId: optionValue(rest, ['--trace-id']), + agent: optionValue(rest, ['--agent']) || 'agentops', + risk: optionValue(rest, ['--risk']), + endpoint: optionValue(rest, ['--endpoint']), + runtime: optionValue(rest, ['--runtime']) || process.env.AGENTOPS_RUNTIME || 'github-copilot-cli', + framework: optionValue(rest, ['--framework']) || process.env.AGENTOPS_FRAMEWORK || 'github-copilot', + dryRun: rest.includes('--dry-run'), + verify: !rest.includes('--no-verify'), + last: parseLastArg(rest, '2h'), + waitMs: durationToMs(optionValue(rest, ['--wait']), 60000), + pollMs: durationToMs(optionValue(rest, ['--poll']), 10000), + json: rest.includes('--json') + }; +} + +function customEventId(now = new Date()) { + const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + return `agentops-custom-${stamp}-${crypto.randomBytes(3).toString('hex')}`; +} + +function rowAttributes(row) { + const attrs = row.attributes || row.Properties || row.properties || {}; + if (typeof attrs !== 'string') return attrs; + + try { + return JSON.parse(attrs); + } catch { + return {}; + } +} + +function normalizeCustomEvent(row = {}, defaults = {}, index = 0) { + const attrs = rowAttributes(row); + const custom = { + ...(row.custom || {}), + ...(row.metrics || {}) + }; + const attributes = { + ...(row.attributes || {}), + ...(row.attrs || {}) + }; + const event = row.event || row.event_name || row.name || attrs['agentops.event.name'] || attrs['event.name'] || defaults.event || 'agent.event'; + const agent = row.agent || row.agent_name || attrs['agentops.agent.name'] || attrs['gen_ai.agent.name'] || defaults.agent || 'custom-agent'; + const parentAgent = row.parentAgent || row.parent_agent || attrs['agentops.parent_agent.name'] || defaults.parentAgent || null; + const delegationId = row.delegationId || row.delegation_id || attrs['agentops.delegation.id'] || defaults.delegationId || null; + const workflow = row.workflow || row.workflow_name || attrs['agentops.workflow.name'] || defaults.workflow || null; + const step = row.step || row.step_name || attrs['agentops.step.name'] || null; + const session = row.session || row.session_id || row.conversation_id || attrs['gen_ai.conversation.id'] || defaults.session || null; + + return { + event, + agent, + parentAgent, + delegationId, + workflow, + step, + session, + outcome: row.outcome || attrs['agentops.outcome'] || null, + risk: row.risk || attrs['agentops.risk'] || null, + score: row.score === undefined || row.score === null ? null : Number(row.score), + entityType: row.entityType || row.entity_type || attrs['agentops.entity.type'] || null, + entityIdHash: row.entityIdHash || row.entity_id_hash || attrs['agentops.entity.id_hash'] || null, + tags: Array.isArray(row.tags) ? row.tags : [], + custom: { + ...Object.fromEntries(Object.entries(custom).map(([key, value]) => [customAttributeKey(key), value])), + 'agentops.custom.row_index': index + }, + attributes: parseTelemetryAttributes( + Object.entries(attributes).map(([key, value]) => `${key}=${value}`) + ) + }; +} + +function customEventAttributes(event, defaults = {}) { + if (!event.event) throw new Error('custom event requires --event'); + if (!event.agent) throw new Error('custom event requires --agent'); + + const attrs = { + 'agentops.custom_event_id': defaults.id, + 'agentops.schema.version': '1', + 'agentops.event.kind': 'agent.event', + 'agentops.event.name': event.event, + 'gen_ai.operation.name': event.event, + 'gen_ai.agent.name': event.agent, + 'agentops.agent.name': event.agent, + 'gen_ai.conversation.id': event.session || defaults.session || defaults.id, + 'content.capture.enabled': false, + ...event.custom, + ...event.attributes + }; + if (event.workflow) attrs['agentops.workflow.name'] = event.workflow; + if (event.parentAgent) attrs['agentops.parent_agent.name'] = event.parentAgent; + if (event.delegationId) attrs['agentops.delegation.id'] = event.delegationId; + if (event.step) attrs['agentops.step.name'] = event.step; + if (event.outcome) attrs['agentops.outcome'] = event.outcome; + if (event.risk) attrs['agentops.risk'] = event.risk; + if (event.score !== null && event.score !== undefined && Number.isFinite(event.score)) attrs['agentops.score'] = event.score; + if (event.entityType) attrs['agentops.entity.type'] = event.entityType; + if (event.entityIdHash) attrs['agentops.entity.id_hash'] = event.entityIdHash; + if (event.tags?.length) attrs['agentops.tags'] = event.tags.join(','); + return Object.entries(attrs).map(([key, value]) => otlpAttr(key, value)); +} + +function otlpCustomEventPayload(events, options = {}) { + const id = options.id || customEventId(options.now); + const traceId = crypto.randomBytes(16).toString('hex'); + const start = BigInt(options.nowMs || Date.now()) * 1000000n; + const normalized = events.map((event, index) => normalizeCustomEvent(event, { ...options, id }, index)); + const spans = normalized.map((event, index) => { + const spanStart = start + BigInt(index * 10) * 1000000n; + return { + traceId, + spanId: crypto.randomBytes(8).toString('hex'), + name: `agentops.custom.${event.event}`, + kind: 1, + startTimeUnixNano: spanStart.toString(), + endTimeUnixNano: (spanStart + 10000000n).toString(), + attributes: customEventAttributes(event, { ...options, id }), + status: { code: event.outcome === 'failed' ? 2 : 1 } + }; + }); + + return { + id, + normalized, + payload: { + resourceSpans: [ + { + resource: { + attributes: [ + otlpAttr('service.name', options.serviceName || 'github-copilot-cli'), + otlpAttr('service.namespace', 'copilot-agentops'), + otlpAttr('agent.framework', options.framework || 'github-copilot'), + otlpAttr('agent.runtime', options.runtime || 'github-copilot-cli'), + otlpAttr('agentops.profile', 'custom-event'), + otlpAttr('agentops.custom_event_id', id) + ] + }, + scopeSpans: [ + { + scope: { name: 'agentops.custom-event', version: '0.1.0' }, + spans + } + ] + } + ] + } + }; +} + +function customAzureQuery(id, last = '2h') { + const lookback = validateKqlDuration(last); + const escapedId = escapeKqlString(id); + return `AppDependencies\n| where TimeGenerated > ago(${lookback})\n| where Properties has "${escapedId}"\n| extend Event=tostring(Properties["agentops.event.name"]), Agent=tostring(Properties["agentops.agent.name"]), Workflow=tostring(Properties["agentops.workflow.name"]), Step=tostring(Properties["agentops.step.name"])\n| project TimeGenerated, Name, Event, Agent, Workflow, Step, OperationId, Success, Properties\n| order by TimeGenerated desc\n| take 50`; +} + +async function agentopsCustomEmit(options = {}) { + const endpoint = (options.endpoint || otlpHttpEndpoint).replace(/\/$/, ''); + const id = options.id || customEventId(options.now); + const event = { + event: options.event, + agent: options.agent, + parentAgent: options.parentAgent, + delegationId: options.delegationId, + workflow: options.workflow, + step: options.step, + session: options.session, + outcome: options.outcome, + risk: options.risk, + score: options.score, + entityType: options.entityType, + entityIdHash: options.entityIdHash, + tags: options.tags || [], + custom: options.custom || {}, + attributes: options.attributes || {} + }; + const { normalized, payload } = otlpCustomEventPayload([event], { ...options, id }); + const result = { + ok: true, + custom_event_id: id, + endpoint, + dry_run: Boolean(options.dryRun), + verify: options.verify !== false, + workspace_id: options.workspaceId || options.defaultWorkspaceId, + azure_query: customAzureQuery(id, options.last || '2h'), + events: normalized, + payload_preview: { + event: normalized[0].event, + agent: normalized[0].agent, + workflow: normalized[0].workflow, + step: normalized[0].step, + content_capture_enabled: false + } + }; + if (options.dryRun) return result; + + const response = await (options.postJson || postJson)(`${endpoint}/v1/traces`, payload, options); + return { + ...result, + ok: response.ok, + collector_response: response, + next: response.ok + ? ['node agentops-cli/src/index.js attribution --last 2h', 'Open Grafana: AgentOps Attribution or Runtime Events.'] + : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] + }; +} + +async function agentopsAnnotationConfigChange(options = {}) { + if (!options.component) throw new Error('annotation config-change requires --component'); + if (!options.target) throw new Error('annotation config-change requires --target'); + + const custom = { + 'agentops.custom.annotation_type': 'config_change', + 'agentops.custom.component': options.component, + 'agentops.custom.target': options.target, + 'agentops.custom.change_type': options.changeType || 'updated' + }; + if (options.changeId) custom['agentops.custom.change_id'] = options.changeId; + if (options.version) custom['agentops.custom.version'] = options.version; + + const attributes = { + ...(options.runId ? { 'agentops.run.id': options.runId } : {}), + ...(options.traceId ? { 'agentops.trace.id': options.traceId } : {}) + }; + + return agentopsCustomEmit({ + ...options, + event: 'agentops.config.changed', + workflow: 'config-change', + step: options.component, + outcome: 'changed', + entityType: options.component, + entityIdHash: options.target, + tags: ['annotation', 'config-change'], + custom, + attributes + }); +} + +function importJsonl(filePath) { + const rows = readJsonlRows(filePath); + const operations = new Map(); + + for (const row of rows) { + const operation = row.name || row.operation || row.attributes?.['gen_ai.operation.name'] || 'unknown'; + operations.set(operation, (operations.get(operation) || 0) + 1); + } + + return { + file: filePath, + rows: rows.length, + operations: Object.fromEntries(operations) + }; +} + +async function agentopsCustomImport(filePath, options = {}) { + const rows = readJsonlRows(filePath); + const endpoint = (options.endpoint || otlpHttpEndpoint).replace(/\/$/, ''); + const id = options.id || customEventId(options.now); + const events = rows.map((row, index) => normalizeCustomEvent(row, { ...options, id }, index)); + const { payload } = otlpCustomEventPayload(events, { ...options, id }); + const result = { + ok: true, + file: filePath, + rows: rows.length, + custom_event_id: id, + endpoint, + dry_run: Boolean(options.dryRun), + workspace_id: options.workspaceId || options.defaultWorkspaceId, + azure_query: customAzureQuery(id, options.last || '2h'), + events: events.slice(0, 5) + }; + if (options.dryRun) return result; + + const response = await (options.postJson || postJson)(`${endpoint}/v1/traces`, payload, options); + return { + ...result, + ok: response.ok, + collector_response: response, + next: response.ok + ? ['node agentops-cli/src/index.js attribution --last 2h', 'Open Grafana: AgentOps Attribution or Runtime Events.'] + : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] + }; +} + +function createCustomTelemetryContext(dependencies = {}) { + const { defaultWorkspaceId } = dependencies; + const emit = agentopsCustomEmit; + const annotationConfigChange = agentopsAnnotationConfigChange; + const customImport = agentopsCustomImport; + + return { + agentopsCustomEmit(options = {}) { + return emit({ defaultWorkspaceId, ...options }); + }, + agentopsAnnotationConfigChange(options = {}) { + return annotationConfigChange({ defaultWorkspaceId, ...options }); + }, + agentopsCustomImport(filePath, options = {}) { + return customImport(filePath, { defaultWorkspaceId, ...options }); + }, + importJsonl + }; +} + +function renderCustom(result) { + const lines = [ + 'AgentOps custom telemetry', + '', + `Custom event id: ${result.custom_event_id}`, + `Endpoint: ${result.endpoint}`, + `Mode: ${result.dry_run ? 'dry-run' : 'sent'}`, + `Events: ${result.events.length}` + ]; + for (const event of result.events.slice(0, 5)) { + lines.push(`- ${event.event} agent=${event.agent}${event.workflow ? ` workflow=${event.workflow}` : ''}${event.step ? ` step=${event.step}` : ''}`); + } + if (result.collector_response) { + lines.push(result.collector_response.ok + ? `Collector response: ${result.collector_response.statusCode || 'ok'}.` + : `Collector response: failed (${result.collector_response.error || result.collector_response.statusCode || 'unknown'}).`); + } + lines.push('', 'Azure query:', result.azure_query); + if (result.next?.length) { + lines.push('', 'Next:'); + for (const item of result.next) lines.push(`- ${item}`); + } + return `${lines.join('\n')}\n`; +} + +module.exports = { + agentopsAnnotationConfigChange, + agentopsCustomEmit, + agentopsCustomImport, + createCustomTelemetryContext, + customAzureQuery, + customEventAttributes, + customEventId, + customAttributeKey, + importJsonl, + normalizeCustomEvent, + otlpAttr, + otlpCustomEventPayload, + parseAnnotationArgs, + parseCustomArgs, + parseKeyValues, + parseTelemetryAttributes, + renderCustom +}; diff --git a/agentops-cli/src/lib/dashboard-command.js b/agentops-cli/src/lib/dashboard-command.js new file mode 100644 index 0000000..abf91f2 --- /dev/null +++ b/agentops-cli/src/lib/dashboard-command.js @@ -0,0 +1,47 @@ +const { writeJson } = require('./command-output'); +const { validateDashboardContentGuardrails } = require('./dashboard-content-guardrails'); +const { dashboardImportPlan, runDashboardImport } = require('./dashboard-import'); +const { dashboardKqlCheck, substituteGrafanaMacros } = require('./dashboard-kql-check'); +const { dashboardVerify } = require('./dashboard-verify'); +const { + validateDashboardFilters, + validateDashboardLinks, + validateDashboardUx, + validateDashboards +} = require('./dashboard-validation'); + +function dashboardCommand(args = []) { + const [subcommand = 'validate'] = args; + if (!['validate', 'links-check', 'filters-check', 'ux-check', 'content-check', 'kql-check', 'verify', 'import'].includes(subcommand)) throw new Error('dashboard supports: validate|links-check|filters-check|ux-check|content-check|kql-check|verify|import'); + const result = subcommand === 'links-check' + ? validateDashboardLinks() + : subcommand === 'filters-check' + ? validateDashboardFilters() + : subcommand === 'content-check' + ? validateDashboardContentGuardrails() + : subcommand === 'ux-check' + ? validateDashboardUx() + : subcommand === 'verify' + ? dashboardVerify(args.slice(1)) + : subcommand === 'kql-check' + ? dashboardKqlCheck(args.slice(1)) + : subcommand === 'import' + ? runDashboardImport(args.slice(1)) + : validateDashboards(); + writeJson(result); + if (!result.ok) process.exitCode = 1; +} + +module.exports = { + dashboardImportPlan, + dashboardCommand, + dashboardKqlCheck, + dashboardVerify, + runDashboardImport, + substituteGrafanaMacros, + validateDashboardContentGuardrails, + validateDashboardLinks, + validateDashboardFilters, + validateDashboardUx, + validateDashboards +}; diff --git a/agentops-cli/src/lib/dashboard-content-guardrails.js b/agentops-cli/src/lib/dashboard-content-guardrails.js index cb9136b..caac2c3 100644 --- a/agentops-cli/src/lib/dashboard-content-guardrails.js +++ b/agentops-cli/src/lib/dashboard-content-guardrails.js @@ -1,6 +1,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { readJson } = require('./json'); const { repoRoot } = require('./paths'); const explicitContentViewerTitle = 'Prompt and response viewer (explicit opt-in)'; @@ -36,7 +37,7 @@ function v2DashboardFiles(root = repoRoot) { function loadV2Dashboards(root = repoRoot) { return v2DashboardFiles(root).map(file => ({ file, - body: JSON.parse(fs.readFileSync(file, 'utf8')) + body: readJson(file) })); } diff --git a/agentops-cli/src/lib/dashboard-import.js b/agentops-cli/src/lib/dashboard-import.js new file mode 100644 index 0000000..4f1a6bc --- /dev/null +++ b/agentops-cli/src/lib/dashboard-import.js @@ -0,0 +1,100 @@ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { hasFlag, optionValue } = require('./args'); +const { dashboardJsonFiles } = require('./dashboard-validation'); +const { repoRoot } = require('./paths'); +const { checkAzureSubscription } = require('./azure/subscription-guard'); + +function dashboardImportPlan(args = [], options = {}) { + const env = options.env || process.env; + const v2Only = !hasFlag(args, '--all'); + const folder = optionValue(args, '--folder', v2Only ? 'AgentOps for Azure' : 'AgentOps'); + const resourceGroup = optionValue(args, '--resource-group', env.AZURE_RESOURCE_GROUP || ''); + const grafanaName = optionValue(args, '--grafana-name', env.GRAFANA_NAME || env.AGENTOPS_GRAFANA_NAME || ''); + const script = path.join(repoRoot, 'scripts', 'grafana-import-dashboard.sh'); + const files = dashboardJsonFiles() + .filter(file => !v2Only || file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)); + const command = [ + `GRAFANA_FOLDER=${JSON.stringify(folder)}`, + v2Only ? 'AGENTOPS_V2_ONLY=true' : 'AGENTOPS_V2_ONLY=false AGENTOPS_INCLUDE_V2=true AGENTOPS_INCLUDE_LEGACY=true', + resourceGroup ? `AZURE_RESOURCE_GROUP=${JSON.stringify(resourceGroup)}` : 'AZURE_RESOURCE_GROUP=<resource-group>', + grafanaName ? `GRAFANA_NAME=${JSON.stringify(grafanaName)}` : 'GRAFANA_NAME=<managed-grafana-name>', + script + ].join(' '); + + return { + ok: files.length > 0, + dry_run: !hasFlag(args, '--yes'), + v2_only: v2Only, + folder, + script, + dashboards: files.length, + files, + requires: [ + 'az login', + 'Azure CLI amg extension', + 'Grafana Editor/Admin access', + 'Azure Monitor datasource UID configured' + ], + command, + errors: files.length > 0 ? [] : ['no dashboards found to import'] + }; +} + +function runDashboardImport(args = [], options = {}) { + const plan = dashboardImportPlan(args, options); + if (!plan.ok || plan.dry_run) return plan; + + const env = { + ...(options.env || process.env), + GRAFANA_FOLDER: plan.folder, + AGENTOPS_V2_ONLY: plan.v2_only ? 'true' : 'false', + AGENTOPS_INCLUDE_V2: 'true', + AGENTOPS_INCLUDE_LEGACY: plan.v2_only ? 'false' : 'true' + }; + const resourceGroup = optionValue(args, '--resource-group', env.AZURE_RESOURCE_GROUP || ''); + const grafanaName = optionValue(args, '--grafana-name', env.GRAFANA_NAME || env.AGENTOPS_GRAFANA_NAME || ''); + if (resourceGroup) env.AZURE_RESOURCE_GROUP = resourceGroup; + if (grafanaName) env.GRAFANA_NAME = grafanaName; + + const spawn = options.spawnSync || spawnSync; + const subscription = checkAzureSubscription({ + spawnSync: spawn, + env, + expectedSubscriptionId: options.expectedSubscriptionId, + approvedSubscriptionIds: options.approvedSubscriptionIds + }); + if (!subscription.ok) { + return { + ...plan, + dry_run: false, + ok: false, + executed: false, + subscription_guard: subscription, + errors: [subscription.error] + }; + } + const result = spawn(plan.script, [], { + cwd: repoRoot, + env, + encoding: 'utf8' + }); + + return { + ...plan, + dry_run: false, + executed: true, + subscription_guard: subscription, + ok: result.status === 0, + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + errors: result.status === 0 ? [] : [result.stderr || result.stdout || `dashboard import exited ${result.status}`] + }; +} + +module.exports = { + dashboardImportPlan, + runDashboardImport +}; diff --git a/agentops-cli/src/lib/dashboard-kql-check.js b/agentops-cli/src/lib/dashboard-kql-check.js new file mode 100644 index 0000000..d7d2aa8 --- /dev/null +++ b/agentops-cli/src/lib/dashboard-kql-check.js @@ -0,0 +1,138 @@ +const { hasFlag, optionValue } = require('./args'); +const { + queryFromPanel, + v2DashboardBodies +} = require('./dashboard-validation'); +const legacy = require('../legacy'); + +const v2KqlSmokePanels = [ + { uid: 'agentops-v2-home', panel: 'Session Health', requireRows: true }, + { uid: 'agentops-v2-home', panel: 'Recommended next actions', requireRows: true }, + { uid: 'agentops-v2-home', panel: 'Saved investigations', requireRows: false }, + { uid: 'agentops-v2-runs-explorer', panel: 'Runs', requireRows: true }, + { uid: 'agentops-v2-run-replay', panel: 'Run summary', requireRows: true }, + { uid: 'agentops-v2-run-replay', panel: 'Agent, skill, and MCP lineage', requireRows: true }, + { uid: 'agentops-v2-run-replay', panel: 'Context and cache posture', requireRows: true }, + { uid: 'agentops-v2-run-replay', panel: 'Why this failed / next check', requireRows: true }, + { uid: 'agentops-v2-run-replay', panel: 'Ask AgentOps context', requireRows: true }, + { uid: 'agentops-v2-run-replay', panel: 'Transcript availability', requireRows: true }, + { uid: 'agentops-v2-run-replay', panel: 'Prompt and response viewer (explicit opt-in)', requireRows: false }, + { uid: 'agentops-v2-models-cost-tokens', panel: 'Model ROI', requireRows: true }, + { uid: 'agentops-v2-tools-mcp-risk', panel: 'Tool risk table', requireRows: true }, + { uid: 'agentops-v2-safety-privacy-policy', panel: 'Blocked or redacted items by kind', requireRows: true }, + { uid: 'agentops-v2-safety-privacy-policy', panel: 'Alert handoff review', requireRows: false }, + { uid: 'agentops-v2-code-outcomes', panel: 'Runs and PR outcomes', requireRows: true }, + { uid: 'agentops-v2-code-outcomes', panel: 'Delivery timing', requireRows: true }, + { uid: 'agentops-v2-evals-quality', panel: 'Low-score runs', requireRows: true }, + { uid: 'agentops-v2-evals-quality', panel: 'Eval scorecard by repo, model, and task', requireRows: true }, + { uid: 'agentops-v2-evals-quality', panel: 'Eval regression follow-up', requireRows: false }, + { uid: 'agentops-v2-evals-quality', panel: 'Before/after run comparison', requireRows: false }, + { uid: 'agentops-v2-evals-quality', panel: 'Benchmark artifact diff review', requireRows: false }, + { uid: 'agentops-v2-evals-quality', panel: 'Benchmark artifact files', requireRows: false }, + { uid: 'agentops-v2-evals-quality', panel: 'Benchmark hidden check packs', requireRows: false }, + { uid: 'agentops-v2-evals-quality', panel: 'Benchmark policy review', requireRows: false }, + { uid: 'agentops-v2-evals-quality', panel: 'Benchmark semantic checks', requireRows: false }, + { uid: 'agentops-v2-evals-quality', panel: 'Benchmark promotion approvals', requireRows: false }, + { uid: 'agentops-v2-insights-regressions', panel: 'Latest insights', requireRows: true }, + { uid: 'agentops-v2-insights-regressions', panel: 'Recurring patterns', requireRows: false }, + { uid: 'agentops-v2-insights-regressions', panel: 'Eval regression queue', requireRows: false }, + { uid: 'agentops-v2-insights-regressions', panel: 'Recommendation artifacts', requireRows: false }, + { uid: 'agentops-v2-insights-regressions', panel: 'Config change annotations', requireRows: false }, + { uid: 'agentops-v2-collector-health', panel: 'Collector checks', requireRows: true }, + { uid: 'agentops-v2-collector-health', panel: 'Schema version coverage', requireRows: false }, + { uid: 'agentops-v2-collector-health', panel: 'Exporter failure review', requireRows: false } +]; + +function substituteGrafanaMacros(query, { last = '24h' } = {}) { + const safeLast = legacy.validateKqlDuration(last); + const variableNames = [ + 'datasource', + 'workspace', + 'timeRange', + 'run_id', + 'session_id', + 'trace_id', + 'surface', + 'repo_hash', + 'branch_hash', + 'model', + 'agent_name', + 'skill_name', + 'mcp_server', + 'sub_agent', + 'task_type', + 'tool_name', + 'tool_risk', + 'pattern_key', + 'privacy_mode', + 'outcome_status', + 'eval_bucket' + ]; + let rendered = String(query || '') + .replaceAll('$__timeFrom()', `ago(${safeLast})`) + .replaceAll('$__timeTo()', 'now()') + .replaceAll('$__interval', '1h'); + for (const name of variableNames) { + rendered = rendered + .replaceAll(`$${name}`, '__all') + .replaceAll(`\${${name}}`, '__all'); + } + return `${rendered}\n| take 5`; +} + +function dashboardKqlCheck(args = [], options = {}) { + const last = optionValue(args, '--last', '24h'); + const requireRows = hasFlag(args, '--require-rows'); + const runQuery = options.runQuery || ((query, queryOptions) => legacy.runAzureLogAnalyticsQuery(query, queryOptions)); + const dashboards = (options.dashboardBodies || v2DashboardBodies)(); + const smokePanels = options.smokePanels || v2KqlSmokePanels; + const byUid = new Map(dashboards.map(item => [item.body.uid, item])); + const checks = []; + + for (const smokePanel of smokePanels) { + const { uid, panel: panelTitle } = smokePanel; + const dashboard = byUid.get(uid); + if (!dashboard) { + checks.push({ uid, panel: panelTitle, ok: false, rows: 0, error: 'dashboard not found' }); + continue; + } + const panel = (dashboard.body.panels || []).find(item => item.title === panelTitle && queryFromPanel(item)); + const rawQuery = queryFromPanel(panel); + if (!rawQuery) { + checks.push({ uid, panel: panelTitle, ok: false, rows: 0, error: 'panel query not found' }); + continue; + } + const query = substituteGrafanaMacros(rawQuery, { last }); + const result = runQuery(query, { + spawnSync: options.spawnSync, + workspaceId: optionValue(args, '--workspace-id', options.workspaceId) + }); + const rows = Array.isArray(result.rows) ? result.rows.length : 0; + const rowsRequired = requireRows && smokePanel.requireRows !== false; + const ok = Boolean(result.ok) && (!rowsRequired || rows > 0); + checks.push({ + uid, + panel: panelTitle, + ok, + rows, + require_rows: rowsRequired, + error: ok ? '' : (result.error || (rowsRequired ? 'query returned no rows' : 'query failed')), + query + }); + } + + const errors = checks.filter(check => !check.ok).map(check => `${check.uid}/${check.panel}: ${check.error}`); + return { + ok: errors.length === 0, + last: legacy.validateKqlDuration(last), + require_rows: requireRows, + checks: checks.map(({ query, ...check }) => check), + errors + }; +} + +module.exports = { + dashboardKqlCheck, + substituteGrafanaMacros, + v2KqlSmokePanels +}; diff --git a/agentops-cli/src/lib/dashboard-validation.js b/agentops-cli/src/lib/dashboard-validation.js new file mode 100644 index 0000000..4f0bf2e --- /dev/null +++ b/agentops-cli/src/lib/dashboard-validation.js @@ -0,0 +1,558 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { readJson } = require('./json'); +const { repoRoot } = require('./paths'); + +function dashboardJsonFiles() { + const roots = [ + path.join(repoRoot, 'grafana'), + path.join(repoRoot, 'grafana', 'dashboards', 'v2') + ]; + return roots.flatMap(root => { + if (!fs.existsSync(root)) return []; + return fs.readdirSync(root) + .filter(file => file.endsWith('.json')) + .map(file => path.join(root, file)); + }).sort(); +} + +function validateDashboards() { + const files = dashboardJsonFiles(); + const errors = []; + const requiredV2Variables = new Set([ + 'datasource', + 'workspace', + 'timeRange', + 'actioner_url', + 'run_id', + 'session_id', + 'trace_id', + 'surface', + 'repo_hash', + 'branch_hash', + 'model', + 'agent_name', + 'skill_name', + 'mcp_server', + 'sub_agent', + 'task_type', + 'tool_name', + 'tool_risk', + 'pattern_key', + 'privacy_mode', + 'outcome_status', + 'eval_bucket' + ]); + + for (const file of files) { + let dashboard; + try { + dashboard = readJson(file); + } catch (error) { + errors.push(`${file}: invalid JSON: ${error.message}`); + continue; + } + if (!dashboard.uid) errors.push(`${file}: missing uid`); + if (!dashboard.title) errors.push(`${file}: missing title`); + if (!Array.isArray(dashboard.panels) || dashboard.panels.length === 0) errors.push(`${file}: missing panels`); + if (file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)) { + const variables = new Set((dashboard.templating?.list || []).map(item => item.name)); + for (const variable of requiredV2Variables) { + if (!variables.has(variable)) errors.push(`${file}: missing V2 variable ${variable}`); + } + if (!Array.isArray(dashboard.links) || dashboard.links.length < 5) errors.push(`${file}: missing V2 nav links`); + } + } + + return { + ok: errors.length === 0, + dashboards: files.length, + errors + }; +} + +function collectPanelLinks(panel, links = []) { + for (const link of panel.fieldConfig?.defaults?.links || []) links.push({ panel: panel.title, link }); + for (const override of panel.fieldConfig?.overrides || []) { + const field = override.matcher?.options || 'unknown-field'; + for (const property of override.properties || []) { + if (property.id !== 'links') continue; + for (const link of property.value || []) links.push({ panel: panel.title, field, link }); + } + } + for (const child of panel.panels || []) collectPanelLinks(child, links); + return links; +} + +function validateDashboardLinks() { + const files = dashboardJsonFiles().filter(file => file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)); + const dashboards = files.map(file => ({ file, body: readJson(file) })); + const uidSet = new Set(dashboards.map(item => item.body.uid)); + const errors = []; + const expectedNav = [ + 'agentops-v2-home', + 'agentops-v2-runs-explorer', + 'agentops-v2-run-replay', + 'agentops-v2-models-cost-tokens', + 'agentops-v2-tools-mcp-risk', + 'agentops-v2-safety-privacy-policy', + 'agentops-v2-code-outcomes', + 'agentops-v2-evals-quality', + 'agentops-v2-insights-regressions', + 'agentops-v2-collector-health' + ]; + const requiredDataLinks = [ + { field: 'RunId', uid: 'agentops-v2-run-replay', variable: 'var-run_id', time: true }, + { field: 'SessionId', uid: 'agentops-v2-run-replay', variable: 'var-session_id', time: true }, + { field: 'TraceId', uid: 'agentops-v2-run-replay', variable: 'var-trace_id', time: true }, + { field: 'ToolName', uid: 'agentops-v2-tools-mcp-risk', variable: 'var-tool_name', time: true }, + { field: 'McpServer', uid: 'agentops-v2-tools-mcp-risk', variable: 'var-mcp_server', time: true }, + { field: 'ModelActual', uid: 'agentops-v2-models-cost-tokens', variable: 'var-model', time: true }, + { field: 'AgentName', uid: 'agentops-v2-runs-explorer', variable: 'var-agent_name', time: true }, + { field: 'SkillName', uid: 'agentops-v2-runs-explorer', variable: 'var-skill_name', time: true }, + { field: 'SubAgentName', uid: 'agentops-v2-run-replay', variable: 'var-sub_agent', time: true }, + { field: 'RepoHash', uid: 'agentops-v2-runs-explorer', variable: 'var-repo_hash', time: true }, + { field: 'PrNumberHash', uid: 'agentops-v2-code-outcomes', variable: 'var-repo_hash', time: true }, + { field: 'CiStatus', uid: 'agentops-v2-code-outcomes', variable: 'var-outcome_status', time: true }, + { field: 'EvalOverall', uid: 'agentops-v2-evals-quality', variable: 'var-run_id', time: true }, + { field: 'PatternKey', uid: 'agentops-v2-insights-regressions', variable: 'var-pattern_key', time: true }, + { field: 'OpenTranscript', uid: 'agentops-v2-run-replay', variable: 'viewPanel=26', time: true }, + { field: 'OpenReplay', uid: 'agentops-v2-run-replay', variable: 'var-run_id', time: true }, + { field: 'OpenTrace', uid: 'agentops-v2-run-replay', variable: 'var-trace_id', time: true }, + { field: 'OpenGithub', uid: 'agentops-v2-code-outcomes', variable: 'var-run_id', time: true }, + { field: 'OpenPattern', uid: 'agentops-v2-insights-regressions', variable: 'var-pattern_key', time: true } + ]; + + for (const { file, body } of dashboards) { + const navUids = new Set((body.links || []).map(link => link.uid).filter(Boolean)); + for (const uid of expectedNav) { + if (!navUids.has(uid)) errors.push(`${file}: missing nav link to ${uid}`); + } + for (const link of body.links || []) { + if (link.uid && !uidSet.has(link.uid)) errors.push(`${file}: nav target ${link.uid} does not exist`); + if (link.uid && link.url !== `/d/${link.uid}`) errors.push(`${file}: nav link ${link.uid} should use /d/${link.uid}`); + if (link.uid && link.keepTime !== true) errors.push(`${file}: nav link ${link.uid} must preserve the active time range`); + if (link.uid && link.includeVars !== true) errors.push(`${file}: nav link ${link.uid} must preserve active dashboard filters`); + } + + const panelLinks = (body.panels || []).flatMap(panel => collectPanelLinks(panel)); + for (const item of panelLinks) { + const match = String(item.link?.url || '').match(/\/d\/([^?]+)/); + if (match && !uidSet.has(match[1])) errors.push(`${file}: panel ${item.panel} links to missing dashboard ${match[1]}`); + } + for (const required of requiredDataLinks) { + const matching = panelLinks.filter(item => item.field === required.field); + if (matching.length === 0) { + errors.push(`${file}: missing data link for ${required.field}`); + continue; + } + if (!matching.some(item => String(item.link.url || '').includes(`/d/${required.uid}`))) { + errors.push(`${file}: ${required.field} data link does not target ${required.uid}`); + } + if (!matching.some(item => String(item.link.url || '').includes(required.variable))) { + errors.push(`${file}: ${required.field} data link does not set ${required.variable}`); + } + if (required.time && !matching.some(item => String(item.link.url || '').includes('__url_time_range'))) { + errors.push(`${file}: ${required.field} data link does not preserve time range`); + } + } + } + + return { + ok: errors.length === 0, + dashboards: dashboards.length, + checked_links: dashboards.reduce((total, item) => total + (item.body.links || []).length + (item.body.panels || []).flatMap(panel => collectPanelLinks(panel)).length, 0), + errors + }; +} + +function v2DashboardBodies() { + return dashboardJsonFiles() + .filter(file => file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`)) + .map(file => ({ file, body: readJson(file) })); +} + +function queryFromPanel(panel) { + return panel?.targets?.[0]?.azureLogAnalytics?.query || panel?.targets?.[0]?.query || ''; +} + +function validateDashboardFilters() { + const dashboards = v2DashboardBodies(); + const errors = []; + const requiredChoices = { + surface: ['__all', 'cli', 'sdk', 'vscode_mcp', 'github_action', 'cloud_agent', 'custom'], + task_type: ['__all', 'explain', 'review', 'test', 'fix', 'refactor', 'docs', 'debug_ci', 'unknown'], + tool_risk: ['__all', 'read-only', 'write-file', 'shell', 'network', 'secret-access', 'browser-control', 'destructive', 'privileged'], + privacy_mode: ['__all', 'strict', 'compat', 'unsafe'], + outcome_status: ['__all', 'success', 'failed', 'cancelled', 'blocked', 'unknown'], + eval_bucket: ['__all', 'ok', 'review', 'poor'] + }; + const queryFilterContracts = { + 'agentops-v2-home': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], + 'agentops-v2-runs-explorer': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], + 'agentops-v2-run-replay': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], + 'agentops-v2-models-cost-tokens': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], + 'agentops-v2-tools-mcp-risk': ['run_id', 'trace_id', 'surface', 'agent_name', 'mcp_server', 'tool_name', 'tool_risk'], + 'agentops-v2-safety-privacy-policy': ['run_id', 'session_id', 'trace_id', 'surface', 'repo_hash', 'branch_hash', 'model', 'agent_name', 'skill_name', 'sub_agent', 'task_type', 'privacy_mode', 'outcome_status', 'eval_bucket'], + 'agentops-v2-code-outcomes': ['run_id', 'repo_hash', 'branch_hash', 'outcome_status'], + 'agentops-v2-evals-quality': ['run_id', 'repo_hash', 'model', 'task_type', 'eval_bucket'], + 'agentops-v2-insights-regressions': ['run_id', 'repo_hash', 'model', 'task_type', 'tool_name', 'pattern_key', 'eval_bucket'], + 'agentops-v2-collector-health': ['privacy_mode'] + }; + + for (const { file, body } of dashboards) { + const variableList = body.templating?.list || []; + const variables = new Set(variableList.map(item => item.name)); + const visibleVariables = variableList.filter(item => Number(item.hide || 0) === 0); + if (visibleVariables.length > 6) errors.push(`${file}: exposes ${visibleVariables.length} filters; maximum is 6`); + for (const technicalName of ['datasource', 'workspace', 'actioner_url']) { + const technical = variableList.find(item => item.name === technicalName); + if (technical && Number(technical.hide) !== 2) errors.push(`${file}: technical filter ${technicalName} must stay hidden`); + } + const queryText = (body.panels || []) + .flatMap(panel => [panel, ...(panel.panels || [])]) + .flatMap(panel => panel.targets || []) + .map(target => target.azureLogAnalytics?.query || target.query || '') + .join('\n'); + const expected = queryFilterContracts[body.uid] || []; + + for (const variable of expected) { + if (!variables.has(variable)) errors.push(`${file}: missing filter variable ${variable}`); + if (!queryText.includes(`$${variable}`) && !queryText.includes(`\${${variable}}`)) { + errors.push(`${file}: filter ${variable} is not wired into any panel query`); + } + } + for (const [name, values] of Object.entries(requiredChoices)) { + const variable = (body.templating?.list || []).find(item => item.name === name); + if (!variable) continue; + const choices = String(variable.query || '').split(',').map(value => value.trim()).filter(Boolean); + for (const value of values) { + if (!choices.includes(value)) errors.push(`${file}: filter ${name} missing dropdown value ${value}`); + } + } + if (expected.includes('eval_bucket') && !queryText.includes("iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')")) { + errors.push(`${file}: eval_bucket filter must accept ok as the user-facing alias for good`); + } + for (const link of body.links || []) { + if (link.uid && link.includeVars !== true) errors.push(`${file}: nav link ${link.uid} does not carry filters`); + if (link.uid && link.keepTime !== true) errors.push(`${file}: nav link ${link.uid} does not carry time range`); + } + } + + return { + ok: errors.length === 0, + dashboards: dashboards.length, + errors + }; +} + +function panelByTitle(dashboard, title) { + return (dashboard.body.panels || []).find(panel => panel.title === title); +} + +function orderedInText(text, terms) { + let cursor = -1; + for (const term of terms) { + const index = String(text || '').indexOf(term); + if (index <= cursor) return false; + cursor = index; + } + return true; +} + +function orderedAfter(text, marker, terms) { + const index = String(text || '').lastIndexOf(marker); + if (index === -1) return false; + return orderedInText(String(text).slice(index), terms); +} + +function validateDashboardUx() { + const dashboards = v2DashboardBodies(); + const byUid = new Map(dashboards.map(item => [item.body.uid, item])); + const errors = []; + const required = [ + 'agentops-v2-home', + 'agentops-v2-runs-explorer', + 'agentops-v2-run-replay', + 'agentops-v2-models-cost-tokens', + 'agentops-v2-tools-mcp-risk', + 'agentops-v2-safety-privacy-policy', + 'agentops-v2-code-outcomes', + 'agentops-v2-evals-quality', + 'agentops-v2-insights-regressions', + 'agentops-v2-collector-health' + ]; + for (const uid of required) { + if (!byUid.has(uid)) errors.push(`missing V2 dashboard ${uid}`); + } + let emptyStateDashboards = 0; + for (const dashboard of dashboards) { + const text = (dashboard.body.panels || []) + .filter(panel => panel.type === 'text') + .map(panel => panel.options?.content || '') + .join('\n'); + if (text.includes('agentops collector smoke --privacy strict --poison --json') && text.includes('agentops demo generate')) { + emptyStateDashboards += 1; + } else { + errors.push(`${dashboard.file}: missing dashboard-level empty-state commands`); + } + } + + const home = byUid.get('agentops-v2-home'); + const homeTitles = new Set((home?.body.panels || []).map(panel => panel.title)); + for (const title of ['Runs', 'Success rate', 'Runs needing review', 'Policy blocks', 'Content items blocked', 'Estimated cost', 'Input tokens', 'Output tokens', 'p95 duration', 'Tests ran %', 'PRs opened', 'Healthy collector checks', 'Session Health', 'Saved investigations']) { + if (!homeTitles.has(title)) errors.push(`home missing top-strip panel ${title}`); + } + const homeText = (home?.body.panels || []) + .filter(panel => panel.type === 'text') + .map(panel => `${panel.title}\n${panel.options?.content || ''}`) + .join('\n'); + for (const snippet of ['These runs are visible in Azure', 'If a recent run is missing', 'agentops delivery status', 'AgentOps managed', 'Native best effort', 'Open latest run', 'agentops open latest --last 2h --json', 'Get recommendation', 'agentops recommend latest --last 2h', 'Ask AgentOps', 'agentops ask-context latest --last 2h --json', '--recommendations <AgentOpsRecommendations_CL.jsonl>', 'docs/copilot-mcp-agentops-prompts.md']) { + if (!homeText.includes(snippet)) errors.push(`home action strip missing ${snippet}`); + } + const savedViewsQuery = queryFromPanel(panelByTitle(home, 'Saved investigations')); + for (const field of ['AgentOpsSavedViews_CL', 'SavedViewId', 'Name', 'QueryHash', 'ChangeAnnotationCount', 'ChangeTargetRefs', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/saved-view/', 'OpenSavedView', 'OpenReplay']) { + if (!savedViewsQuery.includes(field)) errors.push(`saved investigations panel missing ${field}`); + } + const recommendedNextActionsQuery = queryFromPanel(panelByTitle(home, 'Recommended next actions')); + for (const field of ['AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/']) { + if (!recommendedNextActionsQuery.includes(field)) errors.push(`recommended next actions panel missing ${field}`); + } + const sessionHealthQuery = queryFromPanel(panelByTitle(home, 'Session Health')); + for (const field of ['LatestRecommendations', "Delivery='Visible in Azure'", "Coverage='AgentOps managed'", "Coverage='Native best effort'", 'Delivery', 'Coverage', 'HealthStatus', 'RootAgent', 'RecommendedNextAction', 'ToolFailureCount', 'ToolDeniedCount', 'ContentCaptureSignal', 'ContextWindowPct', 'EvalOverall', 'OpenReplay']) { + if (!sessionHealthQuery.includes(field)) errors.push(`session health panel missing ${field}`); + } + + const runs = byUid.get('agentops-v2-runs-explorer'); + const runsQuery = queryFromPanel(panelByTitle(runs, 'Runs')); + for (const action of ['Delivery', 'Coverage', 'OpenReplay', 'OpenTrace', 'OpenGithub']) { + if (!runsQuery.includes(action)) errors.push(`runs explorer missing ${action} action cell`); + } + const tokenUseQuery = queryFromPanel(panelByTitle(runs, 'Token use')); + for (const field of ['InputTokens', 'OutputTokens']) { + if (!tokenUseQuery.includes(field)) errors.push(`runs token use panel missing ${field}`); + } + if (tokenUseQuery.includes('Cost=sum(EstimatedCostUsd)')) { + errors.push('runs token use chart must not mix currency and token units on one axis'); + } + + const replay = byUid.get('agentops-v2-run-replay'); + const replayTitles = new Set((replay?.body.panels || []).map(panel => panel.title)); + for (const title of ['Run summary', 'Ordered timeline', 'Agent, skill, and MCP lineage', 'Context and cache posture', 'Why this failed / next check', 'Latest recommendation', 'Ask AgentOps context', 'Transcript availability', 'Prompt and response viewer (explicit opt-in)', 'Policy, privacy, tests, and GitHub outcome']) { + if (!replayTitles.has(title)) errors.push(`run replay missing panel ${title}`); + } + const timelineQuery = queryFromPanel(panelByTitle(replay, 'Ordered timeline')); + for (const field of ['Sequence', 'EventId', 'ParentEventId', 'Delivery', 'Coverage', 'AttributionConfidence', 'AttributionGap', 'McpToolName', 'CommandName', 'ScriptName', 'InputTokens', 'OutputTokens', 'ReasoningTokens', 'TotalTokens', 'EstimatedCostUsd', 'PermissionKind', 'PermissionDecision', 'PrivacyMode', 'ContentCaptureMode', 'ContentCaptureSignal', 'ContentAction', 'ContentDroppedBytes', 'SecretLike']) { + if (!timelineQuery.includes(field)) errors.push(`ordered timeline missing ${field}`); + } + if (!timelineQuery.includes('order by TimeGenerated asc, SequenceSort asc, EventId asc')) { + errors.push('ordered timeline must deterministically order timestamp ties by sequence and event id'); + } + const lineageQuery = queryFromPanel(panelByTitle(replay, 'Agent, skill, and MCP lineage')); + if (!lineageQuery.includes("Status in ('failed', 'error', 'denied', 'blocked')")) { + errors.push('lineage failures must not classify started lifecycle events as failures'); + } + for (const field of ['MissingAttribution', 'AttributionConfidence', 'Coverage']) { + if (!lineageQuery.includes(field)) errors.push(`lineage missing honesty field ${field}`); + } + const latestRecommendationQuery = queryFromPanel(panelByTitle(replay, 'Latest recommendation')); + for (const field of ['RecommendationId', 'Action', 'ObservedPattern', 'NextAction', 'RecommendationCommand', 'AskContextCommand', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/', 'OpenReplay', 'OpenPattern']) { + if (!latestRecommendationQuery.includes(field)) errors.push(`latest recommendation panel missing ${field}`); + } + const askQuery = queryFromPanel(panelByTitle(replay, 'Ask AgentOps context')); + for (const field of ['RunReplayUrl', 'InvestigationKql', 'AskContextCommand', 'BundleCommand', 'AskPrompt', 'TriageCommand', 'AskAgentOpsLaunch', '/ask-agentops', 'OpenReplay', 'Do not request or enable prompt']) { + if (!askQuery.includes(field)) errors.push(`ask agentops context panel missing ${field}`); + } + const transcriptQuery = queryFromPanel(panelByTitle(replay, 'Transcript availability')); + if (!orderedAfter(transcriptQuery, '| project Status', ['Status', 'SafetyNote', 'OpenTranscript', 'ContentRows', 'FullContentRows', 'RedactedContentRows'])) { + errors.push('transcript availability must put status, safety note, open action, and content counts first'); + } + const contentQuery = queryFromPanel(panelByTitle(replay, 'Prompt and response viewer (explicit opt-in)')); + if (!orderedAfter(contentQuery, '| project TimeGenerated', ['TimeGenerated', 'TurnIndex', 'Role', 'ContentKind', 'MessageText', 'CaptureMode', 'RedactionStatus', 'ViewerNote'])) { + errors.push('prompt/response viewer must read like a transcript before showing hashes and IDs'); + } + if (!contentQuery.includes('AgentOpsContent_CL')) errors.push('prompt/response viewer must use AgentOpsContent_CL only'); + + const tools = byUid.get('agentops-v2-tools-mcp-risk'); + const toolQuery = queryFromPanel(panelByTitle(tools, 'Tool risk table')); + for (const field of ['BadOutcomeCorrelation', 'McpServer', 'ToolRisk', 'DeniedRate']) { + if (!toolQuery.includes(field)) errors.push(`tool risk table missing ${field}`); + } + + const safety = byUid.get('agentops-v2-safety-privacy-policy'); + const safetyTitles = new Set((safety?.body.panels || []).map(panel => panel.title)); + const safetyText = (safety?.body.panels || []) + .filter(panel => panel.type === 'text') + .map(panel => panel.options?.content || '') + .join('\n'); + for (const boundary of ['AgentOps default: strict metadata only', 'content capture off', 'AgentOps telemetry only', 'does not prove what GitHub Copilot', 'other connected service stores']) { + if (!safetyText.includes(boundary)) errors.push(`privacy trust screen missing scope boundary: ${boundary}`); + } + if (!safetyTitles.has('AgentOps capture posture')) errors.push('safety dashboard missing AgentOps capture posture panel'); + const capturePostureQuery = queryFromPanel(panelByTitle(safety, 'AgentOps capture posture')); + for (const field of ['Scope', 'PrivacyMode', 'ContentCaptureMode', 'Coverage', 'Runs', 'Meaning', 'AgentOps telemetry only', 'metadata only']) { + if (!capturePostureQuery.includes(field)) errors.push(`AgentOps capture posture missing ${field}`); + } + for (const title of ['Content items blocked', 'Secret-like items blocked', 'Runs reporting unsafe mode', 'Successful poison tests', 'Strict-mode runs', 'Blocked or redacted items by kind', 'Runs needing privacy or policy review']) { + if (!safetyTitles.has(title)) errors.push(`privacy dashboard missing plain-language panel ${title}`); + } + + for (const uid of ['agentops-v2-home', 'agentops-v2-runs-explorer', 'agentops-v2-run-replay', 'agentops-v2-safety-privacy-policy']) { + const primary = byUid.get(uid); + for (const panel of primary?.body.panels || []) { + if (panel.type === 'text') continue; + if (!String(panel.description || '').trim()) errors.push(`${uid} panel ${panel.title} missing a plain-language description`); + } + } + if (!safetyTitles.has('Alert handoff review')) errors.push('safety dashboard missing Alert handoff review panel'); + const alertHandoffQuery = queryFromPanel(panelByTitle(safety, 'Alert handoff review')); + for (const field of ['AgentOpsAlertHandoffs_CL', 'HandoffId', 'AlertRule', 'SessionId', 'ConfigChangeCount', 'ChangeTargetRefs', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/alert-handoff/', 'OpenReplay']) { + if (!alertHandoffQuery.includes(field)) errors.push(`alert handoff review missing ${field}`); + } + + const code = byUid.get('agentops-v2-code-outcomes'); + const codeTitles = new Set((code?.body.panels || []).map(panel => panel.title)); + for (const title of ['Runs and PR outcomes', 'PR and CI outcomes', 'Delivery timing', 'Edited files but no tests']) { + if (!codeTitles.has(title)) errors.push(`code outcomes missing panel ${title}`); + } + const timingQuery = queryFromPanel(panelByTitle(code, 'Delivery timing')); + for (const field of ['TimeToPrMinutes', 'TimeToMergeMinutes', 'P95TimeToPrMinutes', 'P95TimeToMergeMinutes']) { + if (!timingQuery.includes(field)) errors.push(`delivery timing missing ${field}`); + } + + const evals = byUid.get('agentops-v2-evals-quality'); + const evalsTitles = new Set((evals?.body.panels || []).map(panel => panel.title)); + if (!evalsTitles.has('Eval scorecard by repo, model, and task')) errors.push('evals dashboard missing Eval scorecard by repo, model, and task panel'); + if (!evalsTitles.has('Eval regression follow-up')) errors.push('evals dashboard missing Eval regression follow-up panel'); + if (!evalsTitles.has('Before/after run comparison')) errors.push('evals dashboard missing Before/after run comparison panel'); + if (!evalsTitles.has('Benchmark artifact diff review')) errors.push('evals dashboard missing Benchmark artifact diff review panel'); + if (!evalsTitles.has('Benchmark artifact files')) errors.push('evals dashboard missing Benchmark artifact files panel'); + if (!evalsTitles.has('Benchmark artifact content diffs')) errors.push('evals dashboard missing Benchmark artifact content diffs panel'); + if (!evalsTitles.has('Benchmark hidden check packs')) errors.push('evals dashboard missing Benchmark hidden check packs panel'); + if (!evalsTitles.has('Benchmark policy review')) errors.push('evals dashboard missing Benchmark policy review panel'); + if (!evalsTitles.has('Benchmark semantic checks')) errors.push('evals dashboard missing Benchmark semantic checks panel'); + if (!evalsTitles.has('Benchmark promotion approvals')) errors.push('evals dashboard missing Benchmark promotion approvals panel'); + const evalScorecardQuery = queryFromPanel(panelByTitle(evals, 'Eval scorecard by repo, model, and task')); + for (const field of ['ScorecardStatus', 'PoorRuns', 'ReviewRuns', 'AvgTestDiscipline', 'AvgToolEfficiency', 'AvgSecurity', 'AvgReliability', 'AvgCodeOutcome']) { + if (!evalScorecardQuery.includes(field)) errors.push(`eval scorecard missing ${field}`); + } + const evalFollowUpQuery = queryFromPanel(panelByTitle(evals, 'Eval regression follow-up')); + for (const field of ['EvalBucket', 'ObservedPattern', 'NextAction', 'ChangeAnnotationCount', 'ChangeTargetRefs', 'OpenReplay', 'OpenPattern']) { + if (!evalFollowUpQuery.includes(field)) errors.push(`eval regression follow-up missing ${field}`); + } + const runComparisonQuery = queryFromPanel(panelByTitle(evals, 'Before/after run comparison')); + for (const field of ['BeforeRunId', 'AfterRunId', 'ComparisonStatus', 'EvalDelta', 'CostDelta', 'TokenDelta', 'ToolFailureDelta', 'RiskDelta', 'OpenReplay']) { + if (!runComparisonQuery.includes(field)) errors.push(`before/after run comparison missing ${field}`); + } + const artifactDiffQuery = queryFromPanel(panelByTitle(evals, 'Benchmark artifact diff review')); + for (const field of ['BenchmarkRunId', 'BenchmarkArtifactAdded', 'BenchmarkArtifactModified', 'BenchmarkArtifactDeleted', 'BenchmarkArtifactTotalChanged', 'ReviewAction', 'ChangeTargetRefs']) { + if (!artifactDiffQuery.includes(field)) errors.push(`benchmark artifact diff review missing ${field}`); + } + const artifactFilesQuery = queryFromPanel(panelByTitle(evals, 'Benchmark artifact files')); + for (const field of ['BenchmarkArtifactFiles', 'mv-expand', 'ArtifactTaskId', 'ArtifactChange', 'ArtifactPath']) { + if (!artifactFilesQuery.includes(field)) errors.push(`benchmark artifact files missing ${field}`); + } + const artifactContentDiffQuery = queryFromPanel(panelByTitle(evals, 'Benchmark artifact content diffs')); + for (const field of ['BenchmarkArtifactContentDiffs', 'mv-expand', 'ArtifactTaskId', 'ArtifactChange', 'ArtifactPath', 'DiffPreview']) { + if (!artifactContentDiffQuery.includes(field)) errors.push(`benchmark artifact content diffs missing ${field}`); + } + const hiddenCheckQuery = queryFromPanel(panelByTitle(evals, 'Benchmark hidden check packs')); + for (const field of ['BenchmarkHiddenCheckPacks', 'mv-expand', 'BenchmarkHiddenChecksPassed', 'BenchmarkHiddenChecksFailed', 'HiddenTaskId', 'HiddenPackId', 'HiddenCommandCount']) { + if (!hiddenCheckQuery.includes(field)) errors.push(`benchmark hidden check packs missing ${field}`); + } + const policyQuery = queryFromPanel(panelByTitle(evals, 'Benchmark policy review')); + for (const field of ['BenchmarkPolicyTasks', 'mv-expand', 'BenchmarkPolicyBlocks', 'BenchmarkPermissionProfiles', 'PolicyTaskId', 'PermissionProfile', 'OsSandboxMode', 'OsSandboxActive', 'BlockedRisks', 'ViolationRisks']) { + if (!policyQuery.includes(field)) errors.push(`benchmark policy review missing ${field}`); + } + const semanticQuery = queryFromPanel(panelByTitle(evals, 'Benchmark semantic checks')); + for (const field of ['BenchmarkSemanticChecks', 'mv-expand', 'BenchmarkSemanticCheckCount', 'BenchmarkSemanticAverageScore', 'SemanticTaskId', 'SemanticCheckId', 'SemanticAdapter', 'SemanticScore']) { + if (!semanticQuery.includes(field)) errors.push(`benchmark semantic checks missing ${field}`); + } + const approvalQuery = queryFromPanel(panelByTitle(evals, 'Benchmark promotion approvals')); + for (const field of ['BenchmarkRunId', 'BenchmarkApprovalStatus', 'BenchmarkApprovalCount', 'BenchmarkRequiredApprovals', 'BenchmarkApprovalTicket', 'ApprovalAction']) { + if (!approvalQuery.includes(field)) errors.push(`benchmark promotion approvals missing ${field}`); + } + + const insights = byUid.get('agentops-v2-insights-regressions'); + const insightsTitles = new Set((insights?.body.panels || []).map(panel => panel.title)); + if (!insightsTitles.has('Recurring patterns')) errors.push('insights dashboard missing Recurring patterns panel'); + if (!insightsTitles.has('Eval regression queue')) errors.push('insights dashboard missing Eval regression queue panel'); + if (!insightsTitles.has('Recommendation artifacts')) errors.push('insights dashboard missing Recommendation artifacts panel'); + if (!insightsTitles.has('Config change annotations')) errors.push('insights dashboard missing Config change annotations panel'); + const patternsQuery = queryFromPanel(panelByTitle(insights, 'Recurring patterns')); + for (const field of ['PatternId', 'PatternRuns', 'PatternDimension', 'PatternKey', 'OpenPattern', 'OpenReplay']) { + if (!patternsQuery.includes(field)) errors.push(`recurring patterns panel missing ${field}`); + } + const recommendationsQuery = queryFromPanel(panelByTitle(insights, 'Recommendation artifacts')); + for (const field of ['RecommendationId', 'Action', 'ObservedPattern', 'NextAction', 'BenchmarkRunId', 'BenchmarkDecision', 'BenchmarkArtifactTotalChanged', 'BenchmarkArtifactFiles', 'BenchmarkHiddenCheckPacks', 'BenchmarkPolicyTasks', 'BenchmarkSemanticChecks', 'BenchmarkApprovalStatus', 'ChangeAnnotationCount', 'ChangeAnnotations', 'ChangeTargetRefs', 'AskSharedContext', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/', 'OpenReplay', 'OpenPattern']) { + if (!recommendationsQuery.includes(field)) errors.push(`recommendation artifacts panel missing ${field}`); + } + const evalRegressionQueueQuery = queryFromPanel(panelByTitle(insights, 'Eval regression queue')); + for (const field of ['Source', 'EvalBucket', 'BaselineValue', 'CurrentValue', 'Summary', 'NextAction', 'OpenReplay', 'OpenPattern']) { + if (!evalRegressionQueueQuery.includes(field)) errors.push(`eval regression queue missing ${field}`); + } + const configAnnotationsQuery = queryFromPanel(panelByTitle(insights, 'Config change annotations')); + for (const field of ['agentops.config.changed', 'agentops.custom.annotation_type', 'ChangeComponent', 'ChangeTarget', 'ChangeType', 'ChangeId', 'Version']) { + if (!configAnnotationsQuery.includes(field)) errors.push(`config change annotations missing ${field}`); + } + + const collector = byUid.get('agentops-v2-collector-health'); + const collectorTitles = new Set((collector?.body.panels || []).map(panel => panel.title)); + if (!collectorTitles.has('Schema version coverage')) errors.push('collector health missing Schema version coverage panel'); + if (!collectorTitles.has('Exporter failure review')) errors.push('collector health missing Exporter failure review panel'); + const schemaCoverageQuery = queryFromPanel(panelByTitle(collector, 'Schema version coverage')); + for (const field of ['SchemaStatus', 'ExpectedSchemaVersion', 'MissingSchemaVersion', 'SchemaVersion', 'AgentOpsRunSummary_CL']) { + if (!schemaCoverageQuery.includes(field)) errors.push(`schema version coverage missing ${field}`); + } + const exporterFailureQuery = queryFromPanel(panelByTitle(collector, 'Exporter failure review')); + for (const field of ['ExportFailureReason', 'ExportFailureAction', 'ExportErrors', 'LastExportSuccess', 'agentops collector smoke --privacy strict --poison --json']) { + if (!exporterFailureQuery.includes(field)) errors.push(`exporter failure review missing ${field}`); + } + + return { + ok: errors.length === 0, + dashboards: dashboards.length, + contracts: { + home_top_strip: 12, + home_action_strip: true, + run_replay_panels: 9, + ask_agentops_context: true, + runs_actions: 3, + transcript_first_columns: ['Status', 'SafetyNote', 'OpenTranscript', 'ContentRows'], + code_outcome_timing: true, + recurring_patterns: true, + recommendation_artifacts: true, + artifact_diff_review: true, + artifact_file_review: true, + artifact_content_diff_review: true, + hidden_check_review: true, + policy_review: true, + semantic_review: true, + promotion_approvals: true, + robust_eval_center: true, + alert_handoff_review: true, + privacy_scope_boundary: true, + privacy_capture_posture: true, + schema_version_coverage: true, + exporter_failure_review: true, + pattern_drilldowns: true, + run_centric_ui: true, + empty_state_dashboards: emptyStateDashboards + }, + errors + }; +} + +module.exports = { + collectPanelLinks, + dashboardJsonFiles, + orderedAfter, + orderedInText, + panelByTitle, + queryFromPanel, + validateDashboardFilters, + validateDashboardLinks, + validateDashboardUx, + validateDashboards, + v2DashboardBodies +}; diff --git a/agentops-cli/src/lib/dashboard-verify.js b/agentops-cli/src/lib/dashboard-verify.js new file mode 100644 index 0000000..161dd10 --- /dev/null +++ b/agentops-cli/src/lib/dashboard-verify.js @@ -0,0 +1,54 @@ +const { hasFlag } = require('./args'); +const { validateDashboardContentGuardrails } = require('./dashboard-content-guardrails'); +const { dashboardKqlCheck } = require('./dashboard-kql-check'); +const { + validateDashboardFilters, + validateDashboardLinks, + validateDashboardUx, + validateDashboards +} = require('./dashboard-validation'); + +function dashboardVerify(args = [], options = {}) { + const includeLive = hasFlag(args, '--live') || hasFlag(args, '--kql'); + const checks = { + validate: validateDashboards(), + links: validateDashboardLinks(), + filters: validateDashboardFilters(), + ux: validateDashboardUx(), + content: validateDashboardContentGuardrails() + }; + if (includeLive) checks.kql = dashboardKqlCheck(args, options); + + const errors = Object.entries(checks) + .flatMap(([name, result]) => (result.errors || []).map(error => `${name}: ${error}`)); + return { + ok: errors.length === 0, + live: includeLive, + checks, + summary: { + dashboards: checks.validate.dashboards, + v2_dashboards: checks.links.dashboards, + checked_links: checks.links.checked_links, + filter_dashboards: checks.filters.dashboards, + ux_contracts: checks.ux.contracts, + kql_checks: checks.kql?.checks?.length || 0 + }, + errors, + next: errors.length === 0 + ? [ + includeLive ? 'agentops open' : 'agentops dashboard verify --live --last 24h', + 'agentops validate-azure --last 24h' + ] + : [ + 'agentops dashboard validate', + 'agentops dashboard links-check', + 'agentops dashboard filters-check', + 'agentops dashboard ux-check', + 'agentops dashboard kql-check --last 24h' + ] + }; +} + +module.exports = { + dashboardVerify +}; diff --git a/agentops-cli/src/lib/delivery-command.js b/agentops-cli/src/lib/delivery-command.js new file mode 100644 index 0000000..8c0f63b --- /dev/null +++ b/agentops-cli/src/lib/delivery-command.js @@ -0,0 +1,82 @@ +const { hasFlag, optionValue } = require('./args'); +const { configuredCloudValues } = require('./agentops-config'); +const { writeJsonOrRender } = require('./command-output'); +const { durableDeliveryStatus } = require('./status-summary'); +const { createWrapperDelivery } = require('./copilot/wrapper-delivery'); + +function renderDelivery(result) { + const lines = ['AgentOps delivery', '']; + if (result.action === 'status') { + const waiting = Number(result.pending || 0); + const held = Number(result.quarantined || 0) + Number(result.expired || 0); + lines.push( + `Local queue: ${waiting ? `${waiting} receipt event${waiting === 1 ? '' : 's'} waiting to send` : 'nothing waiting'}`, + `Needs attention: ${held ? `${held} event${held === 1 ? '' : 's'}` : 'none'}` + ); + if (result.acknowledged_cumulative !== undefined) { + lines.push(`Accepted by Azure ingestion: ${result.acknowledged_cumulative} total`); + if (Number(result.acknowledged_cumulative) > 0) lines.push('Azure acceptance does not by itself prove the events are searchable yet.'); + } + if (result.overflow_cumulative !== undefined && Number(result.overflow_cumulative) > 0) { + lines.push(`Not saved because the queue was full: ${result.overflow_cumulative} total`); + } + lines.push( + waiting ? 'Next: run agentops delivery drain to preview sending the waiting events.' : 'Next: no delivery action is needed.', + `Local receipt folder: ${result.directory}` + ); + } else if (!result.executed) { + const waiting = Number(result.before?.pending || 0); + lines.push('Mode: preview', `Waiting: ${waiting}`, 'No Azure request was made.'); + if (waiting > 0) lines.push('Run with --yes only after reviewing the exact subscription, endpoint, and DCR.'); + else lines.push('Nothing is waiting, so there is nothing to send.'); + } else { + lines.push(`State: ${result.state}`, `Accepted this drain: ${result.result?.acknowledged || 0}`, `Still waiting: ${result.result?.status?.pending || 0}`, `Held for review: ${result.result?.status?.quarantined || 0}`); + } + if (result.error) lines.push(`Error: ${result.error}`); + return `${lines.join('\n')}\n`; +} + +async function runDeliveryCommand(args = [], options = {}) { + const [subcommand = 'status'] = args; + const directory = optionValue(args, '--dir', options.directory || process.env.AGENTOPS_DURABLE_SPOOL_DIR || ''); + if (subcommand === 'status') return { action: 'status', ...durableDeliveryStatus(directory ? { directory } : {}) }; + if (subcommand !== 'drain') throw new Error('delivery supports: status, drain'); + const delivery = (options.createDelivery || createWrapperDelivery)({ ...(directory ? { directory } : {}), env: options.env }); + const before = delivery.status(); + if (!hasFlag(args, '--yes')) return { action: 'drain', executed: false, before }; + const configured = configuredCloudValues({ env: options.env, config: options.config }); + const cloud = { + ...configured, + logsIngestionEndpoint: optionValue(args, '--endpoint', configured.logsIngestionEndpoint), + dcrImmutableId: optionValue(args, '--dcr-immutable-id', configured.dcrImmutableId) + }; + const missing = [ + ['subscription', cloud.subscriptionId], + ['logs ingestion endpoint', cloud.logsIngestionEndpoint], + ['DCR immutable ID', cloud.dcrImmutableId] + ].filter(([, value]) => !value).map(([label]) => label); + if (missing.length) { + return { action: 'drain', executed: false, before, error: `Delivery drain is not configured: missing ${missing.join(', ')}` }; + } + const drained = await delivery.drain([], { + cloud, + maxAttempts: Number(optionValue(args, '--max-attempts', '3')), + spawnSync: options.spawnSync, + fetchImpl: options.fetchImpl, + tokenProvider: options.tokenProvider, + sleep: options.sleep + }); + return { action: 'drain', executed: true, before, ...drained }; +} + +async function deliveryCommand(args = []) { + const result = await runDeliveryCommand(args); + writeJsonOrRender(result, hasFlag(args, '--json'), renderDelivery); + if (result.error) process.exitCode = 1; +} + +module.exports = { + deliveryCommand, + renderDelivery, + runDeliveryCommand +}; diff --git a/agentops-cli/src/lib/delivery-state.js b/agentops-cli/src/lib/delivery-state.js new file mode 100644 index 0000000..919e3ed --- /dev/null +++ b/agentops-cli/src/lib/delivery-state.js @@ -0,0 +1,55 @@ +const deliveryText = Object.freeze({ + local_pending: 'Run receipt saved locally · waiting for Azure', + azure_acknowledged: 'Run receipt accepted by Azure · visibility may take a few minutes', + overflow: 'NOT SAVED · local delivery queue is full', + expired: 'Not sent · retry time expired · held locally', + quarantined: 'Held locally · needs review', + native_best_effort: 'Best effort · delivery not yet confirmed', + unobserved: 'Not observed · collector unavailable' +}); + +function receiptDeliveryText(state = 'native_best_effort') { + return deliveryText[state] || deliveryText.native_best_effort; +} + +function deliveryStateFromEnqueue(result = {}) { + if (result.status === 'pending' || result.status === 'deduplicated') return 'local_pending'; + if (result.status === 'overflow') return 'overflow'; + if (result.status === 'expired') return 'expired'; + if (result.status === 'quarantined') return 'quarantined'; + return 'native_best_effort'; +} + +function summarizeDeliveryStatus(status = null) { + if (!status) return { + state: 'native_best_effort', + headline: 'Native Copilot telemetry is best effort; delivery is not confirmed.' + }; + const pending = Number(status.pending || 0) + Number(status.uploading || 0); + const quarantined = Number(status.quarantined || 0); + const expired = Number(status.expired || 0); + const overflow = Number(status.overflow || 0); + const acknowledged = Number(status.acknowledged || 0); + const state = quarantined > 0 ? 'quarantined' + : overflow > 0 ? 'overflow' + : expired > 0 ? 'expired' + : pending > 0 ? 'local_pending' + : acknowledged > 0 ? 'azure_acknowledged' + : 'native_best_effort'; + return { + state, + headline: `${pending} waiting · ${quarantined} held for review · ${expired} expired`, + pending, + quarantined, + expired, + acknowledged_cumulative: acknowledged, + overflow_cumulative: overflow + }; +} + +module.exports = { + deliveryStateFromEnqueue, + deliveryText, + receiptDeliveryText, + summarizeDeliveryStatus +}; diff --git a/agentops-cli/src/lib/demo-command.js b/agentops-cli/src/lib/demo-command.js new file mode 100644 index 0000000..8316e86 --- /dev/null +++ b/agentops-cli/src/lib/demo-command.js @@ -0,0 +1,148 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { hasFlag, optionValue } = require('./args'); +const { writeJson } = require('./command-output'); +const { generateDemoData, writeDemoData } = require('./demo/agentops-demo-data'); +const { buildDemoVerifyResult } = require('./demo-verify'); +const { renderV2Explanation } = require('./explain/v2-explain'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function parseRuns(value) { + const runs = Number(value || 50); + if (!Number.isInteger(runs) || runs <= 0 || runs > 1000) { + throw new Error('--runs must be an integer between 1 and 1000'); + } + return runs; +} + +function flagPair(args, withFlag, withoutFlag, defaultValue = true) { + const withValue = hasFlag(args, withFlag); + const withoutValue = hasFlag(args, withoutFlag); + if (withValue && withoutValue) throw new Error(`Use either ${withFlag} or ${withoutFlag}, not both`); + if (withValue) return true; + if (withoutValue) return false; + return defaultValue; +} + +function demoOptionsFromArgs(args = []) { + return { + withFailures: flagPair(args, '--with-failures', '--without-failures', true), + withPrivacyDrops: flagPair(args, '--with-privacy-drops', '--without-privacy-drops', true), + withGithubOutcomes: flagPair(args, '--with-github-outcomes', '--without-github-outcomes', true), + withContent: hasFlag(args, '--with-content') + }; +} + +function demoVerifyOutputPlan(args = [], options = {}) { + const writeArtifacts = hasFlag(args, '--write'); + const requestedOut = optionValue(args, '--out', ''); + const requestedInsightsOut = optionValue(args, '--insights-out', ''); + if ((requestedOut || requestedInsightsOut) && !writeArtifacts) { + throw new Error('demo verify output paths require --write; verification is workspace-read-only by default'); + } + + const root = options.repoRoot || repoRoot; + const tempRoot = options.tempRoot || os.tmpdir(); + const temporaryRoot = writeArtifacts + ? null + : fs.mkdtempSync(path.join(tempRoot, 'agentops-demo-verify-')); + const outDir = requestedOut + ? path.resolve(requestedOut) + : (writeArtifacts ? path.join(root, '.agentops', 'demo', 'latest') : temporaryRoot); + const insightsOutDir = requestedInsightsOut + ? path.resolve(requestedInsightsOut) + : (writeArtifacts ? path.join(root, '.agentops', 'insights', 'latest') : path.join(temporaryRoot, 'insights')); + + return { + outDir, + insightsOutDir, + writeArtifacts, + artifactMode: writeArtifacts ? 'persistent' : 'temporary' + }; +} + +function demoCommand(args = []) { + const [subcommand = 'generate'] = args; + if (!['generate', 'verify'].includes(subcommand)) throw new Error('demo supports: generate|verify'); + if (subcommand === 'verify') return demoVerifyCommand(args.slice(1)); + + const runs = parseRuns(optionValue(args, '--runs', '50')); + const outDir = path.resolve(optionValue(args, '--out', path.join(repoRoot, '.agentops', 'demo', 'latest'))); + const demoOptions = demoOptionsFromArgs(args); + const result = generateDemoData({ + runs, + ...demoOptions + }); + const written = writeDemoData(result, outDir); + + const payload = { + ok: result.ok, + runs: result.runs, + out_dir: written.out_dir, + manifest: written.manifest, + table_counts: result.table_counts, + scenarios: result.scenarios, + scenario_names: result.scenario_names, + validation_errors: result.validation_errors, + content_capture: demoOptions.withContent ? 'redacted_demo_content' : 'off', + next: [ + 'agentops dashboard validate', + `ls ${written.out_dir}` + ] + }; + + if (hasFlag(args, '--json')) { + writeJson(payload); + } else { + process.stdout.write(`Generated ${payload.runs} AgentOps demo runs.\n`); + if (demoOptions.withContent) process.stdout.write('Included redacted demo prompt/response rows in AgentOpsContent_CL.\n'); + process.stdout.write(`Output: ${payload.out_dir}\n`); + process.stdout.write(`Manifest: ${payload.manifest}\n`); + process.stdout.write('Next: agentops dashboard validate\n'); + } + + if (!result.ok) process.exitCode = 1; +} + +function demoVerifyCommand(args = []) { + const runs = parseRuns(optionValue(args, '--runs', '50')); + const outputPlan = demoVerifyOutputPlan(args); + const { + payload, + explanation, + openLinks, + recommendation + } = buildDemoVerifyResult({ + runs, + outDir: outputPlan.outDir, + insightsOutDir: outputPlan.insightsOutDir, + writeIntent: outputPlan.writeArtifacts, + artifactMode: outputPlan.artifactMode + }); + + if (hasFlag(args, '--json')) { + writeJson(payload); + } else { + process.stdout.write('AgentOps V2 demo verification\n\n'); + process.stdout.write(`Demo runs: ${payload.demo.runs}\n`); + process.stdout.write(`Eval rows: ${payload.insights.table_counts.AgentOpsEval_CL}\n`); + process.stdout.write(`Insight rows: ${payload.insights.table_counts.AgentOpsInsights_CL}\n`); + process.stdout.write(`Artifacts: ${payload.artifact_mode} (${payload.write_intent ? 'explicit write' : 'workspace read-only'})\n`); + process.stdout.write(`Dashboard links: ${payload.links.checked_links}\n\n`); + process.stdout.write(renderV2Explanation(explanation)); + process.stdout.write(`Open Run Story: ${openLinks.links?.replay || 'unavailable'}\n`); + process.stdout.write(`Recommended next action: ${recommendation.next_action}\n`); + } + if (!payload.ok) process.exitCode = 1; +} + +module.exports = { + demoOptionsFromArgs, + demoVerifyOutputPlan, + demoCommand, + demoVerifyCommand, + parseRuns +}; diff --git a/agentops-cli/src/lib/demo-verify.js b/agentops-cli/src/lib/demo-verify.js new file mode 100644 index 0000000..634aca0 --- /dev/null +++ b/agentops-cli/src/lib/demo-verify.js @@ -0,0 +1,99 @@ +const { buildAzureIngestPlan } = require('./azure/v2-ingest-plan'); +const { + validateDashboardLinks, + validateDashboards +} = require('./dashboard-validation'); +const { generateDemoData, writeDemoData } = require('./demo/agentops-demo-data'); +const { explainRun, latestByTime } = require('./explain/v2-explain'); +const { generateInsights, writeInsights } = require('./insights/deterministic-insights'); +const { buildRecommendation } = require('./recommendation-builder'); +const { topInsightForRun } = require('./recommendation-links'); +const { writeRecommendation } = require('./recommendation-store'); +const { v2OpenLinksForRun } = require('./v2-open-links'); + +function buildDemoVerifyResult(options = {}) { + const demo = generateDemoData({ + runs: options.runs, + withFailures: true, + withPrivacyDrops: true, + withGithubOutcomes: true + }); + const writtenDemo = writeDemoData(demo, options.outDir); + const insights = generateInsights({ + runs: demo.tables.AgentOpsRunSummary_CL, + tools: demo.tables.AgentOpsToolCalls_CL, + privacy: demo.tables.AgentOpsPrivacy_CL, + github: demo.tables.AgentOpsGithubOutcomes_CL + }); + const writtenInsights = writeInsights(insights, options.insightsOutDir); + const latestRun = latestByTime(demo.tables.AgentOpsRunSummary_CL); + const explanation = explainRun(latestRun, insights.evals, insights.insights); + const openLinks = v2OpenLinksForRun(latestRun); + const recommendation = buildRecommendation({ + run: latestRun, + insight: topInsightForRun(insights.insights, latestRun?.RunId), + evaluation: insights.evals.find(row => row.RunId === latestRun?.RunId) || null + }); + const writtenRecommendation = writeRecommendation(recommendation, writtenDemo.out_dir); + demo.table_counts.AgentOpsRecommendations_CL = 1; + recommendation.artifact = { + table: 'AgentOpsRecommendations_CL', + file: writtenRecommendation.file, + manifest: writtenRecommendation.manifest, + privacy: 'metadata-only' + }; + const dashboard = validateDashboards(); + const links = validateDashboardLinks(); + const azureIngest = buildAzureIngestPlan({ dir: writtenDemo.out_dir }); + const payload = { + ok: demo.ok && insights.ok && explanation.ok && dashboard.ok && links.ok && azureIngest.ok, + artifact_mode: options.artifactMode || 'persistent', + write_intent: options.writeIntent === true, + demo: { + runs: demo.runs, + out_dir: writtenDemo.out_dir, + table_counts: demo.table_counts + }, + insights: { + out_dir: options.insightsOutDir, + eval_file: writtenInsights.evalFile, + insights_file: writtenInsights.insightsFile, + table_counts: insights.table_counts + }, + azure_ingest: azureIngest, + explanation: { + run_id: explanation.run?.RunId || null, + headline: explanation.headline, + detail: explanation.detail, + eval_overall: explanation.evaluation?.EvalOverall ?? null, + insight_count: explanation.insights.length + }, + open_links: openLinks, + recommendation, + dashboard, + links, + next: [ + `agentops replay latest --file ${writtenDemo.files.AgentOpsEvents_CL}`, + `agentops open latest --runs ${writtenDemo.files.AgentOpsRunSummary_CL}`, + `agentops recommend latest --runs ${writtenDemo.files.AgentOpsRunSummary_CL} --events ${writtenDemo.files.AgentOpsEvents_CL} --evals ${writtenInsights.evalFile} --insights ${writtenInsights.insightsFile}`, + `agentops azure-ingest plan --dir ${writtenDemo.out_dir}`, + `agentops explain latest --runs ${writtenDemo.files.AgentOpsRunSummary_CL} --evals ${writtenInsights.evalFile} --insights ${writtenInsights.insightsFile}` + ] + }; + + return { + payload, + explanation, + openLinks, + recommendation + }; +} + +function buildDemoVerifyPayload(options = {}) { + return buildDemoVerifyResult(options).payload; +} + +module.exports = { + buildDemoVerifyPayload, + buildDemoVerifyResult +}; diff --git a/agentops-cli/src/lib/demo/agentops-demo-data.js b/agentops-cli/src/lib/demo/agentops-demo-data.js index cb4cb8f..7094b71 100644 --- a/agentops-cli/src/lib/demo/agentops-demo-data.js +++ b/agentops-cli/src/lib/demo/agentops-demo-data.js @@ -1,7 +1,8 @@ -const crypto = require('node:crypto'); -const fs = require('node:fs'); const path = require('node:path'); +const { baseScenarios, chooseScenarios, contextProfile } = require('./agentops-demo-scenarios'); +const { writeJsonFile, writeJsonlFile } = require('../command-output'); +const { prefixedHash: stableHash } = require('../hash'); const { validateAgentRun } = require('../schema/agent-run-schema'); const { AGENTOPS_SCHEMA_VERSION } = require('../schema/agentops-attributes'); @@ -20,296 +21,10 @@ const tableNames = [ 'AgentOpsContent_CL' ]; -const baseScenarios = [ - { - name: 'successful-test-writing-run', - taskType: 'test', - model: 'claude-opus-4.7', - status: 'success', - reason: 'tests_passed', - duration: 78000, - input: 92000, - output: 18000, - reasoning: 5200, - cost: 0.48, - tools: 8, - failures: 0, - denied: 0, - testsRan: true, - testsPassed: true, - filesRead: 12, - filesEdited: 3, - risk: 18, - eval: 92, - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - }, - { - name: 'expensive-failed-run', - taskType: 'review', - model: 'gpt-5.5', - status: 'failed', - reason: 'tool_timeout', - duration: 214000, - input: 420000, - output: 61000, - reasoning: 42000, - cost: 4.92, - tools: 27, - failures: 5, - denied: 0, - testsRan: true, - testsPassed: false, - filesRead: 38, - filesEdited: 2, - risk: 64, - eval: 41, - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'failed' } - }, - { - name: 'policy-denied-secret-read', - taskType: 'debug_ci', - model: 'copilot-default', - status: 'blocked', - reason: 'policy_denied_secret_access', - duration: 36000, - input: 51000, - output: 6000, - reasoning: 1200, - cost: 0.12, - tools: 5, - failures: 1, - denied: 1, - testsRan: false, - testsPassed: false, - filesRead: 4, - filesEdited: 0, - risk: 91, - eval: 37, - privacyDrops: 2, - privacyKind: 'secret_like', - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - }, - { - name: 'mcp-tool-failure', - taskType: 'fix', - model: 'claude-sonnet-4.5', - status: 'failed', - reason: 'mcp_tool_error', - duration: 97000, - input: 88000, - output: 15000, - reasoning: 3200, - cost: 0.36, - tools: 12, - failures: 3, - denied: 0, - testsRan: false, - testsPassed: false, - filesRead: 18, - filesEdited: 1, - risk: 58, - eval: 48, - mcp: { server: 'playwright', tool: 'browser-control', risk: 'browser-control', status: 'failed' }, - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - }, - { - name: 'edited-files-no-tests', - taskType: 'refactor', - model: 'copilot-default', - status: 'success', - reason: 'completed_without_tests', - duration: 64000, - input: 67000, - output: 13000, - reasoning: 1600, - cost: 0.22, - tools: 10, - failures: 0, - denied: 0, - testsRan: false, - testsPassed: false, - filesRead: 14, - filesEdited: 4, - risk: 52, - eval: 55, - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - }, - { - name: 'pr-opened-ci-failed', - taskType: 'fix', - model: 'claude-opus-4.7', - status: 'success', - reason: 'pr_opened_ci_failed', - duration: 143000, - input: 180000, - output: 32000, - reasoning: 11000, - cost: 1.28, - tools: 22, - failures: 1, - denied: 0, - testsRan: true, - testsPassed: false, - filesRead: 29, - filesEdited: 6, - risk: 47, - eval: 62, - github: { opened: true, merged: false, closed: false, reverted: false, ci: 'failed' } - }, - { - name: 'pr-opened-and-merged', - taskType: 'fix', - model: 'claude-sonnet-4.5', - status: 'success', - reason: 'merged', - duration: 126000, - input: 132000, - output: 28000, - reasoning: 7200, - cost: 0.82, - tools: 19, - failures: 0, - denied: 0, - testsRan: true, - testsPassed: true, - filesRead: 24, - filesEdited: 5, - risk: 22, - eval: 95, - github: { opened: true, merged: true, closed: false, reverted: false, ci: 'passed' } - }, - { - name: 'model-cost-regression', - taskType: 'review', - model: 'gpt-5.5', - status: 'success', - reason: 'cost_regression', - duration: 158000, - input: 310000, - output: 49000, - reasoning: 36000, - cost: 3.74, - tools: 15, - failures: 0, - denied: 0, - testsRan: false, - testsPassed: false, - filesRead: 41, - filesEdited: 0, - risk: 44, - eval: 70, - insight: 'cost-anomaly', - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - }, - { - name: 'instruction-hash-regression', - taskType: 'docs', - model: 'copilot-default', - status: 'success', - reason: 'eval_regression_after_instruction_change', - duration: 58000, - input: 73000, - output: 9000, - reasoning: 1100, - cost: 0.18, - tools: 7, - failures: 0, - denied: 0, - testsRan: false, - testsPassed: false, - filesRead: 10, - filesEdited: 2, - risk: 39, - eval: 49, - insight: 'instruction-regression', - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - }, - { - name: 'privacy-drop-success', - taskType: 'explain', - model: 'copilot-default', - status: 'success', - reason: 'content_dropped_before_export', - duration: 31000, - input: 44000, - output: 7000, - reasoning: 900, - cost: 0.09, - tools: 4, - failures: 0, - denied: 0, - testsRan: false, - testsPassed: false, - filesRead: 7, - filesEdited: 0, - risk: 28, - eval: 82, - privacyDrops: 6, - privacyKind: 'prompt', - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - }, - { - name: 'collector-export-issue', - taskType: 'unknown', - model: 'copilot-default', - status: 'failed', - reason: 'collector_export_error', - duration: 45000, - input: 21000, - output: 3000, - reasoning: 300, - cost: 0.05, - tools: 2, - failures: 1, - denied: 0, - testsRan: false, - testsPassed: false, - filesRead: 3, - filesEdited: 0, - risk: 67, - eval: 44, - collectorError: true, - github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } - } -]; - -function stableHash(value, prefix = 'h') { - return `${prefix}_${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16)}`; -} - function isoMinutesAgo(minutes) { return new Date(Date.now() - minutes * 60 * 1000).toISOString(); } -function chooseScenarios(options = {}) { - return baseScenarios.filter(scenario => { - if (!options.withFailures && scenario.status === 'failed') return false; - if (!options.withPrivacyDrops && scenario.privacyDrops) return false; - if (!options.withGithubOutcomes && scenario.github.opened) return false; - return true; - }); -} - -function contextProfile(scenario, index) { - const profiles = { - 'expensive-failed-run': { contextPct: 94, cacheRead: 25000, cacheCreation: 16000, tokensRemoved: 36000, permissionWait: 18000 }, - 'model-cost-regression': { contextPct: 89, cacheRead: 12000, cacheCreation: 22000, tokensRemoved: 18000, permissionWait: 3000 }, - 'pr-opened-ci-failed': { contextPct: 81, cacheRead: 21000, cacheCreation: 6400, tokensRemoved: 4000, permissionWait: 7200 }, - 'pr-opened-and-merged': { contextPct: 74, cacheRead: 29000, cacheCreation: 4500, tokensRemoved: 0, permissionWait: 2400 }, - 'policy-denied-secret-read': { contextPct: 38, cacheRead: 2000, cacheCreation: 800, tokensRemoved: 0, permissionWait: 9000 }, - 'mcp-tool-failure': { contextPct: 61, cacheRead: 9000, cacheCreation: 1800, tokensRemoved: 0, permissionWait: 5400 }, - 'instruction-hash-regression': { contextPct: 68, cacheRead: 6000, cacheCreation: 900, tokensRemoved: 3500, permissionWait: 1100 } - }; - const base = profiles[scenario.name] || { contextPct: 42, cacheRead: 3500, cacheCreation: 700, tokensRemoved: 0, permissionWait: 900 }; - return { - ContextWindowPct: Math.min(100, base.contextPct + (index % 4) * 2), - CacheReadTokens: base.cacheRead + index * 113, - CacheCreationTokens: base.cacheCreation + index * 29, - TokensRemoved: base.tokensRemoved, - PermissionWaitMs: base.permissionWait - }; -} - function runAttributes(row) { return { 'agentops.schema.version': '2', @@ -341,8 +56,14 @@ function runAttributes(row) { } function addEvent(tables, time, run, eventName, fields = {}) { + const previous = tables.AgentOpsEvents_CL.filter(event => event.RunId === run.RunId).at(-1); + const sequence = (previous?.Sequence || 0) + 1; + const eventId = stableHash(`${run.RunId}:${sequence}:${eventName}`, 'event'); tables.AgentOpsEvents_CL.push({ TimeGenerated: time, + Sequence: sequence, + EventId: eventId, + ParentEventId: previous?.EventId || '', RunId: run.RunId, SessionId: run.SessionId, TraceId: run.TraceId, @@ -621,7 +342,7 @@ function generateDemoData(options = {}) { : scenario.collectorError ? 'Collector export health should be checked before relying on live data.' : 'A risky tool request was blocked by policy metadata.', - SuggestedNextStep: scenario.collectorError ? 'Run agentops collector smoke --privacy strict --poison --json' : 'Open Run Replay and inspect the linked spans.' + SuggestedNextStep: scenario.collectorError ? 'Run agentops collector smoke --privacy strict --poison --json' : 'Open Run Story and inspect the linked spans.' }); } @@ -669,22 +390,21 @@ function generateDemoData(options = {}) { } function writeDemoData(result, outDir) { - fs.mkdirSync(outDir, { recursive: true }); const files = {}; for (const table of tableNames) { const file = path.join(outDir, `${table}.jsonl`); - fs.writeFileSync(file, `${result.tables[table].map(row => JSON.stringify(row)).join('\n')}\n`); + writeJsonlFile(file, result.tables[table], { trailingNewline: true }); files[table] = file; } const manifest = path.join(outDir, 'manifest.json'); - fs.writeFileSync(manifest, `${JSON.stringify({ + writeJsonFile(manifest, { generated_at: result.generated_at, runs: result.runs, table_counts: result.table_counts, scenarios: result.scenarios, scenario_names: result.scenario_names, files - }, null, 2)}\n`); + }); return { out_dir: outDir, manifest, files }; } diff --git a/agentops-cli/src/lib/demo/agentops-demo-scenarios.js b/agentops-cli/src/lib/demo/agentops-demo-scenarios.js new file mode 100644 index 0000000..329daca --- /dev/null +++ b/agentops-cli/src/lib/demo/agentops-demo-scenarios.js @@ -0,0 +1,287 @@ +const baseScenarios = [ + { + name: 'successful-test-writing-run', + taskType: 'test', + model: 'claude-opus-4.7', + status: 'success', + reason: 'tests_passed', + duration: 78000, + input: 92000, + output: 18000, + reasoning: 5200, + cost: 0.48, + tools: 8, + failures: 0, + denied: 0, + testsRan: true, + testsPassed: true, + filesRead: 12, + filesEdited: 3, + risk: 18, + eval: 92, + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + }, + { + name: 'expensive-failed-run', + taskType: 'review', + model: 'gpt-5.5', + status: 'failed', + reason: 'tool_timeout', + duration: 214000, + input: 420000, + output: 61000, + reasoning: 42000, + cost: 4.92, + tools: 27, + failures: 5, + denied: 0, + testsRan: true, + testsPassed: false, + filesRead: 38, + filesEdited: 2, + risk: 64, + eval: 41, + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'failed' } + }, + { + name: 'policy-denied-secret-read', + taskType: 'debug_ci', + model: 'copilot-default', + status: 'blocked', + reason: 'policy_denied_secret_access', + duration: 36000, + input: 51000, + output: 6000, + reasoning: 1200, + cost: 0.12, + tools: 5, + failures: 1, + denied: 1, + testsRan: false, + testsPassed: false, + filesRead: 4, + filesEdited: 0, + risk: 91, + eval: 37, + privacyDrops: 2, + privacyKind: 'secret_like', + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + }, + { + name: 'mcp-tool-failure', + taskType: 'fix', + model: 'claude-sonnet-4.5', + status: 'failed', + reason: 'mcp_tool_error', + duration: 97000, + input: 88000, + output: 15000, + reasoning: 3200, + cost: 0.36, + tools: 12, + failures: 3, + denied: 0, + testsRan: false, + testsPassed: false, + filesRead: 18, + filesEdited: 1, + risk: 58, + eval: 48, + mcp: { server: 'playwright', tool: 'browser-control', risk: 'browser-control', status: 'failed' }, + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + }, + { + name: 'edited-files-no-tests', + taskType: 'refactor', + model: 'copilot-default', + status: 'success', + reason: 'completed_without_tests', + duration: 64000, + input: 67000, + output: 13000, + reasoning: 1600, + cost: 0.22, + tools: 10, + failures: 0, + denied: 0, + testsRan: false, + testsPassed: false, + filesRead: 14, + filesEdited: 4, + risk: 52, + eval: 55, + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + }, + { + name: 'pr-opened-ci-failed', + taskType: 'fix', + model: 'claude-opus-4.7', + status: 'success', + reason: 'pr_opened_ci_failed', + duration: 143000, + input: 180000, + output: 32000, + reasoning: 11000, + cost: 1.28, + tools: 22, + failures: 1, + denied: 0, + testsRan: true, + testsPassed: false, + filesRead: 29, + filesEdited: 6, + risk: 47, + eval: 62, + github: { opened: true, merged: false, closed: false, reverted: false, ci: 'failed' } + }, + { + name: 'pr-opened-and-merged', + taskType: 'fix', + model: 'claude-sonnet-4.5', + status: 'success', + reason: 'merged', + duration: 126000, + input: 132000, + output: 28000, + reasoning: 7200, + cost: 0.82, + tools: 19, + failures: 0, + denied: 0, + testsRan: true, + testsPassed: true, + filesRead: 24, + filesEdited: 5, + risk: 22, + eval: 95, + github: { opened: true, merged: true, closed: false, reverted: false, ci: 'passed' } + }, + { + name: 'model-cost-regression', + taskType: 'review', + model: 'gpt-5.5', + status: 'success', + reason: 'cost_regression', + duration: 158000, + input: 310000, + output: 49000, + reasoning: 36000, + cost: 3.74, + tools: 15, + failures: 0, + denied: 0, + testsRan: false, + testsPassed: false, + filesRead: 41, + filesEdited: 0, + risk: 44, + eval: 70, + insight: 'cost-anomaly', + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + }, + { + name: 'instruction-hash-regression', + taskType: 'docs', + model: 'copilot-default', + status: 'success', + reason: 'eval_regression_after_instruction_change', + duration: 58000, + input: 73000, + output: 9000, + reasoning: 1100, + cost: 0.18, + tools: 7, + failures: 0, + denied: 0, + testsRan: false, + testsPassed: false, + filesRead: 10, + filesEdited: 2, + risk: 39, + eval: 49, + insight: 'instruction-regression', + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + }, + { + name: 'privacy-drop-success', + taskType: 'explain', + model: 'copilot-default', + status: 'success', + reason: 'content_dropped_before_export', + duration: 31000, + input: 44000, + output: 7000, + reasoning: 900, + cost: 0.09, + tools: 4, + failures: 0, + denied: 0, + testsRan: false, + testsPassed: false, + filesRead: 7, + filesEdited: 0, + risk: 28, + eval: 82, + privacyDrops: 6, + privacyKind: 'prompt', + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + }, + { + name: 'collector-export-issue', + taskType: 'unknown', + model: 'copilot-default', + status: 'failed', + reason: 'collector_export_error', + duration: 45000, + input: 21000, + output: 3000, + reasoning: 300, + cost: 0.05, + tools: 2, + failures: 1, + denied: 0, + testsRan: false, + testsPassed: false, + filesRead: 3, + filesEdited: 0, + risk: 67, + eval: 44, + collectorError: true, + github: { opened: false, merged: false, closed: false, reverted: false, ci: 'not_run' } + } +]; + +function chooseScenarios(options = {}) { + return baseScenarios.filter(scenario => { + if (!options.withFailures && scenario.status === 'failed') return false; + if (!options.withPrivacyDrops && scenario.privacyDrops) return false; + if (!options.withGithubOutcomes && scenario.github.opened) return false; + return true; + }); +} + +function contextProfile(scenario, index) { + const profiles = { + 'expensive-failed-run': { contextPct: 94, cacheRead: 25000, cacheCreation: 16000, tokensRemoved: 36000, permissionWait: 18000 }, + 'model-cost-regression': { contextPct: 89, cacheRead: 12000, cacheCreation: 22000, tokensRemoved: 18000, permissionWait: 3000 }, + 'pr-opened-ci-failed': { contextPct: 81, cacheRead: 21000, cacheCreation: 6400, tokensRemoved: 4000, permissionWait: 7200 }, + 'pr-opened-and-merged': { contextPct: 74, cacheRead: 29000, cacheCreation: 4500, tokensRemoved: 0, permissionWait: 2400 }, + 'policy-denied-secret-read': { contextPct: 38, cacheRead: 2000, cacheCreation: 800, tokensRemoved: 0, permissionWait: 9000 }, + 'mcp-tool-failure': { contextPct: 61, cacheRead: 9000, cacheCreation: 1800, tokensRemoved: 0, permissionWait: 5400 }, + 'instruction-hash-regression': { contextPct: 68, cacheRead: 6000, cacheCreation: 900, tokensRemoved: 3500, permissionWait: 1100 } + }; + const base = profiles[scenario.name] || { contextPct: 42, cacheRead: 3500, cacheCreation: 700, tokensRemoved: 0, permissionWait: 900 }; + return { + ContextWindowPct: Math.min(100, base.contextPct + (index % 4) * 2), + CacheReadTokens: base.cacheRead + index * 113, + CacheCreationTokens: base.cacheCreation + index * 29, + TokensRemoved: base.tokensRemoved, + PermissionWaitMs: base.permissionWait + }; +} + +module.exports = { + baseScenarios, + chooseScenarios, + contextProfile +}; diff --git a/agentops-cli/src/lib/doctor-command.js b/agentops-cli/src/lib/doctor-command.js new file mode 100644 index 0000000..5d6d656 --- /dev/null +++ b/agentops-cli/src/lib/doctor-command.js @@ -0,0 +1,20 @@ +const { hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { doctorSummary, renderDoctor } = require('./doctor-summary'); + +async function doctorCommand(args = []) { + const json = hasFlag(args, '--json'); + const summary = await doctorSummary({ + mode: process.env.AGENTOPS_COLLECTOR_MODE || 'auto', + localOnly: hasFlag(args, '--local-only'), + last: optionValue(args, '--last', undefined) + }); + writeJsonOrRender(summary, json, renderDoctor); + process.exitCode = summary.ok ? 0 : 1; +} + +module.exports = { + doctorCommand, + doctorSummary, + renderDoctor +}; diff --git a/agentops-cli/src/lib/doctor-summary.js b/agentops-cli/src/lib/doctor-summary.js new file mode 100644 index 0000000..0255759 --- /dev/null +++ b/agentops-cli/src/lib/doctor-summary.js @@ -0,0 +1,102 @@ +const fs = require('node:fs'); + +const legacy = require('../legacy'); +const collector = require('./collector-manager'); +const { collectorReleaseContract } = require('./collector-release'); +const { resolveCopilotBinary } = require('./copilot-resolver'); +const { repoPath } = require('./paths'); + +function check(name, ok, detail = null, severity = 'error') { + return { name, ok: Boolean(ok), detail, severity }; +} + +function readText(file) { + const fullPath = repoPath(file); + return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : ''; +} + +function hasPinnedCollectorImage(text) { + return /otel\/opentelemetry-collector-contrib:(?!latest\b)\d+\.\d+\.\d+/.test(text); +} + +function hasLocalhostPortBindings(text) { + return ['127.0.0.1:4318:4318', '127.0.0.1:4317:4317', '127.0.0.1:13133:13133'] + .every(binding => text.includes(binding)); +} + +function collectorConfigChecks() { + const localCompose = readText('collector/docker-compose.yaml'); + const azureCompose = readText('collector/docker-compose.azuremonitor.yaml'); + const azureConfig = readText('collector/otelcol.azuremonitor.yaml'); + const azureStrictConfig = readText('collector/otelcol.azuremonitor.strict.yaml'); + const azureConfigHasPrivacy = ['transform/content_capture_signal', 'attributes/privacy_safe', 'exporters:', 'azuremonitor'] + .every(snippet => azureConfig.includes(snippet)); + const azureStrictHasPrivacy = ['transform/privacy_strict', 'keep_keys(attributes', 'exporters:', 'azuremonitor'] + .every(snippet => azureStrictConfig.includes(snippet)); + const release = collectorReleaseContract(); + + return [ + check('collector-image-pinned', hasPinnedCollectorImage(localCompose) && hasPinnedCollectorImage(azureCompose), 'docker compose defaults use an explicit otel/opentelemetry-collector-contrib version'), + check('collector-release-cadence', release.ok, release.ok ? `${release.version}; review after ${release.review_after}` : release.missing.join(', '), 'warning'), + check('collector-azure-localhost-bindings', hasLocalhostPortBindings(azureCompose), 'Azure Monitor compose binds OTLP and health ports to 127.0.0.1'), + check('collector-azure-config-privacy-parity', azureConfigHasPrivacy && azureStrictHasPrivacy, 'Azure Monitor configs include content signal, privacy filtering, and azuremonitor exporter') + ]; +} + +async function doctorSummary(options = {}) { + const localOnly = Boolean(options.localOnly); + const base = legacy.doctor({ localOnly: true }).map(item => ({ + ...item, + severity: item.ok ? 'info' : 'error' + })); + const validateAzure = options.validateAzure || legacy.validateAzure; + const cloudSummary = localOnly ? null : validateAzure({ + last: options.last, + production: options.production, + spawnSync: options.spawnSync, + azAvailable: options.azAvailable, + expectedDashboards: options.expectedDashboards + }); + const cloudChecks = cloudSummary ? cloudSummary.checks + .filter(item => ['grafana-base-url', 'grafana-resource', 'grafana-datasource', 'grafana-dashboards'].includes(item.name)) + .map(item => ({ + ...item, + severity: 'warning' + })) : []; + const collectorStatus = await collector.status(options); + const copilot = resolveCopilotBinary(); + const configPath = process.env.AGENTOPS_CONFIG_PATH || repoPath('.agentops', 'config.json'); + const connectionStringStored = fs.existsSync(configPath) + && /APPLICATIONINSIGHTS_CONNECTION_STRING|InstrumentationKey=/i.test(fs.readFileSync(configPath, 'utf8')); + const checks = [ + ...base, + check('collector-mode-resolved', collectorStatus.effectiveMode !== 'auto' || collectorStatus.details.length > 0, collectorStatus.details.join(' '), 'warning'), + check('collector-localhost-bindings', collectorStatus.safeLocalhostBinding, collector.composeFile), + check('collector-health', collectorStatus.running, collectorStatus.health?.error || collectorStatus.health?.statusCode || 'not running', 'warning'), + check('collector-binary-available', collectorStatus.binary.ok, collectorStatus.binary.error, collectorStatus.effectiveMode === 'binary' ? 'error' : 'warning'), + ...collectorConfigChecks(), + check('copilot-binary-non-recursive', copilot.ok, copilot.error, localOnly ? 'warning' : 'error'), + check('plugin-reversible', fs.existsSync(repoPath('plugin', 'plugin.json')) && fs.existsSync(repoPath('plugin', 'hooks.json')), 'agentops plugin uninstall removes bundled files'), + check('connection-string-not-on-disk', !connectionStringStored, configPath), + check('experimental-hidden-from-quickstart', true, 'experimental commands live behind agentops experimental'), + ...cloudChecks + ]; + const ok = checks.every(item => item.ok || item.severity === 'warning'); + return { ok, checks, collector: collectorStatus, copilot, cloud: cloudSummary ? { ok: cloudSummary.ok, next: cloudSummary.next } : null }; +} + +function renderDoctor(summary) { + const lines = ['AgentOps doctor']; + for (const item of summary.checks) { + const status = item.ok ? 'ok' : item.severity === 'warning' ? 'warn' : 'failed'; + lines.push(`- ${item.name}: ${status}${item.detail ? ` (${item.detail})` : ''}`); + } + lines.push('', summary.ok ? 'Doctor passed with no blocking local issues.' : 'Doctor found blocking issues.'); + return `${lines.join('\n')}\n`; +} + +module.exports = { + collectorConfigChecks, + doctorSummary, + renderDoctor +}; diff --git a/agentops-cli/src/lib/e2e-browser-check.js b/agentops-cli/src/lib/e2e-browser-check.js new file mode 100644 index 0000000..fed74d4 --- /dev/null +++ b/agentops-cli/src/lib/e2e-browser-check.js @@ -0,0 +1,106 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { optionValue } = require('./args'); +const { azureCliGrafanaBrowserAuth } = require('./azure/grafana-browser-auth'); +const { writeBrowserNotes } = require('./e2e-browser-notes'); +const { browserProfileOptionsFromArgs, grafanaAuthRemediation } = require('./e2e-grafana'); +const { playwrightBrowserCheck } = require('./e2e-playwright'); +const { checkReportHtml } = require('./e2e-report'); +const { latestEvidenceDir } = require('./e2e-runtime'); +const { repoRoot } = require('./paths'); + +function reportPathFromArgs(args = []) { + return path.resolve(optionValue(args, ['--report', '--in'], path.join(latestEvidenceDir(), 'report.html'))); +} + +function e2eAuthProfile(args = []) { + const reportPath = reportPathFromArgs(args); + const profile = browserProfileOptionsFromArgs(args); + const grafanaUrl = optionValue(args, '--url', 'https://example.grafana.azure.com/d/agentops-v2-home'); + return { + ok: true, + reportPath, + browserProfile: { + browserExecutable: profile.browserExecutable || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + browserUserDataDir: profile.browserUserDataDir || '$HOME/.agentops/browser/grafana-profile', + storageState: profile.storageState || '', + headed: profile.headed + }, + remediation: grafanaAuthRemediation({ + reportPath, + browserExecutable: profile.browserExecutable || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + browserUserDataDir: profile.browserUserDataDir || '$HOME/.agentops/browser/grafana-profile', + grafanaUrl + }) + }; +} + +function browserEvidenceStatus(staticCheck, playwright, options = {}) { + const wantsPlaywright = Boolean(options.wantsPlaywright); + const wantsGrafana = Boolean(options.wantsGrafana); + const backendEvidenceVerified = Boolean(staticCheck.ok); + const reportBrowserVerified = wantsPlaywright ? playwright.reportVerified === true : false; + const authenticatedGrafanaVerified = wantsPlaywright && wantsGrafana + ? playwright.authenticatedGrafanaVerified === true + : false; + return { + backendEvidenceVerified, + reportBrowserVerified, + authenticatedGrafanaVerified, + ok: backendEvidenceVerified && (!wantsPlaywright || playwright.ok === true) + }; +} + +async function e2eBrowserCheck(args = []) { + const reportPath = reportPathFromArgs(args); + const out = path.resolve(optionValue(args, '--out', path.join(path.dirname(reportPath), 'browser-notes.md'))); + const screenshotDir = path.resolve(optionValue(args, '--screenshot-dir', path.join(path.dirname(out), 'screenshots'))); + const docsScreenshotDir = args.includes('--v2-docs-screenshots') + ? path.resolve(optionValue(args, '--v2-docs-screenshot-dir', path.join(repoRoot, 'docs', 'screenshots', 'v2'))) + : null; + const allowCheckStatus = args.includes('--allow-check-status'); + if (!fs.existsSync(reportPath)) throw new Error(`Report not found: ${reportPath}`); + const staticCheck = checkReportHtml(fs.readFileSync(reportPath, 'utf8'), { allowCheckStatus }); + const wantsPlaywright = args.includes('--playwright') || process.env.AGENTOPS_E2E_PLAYWRIGHT === '1'; + const wantsGrafana = args.includes('--grafana'); + const grafanaRunId = optionValue(args, '--grafana-run-id', ''); + const profile = browserProfileOptionsFromArgs(args); + const azureAuth = wantsPlaywright && wantsGrafana && profile.azureCliGrafanaAuth + ? azureCliGrafanaBrowserAuth() + : null; + const playwright = wantsPlaywright + ? await playwrightBrowserCheck({ + reportPath, + outDir: screenshotDir, + grafana: wantsGrafana, + grafanaV2Only: args.includes('--grafana-v2-only'), + grafanaRunId, + docsScreenshotDir, + requireGrafanaVisible: args.includes('--require-grafana-visible'), + requireAccessibilitySmoke: args.includes('--require-accessibility-smoke'), + requirePerformance: args.includes('--require-performance'), + ...profile, + grafanaBearerToken: azureAuth?.token, + grafanaAuthEvidence: azureAuth?.evidence + }) + : { status: 'skipped', reason: 'Pass --playwright or set AGENTOPS_E2E_PLAYWRIGHT=1 to capture browser screenshots.' }; + const evidence = browserEvidenceStatus(staticCheck, playwright, { wantsPlaywright, wantsGrafana }); + const result = { + ...evidence, + reportPath, + static: staticCheck, + playwright + }; + fs.mkdirSync(path.dirname(out), { recursive: true }); + writeBrowserNotes(out, result); + result.notes = out; + return result; +} + +module.exports = { + browserEvidenceStatus, + e2eAuthProfile, + e2eBrowserCheck, + reportPathFromArgs +}; diff --git a/agentops-cli/src/lib/e2e-browser-notes.js b/agentops-cli/src/lib/e2e-browser-notes.js new file mode 100644 index 0000000..81886b7 --- /dev/null +++ b/agentops-cli/src/lib/e2e-browser-notes.js @@ -0,0 +1,49 @@ +const fs = require('node:fs'); + +function writeBrowserNotes(filePath, result) { + const lines = [ + '# Browser Validation Notes', + '', + `- Report: ${result.reportPath}`, + `- Static report check: ${result.static.ok ? 'pass' : 'fail'}`, + `- Backend evidence verified: ${result.backendEvidenceVerified ? 'yes' : 'no'}`, + `- Report rendered in browser: ${result.reportBrowserVerified ? 'yes' : 'no'}`, + `- Authenticated Grafana verified: ${result.authenticatedGrafanaVerified ? 'yes' : 'no'}`, + `- Accessibility smoke verified: ${result.playwright.accessibilitySmokeVerified ? 'yes' : 'no'} (heuristic only; WCAG verified: no)`, + `- Dashboard-ready performance verified: ${result.playwright.dashboardReadyPerformanceVerified ? 'yes' : 'no'}`, + `- PASS visible: ${result.static.passVisible ? 'yes' : 'no'}`, + `- Secret-looking values: ${result.static.secretLooking ? 'yes' : 'no'}`, + `- Grafana links: ${result.static.grafanaLinks}`, + `- Evidence JSON links: ${result.static.evidenceLinks}`, + `- Playwright: ${result.playwright.status}` + ]; + if (result.playwright.reason) lines.push(`- Playwright reason: ${result.playwright.reason}`); + if (result.playwright.reportScreenshot) lines.push(`- Report screenshot: ${result.playwright.reportScreenshot}`); + if (result.playwright.browserProfile) { + lines.push(`- Browser profile: ${result.playwright.browserProfile.persistent ? 'persistent profile' : result.playwright.browserProfile.storageState ? 'storage state' : 'fresh context'}`); + } + if (result.playwright.grafana?.length) { + lines.push('', '## Grafana'); + for (const item of result.playwright.grafana) { + lines.push(`- ${item.label}: ${item.dashboardVisible ? 'visible' : item.authBlocked ? 'auth-blocked' : 'not verified'} (${item.url})`); + } + if (result.playwright.requireGrafanaVisible && result.playwright.grafana.some(item => !item.dashboardVisible)) { + lines.push('- Required visible dashboards: failed. Sign in with an authenticated Grafana browser profile and rerun.'); + } + if (result.playwright.authRemediation) { + lines.push('', '## Auth Remediation', '', result.playwright.authRemediation.reason, ''); + lines.push('Sign in once:'); + lines.push('```bash'); + for (const command of result.playwright.authRemediation.sign_in_once) lines.push(command); + lines.push('```', '', 'Verify after sign-in:'); + lines.push('```bash'); + for (const command of result.playwright.authRemediation.verify_after_sign_in) lines.push(command); + lines.push('```'); + } + } + fs.writeFileSync(filePath, `${lines.join('\n')}\n`); +} + +module.exports = { + writeBrowserNotes +}; diff --git a/agentops-cli/src/lib/e2e-command.js b/agentops-cli/src/lib/e2e-command.js new file mode 100644 index 0000000..636dfb0 --- /dev/null +++ b/agentops-cli/src/lib/e2e-command.js @@ -0,0 +1,57 @@ +const { writeJsonOrRender } = require('./command-output'); +const { e2eAuthProfile, e2eBrowserCheck } = require('./e2e-browser-check'); +const { checkReportHtml, htmlLinks, renderReportHtml } = require('./e2e-report'); +const { safeE2eEnv } = require('./e2e-runtime'); +const { e2eReport, e2eRun, grafanaLinksFromOpenSummary } = require('./e2e-run'); +const { + browserProfileOptionsFromArgs, + grafanaAuthRemediation, + grafanaScreenshotTargets, + grafanaVisualOk, + renderAuthProfile +} = require('./e2e-grafana'); + +async function e2eCommand(args = []) { + const [subcommand] = args; + if (subcommand === 'run') { + const result = await e2eRun(args.slice(1)); + writeJsonOrRender(result, args.includes('--json'), value => `E2E evidence: ${value.evidenceDir}\n`); + process.exitCode = result.ok ? 0 : 1; + return; + } + if (subcommand === 'report') { + const result = e2eReport(args.slice(1)); + writeJsonOrRender(result, args.includes('--json'), value => `E2E report: ${value.out}\n`); + return; + } + if (subcommand === 'browser-check') { + const result = await e2eBrowserCheck(args.slice(1)); + writeJsonOrRender(result, args.includes('--json'), value => `E2E browser notes: ${value.notes}\n`); + process.exitCode = result.ok ? 0 : 1; + return; + } + if (subcommand === 'auth-profile') { + const result = e2eAuthProfile(args.slice(1)); + writeJsonOrRender(result, args.includes('--json'), renderAuthProfile); + return; + } + throw new Error('e2e requires run, report, browser-check, or auth-profile'); +} + +module.exports = { + checkReportHtml, + browserProfileOptionsFromArgs, + e2eCommand, + e2eBrowserCheck, + e2eAuthProfile, + e2eReport, + e2eRun, + grafanaAuthRemediation, + grafanaVisualOk, + grafanaScreenshotTargets, + grafanaLinksFromOpenSummary, + htmlLinks, + renderReportHtml, + renderAuthProfile, + safeE2eEnv +}; diff --git a/agentops-cli/src/lib/e2e-grafana.js b/agentops-cli/src/lib/e2e-grafana.js new file mode 100644 index 0000000..d7ce3dc --- /dev/null +++ b/agentops-cli/src/lib/e2e-grafana.js @@ -0,0 +1,110 @@ +const path = require('node:path'); + +const { browserProfileOptionsFromArgs } = require('./browser-options'); + +const V2_SCREENSHOT_NAMES = { + 'Today': 'agentops-v2-home-live.png', + 'Runs': 'agentops-v2-runs-explorer-live.png', + 'Run Story': 'agentops-v2-run-replay-live.png', + 'Models, Cost & Tokens': 'agentops-v2-models-cost-tokens-live.png', + 'Tools & MCP Risk': 'agentops-v2-tools-mcp-risk-live.png', + 'Privacy': 'agentops-v2-safety-privacy-policy-live.png', + 'Code Outcomes': 'agentops-v2-code-outcomes-live.png', + 'Evals & Quality': 'agentops-v2-evals-quality-live.png', + 'Insights & Regressions': 'agentops-v2-insights-regressions-live.png', + 'Collector Health': 'agentops-v2-collector-health-live.png' +}; + +const V2_DASHBOARDS = [ + ['Today', 'agentops-v2-home'], + ['Runs', 'agentops-v2-runs-explorer'], + ['Run Story', 'agentops-v2-run-replay'], + ['Models, Cost & Tokens', 'agentops-v2-models-cost-tokens'], + ['Tools & MCP Risk', 'agentops-v2-tools-mcp-risk'], + ['Privacy', 'agentops-v2-safety-privacy-policy'], + ['Code Outcomes', 'agentops-v2-code-outcomes'], + ['Evals & Quality', 'agentops-v2-evals-quality'], + ['Insights & Regressions', 'agentops-v2-insights-regressions'], + ['Collector Health', 'agentops-v2-collector-health'] +]; + +function screenshotSlug(label = '') { + return String(label) + .replace(/&/g, 'and') + .replace(/[^a-z0-9_-]+/gi, '-') + .replace(/^-|-$/g, '') + .toLowerCase() || 'grafana'; +} + +function grafanaScreenshotTargets(links = [], options = {}) { + const v2Only = Boolean(options.v2Only); + const withRun = value => { + const url = new URL(value); + if (options.runId) url.searchParams.set('var-run_id', String(options.runId)); + return url.toString(); + }; + if (v2Only) { + const firstGrafana = links.find(link => /grafana\.azure\.com/i.test(link.href || link.url || '')); + if (!firstGrafana) return []; + const base = new URL(firstGrafana.href || firstGrafana.url).origin; + return V2_DASHBOARDS.map(([label, uid]) => ({ + label, + url: withRun(`${base}/d/${uid}`), + fileName: V2_SCREENSHOT_NAMES[label], + v2Tour: true, + uid + })); + } + return links + .filter(link => /grafana\.azure\.com/i.test(link.href || link.url || '')) + .map(link => ({ + label: link.text || link.label || 'Grafana', + url: withRun(link.href || link.url), + fileName: V2_SCREENSHOT_NAMES[link.text || link.label] || `${screenshotSlug(link.text || link.label)}.png`, + v2Tour: Boolean(V2_SCREENSHOT_NAMES[link.text || link.label]) + })) + .filter(target => !v2Only || target.v2Tour); +} + +function grafanaVisualOk(items = []) { + return items.length > 0 && items.every(item => item.dashboardVisible && !item.authBlocked); +} + +function grafanaAuthRemediation(options = {}) { + const reportPath = options.reportPath || '.agentops/e2e/latest/report.html'; + const browserExecutable = options.browserExecutable || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + const browserUserDataDir = options.browserUserDataDir || '$HOME/.agentops/browser/grafana-profile'; + const grafanaUrl = options.grafanaUrl || 'https://example.grafana.azure.com/d/agentops-v2-home'; + return { + reason: 'Azure Managed Grafana redirected to Microsoft sign-in.', + sign_in_once: [ + `mkdir -p ${path.dirname(browserUserDataDir)}`, + `"${browserExecutable}" --user-data-dir="${browserUserDataDir}" "${grafanaUrl}"` + ], + verify_after_sign_in: [ + 'AGENTOPS_PLAYWRIGHT_MODULE_DIR=/path/to/node_modules', + `agentops e2e browser-check --report ${reportPath} --playwright --grafana --grafana-v2-only --require-grafana-visible --browser-executable "${browserExecutable}" --browser-user-data-dir "${browserUserDataDir}" --json` + ], + note: 'The strict visual gate cannot pass until the supplied browser profile can open the V2 dashboards without Microsoft SSO.' + }; +} + +function renderAuthProfile(result) { + return [ + 'Grafana browser profile setup', + '', + 'Sign in once:', + ...result.remediation.sign_in_once.map(command => `- ${command}`), + '', + 'Verify after sign-in:', + ...result.remediation.verify_after_sign_in.map(command => `- ${command}`) + ].join('\n') + '\n'; +} + +module.exports = { + browserProfileOptionsFromArgs, + grafanaAuthRemediation, + grafanaScreenshotTargets, + grafanaVisualOk, + renderAuthProfile +}; diff --git a/agentops-cli/src/lib/e2e-playwright.js b/agentops-cli/src/lib/e2e-playwright.js new file mode 100644 index 0000000..b9a6cc8 --- /dev/null +++ b/agentops-cli/src/lib/e2e-playwright.js @@ -0,0 +1,249 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const { + grafanaAuthRemediation, + grafanaScreenshotTargets, + grafanaVisualOk +} = require('./e2e-grafana'); +const { browserProfileRuntimeDefaults } = require('./browser-options'); + +function defaultLoadPlaywright(env = process.env) { + try { + return { playwright: require('playwright'), error: null }; + } catch (error) { + for (const dir of String(env.AGENTOPS_PLAYWRIGHT_MODULE_DIR || env.NODE_PATH || '').split(path.delimiter).filter(Boolean)) { + try { + return { playwright: require(path.join(dir, 'playwright')), error: null }; + } catch {} + } + return { playwright: null, error }; + } +} + +function summarizePageAudit(snapshot = {}) { + const navigation = snapshot.navigation || {}; + const accessibility = snapshot.accessibility || {}; + const unlabeledInteractive = Number(accessibility.unlabeledInteractive || 0); + const duplicateIds = Number(accessibility.duplicateIds || 0); + const accessibilityOk = Boolean( + accessibility.hasTitle + && accessibility.hasLanguage + && accessibility.hasMain + && accessibility.hasNavigation + && accessibility.hasSkipLink + && unlabeledInteractive === 0 + && duplicateIds === 0 + ); + const loadMs = Number(navigation.loadEventEnd || navigation.duration || 0); + const performanceOk = loadMs > 0 && loadMs <= 10000; + return { + accessibility: { + level: 'heuristic-smoke', + wcagVerified: false, + ok: accessibilityOk, + ...accessibility, + unlabeledInteractive, + duplicateIds + }, + performance: { + level: 'navigation-smoke', + ok: performanceOk, + navigationLoadMs: Math.round(loadMs), + domContentLoadedMs: Math.round(Number(navigation.domContentLoadedEventEnd || 0)), + firstContentfulPaintMs: Math.round(Number(snapshot.firstContentfulPaint || 0)), + resources: Number(snapshot.resourceCount || 0), + transferBytes: Number(snapshot.transferBytes || 0) + } + }; +} + +async function auditPage(page) { + const snapshot = await page.evaluate(() => { + const navigation = performance.getEntriesByType('navigation')[0]?.toJSON?.() || {}; + const paints = performance.getEntriesByType('paint'); + const firstContentfulPaint = paints.find(entry => entry.name === 'first-contentful-paint')?.startTime || 0; + const resources = performance.getEntriesByType('resource'); + const ids = [...document.querySelectorAll('[id]')].map(element => element.id).filter(Boolean); + const duplicateIds = ids.length - new Set(ids).size; + const interactive = [...document.querySelectorAll('button, input, select, textarea, a[href], [role="button"], [role="link"]')]; + const unlabeledElements = interactive.filter(element => { + if (element.getAttribute('aria-label') || element.getAttribute('aria-labelledby') || element.getAttribute('title')) return false; + if ((element.textContent || '').trim()) return false; + if (element.tagName === 'INPUT' && element.getAttribute('placeholder')) return false; + const id = element.getAttribute('id'); + return !(id && document.querySelector(`label[for="${CSS.escape(id)}"]`)); + }); + const unlabeledInteractiveDetails = unlabeledElements.slice(0, 50).map(element => ({ + tag: element.tagName.toLowerCase(), + role: element.getAttribute('role') || '', + type: element.getAttribute('type') || '', + testIdKind: /Dashboard template variables/i.test(element.getAttribute('data-testid') || '') + ? 'dashboard-template-variable' + : (element.hasAttribute('data-testid') ? 'other' : ''), + inDashboardContent: Boolean(element.closest('[data-testid="dashboard-container"], .dashboard-container, main')) + })); + const idCounts = ids.reduce((counts, id) => counts.set(id, (counts.get(id) || 0) + 1), new Map()); + return { + navigation, + firstContentfulPaint, + resourceCount: resources.length, + transferBytes: resources.reduce((total, entry) => total + Number(entry.transferSize || 0), 0), + accessibility: { + hasTitle: Boolean(document.title.trim()), + hasLanguage: Boolean(document.documentElement.lang), + hasMain: Boolean(document.querySelector('main, [role="main"], #pageContent')), + hasNavigation: Boolean(document.querySelector('nav, [role="navigation"]')), + hasSkipLink: [...document.querySelectorAll('a[href]')].some(link => /skip to main/i.test(link.textContent || '')), + headings: document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]').length, + unlabeledInteractive: unlabeledElements.length, + unlabeledInteractiveDetails, + duplicateIds, + duplicateIdValues: [...idCounts.entries()].filter(([, count]) => count > 1).map(([id, count]) => ({ id, count })) + } + }; + }); + return summarizePageAudit(snapshot); +} + +async function playwrightBrowserCheck(options = {}) { + const browserDefaults = browserProfileRuntimeDefaults(); + const { + reportPath, + outDir, + grafana = false, + grafanaV2Only = false, + grafanaRunId = '', + docsScreenshotDir = null, + requireGrafanaVisible = false, + requireAccessibilitySmoke = false, + requirePerformance = false, + browserExecutable = browserDefaults.browserExecutable, + browserUserDataDir = browserDefaults.browserUserDataDir, + storageState = browserDefaults.storageState, + grafanaBearerToken = '', + grafanaAuthEvidence = null, + headed = browserDefaults.headed, + loadPlaywright = defaultLoadPlaywright + } = options; + const loaded = loadPlaywright(process.env); + if (!loaded.playwright) { + return { status: 'skipped', reason: `Playwright is not available: ${loaded.error.message}` }; + } + const playwright = loaded.playwright; + + const viewport = { width: 1440, height: 1000 }; + const contextOptions = { + viewport, + ...(grafanaBearerToken ? { extraHTTPHeaders: { Authorization: `Bearer ${grafanaBearerToken}` } } : {}) + }; + const launch = { headless: !headed }; + if (browserExecutable) launch.executablePath = browserExecutable; + let browser = null; + let context = null; + if (browserUserDataDir) { + context = await playwright.chromium.launchPersistentContext(path.resolve(browserUserDataDir), { + ...launch, + ...contextOptions + }); + } else { + browser = await playwright.chromium.launch(launch); + context = await browser.newContext({ + ...contextOptions, + ...(storageState ? { storageState: path.resolve(storageState) } : {}) + }); + } + const page = context.pages()[0] || await context.newPage(); + const url = pathToFileURL(reportPath).toString(); + await page.goto(url, { waitUntil: 'networkidle' }); + fs.mkdirSync(outDir, { recursive: true }); + const reportScreenshot = path.join(outDir, 'report.png'); + await page.screenshot({ path: reportScreenshot, fullPage: true }); + const text = await page.locator('body').innerText(); + const browserResult = { + status: 'checked', + reportScreenshot, + passVisible: /\bPASS\b/.test(text), + secretLooking: /(SECRET_[A-Z_]+|InstrumentationKey=|CONNECTION_STRING=|PASSWORD=|TOKEN=|KEY=)/i.test(text), + grafana: [], + browserProfile: { + persistent: Boolean(browserUserDataDir), + storageState: Boolean(storageState), + headed: Boolean(headed) + }, + ...(grafanaAuthEvidence ? { grafanaAuth: grafanaAuthEvidence } : {}) + }; + + if (grafana) { + const links = await page.locator('a').evaluateAll(nodes => nodes.map(node => ({ + href: node.href, + text: node.textContent.trim() + }))); + for (const target of grafanaScreenshotTargets(links, { v2Only: grafanaV2Only, runId: grafanaRunId })) { + const dashboard = await context.newPage(); + const dashboardStartedAt = Date.now(); + await dashboard.goto(target.url, { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {}); + await dashboard.waitForTimeout(5000); + const body = await dashboard.locator('body').innerText({ timeout: 5000 }).catch(() => ''); + const pageAudit = await auditPage(dashboard).catch(error => ({ error: error.message })); + if (pageAudit.performance) { + pageAudit.performance.dashboardObservedReadyMs = Date.now() - dashboardStartedAt; + pageAudit.performance.dashboardReadyOk = pageAudit.performance.dashboardObservedReadyMs <= 10000; + } + const screenshot = path.join(outDir, target.fileName); + await dashboard.screenshot({ path: screenshot, fullPage: true }).catch(() => {}); + let docsScreenshot = null; + if (docsScreenshotDir && target.v2Tour && fs.existsSync(screenshot) && !/Sign in|Can.t access your account|login.microsoftonline.com/i.test(body + dashboard.url())) { + fs.mkdirSync(docsScreenshotDir, { recursive: true }); + docsScreenshot = path.join(docsScreenshotDir, target.fileName); + fs.copyFileSync(screenshot, docsScreenshot); + } + browserResult.grafana.push({ + label: target.label, + url: target.url, + screenshot, + docsScreenshot, + v2Tour: target.v2Tour, + authBlocked: /Sign in|Can.t access your account|login.microsoftonline.com/i.test(body + dashboard.url()), + dashboardVisible: /AgentOps|Copilot|Sessions|Session Detail|No data/i.test(body), + pageAudit + }); + await dashboard.close(); + } + } + + await context.close(); + if (browser) await browser.close(); + browserResult.reportVerified = browserResult.passVisible && !browserResult.secretLooking; + browserResult.authenticatedGrafanaVerified = grafana && grafanaVisualOk(browserResult.grafana); + browserResult.accessibilitySmokeVerified = grafana && browserResult.grafana.length > 0 + && browserResult.grafana.every(item => item.pageAudit?.accessibility?.ok === true); + browserResult.wcagVerified = false; + browserResult.dashboardReadyPerformanceVerified = grafana && browserResult.grafana.length > 0 + && browserResult.grafana.every(item => item.pageAudit?.performance?.ok === true && item.pageAudit?.performance?.dashboardReadyOk === true); + browserResult.ok = browserResult.reportVerified + && (!grafana || browserResult.authenticatedGrafanaVerified) + && (!requireAccessibilitySmoke || browserResult.accessibilitySmokeVerified) + && (!requirePerformance || browserResult.dashboardReadyPerformanceVerified); + if (grafana) browserResult.requireGrafanaVisible = requireGrafanaVisible; + if (grafana) browserResult.requireAccessibilitySmoke = requireAccessibilitySmoke; + if (grafana) browserResult.requirePerformance = requirePerformance; + if (grafana && browserResult.grafana.some(item => item.authBlocked)) { + const firstBlocked = browserResult.grafana.find(item => item.authBlocked); + browserResult.authRemediation = grafanaAuthRemediation({ + reportPath, + browserExecutable, + browserUserDataDir, + grafanaUrl: firstBlocked?.url + }); + } + return browserResult; +} + +module.exports = { + auditPage, + defaultLoadPlaywright, + playwrightBrowserCheck, + summarizePageAudit +}; diff --git a/agentops-cli/src/lib/e2e-report.js b/agentops-cli/src/lib/e2e-report.js new file mode 100644 index 0000000..c0d186d --- /dev/null +++ b/agentops-cli/src/lib/e2e-report.js @@ -0,0 +1,107 @@ +const path = require('node:path'); + +function htmlEscape(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +} + +function renderReportHtml(report) { + const status = report.ok ? 'PASS' : 'CHECK'; + return `<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>AgentOps E2E Report + + + + + +
+

AgentOps E2E Report ${status}

+
+

Summary

+

Collector mode: ${htmlEscape(report.collector?.effectiveMode || report.collector?.mode || 'unknown')}

+

Privacy mode: ${htmlEscape(report.privacyMode)}

+

E2E marker: ${htmlEscape(report.e2eId || 'not available')}

+

Latest session: ${htmlEscape(report.latestSessionId || 'not available')}

+

Latest matched marker: ${htmlEscape(report.latestE2eMatched ? 'yes' : 'no')}

+

Backend live run: ${htmlEscape(report.live ? (report.ok && report.latestE2eMatched ? 'verified' : 'failed') : 'not requested')}

+

Authenticated Grafana visual verification: not part of this report; run agentops e2e browser-check --playwright --grafana.

+
+
+

Privacy Poison Test

+
${htmlEscape(JSON.stringify(report.poison, null, 2))}
+
+
+

Grafana Links

+ ${(report.grafanaLinks || []).map(link => `

${htmlEscape(link.label)}

`).join('\n') || '

No Grafana links available.

'} +
+
+

Evidence Files

+ ${(report.evidenceFiles || []).map(file => `

${htmlEscape(path.basename(file))}

`).join('\n')} +
+
+ + +`; +} + +function htmlLinks(html) { + const links = []; + const pattern = /]*href="([^"]+)"[^>]*>(.*?)<\/a>/gis; + let match; + while ((match = pattern.exec(String(html || '')))) { + links.push({ + href: match[1].replace(/&/g, '&'), + text: match[2].replace(/<[^>]+>/g, '').trim() + }); + } + return links; +} + +function checkReportHtml(html, options = {}) { + const text = String(html || '').replace(/<[^>]+>/g, ' '); + const links = htmlLinks(html); + const grafanaLinks = links.filter(link => /grafana\.azure\.com/i.test(link.href)); + const evidenceLinks = links.filter(link => /\.json($|[?#])/i.test(link.href)); + const secretPattern = /(SECRET_[A-Z_]+|InstrumentationKey=|CONNECTION_STRING=|PASSWORD=|TOKEN=|KEY=)/i; + const passVisible = /\bPASS\b/.test(text); + const allowCheckStatus = Boolean(options.allowCheckStatus); + return { + ok: (passVisible || allowCheckStatus) && !secretPattern.test(text) && grafanaLinks.length > 0 && evidenceLinks.length > 0, + passVisible, + secretLooking: secretPattern.test(text), + grafanaLinks: grafanaLinks.length, + evidenceLinks: evidenceLinks.length, + links + }; +} + +module.exports = { + checkReportHtml, + htmlLinks, + renderReportHtml +}; diff --git a/agentops-cli/src/lib/e2e-run.js b/agentops-cli/src/lib/e2e-run.js new file mode 100644 index 0000000..86f65de --- /dev/null +++ b/agentops-cli/src/lib/e2e-run.js @@ -0,0 +1,186 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const collector = require('./collector-manager'); +const { optionValue } = require('./args'); +const { readJson } = require('./json'); +const { redactedEnvSummary } = require('./privacy'); +const { renderReportHtml } = require('./e2e-report'); +const { + evidenceDir, + latestEvidenceDir, + runAgentops, + safeE2eEnv, + waitForLatestE2eSession, + writeJson +} = require('./e2e-runtime'); +const legacy = require('../legacy'); + +function grafanaLinksFromOpenSummary(summary = legacy.openLinksSummary()) { + return [ + summary.azure_agents_view_url + ? { label: 'Azure Monitor Agents view', url: summary.azure_agents_view_url } + : summary.application_insights_url + ? { label: 'Application Insights (open Agents)', url: summary.application_insights_url } + : null, + { label: 'Today', url: summary.v2_home_url }, + { label: 'Runs', url: summary.v2_runs_url }, + { label: 'Run Story', url: summary.v2_replay_url }, + { label: 'Overview', url: summary.main_dashboard_url }, + { label: 'Sessions', url: summary.sessions_dashboard_url }, + { label: 'Latest Session', url: summary.latest_session_url } + ].filter(link => link?.url); +} + +function defaultCopilotArgs() { + return [ + 'copilot', + '--no-ask-user', + '--no-remote', + '--add-dir', + '.', + "--allow-tool=shell(pwd)", + "--allow-tool=shell(ls:*)", + '-p', + 'AgentOps E2E test. Do not edit files. Run pwd and ls docs | head if available, then reply with exactly one short summary sentence containing AGENTOPS_E2E_OK.' + ]; +} + +async function e2eRun(args = [], options = {}) { + const live = args.includes('--live'); + const last = optionValue(args, '--last', '2h'); + const dir = (options.evidenceDir || evidenceDir)(); + const e2eId = `agentops-e2e-${path.basename(dir)}`; + const latestDir = (options.latestEvidenceDir || latestEvidenceDir)(); + const runCollector = options.collector || collector; + const runAgentopsCommand = options.runAgentops || runAgentops; + const waitForLatest = options.waitForLatestE2eSession || waitForLatestE2eSession; + const openLinksSummary = options.openLinksSummary || legacy.openLinksSummary; + const renderReport = options.renderReportHtml || renderReportHtml; + fs.mkdirSync(dir, { recursive: true }); + fs.rmSync(latestDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(latestDir), { recursive: true }); + fs.symlinkSync(dir, latestDir, 'dir'); + + const e2eEnv = safeE2eEnv(); + const doctor = runAgentopsCommand(['doctor', '--json'], { env: e2eEnv }); + const collectorStart = await runCollector.start({ mode: 'auto', privacy: 'strict' }); + const collectorStatus = await runCollector.status({ mode: 'auto', privacy: 'strict' }); + const poison = await runCollector.smoke({ privacy: 'strict', poison: true }); + const outputs = []; + writeJson(path.join(dir, 'doctor.json'), doctor); + writeJson(path.join(dir, 'collector-start.json'), collectorStart); + writeJson(path.join(dir, 'collector-status.json'), collectorStatus); + writeJson(path.join(dir, 'poison.json'), poison); + + const copilotArgs = defaultCopilotArgs(); + + let latest = null; + let replay = null; + let validateAzure = null; + let open = null; + let copilot = null; + let latestPayload = null; + let latestSessionId = null; + let latestE2eMatched = false; + let latestAttempts = 0; + + if (live) { + copilot = runAgentopsCommand(copilotArgs, { + timeout: 300000, + env: safeE2eEnv({ AGENTOPS_E2E_ID: e2eId }) + }); + writeJson(path.join(dir, 'copilot.json'), copilot); + outputs.push(copilot); + + const latestWait = await waitForLatest(e2eId, last); + latest = latestWait.latest; + latestPayload = latestWait.payload; + latestE2eMatched = latestWait.matched; + latestAttempts = latestWait.attempts; + writeJson(path.join(dir, 'latest.json'), latest); + outputs.push(latest); + + latestSessionId = latestPayload?.session?.id || latestPayload?.session_id || null; + if (latestSessionId) { + replay = runAgentopsCommand(['replay', 'latest', '--last', last], { env: e2eEnv }); + writeJson(path.join(dir, 'replay.json'), replay); + outputs.push(replay); + } + + validateAzure = runAgentopsCommand(['validate-azure', '--last', last, '--json'], { env: e2eEnv }); + writeJson(path.join(dir, 'validate-azure.json'), validateAzure); + outputs.push(validateAzure); + + open = runAgentopsCommand(['open', '--last', last, '--json'], { env: e2eEnv }); + writeJson(path.join(dir, 'open.json'), open); + outputs.push(open); + } + + const summary = { + ok: poison.ok && (!live || (copilot?.status === 0 && latest?.status === 0 && latestE2eMatched)), + live, + e2eId, + evidenceDir: dir, + privacyMode: 'strict', + environment: redactedEnvSummary(safeE2eEnv({ AGENTOPS_E2E_ID: e2eId })), + doctor, + collectorStart, + collector: collectorStatus, + poison, + liveCopilot: live ? copilot : { status: 'skipped', reason: 'Pass --live to run Copilot.' }, + latest, + latestSessionId, + latestE2eMatched, + latestAttempts, + replay, + validateAzure, + open, + copilotCommand: copilotArgs.map(value => /SECRET|TOKEN|KEY|CONNECTION_STRING/i.test(value) ? '[REDACTED]' : value), + grafanaLinks: open?.stdout ? (() => { + try { + return grafanaLinksFromOpenSummary(JSON.parse(open.stdout)); + } catch { + return grafanaLinksFromOpenSummary(openLinksSummary()); + } + })() : grafanaLinksFromOpenSummary(openLinksSummary()), + evidenceFiles: fs.readdirSync(dir).map(file => path.join(dir, file)) + }; + writeJson(path.join(dir, 'summary.json'), summary); + if (args.includes('--browser-report')) { + fs.writeFileSync(path.join(dir, 'report.html'), renderReport(summary)); + } + return summary; +} + +function e2eReport(args = []) { + const outIndex = args.indexOf('--out'); + const out = outIndex === -1 ? path.join(latestEvidenceDir(), 'report.html') : path.resolve(args[outIndex + 1]); + const dir = path.dirname(out); + fs.mkdirSync(dir, { recursive: true }); + const summaryPath = path.join(dir, 'summary.json'); + const summary = fs.existsSync(summaryPath) + ? readJson(summaryPath) + : { + ok: false, + privacyMode: 'strict', + collector: null, + poison: null, + latestSessionId: null, + grafanaLinks: grafanaLinksFromOpenSummary(), + evidenceFiles: [] + }; + const report = { + ...summary, + grafanaLinks: summary.grafanaLinks || grafanaLinksFromOpenSummary(), + evidenceFiles: fs.existsSync(dir) ? fs.readdirSync(dir).map(file => path.join(dir, file)) : [] + }; + fs.writeFileSync(out, renderReportHtml(report)); + return { ok: true, out, report }; +} + +module.exports = { + e2eReport, + e2eRun, + grafanaLinksFromOpenSummary +}; diff --git a/agentops-cli/src/lib/e2e-runtime.js b/agentops-cli/src/lib/e2e-runtime.js new file mode 100644 index 0000000..a993b61 --- /dev/null +++ b/agentops-cli/src/lib/e2e-runtime.js @@ -0,0 +1,90 @@ +const childProcess = require('node:child_process'); +const path = require('node:path'); + +const { writeJsonFile: writeJson } = require('./command-output'); +const { repoRoot } = require('./paths'); +const { sleep } = require('./timing'); + +function timestamp() { + return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); +} + +function evidenceDir(name = timestamp()) { + return path.join(repoRoot, '.agentops', 'e2e', name); +} + +function latestEvidenceDir() { + return path.join(repoRoot, '.agentops', 'e2e', 'latest'); +} + +function redactText(text = '') { + return String(text) + .replace(/InstrumentationKey=[^;\s"]+/gi, 'InstrumentationKey=[REDACTED]') + .replace(/(Authorization=Bearer\s+)[^\s"]+/gi, '$1[REDACTED]') + .replace(/([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CONNECTION_STRING)[A-Z0-9_]*=)[^\s"]+/gi, '$1[REDACTED]'); +} + +function runAgentops(args, options = {}) { + const result = childProcess.spawnSync(process.execPath, [path.join(repoRoot, 'agentops-cli', 'src', 'index.js'), ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, ...(options.env || {}) }, + timeout: options.timeout || 120000 + }); + return { + command: ['agentops', ...args].join(' '), + status: result.status, + stdout: redactText(result.stdout || ''), + stderr: redactText(result.stderr || ''), + error: result.error ? result.error.message : null + }; +} + +function safeE2eEnv(extra = {}) { + return { + AGENTOPS_PRIVACY_MODE: 'strict', + AGENTOPS_CAPTURE_CONTENT: 'false', + AGENTOPS_DISABLE_CONTENT_CAPTURE_OVERRIDE: '1', + COPILOT_OTEL_CAPTURE_CONTENT: 'false', + ...extra + }; +} + +async function waitForLatestE2eSession(e2eId, last, options = {}) { + const timeoutMs = options.timeoutMs || 180000; + const intervalMs = options.intervalMs || 10000; + const deadline = Date.now() + timeoutMs; + let attempts = 0; + let latest = null; + let payload = null; + + while (Date.now() <= deadline) { + attempts += 1; + latest = runAgentops(['latest', '--last', last, '--json']); + try { + payload = JSON.parse(latest.stdout); + } catch { + payload = null; + } + + const ids = payload?.session?.e2e_ids || []; + if (latest.status === 0 && (payload?.session?.e2e_id === e2eId || ids.includes(e2eId))) { + return { latest, payload, attempts, matched: true }; + } + + await sleep(intervalMs); + } + + return { latest, payload, attempts, matched: false }; +} + +module.exports = { + evidenceDir, + latestEvidenceDir, + redactText, + runAgentops, + safeE2eEnv, + timestamp, + waitForLatestE2eSession, + writeJson +}; diff --git a/agentops-cli/src/lib/enterprise-validation.js b/agentops-cli/src/lib/enterprise-validation.js new file mode 100644 index 0000000..3ebdbd0 --- /dev/null +++ b/agentops-cli/src/lib/enterprise-validation.js @@ -0,0 +1,308 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { repoRoot } = require('./paths'); +const { readAgentOpsConfig } = require('./agentops-config'); + +function repoFileText(relativePath, options = {}) { + const base = options.root || repoRoot; + const fullPath = path.join(base, relativePath); + return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : ''; +} + +function enterpriseCheck(name, ok, severity, detail) { + return { name, ok: Boolean(ok), severity, detail }; +} + +function validateEnterprise(options = {}) { + const env = options.env || process.env; + const config = options.config || readAgentOpsConfig({ configPath: options.configPath, quiet: true }).values; + const mainBicep = repoFileText('infra/bicep/main.bicep', options); + const logAnalyticsBicep = repoFileText('infra/bicep/log-analytics.bicep', options); + const grafanaBicep = repoFileText('infra/bicep/grafana.bicep', options); + const keyVaultBicep = repoFileText('infra/bicep/key-vault.bicep', options); + const appInsightsBicep = repoFileText('infra/bicep/app-insights.bicep', options); + const alertsBicep = repoFileText('infra/bicep/alerts.bicep', options); + const rbacBicep = repoFileText('infra/bicep/rbac.bicep', options); + const budgetBicep = repoFileText('infra/bicep/budget.bicep', options); + const datasourceProvisioning = repoFileText('grafana/provisioning/datasources/azure-monitor.yaml', options); + const azureCollector = repoFileText('collector/otelcol.azuremonitor.yaml', options); + const azureCompose = repoFileText('collector/docker-compose.azuremonitor.yaml', options); + const azureWhatIf = repoFileText('scripts/azure-what-if.sh', options); + const enterpriseDeploy = repoFileText('scripts/azure-deploy-enterprise-pilot.sh', options); + const readme = repoFileText('README.md', options); + const enterprisePilot = repoFileText('docs/enterprise-pilot.md', options); + const azureProdHardening = repoFileText('docs/azure-production-hardening.md', options); + const threatModel = repoFileText('docs/threat-model.md', options); + + const checks = [ + enterpriseCheck( + 'deployment-profiles', + /param deploymentProfile string/.test(mainBicep) && /'dev'/.test(mainBicep) && /'team'/.test(mainBicep) && /'enterprise'/.test(mainBicep), + 'high', + 'Bicep exposes dev/team/enterprise profiles.' + ), + enterpriseCheck( + 'daily-ingestion-cap', + /dailyIngestionCapGb/.test(mainBicep) && /workspaceCapping/.test(logAnalyticsBicep) && /dailyQuotaGb/.test(logAnalyticsBicep), + 'critical', + 'Log Analytics has a default daily ingestion cap as a spike guardrail.' + ), + enterpriseCheck( + 'retention-parameter', + /logRetentionDays/.test(mainBicep) && /retentionInDays/.test(logAnalyticsBicep), + 'high', + 'Retention is explicit and profile-driven.' + ), + enterpriseCheck( + 'metadata-only-tags', + /telemetryContent: 'metadata-only'/.test(mainBicep), + 'medium', + 'Azure resources are tagged as metadata-only telemetry.' + ), + enterpriseCheck( + 'actioner-disabled-default', + /param deployActioner bool = false/.test(mainBicep), + 'critical', + 'Actioning workflows are opt-in, not enabled by default.' + ), + enterpriseCheck( + 'alerts-disabled-default', + /param deployAlerts bool = false/.test(mainBicep) && /param enableAlerts bool = false/.test(mainBicep), + 'high', + 'Alerts are opt-in until thresholds and action groups are tuned.' + ), + enterpriseCheck( + 'rbac-disabled-default', + /param deployRbacAssignments bool = false/.test(mainBicep), + 'high', + 'RBAC assignment automation is opt-in because it mutates access control.' + ), + enterpriseCheck( + 'budget-disabled-default', + /param deployBudget bool = false/.test(mainBicep) && /budgetContactEmails/.test(mainBicep), + 'high', + 'Budget creation is opt-in and requires explicit contact emails.' + ), + enterpriseCheck( + 'collector-localhost-published', + /127\.0\.0\.1:4318:4318/.test(azureCompose) && /127\.0\.0\.1:4317:4317/.test(azureCompose), + 'critical', + 'Docker publishes OTLP only on localhost.' + ), + enterpriseCheck( + 'collector-content-scrub', + [ + 'gen_ai.input.messages', + 'gen_ai.output.messages', + 'gen_ai.prompt', + 'gen_ai.completion', + 'gen_ai.tool.call.arguments', + 'gen_ai.tool.call.result', + 'http.request.body.content', + 'http.response.body.content' + ].every(key => azureCollector.includes(key)) && /action: delete/.test(azureCollector), + 'critical', + 'Collector deletes prompt, response, tool payload, URL, and body content before Azure export.' + ), + enterpriseCheck( + 'content-capture-env-off', + String(env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT || '').toLowerCase() !== 'true' && + String(env.COPILOT_OTEL_CAPTURE_CONTENT || '').toLowerCase() !== 'true', + 'critical', + 'Content capture is not enabled in this environment.' + ), + enterpriseCheck( + 'grafana-api-keys-disabled', + /apiKey: 'Disabled'/.test(grafanaBicep), + 'high', + 'Azure Managed Grafana API keys are disabled.' + ), + enterpriseCheck( + 'grafana-managed-identity', + /identity:\s*{\s*type: 'SystemAssigned'/s.test(grafanaBicep) && /azureAuthType: msi/.test(datasourceProvisioning), + 'high', + 'Managed Grafana and the Azure Monitor datasource use managed identity auth.' + ), + enterpriseCheck( + 'grafana-network-posture-params', + /param grafanaPublicNetworkAccess string/.test(mainBicep) && + /param grafanaZoneRedundancy string/.test(mainBicep) && + /publicNetworkAccess: publicNetworkAccess/.test(grafanaBicep) && + /zoneRedundancy: zoneRedundancy/.test(grafanaBicep), + 'medium', + 'Grafana public access and zone redundancy are explicit deployment choices.' + ), + enterpriseCheck( + 'alert-action-groups-parameter', + /param alertActionGroupResourceIds array/.test(mainBicep) && + /param actionGroupResourceIds array/.test(alertsBicep) && + /actionGroups: actionGroupResourceIds/.test(alertsBicep), + 'high', + 'Alert routing uses explicit Azure Monitor action group resource IDs.' + ), + enterpriseCheck( + 'key-vault-rbac-purge-protection', + /enableRbacAuthorization: true/.test(keyVaultBicep) && /enablePurgeProtection: true/.test(keyVaultBicep), + 'high', + 'Key Vault uses RBAC authorization and purge protection.' + ), + enterpriseCheck( + 'log-access-resource-permissions', + /enableLogAccessUsingOnlyResourcePermissions: true/.test(logAnalyticsBicep), + 'high', + 'Log Analytics access follows resource permissions.' + ), + enterpriseCheck( + 'app-insights-workspace-based', + /WorkspaceResourceId/.test(appInsightsBicep) && /IngestionMode: 'LogAnalytics'/.test(appInsightsBicep), + 'high', + 'Application Insights is workspace-based for central query and retention control.' + ), + enterpriseCheck( + 'least-privilege-rbac-module', + /Microsoft\.Authorization\/roleAssignments@2022-04-01/.test(rbacBicep) && + /principalType: 'Group'/.test(rbacBicep) && + /3b03c2da-16b3-4a49-8834-0f8130efdd3b/.test(rbacBicep) && + /60921a7e-fef1-4a43-9b16-a26c52ad4769/.test(rbacBicep), + 'high', + 'Optional RBAC module assigns least-privilege roles to Entra security groups.' + ), + enterpriseCheck( + 'budget-module', + /Microsoft\.Consumption\/budgets@/.test(budgetBicep) && + /Actual_GreaterThan_80_Percent/.test(budgetBicep) && + /Actual_GreaterThan_100_Percent/.test(budgetBicep), + 'high', + 'Optional budget module alerts owners at 80 percent and 100 percent.' + ), + enterpriseCheck( + 'what-if-enterprise-params', + /AGENTOPS_DEPLOY_RBAC_ASSIGNMENTS/.test(azureWhatIf) && + /AGENTOPS_DEPLOY_BUDGET/.test(azureWhatIf) && + /AGENTOPS_BUDGET_CONTACT_EMAILS/.test(azureWhatIf) && + /AGENTOPS_DEPLOY_ALERTS/.test(azureWhatIf) && + /AGENTOPS_ENABLE_ALERTS/.test(azureWhatIf) && + /AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS/.test(azureWhatIf) && + /AGENTOPS_GRAFANA_PUBLIC_NETWORK_ACCESS/.test(azureWhatIf), + 'medium', + 'what-if supports RBAC, budget, alert routing, and Grafana network posture parameters.' + ), + enterpriseCheck( + 'enterprise-deploy-script', + /az deployment group create/.test(enterpriseDeploy) && + /AGENTOPS_DEPLOY_RBAC_ASSIGNMENTS/.test(enterpriseDeploy) && + /AGENTOPS_DEPLOY_BUDGET/.test(enterpriseDeploy) && + /AGENTOPS_DEPLOY_ALERTS/.test(enterpriseDeploy) && + /AGENTOPS_ENABLE_ALERTS/.test(enterpriseDeploy) && + /AGENTOPS_ALERT_ACTION_GROUP_RESOURCE_IDS/.test(enterpriseDeploy) && + /AGENTOPS_GRAFANA_PUBLIC_NETWORK_ACCESS/.test(enterpriseDeploy), + 'medium', + 'Enterprise pilot script deploys the same RBAC, budget, alert, and Grafana posture parameters reviewed by what-if.' + ), + enterpriseCheck( + 'connection-string-not-configured', + !Object.keys(config).some(key => /connection|string|instrumentation/i.test(key)), + 'critical', + 'Local AgentOps config stores names/IDs, not connection strings.' + ), + enterpriseCheck( + 'azd-no-connection-string-output', + !/output APPLICATIONINSIGHTS_CONNECTION_STRING/.test(mainBicep), + 'critical', + 'azd outputs do not persist the Application Insights connection string.' + ), + enterpriseCheck( + 'azd-outputs-importable', + /output APPLICATIONINSIGHTS_NAME/.test(mainBicep) && + /output LOG_ANALYTICS_DAILY_QUOTA_GB/.test(mainBicep) && + /output GRAFANA_ENDPOINT/.test(mainBicep), + 'medium', + 'azd outputs include names, endpoints, and cost guardrail values.' + ), + enterpriseCheck( + 'enterprise-docs', + /Enterprise-safe, cost-bounded setup/.test(readme), + 'medium', + 'README documents the enterprise-safe path.' + ), + enterpriseCheck( + 'pilot-review-docs', + /Data Classification/.test(enterprisePilot) && + /Review Checklist/.test(enterprisePilot) && + /Rollback/.test(enterprisePilot), + 'medium', + 'Enterprise pilot guide documents data classification, review, and rollback.' + ), + enterpriseCheck( + 'azure-production-hardening-docs', + /Managed Grafana Access/i.test(azureProdHardening) && + /Log Analytics Posture/i.test(azureProdHardening) && + /Alert Routing/i.test(azureProdHardening) && + /Private Access/i.test(azureProdHardening), + 'medium', + 'Azure production hardening doc covers Grafana access, Log Analytics, alert routing, and private access.' + ), + enterpriseCheck( + 'threat-model', + /Trust Boundaries/.test(threatModel) && + /Threats And Mitigations/.test(threatModel) && + /Residual Risk/.test(threatModel), + 'medium', + 'Threat model documents boundaries, mitigations, and residual risk.' + ) + ]; + + const blocking = checks.filter(check => !check.ok && check.severity !== 'warning'); + const warnings = checks.filter(check => !check.ok && check.severity === 'warning'); + const score = Math.max(0, 100 - blocking.length * 8 - warnings.length * 3); + const next = []; + + if (blocking.length === 0) { + next.push('Run agentops validate-azure --profile internal --remediation-plan --json to decide whether the live Azure environment is pilot-ready.'); + next.push('Run ./scripts/azure-what-if.sh and review retention/cap values before azd provision.'); + next.push('Run agentops collector smoke --privacy strict --poison after provisioning.'); + } else { + next.push('Fix failed critical/high checks before enterprise rollout.'); + } + + return { + ok: blocking.length === 0, + score, + score_kind: 'local-blueprint', + validation_scope: 'local deployment files, configuration, and process environment', + live_environment_checked: false, + enterprise_pilot_ready: false, + readiness_statement: blocking.length === 0 + ? 'The local enterprise blueprint passed. This does not prove the deployed Azure environment is enterprise-pilot ready.' + : 'The local enterprise blueprint is incomplete. The deployed Azure environment was not checked.', + checks, + failed: blocking.map(check => check.name), + warnings: warnings.map(check => check.name), + next + }; +} + +function renderValidateEnterprise(result) { + const lines = [ + 'AgentOps enterprise blueprint validation', + '', + `Blueprint score: ${result.score}/100.`, + `Live Azure checked: ${result.live_environment_checked ? 'yes' : 'no'}.`, + `Enterprise pilot ready: ${result.enterprise_pilot_ready ? 'yes' : 'not proven'}.`, + result.readiness_statement + ]; + for (const check of result.checks) { + const status = check.ok ? 'ok' : 'failed'; + lines.push(`- ${check.name}: ${status} [${check.severity}]${check.detail ? ` (${check.detail})` : ''}`); + } + lines.push('', result.ok ? 'Local enterprise guardrails passed.' : 'Local enterprise guardrails are incomplete.'); + lines.push('Next:'); + for (const item of result.next) lines.push(`- ${item}`); + return `${lines.join('\n')}\n`; +} + + +module.exports = { + renderValidateEnterprise, + validateEnterprise +}; diff --git a/agentops-cli/src/lib/explain-command.js b/agentops-cli/src/lib/explain-command.js new file mode 100644 index 0000000..ffe9d28 --- /dev/null +++ b/agentops-cli/src/lib/explain-command.js @@ -0,0 +1,39 @@ +const path = require('node:path'); + +const legacy = require('../legacy'); +const { hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { explainFromFiles, renderV2Explanation } = require('./explain/v2-explain'); + +function hasV2Args(args) { + return Boolean(optionValue(args, '--runs') || optionValue(args, '--evals') || optionValue(args, '--insights')); +} + +function explainCommand(args = []) { + const target = args[0] || 'latest'; + if (target !== 'latest' && !target.startsWith('run_') && !target.startsWith('run-')) { + throw new Error('explain supports: latest or '); + } + + if (!hasV2Args(args)) { + if (target !== 'latest') throw new Error('legacy explain supports only latest unless --runs is supplied'); + const summary = legacy.latestSummaryFromArgs(args.slice(1)); + const explanation = legacy.explainLatest(summary); + writeJsonOrRender(explanation, hasFlag(args, '--json'), legacy.renderExplanation); + return; + } + + const explanation = explainFromFiles({ + runId: target, + runsFile: path.resolve(optionValue(args, '--runs')), + evalsFile: optionValue(args, '--evals') ? path.resolve(optionValue(args, '--evals')) : null, + insightsFile: optionValue(args, '--insights') ? path.resolve(optionValue(args, '--insights')) : null + }); + writeJsonOrRender(explanation, hasFlag(args, '--json'), renderV2Explanation); + if (!explanation.ok) process.exitCode = 1; +} + +module.exports = { + explainCommand, + hasV2Args +}; diff --git a/agentops-cli/src/lib/explain/v2-explain.js b/agentops-cli/src/lib/explain/v2-explain.js index 31cc813..dd4d35d 100644 --- a/agentops-cli/src/lib/explain/v2-explain.js +++ b/agentops-cli/src/lib/explain/v2-explain.js @@ -1,10 +1,4 @@ -const fs = require('node:fs'); - -function readJsonl(filePath) { - if (!filePath) return []; - const text = fs.readFileSync(filePath, 'utf8'); - return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} +const { readJsonl } = require('../json'); function latestByTime(rows) { return [...rows].sort((left, right) => String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || '')))[0] || null; @@ -34,7 +28,7 @@ function explainRun(run, evalRows = [], insightRows = []) { detail = topInsight.SuggestedNextStep || 'Open the linked dashboard and inspect the run timeline.'; } else if (failed) { headline = `Run ended as ${run.OutcomeStatus}`; - detail = run.OutcomeReason || 'Inspect Run Replay for the failed span or tool call.'; + detail = run.OutcomeReason || 'Inspect Run Story for the failed span or tool call.'; } else if (score !== undefined && score < 60) { headline = `Eval score is low (${score})`; detail = evaluation.EvalReason || 'Review eval component scores before repeating this task.'; diff --git a/agentops-cli/src/lib/github-enrich-command.js b/agentops-cli/src/lib/github-enrich-command.js new file mode 100644 index 0000000..3484aed --- /dev/null +++ b/agentops-cli/src/lib/github-enrich-command.js @@ -0,0 +1,40 @@ +const path = require('node:path'); + +const { hasFlag, optionValue } = require('./args'); +const { writeJson } = require('./command-output'); +const { enrichGithubOutcomes, writeGithubOutcomes } = require('./github/outcome-enricher'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function githubEnrichCommand(args = []) { + const outDir = path.resolve(optionValue(args, '--out', path.join(repoRoot, '.agentops', 'github-outcomes', 'latest'))); + const limit = Number(optionValue(args, '--limit', '30')); + const runsFile = optionValue(args, '--runs') ? path.resolve(optionValue(args, '--runs')) : null; + if (!Number.isInteger(limit) || limit <= 0 || limit > 200) throw new Error('--limit must be an integer between 1 and 200'); + + const result = enrichGithubOutcomes({ limit, runsFile }); + if (result.ok) { + const written = writeGithubOutcomes(result.rows, outDir); + result.out_dir = written.out_dir; + result.manifest = written.manifest; + result.file = written.file; + result.next = [ + `agentops latest --file ${written.file}`, + 'agentops dashboard validate' + ]; + } + + if (hasFlag(args, '--json')) { + writeJson(result); + } else if (result.ok) { + process.stdout.write(`Generated ${result.rows.length} GitHub outcome row${result.rows.length === 1 ? '' : 's'}.\n`); + process.stdout.write(`Output: ${result.file}\n`); + } else { + process.stdout.write(`Could not enrich GitHub outcomes: ${result.error}\n`); + } + if (!result.ok) process.exitCode = 1; +} + +module.exports = { + githubEnrichCommand +}; diff --git a/agentops-cli/src/lib/github/outcome-enricher.js b/agentops-cli/src/lib/github/outcome-enricher.js index 3cbb21e..1ff9ae2 100644 --- a/agentops-cli/src/lib/github/outcome-enricher.js +++ b/agentops-cli/src/lib/github/outcome-enricher.js @@ -1,8 +1,9 @@ const childProcess = require('node:child_process'); -const fs = require('node:fs'); const path = require('node:path'); const { ciStatusFromChecks } = require('./actions-mapper'); +const { writeJsonFile, writeJsonlFile } = require('../command-output'); +const { readJsonl } = require('../json'); const { rowFromPullRequest, stableHash } = require('./pr-mapper'); function parseJson(text, fallback) { @@ -27,11 +28,6 @@ function runGh(args, options = {}) { return { ok: true, error: null, value: result.stdout }; } -function readJsonl(filePath) { - if (!filePath) return []; - return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} - function runMapFromRows(rows = []) { const map = new Map(); for (const row of rows) { @@ -87,15 +83,14 @@ function enrichGithubOutcomes(options = {}) { } function writeGithubOutcomes(rows, outDir) { - fs.mkdirSync(outDir, { recursive: true }); const file = path.join(outDir, 'AgentOpsGithubOutcomes_CL.jsonl'); - fs.writeFileSync(file, `${rows.map(row => JSON.stringify(row)).join('\n')}${rows.length ? '\n' : ''}`); + writeJsonlFile(file, rows); const manifest = path.join(outDir, 'manifest.json'); - fs.writeFileSync(manifest, `${JSON.stringify({ + writeJsonFile(manifest, { generated_at: new Date().toISOString(), table_counts: { AgentOpsGithubOutcomes_CL: rows.length }, files: { AgentOpsGithubOutcomes_CL: file } - }, null, 2)}\n`); + }); return { out_dir: outDir, manifest, file }; } diff --git a/agentops-cli/src/lib/github/pr-mapper.js b/agentops-cli/src/lib/github/pr-mapper.js index f287bf6..dc98dc1 100644 --- a/agentops-cli/src/lib/github/pr-mapper.js +++ b/agentops-cli/src/lib/github/pr-mapper.js @@ -1,12 +1,7 @@ -const crypto = require('node:crypto'); - +const { prefixedHashOrEmpty: stableHash } = require('../hash'); const { ciStatusFromChecks } = require('./actions-mapper'); const { isRevertPullRequest } = require('./revert-detector'); -function stableHash(value, prefix = 'h') { - return `${prefix}_${crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 16)}`; -} - function minutesBetween(start, end) { if (!start || !end) return null; const startMs = Date.parse(start); diff --git a/agentops-cli/src/lib/hash.js b/agentops-cli/src/lib/hash.js new file mode 100644 index 0000000..6db7543 --- /dev/null +++ b/agentops-cli/src/lib/hash.js @@ -0,0 +1,19 @@ +const crypto = require('node:crypto'); + +function hashText(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function prefixedHash(value, prefix = 'h') { + return `${prefix}_${hashText(String(value)).slice(0, 16)}`; +} + +function prefixedHashOrEmpty(value, prefix = 'h') { + return prefixedHash(value || '', prefix); +} + +module.exports = { + hashText, + prefixedHash, + prefixedHashOrEmpty +}; diff --git a/agentops-cli/src/lib/health-command.js b/agentops-cli/src/lib/health-command.js new file mode 100644 index 0000000..44b9b7d --- /dev/null +++ b/agentops-cli/src/lib/health-command.js @@ -0,0 +1,25 @@ +const { hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { + healthSummary, + renderHealth, + runHealthFromRows, + summarizeChecks +} = require('./health-summary'); + +async function healthCommand(args = []) { + const summary = await healthSummary({ + runsFile: optionValue(args, '--runs'), + mode: process.env.AGENTOPS_COLLECTOR_MODE || 'auto' + }); + writeJsonOrRender(summary, hasFlag(args, '--json'), renderHealth); + process.exitCode = summary.ok ? 0 : 1; +} + +module.exports = { + healthCommand, + healthSummary, + renderHealth, + runHealthFromRows, + summarizeChecks +}; diff --git a/agentops-cli/src/lib/health-summary.js b/agentops-cli/src/lib/health-summary.js new file mode 100644 index 0000000..d0906e7 --- /dev/null +++ b/agentops-cli/src/lib/health-summary.js @@ -0,0 +1,94 @@ +const path = require('node:path'); + +const { doctorSummary } = require('./doctor-summary'); +const { latestByTime } = require('./explain/v2-explain'); +const { readJsonl } = require('./json'); +const { statusSummary } = require('./status-summary'); + +function summarizeChecks(checks = []) { + const blocking = checks.filter(check => !check.ok && check.severity !== 'warning').length; + const warnings = checks.filter(check => !check.ok && check.severity === 'warning').length; + return { + total: checks.length, + passed: checks.filter(check => check.ok).length, + warnings, + blocking + }; +} + +function runHealthFromRows(runs = []) { + const run = latestByTime(runs); + if (!run) return null; + const failed = run.OutcomeStatus && run.OutcomeStatus !== 'success'; + const needsValidation = Number(run.FilesEditedCount || 0) > 0 && !run.TestsRan; + const privacyDrop = Number(run.PrivacyDropCount || 0) > 0 || run.PrivacyMode === 'none'; + return { + run_id: run.RunId || '', + session_id: run.SessionId || '', + status: failed || needsValidation || privacyDrop ? 'needs-attention' : 'healthy', + outcome: run.OutcomeStatus || 'unknown', + reason: run.OutcomeReason || '', + tests_ran: Boolean(run.TestsRan), + privacy_mode: run.PrivacyMode || '', + next_action: failed + ? 'Open Run Story and inspect the failed span, blocked tool, eval score, and GitHub outcome.' + : needsValidation + ? 'Run validation for the edited files before promoting this result.' + : privacyDrop + ? 'Review privacy drops and keep strict mode enabled for shared environments.' + : 'Keep strict privacy mode enabled and compare the next similar run for drift.' + }; +} + +async function healthSummary(options = {}) { + const [status, doctor] = await Promise.all([ + statusSummary(), + doctorSummary({ localOnly: true, mode: options.mode || 'auto' }) + ]); + const checkSummary = summarizeChecks(doctor.checks); + const runs = options.runsFile ? readJsonl(path.resolve(options.runsFile)) : []; + const latestRun = runHealthFromRows(runs); + const blocking = checkSummary.blocking > 0; + const warning = checkSummary.warnings > 0 || !status.collector.running || latestRun?.status === 'needs-attention'; + return { + ok: !blocking, + status: blocking ? 'blocking' : warning ? 'needs-attention' : 'healthy', + checks: checkSummary, + local: { + content_capture_off: status.content_capture_off, + collector_running: Boolean(status.collector.running), + collector_mode: status.collector.effectiveMode || status.collector.mode || '', + collector_privacy_mode: status.collector.privacyMode || '', + collector_localhost_only: Boolean(status.collector.safeLocalhostBinding), + copilot_ok: Boolean(status.copilot.ok), + copilot_source: status.copilot.source || '' + }, + latest_run: latestRun, + next_action: blocking + ? 'Run `agentops doctor` and fix blocking local readiness issues.' + : warning + ? 'Review warnings, then run `agentops smoke --real-copilot --wait 2m --poll 10s`.' + : 'Run `agentops smoke --real-copilot --wait 2m --poll 10s` and open the printed Run Story link.' + }; +} + +function renderHealth(summary) { + const lines = [ + 'AgentOps health', + '', + `Status: ${summary.status}`, + `Checks: ${summary.checks.passed}/${summary.checks.total} passed, ${summary.checks.warnings} warning(s), ${summary.checks.blocking} blocking.`, + `Collector: ${summary.local.collector_running ? 'running' : 'not running'} (${summary.local.collector_mode || 'unknown'}, ${summary.local.collector_privacy_mode || 'unknown'}).`, + `Content capture: ${summary.local.content_capture_off ? 'off' : 'enabled or unknown'}.` + ]; + if (summary.latest_run) lines.push(`Latest run: ${summary.latest_run.run_id || 'unknown'} (${summary.latest_run.status}).`); + lines.push(`Next: ${summary.next_action}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + healthSummary, + renderHealth, + runHealthFromRows, + summarizeChecks +}; diff --git a/agentops-cli/src/lib/insights-command.js b/agentops-cli/src/lib/insights-command.js new file mode 100644 index 0000000..19e4839 --- /dev/null +++ b/agentops-cli/src/lib/insights-command.js @@ -0,0 +1,100 @@ +const path = require('node:path'); + +const { hasFlag, optionValue } = require('./args'); +const { writeJson, writeJsonOrRender } = require('./command-output'); +const { generateInsights, readJsonl, writeInsights } = require('./insights/deterministic-insights'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function patternRows(rows = []) { + return rows + .filter(row => row.PatternId || row.PatternKey || String(row.InsightType || '').startsWith('recurring-')) + .sort((a, b) => Number(b.PatternRuns || 0) - Number(a.PatternRuns || 0) || String(b.TimeGenerated || '').localeCompare(String(a.TimeGenerated || ''))); +} + +function renderPatterns(rows = []) { + const patterns = patternRows(rows); + const lines = ['AgentOps recurring patterns', '']; + if (patterns.length === 0) { + lines.push('No recurring metadata-only patterns found.'); + lines.push('Next: run `agentops insights generate --runs ` after collecting more runs.'); + return `${lines.join('\n')}\n`; + } + for (const row of patterns.slice(0, 10)) { + lines.push(`- ${row.Severity || 'info'} ${row.InsightType}: ${row.PatternRuns || 0} run(s), ${row.PatternDimension || 'pattern'}`); + lines.push(` ${row.Summary || ''}`); + lines.push(` Next: ${row.SuggestedNextStep || 'Open Insights & Regressions.'}`); + lines.push(` PatternKey: ${row.PatternKey || ''}`); + } + return `${lines.join('\n')}\n`; +} + +function normalizeInsightsArgs(args = []) { + const [first] = args; + if (!first || first.startsWith('--')) { + return [hasFlag(args, '--runs') ? 'generate' : 'patterns', ...args]; + } + return args; +} + +function insightsCommand(args = []) { + const normalizedArgs = normalizeInsightsArgs(args); + const [subcommand = 'patterns'] = normalizedArgs; + if (!['generate', 'patterns'].includes(subcommand)) throw new Error('insights supports: generate|patterns'); + + if (subcommand === 'patterns') { + const insightsFile = optionValue(normalizedArgs, '--insights', path.join(repoRoot, '.agentops', 'insights', 'latest', 'AgentOpsInsights_CL.jsonl')); + const patterns = patternRows(readJsonl(path.resolve(insightsFile))); + const payload = { + ok: true, + insights_file: path.resolve(insightsFile), + patterns, + pattern_count: patterns.length, + next: [ + 'agentops open latest --runs .agentops/demo/latest/AgentOpsRunSummary_CL.jsonl', + 'Open the Insights & Regressions dashboard and click OpenPattern.' + ] + }; + writeJsonOrRender(payload, hasFlag(normalizedArgs, '--json'), value => renderPatterns(value.patterns)); + return; + } + + const runsFile = optionValue(normalizedArgs, '--runs'); + if (!runsFile) throw new Error('insights generate requires --runs '); + + const outDir = path.resolve(optionValue(normalizedArgs, '--out', path.join(repoRoot, '.agentops', 'insights', 'latest'))); + const result = generateInsights({ + runs: readJsonl(path.resolve(runsFile)), + tools: readJsonl(optionValue(normalizedArgs, '--tools')), + privacy: readJsonl(optionValue(normalizedArgs, '--privacy')), + github: readJsonl(optionValue(normalizedArgs, '--github')), + evals: readJsonl(optionValue(normalizedArgs, '--baseline-evals')), + baselineTools: readJsonl(optionValue(normalizedArgs, '--baseline-tools')) + }); + const written = writeInsights(result, outDir); + const payload = { + ok: result.ok, + out_dir: outDir, + eval_file: written.evalFile, + insights_file: written.insightsFile, + table_counts: result.table_counts, + next: [ + `agentops replay latest --file ${optionValue(normalizedArgs, '--events', '.agentops/demo/latest/AgentOpsEvents_CL.jsonl')}`, + 'agentops dashboard validate' + ] + }; + + if (hasFlag(normalizedArgs, '--json')) { + writeJson(payload); + } else { + process.stdout.write(`Generated ${payload.table_counts.AgentOpsEval_CL} eval row${payload.table_counts.AgentOpsEval_CL === 1 ? '' : 's'} and ${payload.table_counts.AgentOpsInsights_CL} insight row${payload.table_counts.AgentOpsInsights_CL === 1 ? '' : 's'}.\n`); + process.stdout.write(`Output: ${payload.out_dir}\n`); + } +} + +module.exports = { + insightsCommand, + normalizeInsightsArgs, + patternRows, + renderPatterns +}; diff --git a/agentops-cli/src/lib/insights/deterministic-insights.js b/agentops-cli/src/lib/insights/deterministic-insights.js index f61c946..41af6f6 100644 --- a/agentops-cli/src/lib/insights/deterministic-insights.js +++ b/agentops-cli/src/lib/insights/deterministic-insights.js @@ -1,16 +1,11 @@ -const fs = require('node:fs'); const path = require('node:path'); +const { writeJsonlFile } = require('../command-output'); const { evaluateRunQuality } = require('../evals'); +const { readJsonl } = require('../json'); const { detectOutliers } = require('./outlier-detector'); const { detectEvalRegression, detectToolRegression } = require('./regression-detector'); -function readJsonl(filePath) { - if (!filePath) return []; - const text = fs.readFileSync(filePath, 'utf8'); - return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} - function byRun(rows) { const map = new Map(); for (const row of rows) { @@ -84,7 +79,7 @@ function addRecurringPatterns(insights, runs) { rows.length >= 5 ? 'high' : 'medium', 'task_model_outcome', `${rows.length} failed runs share the same task/model/outcome shape.`, - 'Open Runs Explorer filtered by task and model, then inspect the newest failed Run Replay.', + 'Open Runs Explorer filtered by task and model, then inspect the newest failed Run Story.', { PatternKey: key, CurrentValue: rows.length } ); } @@ -213,7 +208,7 @@ function generateInsights(tables = {}) { addInsight(insights, run, 'policy-deny', 'high', 'A tool request was blocked by policy metadata.', 'Review the denied tool risk and tighten the task or permissions.'); } if (Number(run.ContextWindowPct || 0) >= 90 || Number(run.TokensRemoved || 0) > 0) { - addInsight(insights, run, 'context-pressure', 'medium', 'Context pressure or token removal was observed during the run.', 'Open Run Replay and inspect context/cache posture before retrying.'); + addInsight(insights, run, 'context-pressure', 'medium', 'Context pressure or token removal was observed during the run.', 'Open Run Story and inspect context/cache posture before retrying.'); } if (context.privacy.length > 0) { addInsight(insights, run, 'privacy-drop', 'medium', 'Content-like fields were observed and dropped before export.', 'Keep strict mode enabled and inspect the source surface for unexpected content fields.'); @@ -251,11 +246,10 @@ function generateInsights(tables = {}) { } function writeInsights(result, outDir) { - fs.mkdirSync(outDir, { recursive: true }); const evalFile = path.join(outDir, 'AgentOpsEval_CL.jsonl'); const insightsFile = path.join(outDir, 'AgentOpsInsights_CL.jsonl'); - fs.writeFileSync(evalFile, `${result.evals.map(row => JSON.stringify(row)).join('\n')}${result.evals.length ? '\n' : ''}`); - fs.writeFileSync(insightsFile, `${result.insights.map(row => JSON.stringify(row)).join('\n')}${result.insights.length ? '\n' : ''}`); + writeJsonlFile(evalFile, result.evals); + writeJsonlFile(insightsFile, result.insights); return { evalFile, insightsFile }; } diff --git a/agentops-cli/src/lib/insights/outlier-detector.js b/agentops-cli/src/lib/insights/outlier-detector.js index e316042..2bdf2fb 100644 --- a/agentops-cli/src/lib/insights/outlier-detector.js +++ b/agentops-cli/src/lib/insights/outlier-detector.js @@ -49,7 +49,7 @@ function detectLatencyOutlier(run = {}, baselineRuns = [], options = {}) { summary: baseline > 0 ? `Run duration is ${Math.round(current / 1000)}s, above the ${Math.round(baseline / 1000)}s baseline for this task.` : 'Run duration is high for this task.', - suggestedNextStep: 'Open Run Replay and inspect the slow model/tool spans.', + suggestedNextStep: 'Open Run Story and inspect the slow model/tool spans.', baselineValue: baseline || null, currentValue: current }; diff --git a/agentops-cli/src/lib/insights/regression-detector.js b/agentops-cli/src/lib/insights/regression-detector.js index d7f06d5..263ff8d 100644 --- a/agentops-cli/src/lib/insights/regression-detector.js +++ b/agentops-cli/src/lib/insights/regression-detector.js @@ -39,7 +39,7 @@ function detectToolRegression(run = {}, tools = [], baselineTools = [], options type: 'tool-regression', severity: currentFailureRate >= 0.5 ? 'high' : 'medium', summary: 'One or more tool calls failed above the recent baseline.', - suggestedNextStep: 'Open Run Replay and inspect the failed tool span.', + suggestedNextStep: 'Open Run Story and inspect the failed tool span.', baselineValue: baselineFailureRate, currentValue: currentFailureRate, toolName diff --git a/agentops-cli/src/lib/json.js b/agentops-cli/src/lib/json.js new file mode 100644 index 0000000..90706d6 --- /dev/null +++ b/agentops-cli/src/lib/json.js @@ -0,0 +1,25 @@ +const fs = require('node:fs'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function readJsonl(filePath) { + if (!filePath) return []; + return fs.readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .filter(Boolean) + .map(line => JSON.parse(line)); +} + +function readJsonlIfExists(filePath) { + if (!filePath || !fs.existsSync(filePath)) return []; + return readJsonl(filePath); +} + +module.exports = { + readJson, + readJsonl, + readJsonlIfExists, + readJsonlRows: readJsonl +}; diff --git a/agentops-cli/src/lib/kql.js b/agentops-cli/src/lib/kql.js new file mode 100644 index 0000000..5d067ad --- /dev/null +++ b/agentops-cli/src/lib/kql.js @@ -0,0 +1,15 @@ +function validateKqlDuration(value) { + if (!/^[1-9][0-9]*(s|m|h|d)$/.test(value)) { + throw new Error('--last must be a duration like 30m, 24h, or 7d'); + } + return value; +} + +function escapeKqlString(value) { + return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +module.exports = { + escapeKqlString, + validateKqlDuration +}; diff --git a/agentops-cli/src/lib/legacy-runtime.js b/agentops-cli/src/lib/legacy-runtime.js new file mode 100644 index 0000000..d5fe42a --- /dev/null +++ b/agentops-cli/src/lib/legacy-runtime.js @@ -0,0 +1,939 @@ +const os = require('node:os'); +const path = require('node:path'); +const { createAlerts } = require('../alerts'); +const { createPrimitives } = require('../primitives'); +const { createRecommendations } = require('../recommendations'); +const { createSavedViews } = require('../saved-views'); +const { createTelemetry } = require('../telemetry'); +const { readJson } = require('./json'); +const { defaultUserAgentOpsPath, repoRoot } = require('./paths'); +const { usage } = require('./usage'); +const { createLocalStatus } = require('./local-status'); +const { createCoreCommand } = require('./core-command'); +const { escapeKqlString, validateKqlDuration } = require('./kql'); +const { + attributionUsageQuery, + baseFilter, + collectorHealthQuery, + contextPressureQuery, + directSessionKey, + encodeGrafanaValue, + fallbackSessionKey, + fieldCatalogQuery, + grafanaUrlWithVars, + kqlFileQuery, + otelCompatibilityQuery, + sessionKey, + sessionQuery, + tokenRollupAuditQuery, + traceQuery +} = require('./observability-queries'); +const { + agentopsConfigure, + compactConfig, + configFromEnvValues, + configuredCloudValues: configuredCloudValuesFromConfig, + parseConfigureArgs, + parseConfigureSetArgs, + parseEnvAssignments, + readAgentOpsConfig, + renderConfigure, + writeAgentOpsConfig +} = require('./agentops-config'); +const { + buildOtelSetup, + parseOtelSetupArgs, + renderOtelSetup +} = require('./otel-setup'); +const { createCommandRuntime } = require('./command-runtime'); +const { + agentInstallTarget, + installDefaultAgents, + installDefaultSkills, + installPlugin, + listDefaultAgents, + listDefaultSkills, + parseFrontmatter, + plural, + renderAgentsInstall, + renderAgentsUninstall, + renderPluginInstall, + renderPluginUninstall, + renderSkillsInstall, + renderSkillsUninstall, + skillInstallTarget, + uninstallDefaultAgents, + uninstallDefaultSkills, + uninstallPlugin +} = require('./plugin-assets'); +const { createPluginAssetCommand } = require('./plugin-asset-command'); +const { + agentopsWorkflows, + parseWorkflowsArgs, + renderWorkflow, + renderWorkflowsList +} = require('./workflows'); +const { createWorkflowCommand } = require('./workflow-command'); +const { createUtilityCommand } = require('./utility-command'); +const { checkAzureSubscription } = require('./azure/subscription-guard'); +const { + parseBenchmarkApproveArgs, + parseBenchmarkArtifactsArgs, + parseBenchmarkCompareArgs: parseBenchmarkCompareArgsBase, + parseBenchmarkFixturePackArgs, + parseBenchmarkReportArgs: parseBenchmarkReportArgsBase, + parseBenchmarkRunArgs +} = require('./benchmark-args'); +const { + benchmarkCheatSignals, + numberValue, + roundNumber +} = require('./benchmark-scoring'); +const { + benchmarkFixturePack, + listBenchmarks: listBenchmarksBase, + loadBenchmarkSuites: loadBenchmarkSuitesBase, + validateBenchmarkTask: validateBenchmarkTaskBase +} = require('./benchmark-validation'); +const { + benchmarkRunPlan: benchmarkRunPlanBase, + runBenchmarkSuite: runBenchmarkSuiteBase +} = require('./benchmark-execution'); +const { + benchmarkApproval +} = require('./benchmark-approval'); +const { + benchmarkAzureTelemetry: benchmarkAzureTelemetryBase, + benchmarkAzureTelemetryQuery, + enrichBenchmarkSummariesWithAzure: enrichBenchmarkSummariesWithAzureBase +} = require('./benchmark-azure-telemetry'); +const { + benchmarkArtifactReview: benchmarkArtifactReviewBase, + benchmarkReport: benchmarkReportBase, + compareBenchmarkRuns: compareBenchmarkRunsBase, + defaultBenchmarkSummaryDir: defaultBenchmarkSummaryDirBase, + loadBenchmarkSummaries: loadBenchmarkSummariesBase +} = require('./benchmark-report'); +const { createBenchmarkContext } = require('./benchmark-context'); +const { + benchmarkJudgeProviderGuide, + renderBenchmarkJudgeProviderGuide +} = require('./benchmark-judge-guide'); +const { createBenchmarkCommand } = require('./benchmark-command'); +const { createObservabilityQueryCommand } = require('./observability-query-command'); +const { + attributionSmokeId, + createSmokeContext, + liveReplaySmokeId, + otlpAttributionSmokeTracePayload, + otlpLiveReplaySmokeTracePayload, + otlpSmokeTracePayload, + parseSmokeArgs, + realCopilotSmokeCommand, + renderSmoke, + smokeId +} = require('./smoke'); +const { createSmokeCommand } = require('./smoke-command'); +const { + createCustomTelemetryContext, + customAzureQuery, + customEventAttributes, + customEventId, + importJsonl, + otlpCustomEventPayload, + parseAnnotationArgs, + parseCustomArgs, + renderCustom +} = require('./custom-telemetry'); +const { createCustomTelemetryCommand } = require('./custom-telemetry-command'); +const { createSessionSummary } = require('./session-summary'); +const { createSessionCommand } = require('./session-command'); +const { createAskContext } = require('./ask-context'); +const { createCollectorValidation } = require('./collector-validation'); +const { + durationToMs, + optionValue, + optionValues, + parseLastArg, + parseSkillsArgs +} = require('./cli-options'); +const { createCloudValidation } = require('./cloud-validation'); +const { createAlertActions } = require('./alert-actions'); +const { createAlertCommand } = require('./alert-command'); +const { createSetupInit } = require('./setup-init'); +const { createPlannedCommand } = require('./planned-command'); +const { + agentOpsScheduledQueryRules, + azAvailable, + azErrorDetail, + parseJsonOutput, + renderValidateAzure, + validateAzure: validateAzureBase, + runAz +} = require('./azure-validation'); + +const root = repoRoot; + +const agentopsConfig = readAgentOpsConfig({ quiet: true }).values; +const configuredWorkspaceId = process.env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID || process.env.LOG_ANALYTICS_WORKSPACE_ID || agentopsConfig.workspaceId || ''; +const workspaceId = configuredWorkspaceId || '00000000-0000-0000-0000-000000000000'; +const grafanaBaseUrl = (process.env.AGENTOPS_GRAFANA_BASE_URL || agentopsConfig.grafanaBaseUrl || 'https://your-grafana.grafana.azure.com').replace(/\/$/, ''); +const mainGrafanaDashboardUrl = `${grafanaBaseUrl}/d/copilot-agentops/copilot-cli-agentops`; +const sessionsGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-sessions/agentops-sessions`; +const v2HomeGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-v2-home`; +const v2RunsGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-v2-runs-explorer`; +const v2ReplayGrafanaDashboardUrl = `${grafanaBaseUrl}/d/agentops-v2-run-replay`; +const grafanaDatasourceUid = process.env.AGENTOPS_GRAFANA_DATASOURCE_UID || agentopsConfig.grafanaDatasourceUid || 'azure-monitor-oob'; +const agentsViewUrl = process.env.AGENTOPS_AZURE_AGENTS_URL || agentopsConfig.agentsViewUrl || ''; +const cloudVerified = process.env.AGENTOPS_AZURE_CLOUD_VERIFIED === 'true'; +const azureSubscriptionId = process.env.AGENTOPS_AZURE_SUBSCRIPTION_ID || process.env.AZURE_SUBSCRIPTION_ID || agentopsConfig.subscriptionId || '00000000-0000-0000-0000-000000000000'; +const azureResourceGroup = process.env.AGENTOPS_AZURE_RESOURCE_GROUP || process.env.AZURE_RESOURCE_GROUP || agentopsConfig.resourceGroup || 'rg-agentops-dev'; +const appInsightsName = process.env.APPLICATIONINSIGHTS_NAME || process.env.AGENTOPS_APPLICATIONINSIGHTS_NAME || agentopsConfig.appInsightsName || ''; +const appInsightsResourceUrl = appInsightsName && azureSubscriptionId && azureResourceGroup + ? `https://portal.azure.com/#@/resource/subscriptions/${encodeURIComponent(azureSubscriptionId)}/resourceGroups/${encodeURIComponent(azureResourceGroup)}/providers/microsoft.insights/components/${encodeURIComponent(appInsightsName)}/overview` + : ''; +const logAnalyticsWorkspaceName = process.env.AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME || agentopsConfig.workspaceName || 'law-agentops-dev'; +const portalLogsUrl = process.env.AGENTOPS_AZURE_PORTAL_LOGS_URL || agentopsConfig.portalLogsUrl || `https://portal.azure.com/#@/resource/subscriptions/${azureSubscriptionId}/resourceGroups/${azureResourceGroup}/providers/Microsoft.OperationalInsights/workspaces/${logAnalyticsWorkspaceName}/logs`; +const defaultInstallDir = process.env.AGENTOPS_BIN_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '', '.local', 'bin'); +const benchmarksDir = path.join(root, 'benchmarks'); +const benchmarkRunBaseDir = path.join(os.tmpdir(), 'agentops-benchmark-runs'); +const savedViewsPath = process.env.AGENTOPS_VIEWS_PATH || defaultUserAgentOpsPath('views.json'); + +const { + agentopsStatusSummary, + commandCandidates, + doctor, + installedShimStatus, + renderStatus, + scan +} = createLocalStatus({ defaultInstallDir, root }); + +const { + configuredCloudValues, + flattenGrafanaList, + grafanaDashboardImportCommand, + grafanaItemUid, + isConfiguredValue, + listGrafanaDashboardFiles, + runGrafanaDashboardImportRemediation, + validateAzure +} = createCloudValidation({ + azureResourceGroup, + configuredCloudValuesFromConfig, + grafanaDatasourceUid, + logAnalyticsWorkspaceName, + readJson, + root, + runAzureLogAnalyticsQuery: (...args) => runAzureLogAnalyticsQuery(...args), + validateAzureBase +}); + +const { + buildLink, + commandPlan, + runPlannedCommand +} = createCommandRuntime({ + commandCandidates, + configuredCloudValues, + grafanaBaseUrl, + portalLogsUrl, + root, + workspaceId +}); + +const { + agentopsInit, + agentopsSetupGuide, + parseInitArgs, + parseSetupArgs, + renderInit, + renderSetupGuide +} = createSetupInit({ + agentopsConfigure, + agentopsStatusSummary, + checkAzureSubscription, + commandCandidates, + configFromEnvValues, + configuredCloudValues, + defaultInstallDir, + doctor, + durationToMs, + grafanaDashboardImportCommand, + installDefaultAgents, + installDefaultSkills, + installedShimStatus, + isConfiguredValue, + optionValue, + parseEnvAssignments, + plural, + realCopilotSmokeCommand, + validateAzure +}); + +const { + renderValidateEnterprise, + validateEnterprise +} = require('./enterprise-validation'); +const { createValidationCommand } = require('./validation-command'); + +const { + attributeValue, + explainLatest, + isFailedRow, + isSpanTelemetryRow, + latestAzureSessionSummary, + latestSessionAzureQuery, + latestSessionSummary, + latestSummaryFromArgs, + numberAttribute, + openLinksSummary, + operationFromRow, + readJsonlRows, + renderExplanation, + renderLatest, + rowAttributes, + telemetryTime, + runAzureLogAnalyticsQuery, + sessionFromRow, + summarizeSession +} = createSessionSummary({ + buildLink, + appInsightsResourceUrl, + cloudVerified, + configuredWorkspaceId, + agentsViewUrl, + mainGrafanaDashboardUrl, + optionValue, + parseLastArg, + sessionsGrafanaDashboardUrl, + v2HomeGrafanaDashboardUrl, + v2ReplayGrafanaDashboardUrl, + v2RunsGrafanaDashboardUrl, + workspaceId +}); + +const { + askAgentOpsContext, + parseAskContextArgs, + renderAskContext +} = createAskContext({ + buildLink, + latestSessionAzureQuery, + latestSummaryFromArgs, + parseLastArg, + portalLogsUrl, + sessionsGrafanaDashboardUrl, + validateKqlDuration, + workspaceId +}); + +const { + renderOpenLinks, + validateCollector +} = createCollectorValidation({ + openLinksSummary +}); + +const { + validationCommand, + validationCommandNames +} = createValidationCommand({ + parseLastArg, + renderValidateAzure, + renderValidateEnterprise, + validateAzure, + validateCollector, + validateEnterprise +}); + +const { + alertRecommendationQuery, + alertRecommendations, + alertTunePlan, + alertResourceState, + alertPolicy, + alertHistoryQuery, + alertHistory, + alertDetail, + alertActionPlan, + alertArtifact, + alertIncidentTimeline, + alertHandoff, + alertRoutePlan +} = createAlerts({ + workspaceId, + baseFilter, + sessionKey, + validateKqlDuration, + buildLink +}); + +const { + recommendationForExplanation, + renderRecommendation +} = createRecommendations({ + buildLink, + mainGrafanaDashboardUrl, + latestSessionAzureQuery +}); + +const { + alertActionGroupPlan, + alertActionGroupRoute, + alertAzureDevOpsWorkItemRoute, + alertGithubIssueRoute, + alertOpenRun, + alertReview, + alertThresholdPatch, + alertThresholdSimulation +} = createAlertActions({ + alertActionPlan, + alertArtifact, + alertDetail, + alertHandoff, + alertHistoryQuery, + alertRecommendationQuery, + alertRoutePlan, + alertTunePlan, + baseFilter, + grafanaUrlWithVars, + root, + sessionKey, + validateKqlDuration, + v2ReplayGrafanaDashboardUrl, + v2RunsGrafanaDashboardUrl +}); + +const { + alertCommand, + incidentCommand +} = createAlertCommand({ + agentOpsScheduledQueryRules, + alertActionGroupPlan, + alertActionGroupRoute, + alertActionPlan, + alertArtifact, + alertAzureDevOpsWorkItemRoute, + alertDetail, + alertGithubIssueRoute, + alertHandoff, + alertHistory, + alertIncidentTimeline, + alertOpenRun, + alertPolicy, + alertRecommendations, + alertResourceState, + alertReview, + alertRoutePlan, + alertThresholdPatch, + alertThresholdSimulation, + alertTunePlan, + azAvailable, + azErrorDetail, + configuredCloudValues, + optionValue, + optionValues, + parseJsonOutput, + parseLastArg, + readJsonlRows, + runAz +}); + +const { + copilotPrimitivesInventory +} = createPrimitives({ + root, + workspaceId, + kqlFileQuery, + validateKqlDuration, + optionValue +}); + +const { + liveViewFromArgs, + replayTimeline, + renderLive, + renderReplay, + sleep, + spanRowsFromSource +} = createTelemetry({ + optionValue, + parseLastArg, + readJsonlRows, + validateKqlDuration, + latestSessionAzureQuery, + runAzureLogAnalyticsQuery, + rowAttributes, + operationFromRow, + attributeValue, + numberAttribute, + isFailedRow, + isSpanTelemetryRow, + sessionFromRow, + telemetryTime, + numberValue, + roundNumber +}); + +const { + sessionCommand, + sessionCommandNames +} = createSessionCommand({ + explainLatest, + latestSummaryFromArgs, + liveViewFromArgs, + openLinksSummary, + optionValue, + parseLastArg, + recommendationForExplanation, + renderExplanation, + renderLatest, + renderLive, + renderOpenLinks, + renderRecommendation, + renderReplay, + replayTimeline, + runAzureLogAnalyticsQuery, + sessionQuery, + sleep, + spanRowsFromSource, + validateKqlDuration +}); + +const { + agentopsAttributionSmoke, + agentopsLiveReplaySmoke, + agentopsSmoke, + verifySmokeInAzure +} = createSmokeContext({ + defaultWorkspaceId: workspaceId, + grafanaBaseUrl, + latestSummaryFromArgs, + openLinksSummary, + runAzureLogAnalyticsQuery, + sleep +}); + +const { + smokeCommand, + smokeCommandNames +} = createSmokeCommand({ + agentopsAttributionSmoke, + agentopsLiveReplaySmoke, + agentopsSmoke, + parseSmokeArgs, + renderSmoke +}); + +const { + agentopsAnnotationConfigChange, + agentopsCustomEmit, + agentopsCustomImport +} = createCustomTelemetryContext({ + defaultWorkspaceId: workspaceId +}); + +const { + customTelemetryCommand, + customTelemetryCommandNames +} = createCustomTelemetryCommand({ + agentopsAnnotationConfigChange, + agentopsCustomEmit, + agentopsCustomImport, + parseAnnotationArgs, + parseCustomArgs, + renderCustom +}); + +const { + parseSavedViewArgs, + readSavedViews, + savedViewCommand +} = createSavedViews({ + savedViewsPath, + readJson, + buildLink +}); + +const { + benchmarkArtifactReview, + benchmarkAzureTelemetry, + benchmarkReport, + benchmarkRunPlan, + compareBenchmarkRuns, + defaultBenchmarkSummaryDir, + enrichBenchmarkSummariesWithAzure, + listBenchmarks, + loadBenchmarkSummaries, + loadBenchmarkSuites, + parseBenchmarkCompareArgs, + parseBenchmarkReportArgs, + runBenchmarkSuite, + validateBenchmarkTask +} = createBenchmarkContext({ + benchmarkArtifactReviewBase, + benchmarkAzureTelemetryBase, + benchmarkReportBase, + benchmarkRunBaseDir, + benchmarkRunPlanBase, + benchmarksDir, + compareBenchmarkRunsBase, + defaultBenchmarkSummaryDirBase, + enrichBenchmarkSummariesWithAzureBase, + listBenchmarksBase, + loadBenchmarkSummariesBase, + loadBenchmarkSuitesBase, + parseBenchmarkCompareArgsBase, + parseBenchmarkReportArgsBase, + root, + runAzureLogAnalyticsQuery, + runBenchmarkSuiteBase, + validateBenchmarkTaskBase, + validateKqlDuration +}); + +const { + benchmarkCommand +} = createBenchmarkCommand({ + benchmarkApproval, + benchmarkArtifactReview, + benchmarkFixturePack, + benchmarkJudgeProviderGuide, + benchmarkReport, + compareBenchmarkRuns, + listBenchmarks, + parseBenchmarkApproveArgs, + parseBenchmarkArtifactsArgs, + parseBenchmarkCompareArgs, + parseBenchmarkFixturePackArgs, + parseBenchmarkReportArgs, + parseBenchmarkRunArgs, + renderBenchmarkJudgeProviderGuide, + runBenchmarkSuite +}); + +const { + pluginAssetCommand, + pluginAssetCommandNames +} = createPluginAssetCommand({ + agentInstallTarget, + installDefaultAgents, + installDefaultSkills, + installPlugin, + listDefaultAgents, + listDefaultSkills, + parseSkillsArgs, + renderAgentsInstall, + renderAgentsUninstall, + renderPluginInstall, + renderPluginUninstall, + renderSkillsInstall, + renderSkillsUninstall, + skillInstallTarget, + uninstallDefaultAgents, + uninstallDefaultSkills, + uninstallPlugin +}); + +const { + queryCommand, + queryCommandNames +} = createObservabilityQueryCommand({ + attributionUsageQuery, + buildLink, + collectorHealthQuery, + contextPressureQuery, + fieldCatalogQuery, + kqlFileQuery, + otelCompatibilityQuery, + parseLastArg, + tokenRollupAuditQuery, + workspaceId +}); + +const { workflowCommand } = createWorkflowCommand({ + agentopsWorkflows, + parseWorkflowsArgs, + renderWorkflow, + renderWorkflowsList +}); + +const { + utilityCommand, + utilityCommandNames +} = createUtilityCommand({ + copilotPrimitivesInventory, + doctor, + importJsonl, + parseSavedViewArgs, + savedViewCommand, + scan +}); + +const { + coreCommand, + coreCommandNames +} = createCoreCommand({ + agentopsConfigure, + agentopsInit, + agentopsSetupGuide, + askAgentOpsContext, + buildOtelSetup, + parseAskContextArgs, + parseConfigureArgs, + parseInitArgs, + parseOtelSetupArgs, + parseSetupArgs, + renderAskContext, + renderConfigure, + renderInit, + renderOtelSetup, + renderSetupGuide, + renderStatus +}); + +const { + plannedCommand, + plannedCommandNames +} = createPlannedCommand({ + commandPlan, + runPlannedCommand +}); + +async function main(argv) { + const [command, ...args] = argv; + + if (!command || command === '--help' || command === '-h') { + process.stdout.write(usage()); + return; + } + + if (coreCommandNames.includes(command)) { + coreCommand(command, args); + return; + } + + if (sessionCommandNames.includes(command)) { + await sessionCommand(command, args); + return; + } + + if (command === 'workflows') { + workflowCommand(args); + return; + } + + if (pluginAssetCommandNames.includes(command)) { + pluginAssetCommand(command, args); + return; + } + + if (utilityCommandNames.includes(command)) { + utilityCommand(command, args); + return; + } + + if (customTelemetryCommandNames.includes(command)) { + await customTelemetryCommand(command, args); + return; + } + + if (validationCommandNames.includes(command)) { + await validationCommand(command, args); + return; + } + + if (smokeCommandNames.includes(command)) { + await smokeCommand(command, args); + return; + } + + if (plannedCommandNames.includes(command)) { + plannedCommand(command, args); + return; + } + + if (command === 'benchmark') { + benchmarkCommand(args); + return; + } + + if (queryCommandNames.includes(command)) { + queryCommand(command, args); + return; + } + + if (command === 'alert') { + alertCommand(args); + return; + } + + if (command === 'incident') { + incidentCommand(args); + return; + } + + throw new Error(`Unknown command: ${command}`); +} + +if (require.main === module) { + main(process.argv.slice(2)).catch(error => { + process.stderr.write(`${error.message}\n`); + process.exit(1); + }); +} + +module.exports = { + main, + agentopsAttributionSmoke, + agentopsInit, + agentopsConfigure, + agentopsSetupGuide, + agentopsSmoke, + agentopsLiveReplaySmoke, + agentopsStatusSummary, + agentopsWorkflows, + alertRecommendationQuery, + alertRecommendations, + alertTunePlan, + alertThresholdSimulation, + alertThresholdPatch, + alertResourceState, + alertPolicy, + alertHistoryQuery, + alertHistory, + alertDetail, + alertOpenRun, + alertReview, + alertActionPlan, + alertArtifact, + alertActionGroupPlan, + alertActionGroupRoute, + alertIncidentTimeline, + alertAzureDevOpsWorkItemRoute, + alertHandoff, + alertGithubIssueRoute, + alertRoutePlan, + askAgentOpsContext, + attributionUsageQuery, + benchmarkCheatSignals, + benchmarkAzureTelemetry, + benchmarkAzureTelemetryQuery, + benchmarkApproval, + benchmarkArtifactReview, + benchmarkFixturePack, + benchmarkJudgeProviderGuide, + benchmarkReport, + benchmarkRunBaseDir, + benchmarkRunPlan, + buildOtelSetup, + buildLink, + commandPlan, + compareBenchmarkRuns, + collectorHealthQuery, + compactConfig, + contextPressureQuery, + copilotPrimitivesInventory, + agentopsCustomEmit, + agentopsCustomImport, + agentopsAnnotationConfigChange, + customAzureQuery, + customEventAttributes, + customEventId, + doctor, + durationToMs, + explainLatest, + fieldCatalogQuery, + configFromEnvValues, + importJsonl, + installedShimStatus, + agentInstallTarget, + attributionSmokeId, + liveReplaySmokeId, + installDefaultAgents, + installDefaultSkills, + installPlugin, + kqlFileQuery, + latestAzureSessionSummary, + latestSessionAzureQuery, + latestSessionSummary, + latestSummaryFromArgs, + listGrafanaDashboardFiles, + listDefaultAgents, + listDefaultSkills, + listBenchmarks, + loadBenchmarkSummaries, + loadBenchmarkSuites, + liveViewFromArgs, + openLinksSummary, + otlpAttributionSmokeTracePayload, + otlpCustomEventPayload, + otlpLiveReplaySmokeTracePayload, + parseBenchmarkCompareArgs, + parseBenchmarkApproveArgs, + parseBenchmarkArtifactsArgs, + parseBenchmarkFixturePackArgs, + parseBenchmarkReportArgs, + parseBenchmarkRunArgs, + parseConfigureArgs, + parseConfigureSetArgs, + parseCustomArgs, + parseAnnotationArgs, + parseEnvAssignments, + parseOtelSetupArgs, + parseFrontmatter, + parseSavedViewArgs, + parseSetupArgs, + parseSmokeArgs, + replayTimeline, + renderExplanation, + renderAskContext, + renderConfigure, + renderCustom, + renderInit, + renderLatest, + renderLive, + renderOpenLinks, + renderOtelSetup, + renderRecommendation, + renderReplay, + renderSetupGuide, + renderSmoke, + renderAgentsInstall, + renderAgentsUninstall, + renderBenchmarkJudgeProviderGuide, + renderPluginInstall, + renderPluginUninstall, + renderSkillsInstall, + renderSkillsUninstall, + renderStatus, + renderValidateEnterprise, + renderValidateAzure, + renderWorkflow, + renderWorkflowsList, + recommendationForExplanation, + readAgentOpsConfig, + readJsonlRows, + readSavedViews, + runAzureLogAnalyticsQuery, + runBenchmarkSuite, + savedViewCommand, + scan, + sessionQuery, + spanRowsFromSource, + skillInstallTarget, + otelCompatibilityQuery, + tokenRollupAuditQuery, + enrichBenchmarkSummariesWithAzure, + traceQuery, + validateEnterprise, + validateAzure, + validateKqlDuration, + validateBenchmarkTask, + validateCollector, + verifySmokeInAzure, + uninstallDefaultAgents, + uninstallDefaultSkills, + uninstallPlugin, + writeAgentOpsConfig +}; diff --git a/agentops-cli/src/lib/local-status.js b/agentops-cli/src/lib/local-status.js new file mode 100644 index 0000000..6a542f1 --- /dev/null +++ b/agentops-cli/src/lib/local-status.js @@ -0,0 +1,276 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { hashText } = require('./hash'); +const { parseFrontmatter } = require('./plugin-assets'); +const { readJson } = require('./json'); + +function createLocalStatus(options = {}) { + const root = options.root; + const defaultInstallDir = options.defaultInstallDir; + + function walk(dir, predicate, results = []) { + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) walk(fullPath, predicate, results); + if (entry.isFile() && predicate(fullPath)) results.push(fullPath); + } + return results; + } + + function repoHash() { + const gitConfig = path.join(root, '.git', 'config'); + if (!fs.existsSync(gitConfig)) return hashText('unknown'); + const text = fs.readFileSync(gitConfig, 'utf8'); + const match = text.match(/url = (.+)/); + return hashText(match ? match[1].trim() : 'unknown'); + } + + function commandCandidates(commandName) { + const pathValue = process.env.PATH || ''; + const pathExt = process.platform === 'win32' + ? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) + : ['']; + const names = process.platform === 'win32' && !path.extname(commandName) + ? pathExt.map(ext => `${commandName}${ext.toLowerCase()}`).concat(pathExt.map(ext => `${commandName}${ext.toUpperCase()}`)) + : [commandName]; + const seen = new Set(); + const results = []; + + for (const dir of pathValue.split(path.delimiter).filter(Boolean)) { + for (const name of names) { + const candidate = path.join(dir, name); + const key = process.platform === 'win32' ? candidate.toLowerCase() : candidate; + if (seen.has(key)) continue; + seen.add(key); + if (fs.existsSync(candidate)) results.push(candidate); + } + } + + return results; + } + + function shadowObservesCopilot(shadowPath) { + if (!fs.existsSync(shadowPath)) return false; + try { + const resolved = fs.realpathSync(shadowPath); + const marker = /(?:copilot-agentops|copilot-observe|agentops-cli[\\/].*copilot)/i; + if (marker.test(resolved)) return true; + const stat = fs.statSync(resolved); + if (!stat.isFile() || stat.size > 1024 * 1024) return false; + return marker.test(fs.readFileSync(resolved, 'utf8')); + } catch { + return false; + } + } + + function installedShimStatus(installDir = defaultInstallDir) { + const shadowName = process.platform === 'win32' ? 'copilot.cmd' : 'copilot'; + const agentopsName = process.platform === 'win32' ? 'copilot-agentops.cmd' : 'copilot-agentops'; + const agentopsCliName = process.platform === 'win32' ? 'agentops.cmd' : 'agentops'; + const shadowPath = path.join(installDir, shadowName); + const agentopsPath = path.join(installDir, agentopsName); + const agentopsCliPath = path.join(installDir, agentopsCliName); + const copilotCommands = commandCandidates('copilot'); + const installDirFull = path.resolve(installDir); + const firstCopilot = copilotCommands[0] || null; + const shadowInstalled = fs.existsSync(shadowPath); + const shadowFirst = firstCopilot ? path.resolve(firstCopilot).startsWith(installDirFull) : false; + const shadowValid = shadowObservesCopilot(shadowPath); + const realCopilot = copilotCommands.find(candidate => !path.resolve(candidate).startsWith(installDirFull)) || null; + + return { + install_dir: installDir, + agentops_cli_installed: fs.existsSync(agentopsCliPath), + agentops_cli_path: agentopsCliPath, + copilot_agentops_installed: fs.existsSync(agentopsPath), + copilot_agentops_path: agentopsPath, + shadow_installed: shadowInstalled, + shadow_valid: shadowValid, + shadow_path: shadowPath, + plain_copilot_observed: shadowInstalled && shadowValid && shadowFirst, + first_copilot_on_path: firstCopilot, + real_copilot: realCopilot, + copilot_candidates: copilotCommands + }; + } + + function checkByName(checks, name) { + return checks.find(check => check.name === name); + } + + function agentopsStatusSummary({ checks = doctor({ localOnly: true }) } = {}) { + const required = checks.filter(check => check.name.startsWith('exists:')); + const missing = required.filter(check => !check.ok).map(check => check.name.slice('exists:'.length)); + const contentCapture = checkByName(checks, 'content-capture-disabled'); + const httpLocal = checkByName(checks, 'collector-http-localhost'); + const grpcLocal = checkByName(checks, 'collector-grpc-localhost'); + const agentopsCli = checkByName(checks, 'agentops-command'); + const agentopsShim = checkByName(checks, 'copilot-agentops-command'); + const shadowShim = checkByName(checks, 'plain-copilot-shadow'); + + return { + ok: checks.every(check => check.ok), + required_files: { + found: required.length - missing.length, + total: required.length, + missing + }, + content_capture_off: Boolean(contentCapture?.ok), + collector_localhost: Boolean(httpLocal?.ok && grpcLocal?.ok), + shim: { + agentops_cli: agentopsCli?.status || 'unknown', + agentops_command: agentopsShim?.status || 'unknown', + shadow: shadowShim?.status || 'unknown', + first_copilot_on_path: shadowShim?.first_copilot_on_path || null, + real_copilot: shadowShim?.real_copilot || null + } + }; + } + + function renderStatus(summary = agentopsStatusSummary()) { + const lines = [ + 'AgentOps status', + '', + `Required files: ${summary.required_files.found} of ${summary.required_files.total} found.` + ]; + + if (summary.required_files.missing.length > 0) { + lines.push(`Missing files: ${summary.required_files.missing.join(', ')}.`); + } + + lines.push(summary.content_capture_off + ? 'Content capture: off. Prompts/code were not recorded.' + : 'Content capture: on. Turn it off before sharing telemetry.'); + lines.push(summary.collector_localhost + ? 'Collector config: localhost for HTTP and gRPC.' + : 'Collector config: not confirmed as localhost.'); + + const agentopsCli = summary.shim.agentops_cli === 'installed' ? 'installed' : 'not installed'; + const agentopsCommand = summary.shim.agentops_command === 'installed' ? 'installed' : 'not installed'; + const shadow = summary.shim.shadow === 'observed' + ? 'plain copilot is routed through AgentOps' + : summary.shim.shadow === 'installed_not_first_on_path' + ? 'installed, but not first on PATH' + : summary.shim.shadow === 'not_installed' + ? 'plain copilot shadow is not installed' + : summary.shim.shadow.replace(/_/g, ' '); + lines.push(`Shim: agentops is ${agentopsCli}; copilot-agentops is ${agentopsCommand}; ${shadow}.`); + + return `${lines.join('\n')}\n`; + } + + function scan() { + const agents = walk(path.join(root, 'plugin', 'agents'), file => file.endsWith('.agent.md')).map(file => ({ + path: path.relative(root, file), + definition_hash: hashText(fs.readFileSync(file, 'utf8')), + ...parseFrontmatter(file) + })); + + const skills = walk(path.join(root, 'plugin', 'skills'), file => path.basename(file) === 'SKILL.md').map(file => ({ + path: path.relative(root, file), + definition_hash: hashText(fs.readFileSync(file, 'utf8')), + ...parseFrontmatter(file) + })); + + const hookPath = path.join(root, 'plugin', 'hooks.json'); + const hooks = fs.existsSync(hookPath) ? readJson(hookPath) : null; + + const mcpPath = path.join(root, 'plugin', '.mcp.json'); + const mcp = fs.existsSync(mcpPath) ? readJson(mcpPath) : null; + + return { + repo_hash: repoHash(), + timestamp: new Date().toISOString(), + agents, + skills, + hooks, + mcp_servers: mcp ? Object.keys(mcp.mcpServers || mcp.servers || {}) : [] + }; + } + + function doctor({ localOnly }) { + const checks = []; + const requiredFiles = [ + 'copilot/copilot-observe', + 'copilot/copilot-observe.ps1', + 'collector/otelcol.local.yaml', + 'collector/otelcol.local.strict.yaml', + 'collector/docker-compose.yaml', + 'plugin/plugin.json', + 'plugin/hooks.json', + 'scripts/copilot-agentops', + 'scripts/copilot-agentops.ps1', + 'scripts/install-copilot-agentops-shim.sh', + 'scripts/install-copilot-agentops-shim.ps1', + 'scripts/uninstall-copilot-agentops-shim.sh', + 'scripts/uninstall-copilot-agentops-shim.ps1', + 'azure.yaml' + ]; + + for (const file of requiredFiles) { + checks.push({ name: `exists:${file}`, ok: fs.existsSync(path.join(root, file)) }); + } + + const contentCapture = process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT === 'true'; + checks.push({ name: 'content-capture-disabled', ok: !contentCapture }); + + const localConfig = fs.readFileSync(path.join(root, 'collector', 'otelcol.local.yaml'), 'utf8'); + const strictLocalConfig = fs.readFileSync(path.join(root, 'collector', 'otelcol.local.strict.yaml'), 'utf8'); + checks.push({ name: 'collector-http-localhost', ok: localConfig.includes('endpoint: 127.0.0.1:4318') }); + checks.push({ name: 'collector-grpc-localhost', ok: localConfig.includes('endpoint: 127.0.0.1:4317') }); + checks.push({ name: 'collector-strict-http-localhost', ok: strictLocalConfig.includes('endpoint: 127.0.0.1:4318') }); + checks.push({ name: 'collector-strict-grpc-localhost', ok: strictLocalConfig.includes('endpoint: 127.0.0.1:4317') }); + checks.push({ name: 'collector-strict-receipt-localhost', ok: strictLocalConfig.includes('endpoint: 127.0.0.1:4319') }); + + const scanResult = scan(); + checks.push({ name: 'agents-present', ok: scanResult.agents.length >= 1 }); + checks.push({ name: 'skills-present', ok: scanResult.skills.length >= 1 }); + + const shim = installedShimStatus(); + checks.push({ + name: 'agentops-command', + ok: true, + status: shim.agentops_cli_installed ? 'installed' : 'not_installed', + path: shim.agentops_cli_path + }); + checks.push({ + name: 'copilot-agentops-command', + ok: true, + status: shim.copilot_agentops_installed ? 'installed' : 'not_installed', + path: shim.copilot_agentops_path + }); + checks.push({ + name: 'plain-copilot-shadow', + ok: true, + status: shim.plain_copilot_observed + ? 'observed' + : (shim.shadow_installed + ? (shim.shadow_valid ? 'installed_not_first_on_path' : 'installed_invalid') + : 'not_installed'), + first_copilot_on_path: shim.first_copilot_on_path, + real_copilot: shim.real_copilot, + shadow_path: shim.shadow_path + }); + + if (!localOnly) { + checks.push({ name: 'azure-validation', ok: false, note: 'Run azure-validate before deployment.' }); + } + + return checks; + } + + return { + agentopsStatusSummary, + commandCandidates, + doctor, + installedShimStatus, + renderStatus, + scan, + shadowObservesCopilot + }; +} + +module.exports = { + createLocalStatus +}; diff --git a/agentops-cli/src/lib/mcp-proxy-command.js b/agentops-cli/src/lib/mcp-proxy-command.js new file mode 100644 index 0000000..451b448 --- /dev/null +++ b/agentops-cli/src/lib/mcp-proxy-command.js @@ -0,0 +1,30 @@ +const path = require('node:path'); + +const { optionValue } = require('./args'); +const { proxyStdio } = require('./mcp/proxy-stdio'); + +function splitCommand(args) { + const separator = args.indexOf('--'); + if (separator === -1 || separator === args.length - 1) { + throw new Error('mcp-proxy requires -- [args...]'); + } + return args.slice(separator + 1); +} + +function mcpProxyCommand(args = []) { + const serverName = optionValue(args, '--server-name', 'unknown-mcp'); + const outFile = path.resolve(optionValue(args, '--out', path.join(process.cwd(), '.agentops', 'mcp-proxy', 'AgentOpsMcpCalls_CL.jsonl'))); + const commandAndArgs = splitCommand(args); + return proxyStdio({ + serverName, + outFile, + command: commandAndArgs[0], + args: commandAndArgs.slice(1), + sandboxed: args.includes('--sandboxed') + }); +} + +module.exports = { + mcpProxyCommand, + splitCommand +}; diff --git a/agentops-cli/src/lib/mcp/proxy-stdio.js b/agentops-cli/src/lib/mcp/proxy-stdio.js index 3363b45..bc43119 100644 --- a/agentops-cli/src/lib/mcp/proxy-stdio.js +++ b/agentops-cli/src/lib/mcp/proxy-stdio.js @@ -1,7 +1,7 @@ const childProcess = require('node:child_process'); -const fs = require('node:fs'); const path = require('node:path'); +const { appendJsonlFile } = require('../command-output'); const { classifyMcpToolRisk } = require('./risk-classifier'); const { argsSchemaHash, jsonByteSize, stableHashJson } = require('./redactor'); const { injectTraceContext } = require('./trace-context'); @@ -91,8 +91,7 @@ function createMcpProxyObserver(options = {}) { } function appendJsonl(filePath, row) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.appendFileSync(filePath, `${JSON.stringify(row)}\n`); + appendJsonlFile(filePath, row); } function proxyStdio(options = {}) { diff --git a/agentops-cli/src/lib/mcp/redactor.js b/agentops-cli/src/lib/mcp/redactor.js index a3808cf..9a9842d 100644 --- a/agentops-cli/src/lib/mcp/redactor.js +++ b/agentops-cli/src/lib/mcp/redactor.js @@ -1,8 +1,8 @@ -const crypto = require('node:crypto'); +const { prefixedHash } = require('../hash'); function stableHashJson(value, prefix = 'schema') { const json = JSON.stringify(value ?? null); - return `${prefix}_${crypto.createHash('sha256').update(json).digest('hex').slice(0, 16)}`; + return prefixedHash(json, prefix); } function jsonByteSize(value) { diff --git a/agentops-cli/src/lib/native-receipt.js b/agentops-cli/src/lib/native-receipt.js new file mode 100644 index 0000000..0420397 --- /dev/null +++ b/agentops-cli/src/lib/native-receipt.js @@ -0,0 +1,632 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { optionValue } = require('./args'); +const { collectorHome } = require('./paths'); + +const RECEIPT_PATH_ENV = 'AGENTOPS_OTEL_RECEIPT_PATH'; +const DEFAULT_RECEIPT_PATH = path.join(collectorHome, 'native-receipt.jsonl'); +const MAX_RECEIPT_BYTES = 20 * 1024 * 1024; + +const SAFE_ATTRIBUTE_KEYS = new Set([ + 'service.name', + 'service.namespace', + 'service.version', + 'telemetry.sdk.name', + 'telemetry.sdk.language', + 'telemetry.sdk.version', + 'agent.framework', + 'agent.runtime', + 'agentops.run.id', + 'agentops.session.id', + 'agentops.surface', + 'agentops.privacy.mode', + 'agentops.content_capture.mode', + 'agentops.content_capture.signal', + 'agentops.event.name', + 'agentops.event.sequence', + 'agentops.outcome', + 'agentops.status', + 'agentops.run.status', + 'agentops.session.status', + 'agentops.agent.name', + 'agentops.mcp.server', + 'agentops.mcp.tool', + 'agentops.mcp.allowed', + 'agentops.cost.estimated_usd', + 'agentops.duration.ms', + 'agentops.content.dropped_bytes', + 'agentops.tools.count', + 'agentops.files.edited_count', + 'agentops.lines.added', + 'agentops.lines.removed', + 'agentops.cli.agent', + 'agentops.cli.model', + 'gen_ai.operation.name', + 'gen_ai.provider.name', + 'gen_ai.request.model', + 'gen_ai.response.model', + 'gen_ai.conversation.id', + 'gen_ai.tool.name', + 'gen_ai.tool.type', + 'gen_ai.usage.input_tokens', + 'gen_ai.usage.output_tokens', + 'gen_ai.usage.cache_read.input_tokens', + 'gen_ai.usage.cache_creation.input_tokens', + 'github.copilot.interaction_id', + 'github.copilot.cost', + 'github.copilot.aiu', + 'github.copilot.success', + 'github.copilot.hook.type', + 'github.copilot.shutdown_type', + 'github.copilot.abort_reason', + 'error.type', + 'exception.type', + 'http.status_code' +]); + +const CONTENT_ATTRIBUTE_KEYS = new Set([ + 'gen_ai.input.messages', + 'gen_ai.output.messages', + 'gen_ai.prompt', + 'gen_ai.completion', + 'gen_ai.system_instructions', + 'gen_ai.tool.definitions', + 'gen_ai.tool.call.arguments', + 'gen_ai.tool.call.result', + 'github.copilot.message', + 'http.request.body.content', + 'http.response.body.content', + 'url.full', + 'code.filepath', + 'prompt', + 'completion', + 'tool.arguments', + 'tool.result' +]); + +const TERMINAL_OUTCOMES = new Map([ + ['completed', 'TASK_COMPLETED'], + ['complete', 'TASK_COMPLETED'], + ['success', 'TASK_COMPLETED'], + ['succeeded', 'TASK_COMPLETED'], + ['ok', 'TASK_COMPLETED'], + ['failed', 'FAILED'], + ['failure', 'FAILED'], + ['error', 'FAILED'], + ['errored', 'FAILED'] +]); + +const TERMINAL_EVENT_STATUSES = new Map([ + ['agentops.run.end', 'TASK_COMPLETED'], + ['agentops.run.complete', 'TASK_COMPLETED'], + ['agentops.run.completed', 'TASK_COMPLETED'], + ['agentops.run.failed', 'FAILED'], + ['agentops.session.end', 'SESSION_COMPLETED'], + ['agentops.session.complete', 'SESSION_COMPLETED'], + ['agentops.session.completed', 'SESSION_COMPLETED'], + ['agentops.session.failed', 'FAILED'], + ['session.task_complete', 'TASK_COMPLETED'], + ['github.copilot.session.task_complete', 'TASK_COMPLETED'], + ['session.idle', 'PROCESSING_STOPPED'], + ['github.copilot.session.idle', 'PROCESSING_STOPPED'] +]); + +const STATUS_PRIORITY = Object.freeze({ + OBSERVED: 1, + PROCESSING_STOPPED: 2, + SESSION_COMPLETED: 3, + TASK_COMPLETED: 4, + FAILED: 5 +}); + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function stringValue(value) { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return ''; +} + +function isSecretLike(value) { + return /(?:secret|password|passwd|api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|bearer|credential|private[_-]?key|gh[pousr]_|sk-[A-Za-z0-9]|connectionstring)/i.test(value); +} + +function safeIdentifier(value, maxLength = 200) { + const text = stringValue(value).trim(); + if (!text || text.length > maxLength || isSecretLike(text)) return ''; + if (/^[a-z][a-z\d+.-]{1,20}:\/\//i.test(text) || /[?#=%\s]/.test(text)) return ''; + return /^[A-Za-z0-9_.:/@+-]+$/.test(text) ? text : ''; +} + +function safeEnum(value) { + const text = safeIdentifier(value, 80); + return text ? text.toLowerCase() : ''; +} + +function numberValue(value) { + if (typeof value === 'bigint') return Number(value); + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function decodeOtlpValue(value) { + if (!isObject(value)) return undefined; + if (Object.hasOwn(value, 'stringValue')) return stringValue(value.stringValue); + if (Object.hasOwn(value, 'boolValue')) return Boolean(value.boolValue); + if (Object.hasOwn(value, 'intValue')) return numberValue(value.intValue); + if (Object.hasOwn(value, 'doubleValue')) return numberValue(value.doubleValue); + return undefined; +} + +function safeAttributes(attributes = []) { + const values = {}; + let contentSignal = false; + if (!Array.isArray(attributes)) return { values, contentSignal }; + + for (const attribute of attributes) { + if (!isObject(attribute) || typeof attribute.key !== 'string') continue; + const key = attribute.key; + if (CONTENT_ATTRIBUTE_KEYS.has(key)) { + contentSignal = true; + continue; + } + if (!SAFE_ATTRIBUTE_KEYS.has(key)) continue; + const value = decodeOtlpValue(attribute.value); + if (value !== undefined) values[key] = value; + } + + return { values, contentSignal }; +} + +function mergeAttributes(...maps) { + return Object.assign({}, ...maps.filter(isObject)); +} + +function timestampToIso(value) { + if (value === undefined || value === null || value === '') return null; + try { + if (typeof value === 'string' && /^\d+$/.test(value)) { + const milliseconds = BigInt(value) / 1000000n; + return new Date(Number(milliseconds)).toISOString(); + } + const numeric = Number(value); + if (!Number.isFinite(numeric)) return null; + const milliseconds = numeric > 100000000000 ? numeric / 1000000 : numeric * 1000; + return new Date(milliseconds).toISOString(); + } catch { + return null; + } +} + +function latestTime(left, right) { + if (!left) return right || null; + if (!right) return left; + return new Date(right) > new Date(left) ? right : left; +} + +function earliestTime(left, right) { + if (!left) return right || null; + if (!right) return left; + return new Date(right) < new Date(left) ? right : left; +} + +function safeListAdd(list, value) { + const safe = safeIdentifier(value); + if (safe && !list.includes(safe)) list.push(safe); +} + +function lifecycleStatus(attributes, signalName = '', status = null) { + const eventName = safeEnum(attributes['agentops.event.name']) || safeEnum(signalName); + const outcome = safeEnum(attributes['agentops.outcome']) + || safeEnum(attributes['agentops.status']) + || safeEnum(attributes['agentops.run.status']) + || safeEnum(attributes['agentops.session.status']); + const outcomeStatus = TERMINAL_OUTCOMES.get(outcome); + if (outcomeStatus) return outcomeStatus; + const eventStatus = TERMINAL_EVENT_STATUSES.get(eventName); + if (!eventStatus) return null; + const statusCode = safeEnum(status?.code) || safeEnum(attributes['http.status_code']); + return ['error', 'status_code_error'].includes(statusCode) ? 'FAILED' : eventStatus; +} + +function hasError(attributes, status) { + const statusCode = safeEnum(status?.code); + return statusCode === 'status_code_error' + || statusCode === 'error' + || Boolean(safeIdentifier(attributes['error.type']) || safeIdentifier(attributes['exception.type'])); +} + +function eventLifecycleStatus(events = []) { + if (!Array.isArray(events)) return null; + for (const event of events) { + if (!isObject(event)) continue; + const eventName = safeEnum(event.name); + const attributes = safeAttributes(event.attributes).values; + const hookType = safeEnum(attributes['github.copilot.hook.type']); + if (eventName === 'github.copilot.hook.end' && hookType === 'agentstop') return 'PROCESSING_STOPPED'; + if (TERMINAL_EVENT_STATUSES.has(eventName)) return TERMINAL_EVENT_STATUSES.get(eventName); + if (eventName === 'github.copilot.session.shutdown' || eventName === 'session.shutdown') { + const shutdownType = safeEnum(attributes['github.copilot.shutdown_type']); + return ['error', 'abort', 'timeout'].includes(shutdownType) ? 'FAILED' : 'SESSION_COMPLETED'; + } + if (eventName === 'github.copilot.session.abort' || eventName === 'session.abort') return 'FAILED'; + } + return null; +} + +function preferStatus(current, candidate) { + if (!candidate) return current; + if (!current || (STATUS_PRIORITY[candidate] || 0) >= (STATUS_PRIORITY[current] || 0)) return candidate; + return current; +} + +function telemetryRecord(kind, item, resourceAttributes, inheritedContentSignal = false, extra = {}) { + const itemAttributes = safeAttributes(item?.attributes).values; + const itemContent = safeAttributes(item?.attributes).contentSignal; + const attributes = mergeAttributes(resourceAttributes, itemAttributes); + const spanName = kind === 'span' ? safeIdentifier(item?.name) : ''; + const metricName = safeIdentifier(extra.metricName); + const operation = safeIdentifier(attributes['gen_ai.operation.name']) || metricName; + const tool = safeIdentifier(attributes['gen_ai.tool.name'] || attributes['agentops.mcp.tool']); + const sessionId = safeIdentifier(attributes['agentops.session.id']) + || safeIdentifier(attributes['gen_ai.conversation.id']) + || safeIdentifier(attributes['github.copilot.interaction_id']); + const traceId = safeIdentifier(item?.traceId, 64); + const start = timestampToIso(item?.startTimeUnixNano ?? item?.start_time_unix_nano ?? item?.timeUnixNano); + const end = timestampToIso(item?.endTimeUnixNano ?? item?.end_time_unix_nano ?? item?.observedTimeUnixNano ?? item?.timeUnixNano); + const metricValue = numberValue(extra.metricValue); + const inputTokens = numberValue(attributes['gen_ai.usage.input_tokens']) + || (/input.*token/i.test(metricName) ? metricValue : 0); + const outputTokens = numberValue(attributes['gen_ai.usage.output_tokens']) + || (/output.*token/i.test(metricName) ? metricValue : 0); + const credits = numberValue(attributes['github.copilot.cost'] || attributes['agentops.cost.estimated_usd']) + || (/(?:cost|credit|aiu)/i.test(metricName) ? metricValue : 0); + const contentSignal = inheritedContentSignal || itemContent || attributes['agentops.content_capture.signal'] === true || attributes['agentops.content_capture.signal'] === 'true'; + const terminal = lifecycleStatus(attributes, spanName, item?.status) + || eventLifecycleStatus(item?.events); + + return { + kind, + sessionId, + traceId, + start, + end, + operation, + tool, + model: safeIdentifier(attributes['gen_ai.response.model']) || safeIdentifier(attributes['gen_ai.request.model']) || safeIdentifier(attributes['agentops.cli.model']), + agent: safeIdentifier(attributes['agentops.agent.name']) || safeIdentifier(attributes['agentops.cli.agent']), + runId: safeIdentifier(attributes['agentops.run.id']), + inputTokens, + outputTokens, + credits, + contentSignal, + contentDroppedBytes: numberValue(attributes['agentops.content.dropped_bytes']), + failed: kind === 'span' && hasError(attributes, item?.status), + terminal, + safePrivacyMode: safeEnum(attributes['agentops.privacy.mode']), + safeContentMode: safeEnum(attributes['agentops.content_capture.mode']) + }; +} + +function resourceAttributes(resource) { + return safeAttributes(resource?.attributes).values; +} + +function requestRecords(request) { + const records = []; + const spans = Array.isArray(request?.resourceSpans) ? request.resourceSpans : []; + for (const resourceSpan of spans) { + const resource = resourceAttributes(resourceSpan?.resource); + const contentSignal = safeAttributes(resourceSpan?.resource?.attributes).contentSignal; + const scopes = resourceSpan?.scopeSpans || resourceSpan?.scope_spans || []; + for (const scope of Array.isArray(scopes) ? scopes : []) { + for (const span of Array.isArray(scope?.spans) ? scope.spans : []) { + records.push(telemetryRecord('span', span, resource, contentSignal)); + } + } + } + + const metrics = Array.isArray(request?.resourceMetrics) ? request.resourceMetrics : []; + for (const resourceMetric of metrics) { + const resource = resourceAttributes(resourceMetric?.resource); + const contentSignal = safeAttributes(resourceMetric?.resource?.attributes).contentSignal; + const scopes = resourceMetric?.scopeMetrics || resourceMetric?.scope_metrics || []; + for (const scope of Array.isArray(scopes) ? scopes : []) { + for (const metric of Array.isArray(scope?.metrics) ? scope.metrics : []) { + const metricName = safeIdentifier(metric?.name); + const points = metric?.sum?.dataPoints || metric?.gauge?.dataPoints || metric?.histogram?.dataPoints || []; + for (const point of Array.isArray(points) ? points : []) { + records.push(telemetryRecord('metric', point, resource, contentSignal, { + metricName, + metricValue: point?.asInt ?? point?.asDouble ?? point?.value + })); + } + } + } + } + + const logs = Array.isArray(request?.resourceLogs) ? request.resourceLogs : []; + for (const resourceLog of logs) { + const resource = resourceAttributes(resourceLog?.resource); + const contentSignal = safeAttributes(resourceLog?.resource?.attributes).contentSignal; + const scopes = resourceLog?.scopeLogs || resourceLog?.scope_logs || []; + for (const scope of Array.isArray(scopes) ? scopes : []) { + for (const log of Array.isArray(scope?.logRecords) ? scope.logRecords : (Array.isArray(scope?.log_records) ? scope.log_records : [])) { + records.push(telemetryRecord('log', log, resource, contentSignal)); + } + } + } + + return records; +} + +function isOtlpRequest(value) { + return isObject(value) && ['resourceSpans', 'resourceMetrics', 'resourceLogs'].some(key => Object.hasOwn(value, key)); +} + +function parseFileObjects(text) { + const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + const objects = []; + let malformedLines = 0; + for (const line of lines) { + try { + const value = JSON.parse(line); + if (isObject(value)) objects.push(value); + } catch { + malformedLines += 1; + } + } + if (objects.length === 0 && lines.length > 0) { + try { + const value = JSON.parse(text); + if (isObject(value)) { + objects.push(value); + malformedLines = 0; + } + } catch {} + } + return { objects, malformedLines }; +} + +function emptyReceipt(status = 'UNOBSERVED', reason = 'NO_NATIVE_OTLP_RECORDS') { + return { + status, + observed: false, + reason, + session_id: null, + run_id: null, + trace_id: null, + started: null, + ended: null, + duration_ms: null, + operations: [], + tools: [], + models: [], + agents: [], + tool_calls: 0, + failures: 0, + failed_tools: 0, + input_tokens: 0, + output_tokens: 0, + credits: 0, + privacy_mode: 'strict', + content_capture_mode: 'off', + content_signal: false, + content_dropped_bytes: 0, + export_state: 'LOCAL_ONLY', + data_missing: ['safe native OTLP processing/task completion signal'] + }; +} + +function deriveReceipt(records) { + if (!records.length) return emptyReceipt(); + const groups = new Map(); + records.forEach((record, index) => { + const key = record.sessionId || record.traceId || 'unknown-session'; + if (!groups.has(key)) groups.set(key, { key, records: [], index }); + groups.get(key).records.push(record); + }); + + const grouped = [...groups.values()]; + const candidates = grouped.some(group => group.key !== 'unknown-session') + ? grouped.filter(group => group.key !== 'unknown-session') + : grouped; + const selected = candidates.sort((left, right) => { + const leftEnd = left.records.reduce((latest, record) => latestTime(latest, record.end), null); + const rightEnd = right.records.reduce((latest, record) => latestTime(latest, record.end), null); + if (leftEnd && rightEnd) return new Date(rightEnd) - new Date(leftEnd); + if (rightEnd) return 1; + if (leftEnd) return -1; + return right.index - left.index; + })[0]; + + const receipt = emptyReceipt('OBSERVED', 'NO_SAFE_COMPLETION_SIGNAL'); + receipt.observed = true; + receipt.session_id = selected.records.map(record => record.sessionId).find(Boolean) || null; + receipt.trace_id = selected.records.map(record => record.traceId).find(Boolean) || null; + receipt.run_id = selected.records.map(record => record.runId).find(Boolean) || null; + receipt.started = selected.records.reduce((earliest, record) => earliestTime(earliest, record.start), null); + receipt.ended = selected.records.reduce((latest, record) => latestTime(latest, record.end), null); + receipt.duration_ms = receipt.started && receipt.ended + ? Math.max(0, new Date(receipt.ended) - new Date(receipt.started)) + : null; + + for (const record of selected.records) { + safeListAdd(receipt.operations, record.operation); + safeListAdd(receipt.tools, record.tool); + safeListAdd(receipt.models, record.model); + safeListAdd(receipt.agents, record.agent); + if (record.kind === 'span' && (record.tool || record.operation === 'execute_tool')) receipt.tool_calls += 1; + if (record.failed) { + receipt.failures += 1; + if (record.tool || record.operation === 'execute_tool') receipt.failed_tools += 1; + } + receipt.input_tokens += record.inputTokens; + receipt.output_tokens += record.outputTokens; + receipt.credits += record.credits; + receipt.content_signal ||= record.contentSignal; + receipt.content_dropped_bytes += record.contentDroppedBytes; + if (record.safePrivacyMode) receipt.privacy_mode = record.safePrivacyMode.toUpperCase(); + if (record.safeContentMode) receipt.content_capture_mode = record.safeContentMode; + receipt.status = preferStatus(receipt.status, record.terminal); + } + + if (receipt.status === 'OBSERVED' && receipt.failures > 0) receipt.status = 'FAILED'; + receipt.reason = receipt.status === 'OBSERVED' ? 'NO_SAFE_COMPLETION_SIGNAL' : null; + receipt.data_missing = []; + if (!receipt.session_id) receipt.data_missing.push('session id'); + if (!receipt.started || !receipt.ended) receipt.data_missing.push('complete timestamps'); + if (receipt.status === 'OBSERVED') receipt.data_missing.push('safe native OTLP processing/task completion signal'); + return receipt; +} + +function readNativeReceiptFile(filePath) { + if (!filePath) return { selected: false, recognized: false, ok: false }; + const resolvedPath = path.resolve(filePath); + let text; + try { + const stat = fs.statSync(resolvedPath); + if (!stat.isFile()) throw new Error('not a file'); + if (stat.size > MAX_RECEIPT_BYTES) { + return { + selected: true, + recognized: true, + ok: false, + source: 'native-local-file', + status: 'UNOBSERVED', + reason: 'FILE_TOO_LARGE', + receipt: emptyReceipt('UNOBSERVED', 'FILE_TOO_LARGE') + }; + } + text = fs.readFileSync(resolvedPath, 'utf8'); + } catch { + return { + selected: true, + recognized: true, + ok: false, + source: 'native-local-file', + status: 'UNOBSERVED', + reason: 'FILE_UNREADABLE', + receipt: emptyReceipt('UNOBSERVED', 'FILE_UNREADABLE') + }; + } + + const parsed = parseFileObjects(text); + const nativeRequests = parsed.objects.filter(isOtlpRequest); + if (nativeRequests.length === 0) { + const malformedNativeFile = parsed.malformedLines > 0 && parsed.objects.length === 0; + return { + selected: true, + recognized: malformedNativeFile, + ok: false, + source: 'native-local-file', + status: 'UNOBSERVED', + reason: malformedNativeFile ? 'MALFORMED_NATIVE_FILE' : 'NO_NATIVE_OTLP_RECORDS', + receipt: emptyReceipt('UNOBSERVED', malformedNativeFile ? 'MALFORMED_NATIVE_FILE' : 'NO_NATIVE_OTLP_RECORDS') + }; + } + + const records = nativeRequests.flatMap(requestRecords); + const receipt = deriveReceipt(records); + return { + selected: true, + recognized: true, + ok: true, + source: 'native-local-file', + status: receipt.status, + reason: receipt.reason || null, + receipt, + malformed_records: parsed.malformedLines + }; +} + +function readNativeReceiptFromArgs(args = [], env = process.env) { + const explicitPath = optionValue(args, ['--file', '--jsonl']); + const envPath = env?.[RECEIPT_PATH_ENV] || null; + const defaultPath = !explicitPath && !envPath && fs.existsSync(DEFAULT_RECEIPT_PATH) + ? DEFAULT_RECEIPT_PATH + : null; + const filePath = explicitPath || envPath || defaultPath; + const result = readNativeReceiptFile(filePath); + return { + ...result, + selected_by: explicitPath ? 'cli' : envPath ? 'env' : defaultPath ? 'default' : null + }; +} + +function nativeOpenResult(native, links = {}) { + const configuredPrimary = links.primary_investigation_url || null; + const cloudVerified = links.cloud_verified === true; + return { + ok: Boolean(native?.ok), + source: native?.source || 'native-local-file', + status: native?.status || 'UNOBSERVED', + observed: Boolean(native?.receipt?.observed), + reason: native?.reason || native?.receipt?.reason || null, + receipt: native?.receipt || emptyReceipt(), + links: { + primary: cloudVerified ? configuredPrimary : null, + primary_label: cloudVerified ? (links.primary_investigation_label || null) : null, + configured_primary: configuredPrimary, + configured_primary_label: links.primary_investigation_label || null, + cloud_verified: cloudVerified, + cloud_evidence: cloudVerified ? 'explicit-query-back' : 'not-query-verified', + azure_agents_view: links.azure_agents_view_url || null, + application_insights: links.application_insights_url || null, + home: links.v2_home_url || null, + latest_session: links.latest_session_url || null + } + }; +} + +function renderNativeReceipt(result = {}) { + const receipt = result.receipt || emptyReceipt(); + const lines = ['AgentOps native local receipt', '']; + lines.push(`Observed: ${result.status || receipt.status || 'UNOBSERVED'}`); + if (receipt.session_id) lines.push(`Session: ${receipt.session_id}`); + if (receipt.run_id) lines.push(`Run: ${receipt.run_id}`); + if (receipt.duration_ms !== null && receipt.duration_ms !== undefined) lines.push(`Duration: ${Math.round(receipt.duration_ms)}ms`); + if (receipt.tools.length > 0 || receipt.tool_calls > 0) lines.push(`Tools: ${receipt.tools.join(', ') || 'unknown'} (${receipt.tool_calls} call${receipt.tool_calls === 1 ? '' : 's'}, ${receipt.failures} failure${receipt.failures === 1 ? '' : 's'})`); + if (receipt.models.length > 0) lines.push(`Models: ${receipt.models.join(', ')}`); + if (receipt.input_tokens || receipt.output_tokens || receipt.credits) lines.push(`Usage: ${receipt.input_tokens} input tokens, ${receipt.output_tokens} output tokens${receipt.credits ? `, ${receipt.credits} credits` : ''}`); + lines.push(`Privacy: ${receipt.privacy_mode.toLowerCase()}, content returned: no${receipt.content_signal ? ' (signal detected and omitted)' : ''}`); + lines.push(`Delivery: ${receipt.export_state.toLowerCase()}`); + if (receipt.reason) lines.push(`Reason: ${receipt.reason}`); + if (receipt.status === 'UNOBSERVED') { + lines.push('No completion claim was made from the available native records.'); + lines.push('Next: agentops doctor --local-only'); + } else if (receipt.status === 'OBSERVED') { + lines.push('Native telemetry was observed, but no safe processing-stop or task-complete signal was found.'); + lines.push('No task-success claim was made.'); + } else if (receipt.status === 'PROCESSING_STOPPED') { + lines.push('Processing stopped; no task-success claim was made.'); + } else if (receipt.status === 'SESSION_COMPLETED') { + lines.push('Session completion was observed; no task-success claim was made.'); + } else if (result.links?.primary) { + lines.push(`Investigate: ${result.links.primary_label || 'Azure Monitor'} ${result.links.primary}`); + } + if (!result.links?.cloud_verified && result.links?.configured_primary) { + lines.push('Cloud link: configured but not query-verified; no cloud-verified claim was made.'); + } + return `${lines.join('\n')}\n`; +} + +module.exports = { + CONTENT_ATTRIBUTE_KEYS, + DEFAULT_RECEIPT_PATH, + RECEIPT_PATH_ENV, + SAFE_ATTRIBUTE_KEYS, + deriveReceipt, + emptyReceipt, + nativeOpenResult, + parseFileObjects, + readNativeReceiptFile, + readNativeReceiptFromArgs, + renderNativeReceipt, + requestRecords, + safeIdentifier, + timestampToIso +}; diff --git a/agentops-cli/src/lib/observability-queries.js b/agentops-cli/src/lib/observability-queries.js new file mode 100644 index 0000000..475e8a6 --- /dev/null +++ b/agentops-cli/src/lib/observability-queries.js @@ -0,0 +1,306 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { repoRoot } = require('./paths'); +const { escapeKqlString, validateKqlDuration } = require('./kql'); +const { contentLikeKeys, safeAttributeKeys } = require('./privacy'); + +const agentServiceNames = '("github-copilot", "copilot-chat", "github-copilot-cli", "codex", "openai-codex", "openai-codex-cli")'; +const baseFilter = `(Properties has "github.copilot" or Properties has "gen_ai.operation.name" or Properties has "agentops." or AppRoleName in ${agentServiceNames} or tostring(Properties["service.name"]) in ${agentServiceNames} or tostring(Properties["agent.runtime"]) in ("codex", "openai-codex-cli"))`; +const copilotOtelFilter = baseFilter; +const copilotMetricNames = [ + 'gen_ai.client.operation.duration', + 'gen_ai.client.token.usage', + 'gen_ai.client.operation.time_to_first_chunk', + 'gen_ai.client.operation.time_per_output_chunk', + 'github.copilot.tool.call.count', + 'github.copilot.tool.call.duration', + 'github.copilot.agent.turn.count', + 'copilot_chat.tool.call.count', + 'copilot_chat.tool.call.duration', + 'copilot_chat.agent.invocation.duration', + 'copilot_chat.agent.turn.count', + 'copilot_chat.session.count', + 'copilot_chat.time_to_first_token', + 'copilot_chat.edit.acceptance.count', + 'copilot_chat.chat_edit.outcome.count', + 'copilot_chat.lines_of_code.count', + 'copilot_chat.edit.survival.four_gram', + 'copilot_chat.edit.survival.no_revert', + 'copilot_chat.user.action.count', + 'copilot_chat.user.feedback.count', + 'copilot_chat.agent.edit_response.count', + 'copilot_chat.agent.summarization.count', + 'copilot_chat.pull_request.count', + 'copilot_chat.cloud.session.count', + 'copilot_chat.cloud.pr_ready.count' +]; +const copilotEventNames = [ + 'gen_ai.client.inference.operation.details', + 'copilot_chat.session.start', + 'copilot_chat.tool.call', + 'copilot_chat.agent.turn', + 'copilot_chat.edit.feedback', + 'copilot_chat.edit.hunk.action', + 'copilot_chat.inline.done', + 'copilot_chat.edit.survival', + 'copilot_chat.user.feedback', + 'copilot_chat.cloud.session.invoke', + 'github.copilot.hook.start', + 'github.copilot.hook.end', + 'github.copilot.hook.error', + 'github.copilot.session.truncation', + 'github.copilot.session.compaction_start', + 'github.copilot.session.compaction_complete', + 'github.copilot.skill.invoked', + 'github.copilot.session.shutdown', + 'github.copilot.session.abort', + 'exception' +]; +const sessionFallbackPrefix = 'iff(isnotempty(tostring(Properties["gen_ai.agent.id"])), tostring(Properties["gen_ai.agent.id"]), iff(isnotempty(tostring(Properties["service.name"])), tostring(Properties["service.name"]), iff(isnotempty(AppRoleName), AppRoleName, "agent")))'; +const sessionFallbackTurn = 'iff(isnotempty(tostring(Properties["github.copilot.turn_count"])), tostring(Properties["github.copilot.turn_count"]), iff(isnotempty(OperationId), OperationId, "session"))'; +const sessionKey = `case(isnotempty(tostring(Properties["gen_ai.conversation.id"])), tostring(Properties["gen_ai.conversation.id"]), isnotempty(tostring(Properties["github.copilot.interaction_id"])), tostring(Properties["github.copilot.interaction_id"]), strcat(${sessionFallbackPrefix}, "_", ${sessionFallbackTurn}, "_", format_datetime(bin(TimeGenerated, 1h), "yyyyMMdd_HHmm")))`; +const directSessionKey = 'case(isnotempty(tostring(Properties["gen_ai.conversation.id"])), tostring(Properties["gen_ai.conversation.id"]), isnotempty(tostring(Properties["github.copilot.interaction_id"])), tostring(Properties["github.copilot.interaction_id"]), "")'; +const fallbackSessionKey = `strcat(${sessionFallbackPrefix}, "_", ${sessionFallbackTurn}, "_", format_datetime(bin(TimeGenerated, 1h), "yyyyMMdd_HHmm"))`; + +function encodeGrafanaValue(value) { + return encodeURIComponent(value); +} + +function grafanaUrlWithVars(baseUrl, vars = {}) { + const entries = Object.entries(vars).filter(([, value]) => value !== undefined && value !== null && value !== ''); + if (entries.length === 0) return baseUrl; + const separator = baseUrl.includes('?') ? '&' : '?'; + return `${baseUrl}${separator}${entries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeGrafanaValue(String(value))}`).join('&')}`; +} + +function sessionQuery(conversation, last = '24h') { + const escaped = conversation.replace(/"/g, '\\"'); + return `let selected_session = "${escaped}";\nlet base = AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend direct_session=${directSessionKey}, fallback_session=${fallbackSessionKey};\nlet selected_operations = base\n| where direct_session == selected_session or fallback_session == selected_session\n| distinct OperationId;\nbase\n| extend linked_to_selected = OperationId in (selected_operations)\n| where direct_session == selected_session or fallback_session == selected_session or linked_to_selected\n| extend conversation=iff(linked_to_selected, selected_session, iff(isnotempty(direct_session), direct_session, fallback_session)), operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), tool=tostring(Properties["gen_ai.tool.name"]), error=tostring(Properties["error.type"])\n| project TimeGenerated, conversation, OperationId, ParentId, Id, Name, operation, model, tool, DurationMs, Success, ResultCode, error, Properties\n| order by TimeGenerated asc`; +} + +function traceQuery(operationId, last = '24h') { + return `AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| where OperationId == "${operationId.replace(/"/g, '\\"')}"\n| extend conversation=${sessionKey}, operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), tool=tostring(Properties["gen_ai.tool.name"]), error=tostring(Properties["error.type"])\n| project TimeGenerated, conversation, OperationId, ParentId, Id, Name, operation, model, tool, DurationMs, Success, ResultCode, error, Properties\n| order by TimeGenerated asc`; +} + +function fieldCatalogQuery(last = '7d') { + const contentKeys = contentLikeKeys.map(key => JSON.stringify(key)).join(', '); + const safeKeys = safeAttributeKeys.map(key => JSON.stringify(key)).join(', '); + return `let exact_content_keys = dynamic([${contentKeys}]);\nlet known_safe_keys = dynamic([${safeKeys}]);\nAppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend fields = bag_keys(Properties)\n| mv-expand field = fields to typeof(string)\n| extend value = tostring(Properties[field])\n| summarize observed=count(), example_values=make_set_if(value, isnotempty(value), 5) by field\n| extend content_risk = case(field in (exact_content_keys), "exact-content-key", field !in (known_safe_keys) and field matches regex "(?i)(prompt|completion|message|instruction|argument|result|body|secret|password|credential|cookie|url|filepath|file_path|path|token)", "sensitive-key-family", "")\n| order by content_risk desc, observed desc, field asc`; +} + +function contextPressureQuery(last = '7d') { + return `AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend conversation=${sessionKey}, operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), agent=tostring(Properties["gen_ai.agent.name"]), tool=tostring(Properties["gen_ai.tool.name"]), repo=tostring(Properties["agentops.repo.hash"]), error=tostring(Properties["error.type"]), InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), CacheRead=todouble(Properties["gen_ai.usage.cache_read.input_tokens"]), CacheWrite=todouble(Properties["gen_ai.usage.cache_creation.input_tokens"]), Credits=todouble(Properties["github.copilot.cost"]), AIU=todouble(Properties["github.copilot.aiu"])\n| summarize Started=min(TimeGenerated), Ended=max(TimeGenerated), Spans=count(), Runs=countif(operation == "invoke_agent"), Failures=countif(Success == false or isnotempty(error)), ChatSpans=countif(operation == "chat"), ChatInputTokens=sumif(InputTokens, operation == "chat"), ChatOutputTokens=sumif(OutputTokens, operation == "chat"), ChatCacheRead=sumif(CacheRead, operation == "chat"), ChatCacheWrite=sumif(CacheWrite, operation == "chat"), ChatCredits=sumif(Credits, operation == "chat"), ChatAIU=sumif(AIU, operation == "chat"), AgentInputTokens=maxif(InputTokens, operation == "invoke_agent"), AgentOutputTokens=maxif(OutputTokens, operation == "invoke_agent"), AgentCacheRead=maxif(CacheRead, operation == "invoke_agent"), AgentCacheWrite=maxif(CacheWrite, operation == "invoke_agent"), AgentCredits=maxif(Credits, operation == "invoke_agent"), AgentAIU=maxif(AIU, operation == "invoke_agent"), P95DurationMs=percentile(DurationMs, 95), Models=make_set(model, 5), Agents=make_set(agent, 5), Repos=make_set_if(repo, isnotempty(repo), 3), Tools=make_set_if(tool, isnotempty(tool), 10), Errors=make_set_if(error, isnotempty(error), 10) by Session=conversation\n| extend InputTokens=iff(ChatSpans > 0, ChatInputTokens, AgentInputTokens), OutputTokens=iff(ChatSpans > 0, ChatOutputTokens, AgentOutputTokens), CacheRead=iff(ChatSpans > 0, ChatCacheRead, AgentCacheRead), CacheWrite=iff(ChatSpans > 0, ChatCacheWrite, AgentCacheWrite), Credits=iff(ChatSpans > 0, ChatCredits, AgentCredits), AIU=iff(ChatSpans > 0, ChatAIU, AgentAIU)\n| extend FreshInput=iff(InputTokens - CacheRead - CacheWrite < 0, 0.0, InputTokens - CacheRead - CacheWrite), OutputYieldPct=iff(InputTokens > 0, round(100.0 * OutputTokens / InputTokens, 3), 0.0), CacheLeveragePct=iff(InputTokens > 0, round(100.0 * CacheRead / InputTokens, 1), 0.0), EstUsd=round(Credits * 0.01, 4), DurationSec=round(datetime_diff("millisecond", Ended, Started) / 1000.0, 2)\n| extend Pressure=case(InputTokens >= 100000 and OutputYieldPct < 0.1, "severe_low_yield", InputTokens >= 100000, "severe_context", InputTokens >= 30000 and OutputYieldPct < 0.1, "high_low_yield", InputTokens >= 30000, "high_context", FreshInput >= 30000 and CacheLeveragePct < 10, "low_cache_leverage", EstUsd >= 1.0, "expensive", "ok")\n| where Pressure != "ok"\n| project Started, Session, Pressure, InputTokens, OutputTokens, OutputYieldPct, CacheRead, CacheWrite, FreshInput, CacheLeveragePct, Credits, EstUsd, AIU, DurationSec, P95DurationMs, Runs, Spans, Failures, Models, Agents, Repos, Tools, Errors\n| order by InputTokens desc, EstUsd desc\n| take 100`; +} + +function tokenRollupAuditQuery(last = '7d') { + return `AppDependencies\n| where TimeGenerated > ago(${last})\n| where ${baseFilter}\n| extend conversation=${sessionKey}, operation=tostring(Properties["gen_ai.operation.name"]), model=tostring(Properties["gen_ai.request.model"]), agent=tostring(Properties["gen_ai.agent.name"]), InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), CacheRead=todouble(Properties["gen_ai.usage.cache_read.input_tokens"]), CacheWrite=todouble(Properties["gen_ai.usage.cache_creation.input_tokens"]), Credits=todouble(Properties["github.copilot.cost"]), AIU=todouble(Properties["github.copilot.aiu"])\n| summarize Started=min(TimeGenerated), Ended=max(TimeGenerated), Spans=count(), ChatSpans=countif(operation == "chat"), AgentSpans=countif(operation == "invoke_agent"), AllSpanInputTokens=sum(InputTokens), AllSpanOutputTokens=sum(OutputTokens), ChatInputTokens=sumif(InputTokens, operation == "chat"), ChatOutputTokens=sumif(OutputTokens, operation == "chat"), AgentInputTokens=maxif(InputTokens, operation == "invoke_agent"), AgentOutputTokens=maxif(OutputTokens, operation == "invoke_agent"), ChatCredits=sumif(Credits, operation == "chat"), AgentCredits=maxif(Credits, operation == "invoke_agent"), ChatAIU=sumif(AIU, operation == "chat"), AgentAIU=maxif(AIU, operation == "invoke_agent"), Models=make_set(model, 5), Agents=make_set(agent, 5) by Session=conversation\n| extend RecommendedInputTokens=iff(ChatSpans > 0, ChatInputTokens, AgentInputTokens), RecommendedOutputTokens=iff(ChatSpans > 0, ChatOutputTokens, AgentOutputTokens), RecommendedCredits=iff(ChatSpans > 0, ChatCredits, AgentCredits), RecommendedAIU=iff(ChatSpans > 0, ChatAIU, AgentAIU)\n| extend TokenOvercountRatio=iff(RecommendedInputTokens > 0, round(AllSpanInputTokens / RecommendedInputTokens, 2), 0.0), RollupMode=iff(ChatSpans > 0, "chat_spans", "invoke_agent_fallback"), NeedsReview=AllSpanInputTokens > RecommendedInputTokens * 1.25\n| project Started, Ended, Session, RollupMode, NeedsReview, TokenOvercountRatio, AllSpanInputTokens, RecommendedInputTokens, AgentInputTokens, ChatInputTokens, AllSpanOutputTokens, RecommendedOutputTokens, AgentOutputTokens, ChatOutputTokens, RecommendedCredits, RecommendedAIU, Spans, ChatSpans, AgentSpans, Models, Agents\n| order by NeedsReview desc, TokenOvercountRatio desc, AllSpanInputTokens desc\n| take 100`; +} + +function collectorHealthQuery(last = '24h') { + const lookback = validateKqlDuration(last); + return `let lookback = ${lookback}; +let copilot = AppDependencies +| where TimeGenerated > ago(lookback) +| where ${copilotOtelFilter} +| where isempty(tostring(Properties["agentops.smoke_id"])) + and tostring(Properties["agentops.profile"]) !has "smoke" + and isempty(tostring(Properties["agentops.test.kind"])) +| summarize LastCopilotSpan=max(TimeGenerated), CopilotSpans=count(), AgentOpsSpans=countif(Properties has "agentops."), FailedSpans=countif(Success == false or tostring(Success) =~ "false" or isnotempty(tostring(Properties["error.type"]))); +let collectorLogs = AppTraces +| where TimeGenerated > ago(lookback) +| where Message has_any ("otelcol", "azuremonitor", "exporter", "dropped", "retry", "queue", "queued", "sending_queue", "refused", "timeout", "backpressure", "memory_limiter") +| summarize LastCollectorLog=max(TimeGenerated), + CollectorErrors=countif(SeverityLevel >= 3 or Message has_any ("error", "failed", "dropped", "refused", "timeout")), + CollectorWarnings=countif(SeverityLevel == 2 or Message has "warn"), + QueueSignals=countif(Message has_any ("queue", "queued", "sending_queue", "enqueue")), + DroppedSignals=countif(Message has_any ("dropped", "drop", "refused")), + RetrySignals=countif(Message has_any ("retry", "retried", "retrying")), + TimeoutSignals=countif(Message has_any ("timeout", "timed out")), + BackpressureSignals=countif(Message has_any ("backpressure", "memory_limiter", "queue is full", "sending queue", "refused")); +copilot +| extend joinKey=1 +| join kind=fullouter (collectorLogs | extend joinKey=1) on joinKey +| project LastCopilotSpan, CopilotSpans, AgentOpsSpans, FailedSpans, LastCollectorLog, CollectorErrors, CollectorWarnings, QueueSignals, DroppedSignals, RetrySignals, TimeoutSignals, BackpressureSignals +| extend Health=case(isnull(LastCopilotSpan), "no_copilot_spans", CollectorErrors > 0, "collector_errors", BackpressureSignals > 0 or DroppedSignals > 0, "collector_backpressure", "healthy")`; +} + +function otelCompatibilityQuery(last = '2h') { + const lookback = validateKqlDuration(last); + const metricNames = copilotMetricNames.map(name => `"${name}"`).join(', '); + const eventNames = copilotEventNames.map(name => `"${name}"`).join(', '); + return `let lookback = ${lookback}; +let expected_metrics = dynamic([${metricNames}]); +let expected_events = dynamic([${eventNames}]); +let span_summary = AppDependencies +| where TimeGenerated > ago(lookback) +| where ${copilotOtelFilter} +| extend operation=tostring(Properties["gen_ai.operation.name"]), + service=coalesce(AppRoleName, tostring(Properties["service.name"])), + agent=tostring(Properties["gen_ai.agent.name"]), + conversation=tostring(Properties["gen_ai.conversation.id"]), + interaction=tostring(Properties["github.copilot.interaction_id"]), + model=tostring(Properties["gen_ai.request.model"]), + tool=tostring(Properties["gen_ai.tool.name"]), + input_tokens=todouble(Properties["gen_ai.usage.input_tokens"]), + output_tokens=todouble(Properties["gen_ai.usage.output_tokens"]), + cost=todouble(Properties["github.copilot.cost"]), + aiu=todouble(Properties["github.copilot.aiu"]) +| summarize + Spans=count(), + Services=make_set_if(service, isnotempty(service), 10), + Operations=make_set_if(operation, isnotempty(operation), 10), + Agents=make_set_if(agent, isnotempty(agent), 10), + HasOperation=countif(isnotempty(operation)), + HasSession=countif(isnotempty(conversation) or isnotempty(interaction)), + HasModel=countif(isnotempty(model)), + HasTool=countif(isnotempty(tool)), + HasTokenUsage=countif(isnotnull(input_tokens) or isnotnull(output_tokens)), + HasCostOrAIU=countif(isnotnull(cost) or isnotnull(aiu)), + LastSpan=max(TimeGenerated) +| extend joinKey=1; +let metric_summary = union isfuzzy=true AppMetrics +| where TimeGenerated > ago(lookback) +| where Name in (expected_metrics) or tostring(Properties) has_any ("gen_ai", "github.copilot", "copilot_chat") +| summarize + Metrics=count(), + MetricNames=make_set(Name, 50), + HasGenAiMetrics=countif(Name startswith "gen_ai."), + HasCopilotCliMetrics=countif(Name startswith "github.copilot."), + HasVsCodeMetrics=countif(Name startswith "copilot_chat."), + LastMetric=max(TimeGenerated) +| extend joinKey=1; +let event_summary = union isfuzzy=true AppTraces, AppEvents +| where TimeGenerated > ago(lookback) +| extend event=coalesce(tostring(Properties["event.name"]), tostring(Properties["github.copilot.event.name"]), Name) +| where event in (expected_events) or tostring(Properties) has_any ("github.copilot", "copilot_chat", "gen_ai.client.inference") or Message has_any ("github.copilot", "copilot_chat", "gen_ai.client.inference") +| summarize + Events=count(), + EventNames=make_set_if(event, isnotempty(event), 50), + HasLifecycleEvents=countif(event has_any ("session", "hook", "skill", "exception")), + HasVsCodeEvents=countif(event startswith "copilot_chat."), + LastEvent=max(TimeGenerated) +| extend joinKey=1; +span_summary +| join kind=fullouter metric_summary on joinKey +| join kind=fullouter event_summary on joinKey +| extend Status=case(Spans == 0, "missing", + HasOperation == 0 or HasSession == 0, "partial", + HasModel == 0 or HasTokenUsage == 0, "partial", + "ready") +| extend Missing=pack_array( + iff(Spans == 0, "no Copilot/GenAI spans matched", ""), + iff(Spans > 0 and HasOperation == 0, "gen_ai.operation.name", ""), + iff(Spans > 0 and HasSession == 0, "gen_ai.conversation.id or github.copilot.interaction_id", ""), + iff(Spans > 0 and HasModel == 0, "gen_ai.request.model", ""), + iff(Spans > 0 and HasTokenUsage == 0, "gen_ai.usage.input_tokens/output_tokens", ""), + iff(Spans > 0 and HasCostOrAIU == 0, "github.copilot.cost or github.copilot.aiu", ""), + iff(coalesce(Metrics, 0) == 0, "no Copilot/GenAI metrics matched", ""), + iff(coalesce(Events, 0) == 0, "no Copilot/GenAI events matched", "") +) +| project Status, Spans=coalesce(Spans, 0), Metrics=coalesce(Metrics, 0), Events=coalesce(Events, 0), LastSpan, LastMetric, LastEvent, Services, Operations, Agents, MetricNames, EventNames, HasOperation, HasSession, HasModel, HasTool, HasTokenUsage, HasCostOrAIU, HasGenAiMetrics, HasCopilotCliMetrics, HasVsCodeMetrics, HasLifecycleEvents, HasVsCodeEvents, Missing`; +} + +function attributionUsageQuery(last = '7d') { + const lookback = validateKqlDuration(last); + return `let lookback = ${lookback}; +let dependency_rows = AppDependencies +| where TimeGenerated > ago(lookback) +| where ${copilotOtelFilter} +| extend conversation=${sessionKey}, + operation=tostring(Properties["gen_ai.operation.name"]), + agentops_agent=coalesce(tostring(Properties["agentops.agent.name"]), tostring(Properties["agentops.cli.agent"]), tostring(Properties["gen_ai.agent.name"])), + skill=coalesce(tostring(Properties["agentops.skill.name"]), tostring(Properties["github.copilot.skill.name"])), + tool=tostring(Properties["gen_ai.tool.name"]), + mcp_server=coalesce(tostring(Properties["agentops.mcp.server"]), tostring(Properties["agentops.mcp.config.servers"]), extract("^mcp__([^_]+)__", 1, tostring(Properties["gen_ai.tool.name"])), extract("^([^/]+)/", 1, tostring(Properties["gen_ai.tool.name"])), iff(tostring(Properties["gen_ai.tool.name"]) startswith "azure-mcp-", "azure-mcp", "")), + script=coalesce(tostring(Properties["agentops.script.name"]), tostring(Properties["agentops.hook.name"]), tostring(Properties["github.copilot.hook.name"]), tostring(Properties["github.copilot.hook.type"])), + model=tostring(Properties["gen_ai.request.model"]), + repo=tostring(Properties["agentops.repo.hash"]), + error=tostring(Properties["error.type"]), + InputTokens=todouble(Properties["gen_ai.usage.input_tokens"]), + OutputTokens=todouble(Properties["gen_ai.usage.output_tokens"]), + AICredits=todouble(Properties["github.copilot.cost"]), + AIU=todouble(Properties["github.copilot.aiu"]) +| project TimeGenerated, conversation, operation, agentops_agent, skill, tool, mcp_server, script, model, repo, DurationMs, Success, error, InputTokens, OutputTokens, AICredits, AIU, Properties; +let event_rows = union isfuzzy=true AppTraces, AppEvents +| where TimeGenerated > ago(lookback) +| where tostring(Properties) has_any ("agentops.", "github.copilot", "copilot_chat", "codex") or Message has_any ("AgentOps", "github.copilot", "copilot_chat", "codex") +| extend conversation=${sessionKey}, + operation=coalesce(tostring(Properties["gen_ai.operation.name"]), tostring(Properties["event.name"]), Name), + agentops_agent=coalesce(tostring(Properties["agentops.agent.name"]), tostring(Properties["agentops.cli.agent"]), tostring(Properties["gen_ai.agent.name"])), + skill=coalesce(tostring(Properties["agentops.skill.name"]), tostring(Properties["github.copilot.skill.name"])), + tool=tostring(Properties["gen_ai.tool.name"]), + mcp_server=coalesce(tostring(Properties["agentops.mcp.server"]), tostring(Properties["agentops.mcp.config.servers"]), extract("^mcp__([^_]+)__", 1, tostring(Properties["gen_ai.tool.name"])), extract("^([^/]+)/", 1, tostring(Properties["gen_ai.tool.name"])), iff(tostring(Properties["gen_ai.tool.name"]) startswith "azure-mcp-", "azure-mcp", "")), + script=coalesce(tostring(Properties["agentops.script.name"]), tostring(Properties["agentops.hook.name"]), tostring(Properties["github.copilot.hook.name"]), tostring(Properties["github.copilot.hook.type"])), + model=tostring(Properties["gen_ai.request.model"]), + repo=tostring(Properties["agentops.repo.hash"]), + error=tostring(Properties["error.type"]) +| project TimeGenerated, conversation, operation, agentops_agent, skill, tool, mcp_server, script, model, repo, DurationMs=real(null), Success=bool(null), error, InputTokens=real(null), OutputTokens=real(null), AICredits=real(null), AIU=real(null), Properties; +union isfuzzy=true dependency_rows, event_rows +| extend AttributionKind=case(isnotempty(skill), "skill", isnotempty(mcp_server), "mcp", isnotempty(script), "script_or_hook", isnotempty(agentops_agent), "agent", "unattributed"), + AttributionName=case(isnotempty(skill), skill, isnotempty(mcp_server), mcp_server, isnotempty(script), script, isnotempty(agentops_agent), agentops_agent, "unattributed") +| summarize Started=min(TimeGenerated), LastSeen=max(TimeGenerated), Sessions=dcount(conversation), SpansOrEvents=count(), Failures=countif(Success == false or isnotempty(error)), ToolCalls=countif(operation == "execute_tool" or isnotempty(tool)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), AICredits=sum(AICredits), AIU=sum(AIU), Models=make_set_if(model, isnotempty(model), 5), Tools=make_set_if(tool, isnotempty(tool), 10), Errors=make_set_if(error, isnotempty(error), 10) by AttributionKind, AttributionName +| extend EstUsd=round(AICredits * 0.01, 4), FailurePct=iff(SpansOrEvents > 0, round(100.0 * Failures / SpansOrEvents, 1), 0.0) +| where AttributionKind != "unattributed" +| order by Sessions desc, Failures desc, AICredits desc`; +} + +function kqlFileQuery(fileName, last = '7d', options = {}) { + const root = options.root || repoRoot; + const query = fs.readFileSync(path.join(root, 'kql', fileName), 'utf8'); + return query.replace(/let lookback = [^;]+;/, `let lookback = ${last};`); +} + +function buildLink(kind, id, options = {}) { + const last = options.last || '24h'; + const grafanaBaseUrl = options.grafanaBaseUrl; + const portalLogsUrl = options.portalLogsUrl; + const workspaceId = options.workspaceId; + if (kind === 'session') { + return { + kind, + conversation: id, + grafana_url: `${grafanaBaseUrl}/d/agentops-session-detail?var-conversation=${encodeGrafanaValue(id)}`, + azure_portal_url: portalLogsUrl, + workspace_id: workspaceId, + query: sessionQuery(id, last) + }; + } + + if (kind === 'trace') { + return { + kind, + operation_id: id, + grafana_url: `${grafanaBaseUrl}/d/agentops-traces-spans?var-conversation=__all`, + azure_portal_url: portalLogsUrl, + workspace_id: workspaceId, + query: traceQuery(id, last) + }; + } + + throw new Error(`Unknown link kind: ${kind}`); +} + +module.exports = { + agentServiceNames, + attributionUsageQuery, + baseFilter, + buildLink, + collectorHealthQuery, + contextPressureQuery, + copilotEventNames, + copilotMetricNames, + copilotOtelFilter, + directSessionKey, + encodeGrafanaValue, + fallbackSessionKey, + fieldCatalogQuery, + grafanaUrlWithVars, + kqlFileQuery, + otelCompatibilityQuery, + sessionFallbackPrefix, + sessionFallbackTurn, + sessionKey, + sessionQuery, + tokenRollupAuditQuery, + traceQuery +}; diff --git a/agentops-cli/src/lib/observability-query-command.js b/agentops-cli/src/lib/observability-query-command.js new file mode 100644 index 0000000..bd4790c --- /dev/null +++ b/agentops-cli/src/lib/observability-query-command.js @@ -0,0 +1,85 @@ +const { writeJson } = require('./command-output'); + +function createObservabilityQueryCommand(dependencies = {}) { + const { + attributionUsageQuery, + buildLink, + collectorHealthQuery, + contextPressureQuery, + fieldCatalogQuery, + kqlFileQuery, + otelCompatibilityQuery, + parseLastArg, + stdout = process.stdout, + tokenRollupAuditQuery, + workspaceId + } = dependencies; + const queryCommandSpecs = { + fields: { + defaultLast: '7d', + query: fieldCatalogQuery + }, + context: { + defaultLast: '7d', + query: contextPressureQuery + }, + 'token-rollup-audit': { + defaultLast: '7d', + query: tokenRollupAuditQuery + }, + 'collector-health': { + defaultLast: '24h', + query: collectorHealthQuery + }, + 'compat-check': { + defaultLast: '2h', + query: otelCompatibilityQuery + }, + attribution: { + defaultLast: '7d', + query: attributionUsageQuery + }, + 'permission-friction': { + defaultLast: '7d', + query: last => kqlFileQuery('17-permission-friction.kql', last) + }, + lineage: { + defaultLast: '24h', + query: last => kqlFileQuery('19-agent-flow-lineage.kql', last) + }, + policy: { + defaultLast: '7d', + query: last => kqlFileQuery('15-policy-governance.kql', last) + }, + mcp: { + defaultLast: '7d', + query: last => kqlFileQuery('16-mcp-tool-usage.kql', last) + } + }; + const queryCommandNames = Object.freeze(['link', ...Object.keys(queryCommandSpecs)]); + + function queryCommand(command, args) { + if (command === 'link') { + const [kind, id, ...linkArgs] = args; + if (!kind || !id) throw new Error('link requires a kind and id, for example: link session '); + const last = parseLastArg(linkArgs, '24h'); + writeJson(buildLink(kind, id, { last }), stdout); + return; + } + + const spec = queryCommandSpecs[command]; + if (!spec) throw new Error(`Unknown query command: ${command}`); + const last = parseLastArg(args, spec.defaultLast); + const query = spec.query(last); + writeJson({ workspace_id: workspaceId, query }, stdout); + } + + return { + queryCommand, + queryCommandNames + }; +} + +module.exports = { + createObservabilityQueryCommand +}; diff --git a/agentops-cli/src/lib/open-command.js b/agentops-cli/src/lib/open-command.js new file mode 100644 index 0000000..6ef84a7 --- /dev/null +++ b/agentops-cli/src/lib/open-command.js @@ -0,0 +1,46 @@ +const path = require('node:path'); + +const legacy = require('../legacy'); +const { firstPositional, hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { + nativeOpenResult, + readNativeReceiptFromArgs, + renderNativeReceipt +} = require('./native-receipt'); +const { openV2FromFiles, renderOpenV2, v2OpenLinksForRun } = require('./v2-open-links'); + +function openCommand(args = []) { + if (!optionValue(args, '--runs')) { + const native = readNativeReceiptFromArgs(args); + const shouldUseNative = native.recognized || ['env', 'default'].includes(native.selected_by); + if (shouldUseNative) { + const result = nativeOpenResult(native, legacy.openLinksSummary()); + writeJsonOrRender(result, hasFlag(args, '--json'), renderNativeReceipt); + return; + } + + const summary = legacy.latestSummaryFromArgs(args); + const links = legacy.openLinksSummary(summary); + writeJsonOrRender(links, hasFlag(args, '--json'), legacy.renderOpenLinks); + return; + } + + const result = openV2FromFiles({ + runId: firstPositional(args), + runsFile: path.resolve(optionValue(args, '--runs')) + }); + writeJsonOrRender(result, hasFlag(args, '--json'), renderOpenV2); + if (!result.ok) process.exitCode = 1; +} + +module.exports = { + firstPositional, + openCommand, + openV2FromFiles, + nativeOpenResult, + readNativeReceiptFromArgs, + renderNativeReceipt, + renderOpenV2, + v2OpenLinksForRun +}; diff --git a/agentops-cli/src/lib/otel-setup.js b/agentops-cli/src/lib/otel-setup.js new file mode 100644 index 0000000..ca3ebae --- /dev/null +++ b/agentops-cli/src/lib/otel-setup.js @@ -0,0 +1,234 @@ +const net = require('node:net'); +const { otlpHttpEndpoint } = require('./collector-endpoints'); + +function parseOtelSetupArgs(args = []) { + const options = { + endpoint: otlpHttpEndpoint, + serviceName: 'github-copilot', + shell: 'bash', + captureContent: false, + unsafeDirect: false + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--endpoint') { + if (!args[index + 1]) throw new Error('--endpoint requires a URL'); + options.endpoint = args[index + 1]; + index += 1; + } else if (arg === '--service-name') { + if (!args[index + 1]) throw new Error('--service-name requires a value'); + options.serviceName = args[index + 1]; + index += 1; + } else if (arg === '--shell') { + if (!args[index + 1]) throw new Error('--shell requires bash, powershell, or json'); + options.shell = args[index + 1]; + index += 1; + } else if (arg === '--capture-content') { + options.captureContent = true; + } else if (arg === '--unsafe-direct') { + options.unsafeDirect = true; + } else { + throw new Error(`Unknown otel-setup option: ${arg}`); + } + } + if (!['bash', 'powershell', 'json'].includes(options.shell)) { + throw new Error('--shell must be bash, powershell, or json'); + } + assertOtelEndpoint(options.endpoint, options.unsafeDirect); + return options; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function classifyOtelEndpoint(value) { + if (typeof value !== 'string' || value.trim() === '') { + return { classification: 'malformed', reason: 'endpoint must be a non-empty URL' }; + } + + let parsed; + try { + parsed = new URL(value); + } catch (error) { + return { classification: 'malformed', reason: 'endpoint is not a valid URL' }; + } + + if (parsed.protocol === 'file:') { + return { classification: 'file', protocol: parsed.protocol, hostname: parsed.hostname }; + } + + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname) { + return { classification: 'malformed', reason: 'endpoint must be an HTTP(S) URL without embedded credentials' }; + } + + const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (hostname === 'localhost' || hostname.endsWith('.localhost')) { + return { classification: 'loopback', protocol: parsed.protocol, hostname }; + } + + const ipVersion = net.isIP(hostname); + if (ipVersion === 4) { + const octets = hostname.split('.').map(Number); + if (octets[0] === 127) { + return { classification: 'loopback', protocol: parsed.protocol, hostname }; + } + if (octets[0] === 169 && octets[1] === 254) { + return { classification: 'link-local', protocol: parsed.protocol, hostname }; + } + } else if (ipVersion === 6) { + if (hostname === '::1') { + return { classification: 'loopback', protocol: parsed.protocol, hostname }; + } + const firstHextet = Number.parseInt(hostname.split(':')[0] || '0', 16); + if (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) { + return { classification: 'link-local', protocol: parsed.protocol, hostname }; + } + } + + return { classification: 'public', protocol: parsed.protocol, hostname }; +} + +function assertOtelEndpoint(endpoint, unsafeDirect = false) { + const policy = classifyOtelEndpoint(endpoint); + if (policy.classification !== 'loopback' && !unsafeDirect) { + throw new Error( + `OTLP endpoint classified as ${policy.classification}; only loopback endpoints are allowed by default. ` + + 'Use --unsafe-direct to explicitly opt in to a direct endpoint.' + ); + } + return policy; +} + +function buildOtelSetup(options = {}) { + const endpoint = options.endpoint || otlpHttpEndpoint; + const serviceName = options.serviceName || 'github-copilot'; + const captureContent = Boolean(options.captureContent); + const unsafeDirect = Boolean(options.unsafeDirect); + const endpointPolicy = assertOtelEndpoint(endpoint, unsafeDirect); + const resourceAttributes = [ + 'agent.framework=github-copilot', + `agent.runtime=${serviceName}`, + 'agentops.profile=bring-your-own-otel' + ].join(','); + const nativeEnv = { + COPILOT_OTEL_ENABLED: 'true', + COPILOT_OTEL_EXPORTER_TYPE: 'otlp-http', + COPILOT_OTEL_SOURCE_NAME: 'github.copilot', + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf', + OTEL_SERVICE_NAME: serviceName, + OTEL_RESOURCE_ATTRIBUTES: resourceAttributes, + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: captureContent ? 'true' : 'false' + }; + const env = { + ...nativeEnv, + // Retain the old shape for consumers that inspect JSON, but do not render these aliases. + COPILOT_OTEL_ENDPOINT: endpoint, + COPILOT_OTEL_CAPTURE_CONTENT: captureContent ? 'true' : 'false' + }; + const vscode = { + 'github.copilot.chat.otel.enabled': true, + 'github.copilot.chat.otel.exporterType': 'otlp-http', + 'github.copilot.chat.otel.otlpEndpoint': endpoint, + 'github.copilot.chat.otel.captureContent': captureContent, + 'github.copilot.chat.otel.maxAttributeSizeChars': 0, + 'github.copilot.chat.otel.dbSpanExporter.enabled': false + }; + const fileExport = { + vscode: { + 'github.copilot.chat.otel.enabled': true, + 'github.copilot.chat.otel.exporterType': 'file', + 'github.copilot.chat.otel.outfile': './copilot-otel.jsonl', + 'github.copilot.chat.otel.captureContent': captureContent + }, + env: { + COPILOT_OTEL_ENABLED: 'true', + COPILOT_OTEL_EXPORTER_TYPE: 'file', + COPILOT_OTEL_FILE_EXPORTER_PATH: './copilot-otel.jsonl', + COPILOT_OTEL_SOURCE_NAME: 'github.copilot', + OTEL_SERVICE_NAME: serviceName, + OTEL_RESOURCE_ATTRIBUTES: resourceAttributes, + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: captureContent ? 'true' : 'false', + COPILOT_OTEL_CAPTURE_CONTENT: captureContent ? 'true' : 'false' + } + }; + const sessionExport = { + remoteExport: false, + cliFlag: '--no-remote-export', + settingsPath: '~/.copilot/settings.json', + settings: { remoteExport: false } + }; + const sdkTypescript = `import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient({ + telemetry: { + otlpEndpoint: "${endpoint}", + exporterType: "otlp-http", + sourceName: "github.copilot", + captureContent: ${captureContent} + } +});`; + + return { + endpoint, + serviceName, + captureContent, + unsafeDirect, + endpointPolicy, + nativeEnv, + env, + vscode, + fileExport, + sessionExport, + sdkTypescript + }; +} + +function renderOtelSetup(setup, options = {}) { + if (options.shell === 'json') return `${JSON.stringify(setup, null, 2)}\n`; + const lines = [ + 'AgentOps bring-your-own-OTel setup', + '', + 'VS Code settings.json:', + JSON.stringify(setup.vscode, null, 2), + '', + 'Copilot CLI native OTel environment:' + ]; + + if (options.shell === 'powershell') { + for (const [key, value] of Object.entries(setup.nativeEnv || setup.env)) { + lines.push(`$env:${key} = "${String(value).replace(/"/g, '`"')}"`); + } + } else { + for (const [key, value] of Object.entries(setup.nativeEnv || setup.env)) { + lines.push(`export ${key}=${shellQuote(value)}`); + } + } + + lines.push( + '', + 'Copilot SDK TypeScript:', + setup.sdkTypescript, + '', + 'Optional JSONL file export for offline review:', + JSON.stringify(setup.fileExport, null, 2), + '', + 'Run Copilot with the environment above and use your OTLP/HTTP collector as the destination.', + 'agentops compat-check --last 2h', + '', + 'Session export controls are separate from OTel; --no-remote is not proof that session data cannot be exported.', + 'For a session-local run, use copilot --no-remote-export or set {"remoteExport": false} in ~/.copilot/settings.json.', + 'The direct-endpoint safety policy is loopback-only by default; use --unsafe-direct only when you have reviewed the destination.' + ); + + return `${lines.join('\n')}\n`; +} + +module.exports = { + assertOtelEndpoint, + buildOtelSetup, + classifyOtelEndpoint, + parseOtelSetupArgs, + renderOtelSetup +}; diff --git a/agentops-cli/src/lib/otel/genai-normalizer.js b/agentops-cli/src/lib/otel/genai-normalizer.js index a98c00b..6fc4e4d 100644 --- a/agentops-cli/src/lib/otel/genai-normalizer.js +++ b/agentops-cli/src/lib/otel/genai-normalizer.js @@ -1,8 +1,8 @@ -const crypto = require('node:crypto'); +const { hashText } = require('../hash'); function hashValue(value) { if (!value) return ''; - return crypto.createHash('sha256').update(String(value)).digest('hex'); + return hashText(String(value)); } function normalizeGenAiAttributes(attributes = {}, defaults = {}) { diff --git a/agentops-cli/src/lib/otel/mcp-normalizer.js b/agentops-cli/src/lib/otel/mcp-normalizer.js index d33285a..dec4768 100644 --- a/agentops-cli/src/lib/otel/mcp-normalizer.js +++ b/agentops-cli/src/lib/otel/mcp-normalizer.js @@ -1,7 +1,7 @@ -const crypto = require('node:crypto'); +const { hashText } = require('../hash'); function hashJson(value) { - return crypto.createHash('sha256').update(JSON.stringify(value || {})).digest('hex'); + return hashText(JSON.stringify(value || {})); } function classifyMcpToolRisk(toolName = '', metadata = {}) { diff --git a/agentops-cli/src/lib/paths.js b/agentops-cli/src/lib/paths.js index ecc9b5d..bd73e78 100644 --- a/agentops-cli/src/lib/paths.js +++ b/agentops-cli/src/lib/paths.js @@ -1,10 +1,11 @@ const os = require('node:os'); const fs = require('node:fs'); const path = require('node:path'); +const { readJson } = require('./json'); function readPackageName(filePath) { try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')).name; + return readJson(filePath).name; } catch { return null; } @@ -37,6 +38,10 @@ const defaultInstallDir = path.join(os.homedir(), '.local', 'bin'); const agentopsHome = process.env.AGENTOPS_HOME || path.join(os.homedir(), '.agentops'); const collectorHome = process.env.AGENTOPS_COLLECTOR_HOME || path.join(agentopsHome, 'collector'); +function defaultUserAgentOpsPath(relativePath, homeDir = os.homedir()) { + return path.join(homeDir, '.agentops', relativePath); +} + function repoPath(...parts) { return path.join(repoRoot, ...parts); } @@ -51,6 +56,7 @@ module.exports = { collectorConfigPath, collectorDir, collectorHome, + defaultUserAgentOpsPath, copilotDir, defaultInstallDir, packageRoot, diff --git a/agentops-cli/src/lib/planned-command.js b/agentops-cli/src/lib/planned-command.js new file mode 100644 index 0000000..65a6bdc --- /dev/null +++ b/agentops-cli/src/lib/planned-command.js @@ -0,0 +1,31 @@ +const plannedCommandNames = Object.freeze([ + 'install', + 'enable-shadow', + 'disable-shadow', + 'uninstall', + 'collector', + 'start', + 'stop', + 'copilot', + 'codex' +]); + +function createPlannedCommand(dependencies = {}) { + const { + commandPlan, + runPlannedCommand + } = dependencies; + + function plannedCommand(command, args) { + runPlannedCommand(commandPlan(command, args)); + } + + return { + plannedCommand, + plannedCommandNames + }; +} + +module.exports = { + createPlannedCommand +}; diff --git a/agentops-cli/src/lib/plugin-asset-command.js b/agentops-cli/src/lib/plugin-asset-command.js new file mode 100644 index 0000000..c107b14 --- /dev/null +++ b/agentops-cli/src/lib/plugin-asset-command.js @@ -0,0 +1,112 @@ +const { writeJson, writeJsonOrRender } = require('./command-output'); + +const pluginAssetCommandNames = Object.freeze(['plugin', 'agents', 'skills']); + +function createPluginAssetCommand(dependencies = {}) { + const { + agentInstallTarget, + installDefaultAgents, + installDefaultSkills, + installPlugin, + listDefaultAgents, + listDefaultSkills, + parseSkillsArgs, + renderAgentsInstall, + renderAgentsUninstall, + renderPluginInstall, + renderPluginUninstall, + renderSkillsInstall, + renderSkillsUninstall, + skillInstallTarget, + stdout = process.stdout, + uninstallDefaultAgents, + uninstallDefaultSkills, + uninstallPlugin + } = dependencies; + + function pluginCommand(args) { + const options = parseSkillsArgs(args); + if (options.subcommand === 'install') { + const result = installPlugin(options); + writeJsonOrRender(result, options.json, renderPluginInstall, stdout); + return; + } + if (options.subcommand === 'uninstall' || options.subcommand === 'remove') { + const result = uninstallPlugin(options); + writeJsonOrRender(result, options.json, renderPluginUninstall, stdout); + return; + } + throw new Error('plugin requires install or uninstall'); + } + + function agentsCommand(args) { + const options = parseSkillsArgs(args); + if (options.subcommand === 'list') { + writeJson({ agents: listDefaultAgents() }, stdout); + return; + } + if (options.subcommand === 'path') { + stdout.write(`${agentInstallTarget(options).targetDir}\n`); + return; + } + if (options.subcommand === 'install') { + const result = installDefaultAgents(options); + writeJsonOrRender(result, options.json, renderAgentsInstall, stdout); + return; + } + if (options.subcommand === 'uninstall' || options.subcommand === 'remove') { + const result = uninstallDefaultAgents(options); + writeJsonOrRender(result, options.json, renderAgentsUninstall, stdout); + return; + } + throw new Error('agents requires list, path, install, or uninstall'); + } + + function skillsCommand(args) { + const options = parseSkillsArgs(args); + if (options.subcommand === 'list') { + writeJson({ skills: listDefaultSkills() }, stdout); + return; + } + if (options.subcommand === 'path') { + stdout.write(`${skillInstallTarget(options).targetDir}\n`); + return; + } + if (options.subcommand === 'install') { + const result = installDefaultSkills(options); + writeJsonOrRender(result, options.json, renderSkillsInstall, stdout); + return; + } + if (options.subcommand === 'uninstall' || options.subcommand === 'remove') { + const result = uninstallDefaultSkills(options); + writeJsonOrRender(result, options.json, renderSkillsUninstall, stdout); + return; + } + throw new Error('skills requires list, path, install, or uninstall'); + } + + function pluginAssetCommand(command, args) { + if (command === 'plugin') { + pluginCommand(args); + return; + } + if (command === 'agents') { + agentsCommand(args); + return; + } + if (command === 'skills') { + skillsCommand(args); + return; + } + throw new Error(`Unknown plugin asset command: ${command}`); + } + + return { + pluginAssetCommand, + pluginAssetCommandNames + }; +} + +module.exports = { + createPluginAssetCommand +}; diff --git a/agentops-cli/src/lib/plugin-assets.js b/agentops-cli/src/lib/plugin-assets.js new file mode 100644 index 0000000..29f2eab --- /dev/null +++ b/agentops-cli/src/lib/plugin-assets.js @@ -0,0 +1,347 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { repoRoot } = require('./paths'); + +const root = repoRoot; + +function parseFrontmatter(filePath) { + const text = fs.readFileSync(filePath, 'utf8'); + if (!text.startsWith('---')) return {}; + const end = text.indexOf('\n---', 3); + if (end === -1) return {}; + const yaml = text.slice(3, end).trim(); + const data = {}; + + for (const line of yaml.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!match) continue; + const value = match[2].trim().replace(/^['"]|['"]$/g, ''); + data[match[1]] = value; + } + + return data; +} + +function defaultCopilotHome() { + return process.env.AGENTOPS_COPILOT_HOME || process.env.COPILOT_HOME || path.join(os.homedir(), '.copilot'); +} + +function listDefaultSkills(sourceDir = path.join(root, 'plugin', 'skills')) { + if (!fs.existsSync(sourceDir)) return []; + + return fs.readdirSync(sourceDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => { + const skillDir = path.join(sourceDir, entry.name); + const skillFile = path.join(skillDir, 'SKILL.md'); + if (!fs.existsSync(skillFile)) return null; + const frontmatter = parseFrontmatter(skillFile); + return { + name: frontmatter.name || entry.name, + directory: entry.name, + description: frontmatter.description || '', + source: skillFile + }; + }) + .filter(Boolean) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function listDefaultAgents(sourceDir = path.join(root, 'plugin', 'agents')) { + if (!fs.existsSync(sourceDir)) return []; + + return fs.readdirSync(sourceDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.agent.md')) + .map(entry => { + const agentFile = path.join(sourceDir, entry.name); + const frontmatter = parseFrontmatter(agentFile); + return { + name: frontmatter.name || entry.name.replace(/\.agent\.md$/, ''), + file: entry.name, + description: frontmatter.description || '', + source: agentFile + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function skillInstallTarget(options = {}) { + const copilotHome = path.resolve(options.copilotHome || defaultCopilotHome()); + const targetDir = path.resolve(options.skillsDir || path.join(copilotHome, 'skills')); + return { copilotHome, targetDir }; +} + +function agentInstallTarget(options = {}) { + const copilotHome = path.resolve(options.copilotHome || defaultCopilotHome()); + const targetDir = path.resolve(options.agentsDir || path.join(copilotHome, 'agents')); + return { copilotHome, targetDir }; +} + +function installDefaultSkills(options = {}) { + const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'skills')); + const { copilotHome, targetDir } = skillInstallTarget(options); + const force = Boolean(options.force); + const dryRun = Boolean(options.dryRun); + const skills = listDefaultSkills(sourceDir); + const installedSkills = []; + const updated = []; + const skipped = []; + + if (!dryRun) fs.mkdirSync(targetDir, { recursive: true }); + + for (const skill of skills) { + const sourceSkillDir = path.dirname(skill.source); + const targetSkillDir = path.join(targetDir, skill.directory); + const targetExists = fs.existsSync(targetSkillDir); + + if (targetExists && !force) { + skipped.push({ name: skill.name, target: targetSkillDir, reason: 'exists' }); + continue; + } + + if (!dryRun) { + if (targetExists) fs.rmSync(targetSkillDir, { recursive: true, force: true }); + fs.cpSync(sourceSkillDir, targetSkillDir, { recursive: true }); + } + + const record = { name: skill.name, target: targetSkillDir }; + if (targetExists) updated.push(record); + else installedSkills.push(record); + } + + return { + copilotHome, + targetDir, + sourceDir, + force, + dryRun, + skills, + installed: installedSkills.length, + installedSkills, + updated, + skipped + }; +} + +function uninstallDefaultSkills(options = {}) { + const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'skills')); + const { copilotHome, targetDir } = skillInstallTarget(options); + const dryRun = Boolean(options.dryRun); + const skills = listDefaultSkills(sourceDir); + const removed = []; + const missing = []; + + for (const skill of skills) { + const targetSkillDir = path.join(targetDir, skill.directory); + if (!fs.existsSync(targetSkillDir)) { + missing.push({ name: skill.name, target: targetSkillDir }); + continue; + } + + if (!dryRun) fs.rmSync(targetSkillDir, { recursive: true, force: true }); + removed.push({ name: skill.name, target: targetSkillDir }); + } + + return { + copilotHome, + targetDir, + sourceDir, + dryRun, + skills, + removed, + missing + }; +} + +function installDefaultAgents(options = {}) { + const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'agents')); + const { copilotHome, targetDir } = agentInstallTarget(options); + const force = Boolean(options.force); + const dryRun = Boolean(options.dryRun); + const agents = listDefaultAgents(sourceDir); + const installedAgents = []; + const updated = []; + const skipped = []; + + if (!dryRun) fs.mkdirSync(targetDir, { recursive: true }); + + for (const agent of agents) { + const targetFile = path.join(targetDir, agent.file); + const targetExists = fs.existsSync(targetFile); + + if (targetExists && !force) { + skipped.push({ name: agent.name, target: targetFile, reason: 'exists' }); + continue; + } + + if (!dryRun) { + fs.copyFileSync(agent.source, targetFile); + } + + const record = { name: agent.name, target: targetFile }; + if (targetExists) updated.push(record); + else installedAgents.push(record); + } + + return { + copilotHome, + targetDir, + sourceDir, + force, + dryRun, + agents, + installed: installedAgents.length, + installedAgents, + updated, + skipped + }; +} + +function uninstallDefaultAgents(options = {}) { + const sourceDir = path.resolve(options.sourceDir || path.join(root, 'plugin', 'agents')); + const { copilotHome, targetDir } = agentInstallTarget(options); + const dryRun = Boolean(options.dryRun); + const agents = listDefaultAgents(sourceDir); + const removed = []; + const missing = []; + + for (const agent of agents) { + const targetFile = path.join(targetDir, agent.file); + if (!fs.existsSync(targetFile)) { + missing.push({ name: agent.name, target: targetFile }); + continue; + } + + if (!dryRun) fs.rmSync(targetFile, { force: true }); + removed.push({ name: agent.name, target: targetFile }); + } + + return { + copilotHome, + targetDir, + sourceDir, + dryRun, + agents, + removed, + missing + }; +} + +function plural(count, singular, pluralValue = `${singular}s`) { + return `${count} ${count === 1 ? singular : pluralValue}`; +} + +function renderSkillsInstall(result) { + const lines = [ + `Installed AgentOps skills into ${result.targetDir}.`, + `${plural(result.installed, 'new skill')}; ${plural(result.updated.length, 'updated skill')}; skipped ${plural(result.skipped.length, 'existing skill')}.` + ]; + + if (result.skills.length > 0) { + lines.push('', 'Available skills:'); + for (const skill of result.skills) lines.push(`- ${skill.name}`); + const starterSkill = result.skills.find(skill => skill.name === 'agentops-live-triage') || result.skills[0]; + lines.push('', `Ask Copilot: Use ${starterSkill.name} to inspect the latest AgentOps run.`); + } + + if (result.skipped.length > 0) { + lines.push('Run `agentops skills install --force` to refresh skipped skills from this repo.'); + } + + return `${lines.join('\n')}\n`; +} + +function renderSkillsUninstall(result) { + const lines = [ + `Removed AgentOps skills from ${result.targetDir}.`, + `${plural(result.removed.length, 'skill')} removed; ${plural(result.missing.length, 'skill')} already absent.` + ]; + return `${lines.join('\n')}\n`; +} + +function renderAgentsInstall(result) { + const lines = [ + `Installed AgentOps agents into ${result.targetDir}.`, + `${plural(result.installed, 'new agent')}; ${plural(result.updated.length, 'updated agent')}; skipped ${plural(result.skipped.length, 'existing agent')}.` + ]; + + if (result.agents.length > 0) { + lines.push('', 'Available agents:'); + for (const agent of result.agents) lines.push(`- ${agent.name}`); + const starterAgent = result.agents.find(agent => agent.name === 'agentops-orchestrator') || result.agents[0]; + lines.push('', `Ask Copilot: Use ${starterAgent.name} to route my AgentOps question.`); + } + + if (result.skipped.length > 0) { + lines.push('Run `agentops agents install --force` to refresh skipped agents from this repo.'); + } + + return `${lines.join('\n')}\n`; +} + +function renderAgentsUninstall(result) { + const lines = [ + `Removed AgentOps agents from ${result.targetDir}.`, + `${plural(result.removed.length, 'agent')} removed; ${plural(result.missing.length, 'agent')} already absent.` + ]; + return `${lines.join('\n')}\n`; +} + +function installPlugin(options = {}) { + return { + agents: installDefaultAgents(options), + skills: installDefaultSkills(options) + }; +} + +function uninstallPlugin(options = {}) { + return { + agents: uninstallDefaultAgents(options), + skills: uninstallDefaultSkills(options) + }; +} + +function renderPluginInstall(result) { + const lines = [ + 'Installed AgentOps Copilot plugin files.', + `Agents: ${plural(result.agents.installed, 'new agent')}; ${plural(result.agents.updated.length, 'updated agent')}; skipped ${plural(result.agents.skipped.length, 'existing agent')}.`, + `Skills: ${plural(result.skills.installed, 'new skill')}; ${plural(result.skills.updated.length, 'updated skill')}; skipped ${plural(result.skills.skipped.length, 'existing skill')}.`, + '', + 'Ask Copilot: Use agentops-orchestrator to run the first read-only AgentOps check.', + 'Remove later with `agentops plugin uninstall`.' + ]; + return `${lines.join('\n')}\n`; +} + +function renderPluginUninstall(result) { + const lines = [ + 'Removed AgentOps Copilot plugin files.', + `Agents: ${plural(result.agents.removed.length, 'agent')} removed; ${plural(result.agents.missing.length, 'agent')} already absent.`, + `Skills: ${plural(result.skills.removed.length, 'skill')} removed; ${plural(result.skills.missing.length, 'skill')} already absent.` + ]; + return `${lines.join('\n')}\n`; +} + +module.exports = { + agentInstallTarget, + defaultCopilotHome, + installDefaultAgents, + installDefaultSkills, + installPlugin, + listDefaultAgents, + listDefaultSkills, + parseFrontmatter, + plural, + renderAgentsInstall, + renderAgentsUninstall, + renderPluginInstall, + renderPluginUninstall, + renderSkillsInstall, + renderSkillsUninstall, + skillInstallTarget, + uninstallDefaultAgents, + uninstallDefaultSkills, + uninstallPlugin +}; diff --git a/agentops-cli/src/lib/privacy.js b/agentops-cli/src/lib/privacy.js index 975481e..850b0fb 100644 --- a/agentops-cli/src/lib/privacy.js +++ b/agentops-cli/src/lib/privacy.js @@ -52,6 +52,9 @@ const safeAttributeKeys = [ 'agentops.parent_agent.name', 'agentops.sub_agent.name', 'agentops.delegation.id', + 'agentops.subagent.duration_ms', + 'agentops.subagent.total_tokens', + 'agentops.subagent.tool_count', 'agentops.skill.name', 'agentops.skill.hash', 'agentops.workflow.name', @@ -143,7 +146,7 @@ function makePoisonAttributes(id = `agentops-poison-${crypto.randomBytes(4).toSt 'http.request.body.content': 'SECRET_BODY_SHOULD_NOT_LEAVE', 'http.response.body.content': 'SECRET_BODY_SHOULD_NOT_LEAVE', 'url.full': 'https://example.test/path?token=SECRET_URL_SHOULD_NOT_LEAVE', - 'code.filepath': '/Users/conor/private/customer/repo/file.ts', + 'code.filepath': '/workspace/example/repository/file.ts', 'unknown.future.content.field': 'SECRET_UNKNOWN_SHOULD_NOT_LEAVE' }; } diff --git a/agentops-cli/src/lib/product-audit-checks.js b/agentops-cli/src/lib/product-audit-checks.js new file mode 100644 index 0000000..00f2112 --- /dev/null +++ b/agentops-cli/src/lib/product-audit-checks.js @@ -0,0 +1,46 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { repoRoot } = require('./paths'); + +function resolvedProductPath(relativePath, root = repoRoot) { + const direct = path.join(root, relativePath); + if (fs.existsSync(direct)) return direct; + if (relativePath.startsWith('agentops-cli/')) { + const packageLocal = path.join(root, relativePath.slice('agentops-cli/'.length)); + if (fs.existsSync(packageLocal)) return packageLocal; + } + return direct; +} + +function exists(relativePath, root = repoRoot) { + return fs.existsSync(resolvedProductPath(relativePath, root)); +} + +function fileIncludes(relativePath, terms, root = repoRoot) { + if (!exists(relativePath, root)) return false; + const body = fs.readFileSync(resolvedProductPath(relativePath, root), 'utf8'); + return terms.every(term => body.includes(term)); +} + +function check(name, ok, evidence = [], missing = []) { + return { + name, + ok: Boolean(ok), + evidence, + missing + }; +} + +function requiredFilesCheck(name, files, root = repoRoot) { + const missing = files.filter(file => !exists(file, root)); + return check(name, missing.length === 0, files.filter(file => !missing.includes(file)), missing); +} + +module.exports = { + check, + exists, + fileIncludes, + resolvedProductPath, + requiredFilesCheck +}; diff --git a/agentops-cli/src/lib/product-audit-render.js b/agentops-cli/src/lib/product-audit-render.js new file mode 100644 index 0000000..f83ebc1 --- /dev/null +++ b/agentops-cli/src/lib/product-audit-render.js @@ -0,0 +1,27 @@ +function renderProductAudit(result) { + const lines = [ + 'AgentOps product audit', + '', + `Result: ${result.ok ? 'pass' : 'needs work'}.`, + `Local checks: ${result.summary.passed}/${result.summary.checks} passed.`, + `Dashboards: ${result.summary.v2_dashboards}; links checked: ${result.summary.checked_links}.`, + `Live Azure verified: ${result.live_azure_verified ? 'yes' : 'not in this audit'}.`, + `Live Grafana verified: ${result.live_grafana_verified ? 'yes' : 'not in this audit'}.`, + `Visual Grafana verified: ${result.visual_grafana_verified ? 'yes' : result.summary.visual_dashboards ? 'no' : 'not in this audit'}.`, + '', + 'Checks:' + ]; + for (const item of result.checks) { + lines.push(`- ${item.ok ? 'PASS' : 'FAIL'} ${item.name}`); + if (!item.ok && item.missing.length) { + lines.push(` Missing: ${item.missing.slice(0, 5).join(', ')}${item.missing.length > 5 ? ', ...' : ''}`); + } + } + lines.push('', 'Next:'); + for (const command of result.next) lines.push(`- ${command}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + renderProductAudit +}; diff --git a/agentops-cli/src/lib/product-audit-visual.js b/agentops-cli/src/lib/product-audit-visual.js new file mode 100644 index 0000000..bb4d806 --- /dev/null +++ b/agentops-cli/src/lib/product-audit-visual.js @@ -0,0 +1,141 @@ +const path = require('node:path'); + +const { repoRoot } = require('./paths'); +const { + validateVisualEvidence, + visualAuditRecoveryCommands +} = require('./product-visual'); + +function check(name, ok, evidence = [], missing = []) { + return { + name, + ok: Boolean(ok), + evidence, + missing + }; +} + +async function productAuditWithVisual(options = {}, { + productAudit, + browserCheck, + validateEvidence = validateVisualEvidence, + recoveryCommands = visualAuditRecoveryCommands +} = {}) { + const result = productAudit(options); + if (!options.requireVisual) return result; + + const defaultReportPath = path.join(repoRoot, '.agentops', 'e2e', 'latest', 'report.html'); + if (options.visualEvidencePath) { + const evidence = validateEvidence(options.visualEvidencePath); + const visualCheck = check( + 'visual-grafana-rendered-dashboards', + evidence.ok, + evidence.visible, + evidence.missing + ); + const checks = [...result.checks, visualCheck]; + const failed = checks.filter(item => !item.ok); + return { + ...result, + ok: failed.length === 0, + scope: options.live ? 'local-live-and-visual-product-contract' : 'local-and-visual-product-contract', + visual_grafana_verified: evidence.ok, + summary: { + ...result.summary, + checks: checks.length, + passed: checks.length - failed.length, + failed: failed.length, + visual_dashboards: evidence.dashboards.length, + visual_dashboards_visible: evidence.visible.length + }, + checks, + visual: { + ok: evidence.ok, + evidencePath: evidence.evidencePath, + status: 'evidence-file', + dashboards: evidence.dashboards + }, + next: evidence.ok + ? result.next + : [ + 'Regenerate authenticated Grafana visual evidence from a signed-in browser.', + ...recoveryCommands(options.reportPath || defaultReportPath) + ] + }; + } + + const reportPath = options.reportPath || defaultReportPath; + const browserArgs = [ + '--report', + reportPath, + '--playwright', + '--grafana', + '--grafana-v2-only', + '--require-grafana-visible' + ]; + for (const [flag, value] of [ + ['--browser-executable', options.browserExecutable], + ['--browser-user-data-dir', options.browserUserDataDir], + ['--storage-state', options.storageState] + ]) { + if (value) browserArgs.push(flag, value); + } + if (options.headed) browserArgs.push('--headed'); + + let visual; + try { + visual = await browserCheck(browserArgs); + } catch (error) { + visual = { ok: false, error: error.message }; + } + const grafanaItems = visual.playwright?.grafana || []; + const authBlocked = grafanaItems.filter(item => item.authBlocked).map(item => item.label); + const visible = grafanaItems.filter(item => item.dashboardVisible).map(item => item.label); + const visualVerified = Boolean(visual.ok) && grafanaItems.length > 0 && visible.length === grafanaItems.length; + const visualCheck = check( + 'visual-grafana-rendered-dashboards', + visualVerified, + visible, + visualVerified ? [] : [ + visual.error || visual.playwright?.authRemediation?.reason || 'Grafana dashboards did not render in the browser profile', + grafanaItems.length === 0 ? 'No Grafana dashboards were rendered by the visual browser check.' : '', + ...authBlocked.map(label => `${label}: auth-blocked`) + ].filter(Boolean) + ); + + const checks = [...result.checks, visualCheck]; + const failed = checks.filter(item => !item.ok); + const recovery = visual.playwright?.authRemediation + ? [ + ...(visual.playwright.authRemediation.sign_in_once || []), + ...(visual.playwright.authRemediation.verify_after_sign_in || []) + ] + : recoveryCommands(reportPath); + + return { + ...result, + ok: failed.length === 0, + scope: options.live ? 'local-live-and-visual-product-contract' : 'local-and-visual-product-contract', + visual_grafana_verified: visualVerified, + summary: { + ...result.summary, + checks: checks.length, + passed: checks.length - failed.length, + failed: failed.length, + visual_dashboards: grafanaItems.length, + visual_dashboards_visible: visible.length + }, + checks, + visual, + next: visualVerified + ? result.next + : [ + ...recovery, + 'agentops product audit --live --last 2h --require-rows --require-visual --json' + ] + }; +} + +module.exports = { + productAuditWithVisual +}; diff --git a/agentops-cli/src/lib/product-audit.js b/agentops-cli/src/lib/product-audit.js new file mode 100644 index 0000000..e14476d --- /dev/null +++ b/agentops-cli/src/lib/product-audit.js @@ -0,0 +1,519 @@ +const { validateDashboardLinks, validateDashboardUx, validateDashboards } = require('./dashboard-validation'); +const { repoRoot } = require('./paths'); +const { validateWrapperContract } = require('./copilot/wrapper-contract'); +const { validateCopilotOtelFixtureContract } = require('./copilot/fixture-contract'); +const { check, fileIncludes, requiredFilesCheck } = require('./product-audit-checks'); + +function defaultDashboardVerify(args, options) { + return require('./dashboard-verify').dashboardVerify(args, options); +} + +function defaultValidateAzure(options) { + return require('../legacy').validateAzure(options); +} + +function productAudit(options = {}) { + const live = Boolean(options.live); + const last = options.last || '24h'; + const requireRows = Boolean(options.requireRows); + const runDashboardVerify = options.dashboardVerify || defaultDashboardVerify; + const runValidateAzure = options.validateAzure || defaultValidateAzure; + const checks = []; + + checks.push(requiredFilesCheck('agent-run-schema', [ + 'docs/agent-run-data-model.md', + 'docs/otel-genai-mcp-schema.md', + 'agentops-cli/src/lib/schema/agent-run-schema.js', + 'agentops-cli/src/lib/schema/agentops-attributes.js', + 'agentops-cli/src/lib/otel/genai-normalizer.js', + 'agentops-cli/src/lib/otel/mcp-normalizer.js' + ])); + + checks.push(requiredFilesCheck('strict-privacy-pipeline', [ + 'collector/processors/strict-allowlist.yaml', + 'collector/processors/content-signal.yaml', + 'collector/processors/genai-normalizer.yaml', + 'collector/processors/mcp-normalizer.yaml', + 'collector/processors/span-to-run-summary.yaml', + 'collector/release-cadence.json', + 'collector/security-fixtures/privacy-poison-fixtures/content-poison.json', + 'agentops-cli/src/commands/content.js', + 'agentops-cli/src/lib/content-command.js', + 'agentops-cli/src/lib/content-status.js', + 'agentops-cli/src/lib/privacy.js', + 'agentops-cli/src/lib/azure/v2-ingest-plan.js', + 'docs/privacy-threat-model-v2.md' + ])); + + checks.push(check( + 'privacy-defaults', + fileIncludes('README.md', ['without recording prompts', 'tool arguments', 'tool results by default']) + && fileIncludes('agentops-cli/src/lib/copilot/run-metadata.js', ['promptHash', 'commandHash']) + && fileIncludes('copilot/copilot-observe', ['capture_content_enabled="${AGENTOPS_CAPTURE_CONTENT:-false}"', 'COPILOT_OTEL_CAPTURE_CONTENT="false"']) + && fileIncludes('copilot/copilot-observe.ps1', ['$captureContentEnabled', 'COPILOT_OTEL_CAPTURE_CONTENT = "false"']), + [ + 'README.md', + 'agentops-cli/src/lib/copilot/run-metadata.js', + 'copilot/copilot-observe', + 'copilot/copilot-observe.ps1' + ], + [] + )); + + const wrapperContract = validateWrapperContract(repoRoot); + checks.push(check( + 'copilot-wrapper-sync-contract', + wrapperContract.ok, + wrapperContract.files, + wrapperContract.missing + )); + + checks.push(requiredFilesCheck('copilot-cli-surface', [ + 'agentops-cli/src/commands/copilot.js', + 'agentops-cli/src/lib/copilot/command.js', + 'agentops-cli/src/lib/copilot/resolve-real-copilot.js', + 'agentops-cli/src/lib/copilot/run-metadata.js', + 'agentops-cli/src/lib/copilot/flag-contract.js', + 'agentops-cli/src/lib/copilot/fixture-contract.js', + 'agentops-cli/src/lib/copilot/session-parser.js', + 'agentops-cli/src/lib/copilot/tool-classifier.js', + 'agentops-cli/src/lib/copilot/run-summary.js', + 'docs/copilot-cli-instrumentation.md', + 'docs/copilot-cli-flag-contract.md' + ])); + + const copilotFixture = validateCopilotOtelFixtureContract(); + checks.push(check( + 'real-copilot-otel-fixture-contract', + copilotFixture.ok && fileIncludes('docs/telemetry-schema.md', ['copilot-cli-wrapper-snapshot.ndjson.fixture', 'contract fixture']), + [ + 'fixtures/sample-otel/copilot-cli-wrapper-snapshot.ndjson.fixture', + 'agentops-cli/src/lib/copilot/fixture-contract.js', + 'docs/telemetry-schema.md' + ], + copilotFixture.mismatches + )); + + checks.push(requiredFilesCheck('copilot-sdk-adapter', [ + 'packages/agentops-copilot-sdk/package.json', + 'packages/agentops-copilot-sdk/src/index.js', + 'packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js', + 'packages/agentops-copilot-sdk/src/hooks.js', + 'packages/agentops-copilot-sdk/src/otel.js', + 'packages/agentops-copilot-sdk/src/privacy.js', + 'packages/agentops-copilot-sdk/src/event-envelope.js', + 'packages/agentops-copilot-sdk/src/session-events.js', + 'packages/agentops-copilot-sdk/src/otlp-exporter.js', + 'packages/agentops-copilot-sdk/src/index.d.ts', + 'packages/agentops-copilot-sdk/examples/basic-sdk-agent/index.js', + 'docs/copilot-sdk-adapter.md' + ])); + + const sdkOrderedEventsOk = fileIncludes('packages/agentops-copilot-sdk/src/event-envelope.js', [ + 'createSafeEventNormalizer', + 'Sequence', + 'ParentEventId', + 'SchemaVersion', + 'ReasoningTokens', + 'EstimatedCostUsd', + 'ContentDroppedBytes', + 'LinesAdded', + 'agentops.event.sequence', + 'agentops.parent_event_id' + ]) + && fileIncludes('packages/agentops-copilot-sdk/src/session-events.js', [ + 'assistant.usage', + 'subagent.started', + 'skill.invoked', + 'mcpServerName', + 'ContentAction', + 'session.on(handler)' + ]) + && fileIncludes('packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js', [ + 'onEvent', + 'captureContent must remain false', + 'resumeAgentOpsSession', + 'flushAgentOpsTelemetry' + ]) + && fileIncludes('packages/agentops-copilot-sdk/src/otlp-exporter.js', [ + '/v1/traces', + 'otelAttributeMap', + 'requires HTTPS or a loopback HTTP endpoint' + ]) + && fileIncludes('packages/agentops-copilot-sdk/src/hooks.js', [ + 'onErrorOccurred', + 'onPostToolUseFailure' + ]) + && fileIncludes('docs/copilot-sdk-adapter.md', [ + 'early `onEvent`', + '`captureContent=true` is rejected', + '`CopilotCost`' + ]); + checks.push(check( + 'copilot-sdk-ordered-events-contract', + sdkOrderedEventsOk, + [ + 'packages/agentops-copilot-sdk/src/session-events.js', + 'packages/agentops-copilot-sdk/src/event-envelope.js', + 'packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js', + 'packages/agentops-copilot-sdk/src/otlp-exporter.js', + 'packages/agentops-copilot-sdk/src/hooks.js', + 'docs/copilot-sdk-adapter.md' + ], + sdkOrderedEventsOk ? [] : ['ordered SDK event/privacy contract is incomplete'] + )); + + checks.push(requiredFilesCheck('mcp-observability-proxy', [ + 'agentops-cli/src/commands/mcp-proxy.js', + 'agentops-cli/src/lib/mcp-proxy-command.js', + 'agentops-cli/src/lib/mcp/proxy-stdio.js', + 'agentops-cli/src/lib/mcp/proxy-http.js', + 'agentops-cli/src/lib/mcp/risk-classifier.js', + 'agentops-cli/src/lib/mcp/redactor.js', + 'agentops-cli/src/lib/mcp/trace-context.js', + 'docs/mcp-observability-proxy.md', + 'examples/mcp-proxy/demo-server.js' + ])); + + checks.push(requiredFilesCheck('github-outcomes', [ + 'agentops-cli/src/commands/github-enrich.js', + 'agentops-cli/src/lib/github-enrich-command.js', + 'agentops-cli/src/lib/github/outcome-enricher.js', + 'agentops-cli/src/lib/github/pr-mapper.js', + 'agentops-cli/src/lib/github/actions-mapper.js', + 'agentops-cli/src/lib/github/revert-detector.js', + 'docs/github-outcome-enrichment.md' + ])); + + checks.push(requiredFilesCheck('evals-insights-recommendations', [ + 'agentops-cli/src/commands/explain.js', + 'agentops-cli/src/commands/insights.js', + 'agentops-cli/src/commands/recommend.js', + 'agentops-cli/src/commands/triage.js', + 'agentops-cli/src/lib/explain-command.js', + 'agentops-cli/src/lib/insights-command.js', + 'agentops-cli/src/lib/recommend-command.js', + 'agentops-cli/src/lib/triage-command.js', + 'agentops-cli/src/lib/schema/recommendation-schema.js', + 'agentops-cli/src/lib/evals/test-discipline.js', + 'agentops-cli/src/lib/evals/tool-efficiency.js', + 'agentops-cli/src/lib/evals/security.js', + 'agentops-cli/src/lib/evals/reliability.js', + 'agentops-cli/src/lib/evals/code-outcome.js', + 'agentops-cli/src/lib/insights/outlier-detector.js', + 'agentops-cli/src/lib/insights/regression-detector.js', + 'docs/evals-and-insights.md' + ])); + + checks.push(requiredFilesCheck('grafana-v2-pack', [ + 'grafana/dashboards/v2/01-agentops-home.json', + 'grafana/dashboards/v2/02-runs-explorer.json', + 'grafana/dashboards/v2/03-run-replay.json', + 'grafana/dashboards/v2/04-models-cost-tokens.json', + 'grafana/dashboards/v2/05-tools-mcp-risk.json', + 'grafana/dashboards/v2/06-safety-privacy-policy.json', + 'grafana/dashboards/v2/07-code-outcomes.json', + 'grafana/dashboards/v2/08-evals-quality.json', + 'grafana/dashboards/v2/09-insights-regressions.json', + 'grafana/dashboards/v2/10-collector-health.json', + 'grafana/provisioning/dashboards/agentops-v2.yaml', + 'grafana/provisioning/datasources/azure-monitor.yaml', + 'docs/grafana-ux-spec.md', + 'docs/grafana-dashboard-tour-v2.md', + 'docs/grafana-query-library.md' + ])); + + checks.push(requiredFilesCheck('kql-library', [ + 'grafana/kql/run-summary.kql', + 'grafana/kql/runs-explorer.kql', + 'grafana/kql/run-replay.kql', + 'grafana/kql/tool-risk.kql', + 'grafana/kql/privacy-signals.kql', + 'grafana/kql/code-outcomes.kql', + 'grafana/kql/evals.kql', + 'grafana/kql/insights.kql', + 'grafana/kql/collector-health.kql', + 'grafana/kql/content-viewer.kql' + ])); + + const dashboard = validateDashboards(); + checks.push(check('dashboard-json-contract', dashboard.ok, [`${dashboard.dashboards} dashboard files parsed`], dashboard.errors)); + const links = validateDashboardLinks(); + checks.push(check('dashboard-drilldowns', links.ok, [`${links.checked_links} nav/data links checked`], links.errors)); + const ux = validateDashboardUx(); + checks.push(check('dashboard-operator-ux', ux.ok, ['Home, Runs, Replay, transcript, patterns, recommendations, and empty states checked'], ux.errors)); + checks.push(check( + 'run-centric-ui-contract', + ux.ok + && ux.contracts?.run_centric_ui === true + && fileIncludes('docs/agentops-architecture-product-audit.md', [ + 'Run-Centric UI', + 'Session explorer as first screen', + 'Trace waterfall through Run Story', + 'Ask AgentOps panel' + ]), + [ + 'grafana/dashboards/v2/01-agentops-home.json', + 'grafana/dashboards/v2/02-runs-explorer.json', + 'grafana/dashboards/v2/03-run-replay.json', + 'docs/agentops-architecture-product-audit.md' + ], + ux.errors + )); + + checks.push(check( + 'robust-eval-center-contract', + ux.ok + && ux.contracts?.robust_eval_center === true + && fileIncludes('agentops-cli/src/lib/benchmark-validation.js', [ + 'hiddenCheckPacks', + 'benchmarkPermissionProfiles', + 'macos-network-blocked', + 'container-network-blocked', + 'commandFileSeal', + 'semanticChecks', + 'llm-judge' + ]) + && fileIncludes('agentops-cli/src/lib/benchmark-execution.js', [ + 'commandFileSeal', + 'artifactDiff', + 'runBenchmarkSemanticChecks' + ]) + && fileIncludes('agentops-cli/src/lib/benchmark-invocation.js', [ + '--network', + 'none', + 'benchmarkSandboxProfile', + 'benchmarkCopilotInvocation' + ]) + && fileIncludes('agentops-cli/src/lib/benchmark-report.js', [ + 'promotionApproval' + ]) + && fileIncludes('grafana/dashboards/v2/08-evals-quality.json', [ + 'BenchmarkArtifactContentDiffs', + 'BenchmarkApproval' + ]) + && fileIncludes('docs/agentops-architecture-product-audit.md', [ + 'Robust Eval Center', + 'Hidden check packs', + 'Rubric and semantic scoring', + 'Artifact diffing', + 'Promotion policy', + 'Container runtime network isolation' + ]), + [ + 'agentops-cli/src/lib/benchmark-report.js', + 'agentops-cli/src/lib/benchmark-validation.js', + 'agentops-cli/src/lib/benchmark-execution.js', + 'agentops-cli/src/lib/benchmark-invocation.js', + 'grafana/dashboards/v2/08-evals-quality.json', + 'docs/agentops-architecture-product-audit.md' + ], + ux.errors + )); + + checks.push(check( + 'hosted-llm-judge-deployment', + fileIncludes('benchmark-judges/hosted-judge/server.js', ['metadata-only-hosted-llm-judge', 'POST', '/score', 'OPENAI_API_KEY', 'AGENTOPS_JUDGE_TOKEN']) + && fileIncludes('benchmark-judges/hosted-judge/Dockerfile', ['node:22-alpine', 'server.js']) + && fileIncludes('infra/bicep/hosted-judge.bicep', ['Microsoft.App/containerApps', 'judge-token', 'openai-api-key', 'judgeEndpoint']) + && fileIncludes('agentops-cli/src/lib/benchmark-judge-guide.js', ['serviceArtifact', 'benchmark-judges/hosted-judge', 'infra/bicep/hosted-judge.bicep']) + && fileIncludes('docs/agentops-architecture-product-audit.md', ['deployable Azure Container Apps hosted judge', 'hosted-llm-judge-deployment']), + [ + 'benchmark-judges/hosted-judge/server.js', + 'benchmark-judges/hosted-judge/Dockerfile', + 'infra/bicep/hosted-judge.bicep', + 'agentops-cli/src/lib/benchmark-judge-guide.js', + 'docs/agentops-architecture-product-audit.md' + ], + [] + )); + + checks.push(check( + 'managed-benchmark-runner-image', + fileIncludes('benchmark-runners/copilot-sandbox/Dockerfile', [ + 'node:22-bookworm-slim', + 'AGENTOPS_BENCHMARK_RUNNER=managed-container-sandbox', + 'agentops-benchmark-entrypoint' + ]) + && fileIncludes('benchmark-runners/copilot-sandbox/docker-entrypoint.sh', [ + 'COPILOT_HOME', + 'command -v', + 'private derived image' + ]) + && fileIncludes('benchmark-runners/copilot-sandbox/README.md', [ + 'container-network-blocked', + '--network none', + 'private registry' + ]) + && fileIncludes('docs/agentops-architecture-product-audit.md', [ + 'managed benchmark runner base image', + 'managed-benchmark-runner-image' + ]), + [ + 'benchmark-runners/copilot-sandbox/Dockerfile', + 'benchmark-runners/copilot-sandbox/docker-entrypoint.sh', + 'benchmark-runners/copilot-sandbox/README.md', + 'docs/agentops-architecture-product-audit.md' + ], + [] + )); + + checks.push(check( + 'azure-ingest-privacy-plan', + fileIncludes('agentops-cli/src/lib/azure/v2-ingest-plan.js', ['--allow-content', 'AgentOpsContent_CL', 'schema_versioning', 'schema_migration_policy', 'logs-ingestion-upload-plan']) + && fileIncludes('agentops-cli/src/lib/azure-ingest-command.js', ['logs-upload', '--yes']) + && fileIncludes('agentops-cli/src/lib/azure/logs-ingestion-upload.js', ['az', 'rest']) + && fileIncludes('infra/bicep/v2-ingestion.bicep', ['AgentOpsRunSummary_CL', 'dataCollectionRules', 'streamDeclarations', 'logsIngestionEndpoint']) + && fileIncludes('infra/bicep/main.bicep', ['deployV2Ingestion', 'AGENTOPS_LOGS_INGESTION_ENDPOINT', 'AGENTOPS_DCR_IMMUTABLE_ID']) + && fileIncludes('docs/azure-v2-ingestion.md', ['AgentOpsContent_CL', '--allow-content', 'SchemaVersion', 'schema migration policy', 'azure-ingest logs-upload']), + ['agentops-cli/src/lib/azure/v2-ingest-plan.js', 'agentops-cli/src/commands/azure-ingest.js', 'agentops-cli/src/lib/azure-ingest-command.js', 'agentops-cli/src/lib/azure/logs-ingestion-upload.js', 'infra/bicep/v2-ingestion.bicep', 'infra/bicep/main.bicep', 'docs/azure-v2-ingestion.md'], + [] + )); + + checks.push(check( + 'content-transcript-opt-in', + fileIncludes('docs/grafana-ux-spec.md', ['AgentOpsContent_CL', 'opt-in']) + && fileIncludes('README.md', ['agentops content status', 'AgentOpsContent_CL']) + && fileIncludes('grafana/kql/content-viewer.kql', ['AgentOpsContent_CL', 'MessageText']), + ['docs/grafana-ux-spec.md', 'README.md', 'grafana/kql/content-viewer.kql'], + [] + )); + + checks.push(check( + 'first-run-loop', + fileIncludes('README.md', ['agentops init --full --yes', 'agentops copilot', 'agentops open latest']) + && fileIncludes('docs/release-checklist-v2.md', ['init --dry-run --provision-cloud', 'smoke --real-copilot']) + && fileIncludes('agentops-cli/src/lib/setup-init.js', ['Everyday observed use: agentops copilot', 'Cloud provision failed at:']), + ['README.md', 'docs/release-checklist-v2.md', 'agentops-cli/src/lib/setup-init.js'], + [] + )); + + checks.push(check( + 'ask-agentops-response-flow', + fileIncludes('actioner/index.js', ['metadata-only-assistant-response', 'root_cause_candidates', 'rollback_condition', 'change_target_refs', 'expected_metric_movement', 'buildRecommendationReview', 'OperatorReview']) + && fileIncludes('actioner/README.md', ['first-party metadata-only response draft', 'ChangeTargetRefs', 'ExpectedMetricMovement', 'BeforeTelemetry', 'OperatorReview']) + && fileIncludes('docs/agentops-architecture-product-audit.md', ['first-party metadata-only response draft', 'ExpectedMetricMovement', 'OperatorReview']), + ['actioner/index.js', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], + [] + )); + + checks.push(check( + 'ask-agentops-live-response-flow', + fileIncludes('actioner/index.js', ['metadata-only-live-assistant-request', 'AGENTOPS_ASSISTANT_API_URL', 'live-assistant-run', 'fetch(liveBox.dataset.apiUrl']) + && fileIncludes('actioner/README.md', ['AGENTOPS_ASSISTANT_API_URL', 'inline live assistant form', 'metadata-only prompt and compact context']) + && fileIncludes('docs/agentops-architecture-product-audit.md', ['browser-native metadata-only live assistant response flow', 'AGENTOPS_ASSISTANT_API_URL']), + ['actioner/index.js', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], + [] + )); + + checks.push(check( + 'ask-agentops-shared-context', + fileIncludes('actioner/index.js', ['savedViewEvidenceFromPayload', 'alertHandoffEvidenceFromPayload', 'hydrateAskAgentOpsPayload', 'shared_context', 'recommendationBlob', 'savedViewBlob', 'alertHandoffBlob']) + && fileIncludes('actioner/AskAgentOpsShared/function.json', ['ask-agentops/shared', 'recommendation_blob_id', 'saved_view_blob_id', 'alert_handoff_blob_id']) + && fileIncludes('actioner/AskAgentOpsSharedRecommendation/function.json', ['ask-agentops/shared/recommendation/{recommendation_blob_id}', 'recommendationBlob']) + && fileIncludes('actioner/AskAgentOpsSharedSavedView/function.json', ['ask-agentops/shared/saved-view/{saved_view_blob_id}', 'savedViewBlob']) + && fileIncludes('actioner/AskAgentOpsSharedAlertHandoff/function.json', ['ask-agentops/shared/alert-handoff/{alert_handoff_blob_id}', 'alertHandoffBlob']) + && fileIncludes('grafana/dashboards/v2/01-agentops-home.json', ['AskAgentOpsSharedLaunch', '/ask-agentops/shared/saved-view/', '/ask-agentops/shared/recommendation/']) + && fileIncludes('grafana/dashboards/v2/03-run-replay.json', ['AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/']) + && fileIncludes('grafana/dashboards/v2/06-safety-privacy-policy.json', ['AgentOpsAlertHandoffs_CL', 'AskAgentOpsSharedLaunch', '/ask-agentops/shared/alert-handoff/']) + && fileIncludes('grafana/dashboards/v2/09-insights-regressions.json', ['AskAgentOpsSharedLaunch', '/ask-agentops/shared/recommendation/']) + && fileIncludes('actioner/README.md', ['saved_view', 'alert_handoff', '/api/ask-agentops/shared', 'Dashboard action cells use the GET routes']) + && fileIncludes('docs/agentops-architecture-product-audit.md', ['shared-storage hydrated recommendation', 'actioner/AskAgentOpsShared', 'shared Ask AgentOps action cells', 'alert handoff review rows']), + ['actioner/index.js', 'actioner/AskAgentOpsShared/function.json', 'actioner/AskAgentOpsSharedRecommendation/function.json', 'actioner/AskAgentOpsSharedSavedView/function.json', 'actioner/AskAgentOpsSharedAlertHandoff/function.json', 'grafana/dashboards/v2/01-agentops-home.json', 'grafana/dashboards/v2/03-run-replay.json', 'grafana/dashboards/v2/06-safety-privacy-policy.json', 'grafana/dashboards/v2/09-insights-regressions.json', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], + [] + )); + + checks.push(check( + 'recommendation-metric-movement', + fileIncludes('agentops-cli/src/lib/recommendation-store.js', ['compareRecommendationAfterRun', 'AfterTelemetry', 'ObservedMetricMovement']) + && fileIncludes('docs/evals-and-insights.md', ['agentops recommend compare']) + && fileIncludes('docs/agentops-architecture-product-audit.md', ['AfterTelemetry']), + ['agentops-cli/src/lib/recommendation-store.js', 'docs/evals-and-insights.md', 'docs/agentops-architecture-product-audit.md'], + [] + )); + + checks.push(check( + 'recommendation-action-plan', + fileIncludes('agentops-cli/src/lib/recommendation-store.js', ['recommendationActionPlan', 'OperatorReview', 'benchmark_dry_run', 'compare_after_run']) + && fileIncludes('actioner/index.js', ['action_plan_command', 'agentops recommend action-plan']) + && fileIncludes('docs/evals-and-insights.md', ['agentops recommend action-plan']) + && fileIncludes('docs/agentops-architecture-product-audit.md', ['agentops recommend action-plan']), + ['agentops-cli/src/lib/recommendation-store.js', 'actioner/index.js', 'docs/evals-and-insights.md', 'docs/agentops-architecture-product-audit.md'], + [] + )); + + checks.push(check( + 'agent-improvement-guarded-apply', + fileIncludes('actioner/index.js', ['buildGuardedRecommendationApply', 'metadata-only-guarded-apply', 'after-run metric movement', 'patch_handoff']) + && fileIncludes('actioner/README.md', ['guarded apply packet', 'after-run metric movement', 'patch handoff']) + && fileIncludes('docs/agentops-architecture-product-audit.md', ['guarded apply packet', 'metadata-only-guarded-apply']), + ['actioner/index.js', 'actioner/README.md', 'docs/agentops-architecture-product-audit.md'], + [] + )); + + let liveDashboard = null; + let liveAzure = null; + if (live) { + const dashboardArgs = ['--live', '--last', last]; + if (requireRows) dashboardArgs.push('--require-rows'); + liveDashboard = runDashboardVerify(dashboardArgs, options.dashboardOptions || {}); + liveAzure = runValidateAzure({ last, importDashboards: false }); + checks.push(check( + 'live-grafana-dashboard-queries', + liveDashboard.ok, + [ + `${liveDashboard.summary?.kql_checks || 0} live KQL checks`, + `${liveDashboard.summary?.checked_links || 0} dashboard links checked` + ], + liveDashboard.errors || [] + )); + checks.push(check( + 'live-azure-resources', + liveAzure.ok, + (liveAzure.checks || []).filter(item => item.ok).map(item => item.name), + (liveAzure.checks || []).filter(item => !item.ok).map(item => item.name) + )); + } + + const failed = checks.filter(item => !item.ok); + return { + ok: failed.length === 0, + scope: live ? 'local-and-live-product-contract' : 'local-product-contract', + live_azure_verified: Boolean(liveAzure?.ok), + live_grafana_verified: Boolean(liveDashboard?.ok), + visual_grafana_verified: false, + summary: { + checks: checks.length, + passed: checks.length - failed.length, + failed: failed.length, + v2_dashboards: links.dashboards || 0, + checked_links: links.checked_links || 0, + live_kql_checks: liveDashboard?.summary?.kql_checks || 0 + }, + checks, + next: failed.length === 0 + ? (live + ? [ + 'agentops smoke --real-copilot --wait 2m --poll 10s --json', + 'agentops schema validate --json', + 'agentops validate-enterprise --json', + 'agentops collector smoke --privacy strict --poison --json', + 'npm --prefix packages/agentops-copilot-sdk test', + 'agentops e2e browser-check --report .agentops/e2e/latest/report.html --playwright --grafana --grafana-v2-only --require-grafana-visible --json', + 'npm --prefix agentops-cli test' + ] + : [ + 'agentops demo verify --runs 50 --json', + `agentops product audit --live --last ${last}${requireRows ? ' --require-rows' : ''} --json`, + 'agentops validate-azure --import-dashboards --last 24h --json', + 'agentops smoke --real-copilot --wait 2m --poll 10s --json' + ]) + : [ + 'agentops product audit --json', + 'agentops dashboard verify', + 'npm --prefix agentops-cli test' + ] + }; +} + +module.exports = { productAudit }; diff --git a/agentops-cli/src/lib/product-command.js b/agentops-cli/src/lib/product-command.js new file mode 100644 index 0000000..4fb2839 --- /dev/null +++ b/agentops-cli/src/lib/product-command.js @@ -0,0 +1,43 @@ +const path = require('node:path'); + +const { hasFlag, optionValue } = require('./args'); +const { browserProfileOptionsFromArgs } = require('./browser-options'); +const { writeJsonOrRender } = require('./command-output'); +const { e2eBrowserCheck } = require('./e2e-browser-check'); +const { repoRoot } = require('./paths'); +const { productAudit } = require('./product-audit'); +const { renderProductAudit } = require('./product-audit-render'); +const { productAuditWithVisual: runProductAuditWithVisual } = require('./product-audit-visual'); +const { validateVisualEvidence, visualAuditRecoveryCommands } = require('./product-visual'); + +async function productAuditWithVisual(options = {}) { + return runProductAuditWithVisual(options, { + productAudit, + browserCheck: options.browserCheck || e2eBrowserCheck + }); +} + +async function productCommand(args = []) { + const [subcommand = 'audit'] = args; + if (subcommand !== 'audit') throw new Error('product supports: audit'); + const result = await productAuditWithVisual({ + live: hasFlag(args, '--live'), + requireRows: hasFlag(args, '--require-rows'), + requireVisual: hasFlag(args, '--require-visual'), + last: optionValue(args, '--last', '24h'), + reportPath: optionValue(args, '--report', path.join(repoRoot, '.agentops', 'e2e', 'latest', 'report.html')), + ...browserProfileOptionsFromArgs(args), + visualEvidencePath: optionValue(args, '--visual-evidence', '') + }); + writeJsonOrRender(result, hasFlag(args, '--json'), renderProductAudit); + if (!result.ok) process.exitCode = 1; +} + +module.exports = { + productAudit, + productAuditWithVisual, + productCommand, + renderProductAudit, + validateVisualEvidence, + visualAuditRecoveryCommands +}; diff --git a/agentops-cli/src/lib/product-visual.js b/agentops-cli/src/lib/product-visual.js new file mode 100644 index 0000000..26e35fb --- /dev/null +++ b/agentops-cli/src/lib/product-visual.js @@ -0,0 +1,89 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { readJson } = require('./json'); + +const requiredVisualDashboards = [ + 'agentops-v2-home', + 'agentops-v2-runs-explorer', + 'agentops-v2-run-replay', + 'agentops-v2-models-cost-tokens', + 'agentops-v2-tools-mcp-risk', + 'agentops-v2-safety-privacy-policy', + 'agentops-v2-code-outcomes', + 'agentops-v2-evals-quality', + 'agentops-v2-insights-regressions', + 'agentops-v2-collector-health' +]; + +function visualAuditRecoveryCommands(reportPath) { + return [ + 'agentops e2e run --live --browser-report --last 2h --json', + `agentops e2e report --last 2h --out ${reportPath}`, + `agentops product audit --live --last 2h --require-rows --require-visual --report ${reportPath} --json` + ]; +} + +function validateVisualEvidence(evidencePath) { + const resolved = path.resolve(evidencePath || ''); + if (!evidencePath || !fs.existsSync(resolved)) { + return { + ok: false, + evidencePath: resolved, + dashboards: [], + visible: [], + missing: [`Visual evidence file not found: ${resolved}`] + }; + } + + let payload; + try { + payload = readJson(resolved); + } catch (error) { + return { + ok: false, + evidencePath: resolved, + dashboards: [], + visible: [], + missing: [`Visual evidence file is not valid JSON: ${error.message}`] + }; + } + + const dashboards = Array.isArray(payload.dashboards) ? payload.dashboards : []; + const byUid = new Map(dashboards.map(item => [String(item.uid || ''), item])); + const missing = []; + const visible = []; + for (const uid of requiredVisualDashboards) { + const item = byUid.get(uid); + if (!item) { + missing.push(`${uid}: missing`); + continue; + } + const screenshotPath = item.screenshot ? path.resolve(path.dirname(resolved), item.screenshot) : ''; + const screenshotOk = screenshotPath && fs.existsSync(screenshotPath) && fs.statSync(screenshotPath).size > 1000; + const screenshotHash = screenshotOk + ? crypto.createHash('sha256').update(fs.readFileSync(screenshotPath)).digest('hex') + : ''; + if (item.authBlocked) missing.push(`${uid}: auth-blocked`); + if (!item.dashboardVisible) missing.push(`${uid}: not visible`); + if ((item.errors || []).length) missing.push(`${uid}: ${item.errors.join(', ')}`); + if (!screenshotOk) missing.push(`${uid}: screenshot missing or too small`); + if (item.sha256 && screenshotHash !== item.sha256) missing.push(`${uid}: screenshot hash mismatch`); + if (!String(item.url || '').includes(`/d/${uid}`)) missing.push(`${uid}: URL does not match dashboard UID`); + if (!item.authBlocked && item.dashboardVisible && !(item.errors || []).length && screenshotOk) visible.push(uid); + } + + return { + ok: missing.length === 0, + evidencePath: resolved, + dashboards, + visible, + missing + }; +} + +module.exports = { + requiredVisualDashboards, + validateVisualEvidence, + visualAuditRecoveryCommands +}; diff --git a/agentops-cli/src/lib/recommend-command.js b/agentops-cli/src/lib/recommend-command.js new file mode 100644 index 0000000..451640b --- /dev/null +++ b/agentops-cli/src/lib/recommend-command.js @@ -0,0 +1,101 @@ +const { firstPositional, hasFlag, optionValue } = require('./args'); +const { + changeAnnotationsForRun, + normalizeConfigChangeAnnotation: normalizeChangeAnnotation +} = require('./change-annotations'); +const { writeJson } = require('./command-output'); +const { benchmarkEvidenceFromReport } = require('./recommendation-benchmark-evidence'); +const { + actionFromInsight, + buildRecommendation +} = require('./recommendation-builder'); +const { recommendFromFiles } = require('./recommendation-files'); +const { + dashboardUrl, + fileRefsForRecommendation, + linkedDashboardsForRecommendation, + matchingPatternInsight, + topInsightForRun +} = require('./recommendation-links'); +const { renderRecommendationV2 } = require('./recommendation-render'); +const { + compareRecommendationAfterRun, + defaultRecommendationStorePath, + exportRecommendationStore, + recommendationActionPlan, + recommendationActionPlanForRow, + recommendationRow, + recommendationStoreCommand, + saveRecommendation, + writeRecommendation +} = require('./recommendation-store'); + +function recommendCommand(args = []) { + if (args[0] === 'list' || args[0] === 'export' || args[0] === 'compare' || args[0] === 'action-plan') { + writeJson(recommendationStoreCommand(args)); + return; + } + + const runId = firstPositional(args); + const runsFile = optionValue(args, '--runs'); + if (!runsFile) throw new Error('recommend requires --runs for V2 recommendations'); + + const recommendation = recommendFromFiles({ + runId, + runsFile, + evalsFile: optionValue(args, '--evals'), + eventsFile: optionValue(args, '--events'), + insightsFile: optionValue(args, '--insights'), + benchmarkReportFile: optionValue(args, '--benchmark-report'), + benchmarkRunId: optionValue(args, '--benchmark-run') + }); + const outDir = optionValue(args, '--out'); + const written = outDir ? writeRecommendation(recommendation, outDir) : null; + const saved = hasFlag(args, '--save') + ? saveRecommendation(recommendation, optionValue(args, '--store') || defaultRecommendationStorePath()) + : null; + if (written) recommendation.artifact = { + table: 'AgentOpsRecommendations_CL', + file: written.file, + manifest: written.manifest, + privacy: 'metadata-only' + }; + if (saved) recommendation.saved = { + store: saved.path, + recommendation_id: saved.saved.RecommendationId, + count: saved.count, + privacy: 'metadata-only' + }; + + if (hasFlag(args, '--json')) { + writeJson(recommendation); + } else { + process.stdout.write(renderRecommendationV2(recommendation)); + if (written) process.stdout.write(`Artifact: ${written.file}\n`); + if (saved) process.stdout.write(`Saved: ${saved.path}\n`); + } +} + +module.exports = { + actionFromInsight, + buildRecommendation, + changeAnnotationsForRun, + normalizeChangeAnnotation, + dashboardUrl, + benchmarkEvidenceFromReport, + compareRecommendationAfterRun, + exportRecommendationStore, + firstPositional, + fileRefsForRecommendation, + recommendCommand, + recommendFromFiles, + recommendationActionPlan, + recommendationActionPlanForRow, + recommendationStoreCommand, + recommendationRow, + renderRecommendationV2, + saveRecommendation, + matchingPatternInsight, + writeRecommendation, + topInsightForRun +}; diff --git a/agentops-cli/src/lib/recommendation-benchmark-evidence.js b/agentops-cli/src/lib/recommendation-benchmark-evidence.js new file mode 100644 index 0000000..6c95bc7 --- /dev/null +++ b/agentops-cli/src/lib/recommendation-benchmark-evidence.js @@ -0,0 +1,162 @@ +function benchmarkArtifactFileRefs(report = null) { + const rows = []; + for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { + const diff = task.artifactDiff || {}; + for (const change of ['added', 'modified', 'deleted']) { + const files = Array.isArray(diff[change]) ? diff[change] : []; + for (const file of files) { + const artifactPath = String(file || '').replaceAll('\\', '/').trim(); + if (!artifactPath) continue; + rows.push({ + task_id: task.taskId || '', + change, + path: artifactPath + }); + } + } + } + return rows.slice(0, 200); +} + +function benchmarkArtifactContentDiffRefs(report = null) { + const rows = []; + for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { + const explicitDiffs = Array.isArray(task.artifactContentDiffs) ? task.artifactContentDiffs : []; + for (const diff of explicitDiffs) { + if (!diff || typeof diff !== 'object') continue; + const artifactPath = String(diff.path || diff.file || '').replaceAll('\\', '/').trim(); + if (!artifactPath) continue; + rows.push({ + task_id: task.taskId || '', + change: diff.change || diff.status || '', + path: artifactPath, + diff_preview: String(diff.diff || diff.preview || '').split(/\r?\n/).slice(0, 80).join('\n').slice(0, 12000) + }); + } + + const files = Array.isArray(task.artifactReview?.files) ? task.artifactReview.files : []; + for (const file of files) { + const artifactPath = String(file.path || file.file || '').replaceAll('\\', '/').trim(); + const diffLines = Array.isArray(file.diff) ? file.diff : []; + if (!artifactPath || diffLines.length === 0) continue; + rows.push({ + task_id: task.taskId || '', + change: file.change || file.status || '', + path: artifactPath, + diff_preview: diffLines.map(line => String(line).slice(0, 300)).slice(0, 80).join('\n') + }); + } + } + return rows.filter(row => row.diff_preview).slice(0, 50); +} + +function benchmarkHiddenCheckPackRefs(report = null) { + const rows = []; + for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { + const packs = Array.isArray(task.hiddenCheckPacks) ? task.hiddenCheckPacks : []; + for (const pack of packs) { + if (!pack || typeof pack !== 'object') continue; + rows.push({ + task_id: task.taskId || '', + id: pack.id || '', + title: pack.title || pack.id || '', + command_count: pack.commandCount ?? null + }); + } + } + return rows.slice(0, 200); +} + +function benchmarkPolicyRefs(report = null) { + const rows = []; + for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { + const violations = Array.isArray(task.toolPolicyViolations) ? task.toolPolicyViolations : []; + rows.push({ + task_id: task.taskId || '', + permission_profile: task.permissionProfile || '', + os_sandbox_mode: task.osSandbox?.mode || '', + os_sandbox_active: task.osSandboxRuntime?.active === undefined ? null : Boolean(task.osSandboxRuntime.active), + policy_blocks: task.policyBlocks ?? null, + blocked_risks: Array.isArray(task.toolPolicy?.blockedRisks) ? task.toolPolicy.blockedRisks : [], + violation_count: violations.length, + violation_risks: [...new Set(violations.map(violation => violation?.risk).filter(Boolean))].sort() + }); + } + return rows.slice(0, 200); +} + +function benchmarkSemanticCheckRefs(report = null) { + const rows = []; + for (const task of Array.isArray(report?.tasks) ? report.tasks : []) { + const checks = Array.isArray(task.semanticChecks) ? task.semanticChecks : []; + for (const check of checks) { + if (!check || typeof check !== 'object') continue; + rows.push({ + task_id: task.taskId || '', + id: check.id || '', + adapter: check.adapter || '', + file: check.file || '', + ok: check.ok === undefined ? null : Boolean(check.ok), + score: check.score ?? null, + detail: check.detail || '' + }); + } + } + return rows.slice(0, 200); +} + +function benchmarkEvidenceFromReport(report = null) { + if (!report || typeof report !== 'object') return null; + const artifactDiff = report.artifactDiff || {}; + const approval = report.promotion?.approval || report.promotionApproval || {}; + const approvalSource = approval.source + ? String(approval.source).split(/[\\/]/).filter(Boolean).pop() || String(approval.source) + : ''; + return { + run_id: report.runId || '', + decision: report.ok === false ? 'missing' : (report.promotion?.decision || report.recommendation?.action || ''), + pass_rate_pct: report.passRatePct ?? null, + average_score: report.averageScore ?? null, + safety_violation_count: report.safetyViolationCount ?? null, + tool_failures: report.toolFailures ?? null, + total_tokens: report.totalTokens ?? null, + cost: report.cost ?? null, + artifact_diff: { + added: artifactDiff.added ?? null, + modified: artifactDiff.modified ?? null, + deleted: artifactDiff.deleted ?? null, + total_changed: artifactDiff.totalChanged ?? null + }, + artifact_files: benchmarkArtifactFileRefs(report), + artifact_content_diffs: benchmarkArtifactContentDiffRefs(report), + hidden_checks: { + passed: report.hiddenChecks?.passed ?? null, + failed: report.hiddenChecks?.failed ?? null, + packs: benchmarkHiddenCheckPackRefs(report) + }, + policy: { + blocks: report.policyBlocks ?? null, + permission_profiles: report.permissionProfiles || {}, + tasks: benchmarkPolicyRefs(report) + }, + semantic_checks: { + count: report.semanticChecks?.count ?? null, + average_score: report.semanticChecks?.averageScore ?? null, + checks: benchmarkSemanticCheckRefs(report) + }, + approval: { + status: approval.status || '', + approved_count: approval.status === 'approved' ? (approval.approvedBy || []).length : 0, + required_count: report.promotion?.gates?.requiredApprovals ?? report.promotionGates?.requiredApprovals ?? null, + approved_at: approval.approvedAt || '', + ticket: approval.ticket || '', + source: approvalSource + }, + validation: report.promotion?.validation || report.message || '', + rollback: report.promotion?.rollback || (report.ok === false ? 'run or attach benchmark evidence before promotion' : '') + }; +} + +module.exports = { + benchmarkEvidenceFromReport +}; diff --git a/agentops-cli/src/lib/recommendation-builder.js b/agentops-cli/src/lib/recommendation-builder.js new file mode 100644 index 0000000..bc36ccc --- /dev/null +++ b/agentops-cli/src/lib/recommendation-builder.js @@ -0,0 +1,144 @@ +const { changeRef } = require('./change-annotations'); +const { benchmarkEvidenceFromReport } = require('./recommendation-benchmark-evidence'); +const { + dashboardUrl, + fileRefsForRecommendation, + linkedDashboardsForRecommendation +} = require('./recommendation-links'); +const { telemetrySnapshot } = require('./recommendation-store'); + +function metricMovementForRecommendation(run = {}, insight = {}, evaluation = {}, benchmark = null) { + const before = telemetrySnapshot(run, evaluation); + const expected = []; + if (insight?.BaselineValue !== undefined || insight?.CurrentValue !== undefined) { + expected.push({ + metric: insight.InsightType || 'insight-value', + baseline_value: insight.BaselineValue ?? null, + current_value: insight.CurrentValue ?? null, + expected_direction: insight.InsightType === 'eval-regression' ? 'increase' : 'decrease', + source: 'insight' + }); + } + if (before.eval_overall !== null) { + expected.push({ + metric: 'EvalOverall', + baseline_value: before.eval_overall, + current_value: before.eval_overall, + expected_direction: 'increase', + source: 'eval' + }); + } + if (benchmark?.average_score !== undefined && benchmark?.average_score !== null) { + expected.push({ + metric: 'BenchmarkAverageScore', + baseline_value: benchmark.average_score, + current_value: benchmark.average_score, + expected_direction: 'increase', + source: 'benchmark' + }); + } + + return { + expected: { + status: expected.length ? 'ready' : 'needs-baseline', + metrics: expected + }, + before, + after: {}, + observed: { + status: 'awaiting-after-run', + compare_command: 'agentops recommend compare --recommendation-id --after-runs --after-evals ' + } + }; +} + +function actionFromInsight(insight, run = {}) { + if (!insight) { + if (run.OutcomeStatus && run.OutcomeStatus !== 'success') return 'investigate_failed_run'; + if (Number(run.FilesEditedCount || 0) > 0 && !run.TestsRan) return 'run_validation'; + if (Number(run.ContextWindowPct || 0) >= 90 || Number(run.TokensRemoved || 0) > 0) return 'reduce_context'; + return 'keep_observing'; + } + + const type = insight.InsightType || ''; + if (type.startsWith('recurring-')) return 'triage_recurring_pattern'; + if (type.includes('test')) return 'run_validation'; + if (type.includes('tool')) return 'investigate_tool'; + if (type.includes('collector')) return 'check_collector'; + if (type.includes('policy') || type.includes('privacy')) return 'review_policy'; + if (type.includes('cost') || type.includes('context')) return 'reduce_context_or_cost'; + if (type.includes('ci')) return 'fix_ci'; + if (type.includes('eval') || type.includes('instruction') || type.includes('config')) return 'compare_regression'; + return 'investigate'; +} + +function buildRecommendation({ run, insight, evaluation, links, benchmarkReport, changeAnnotations = [] }) { + if (!run) { + return { + ok: false, + action: 'collect_data', + severity: 'medium', + observed_pattern: 'No AgentOps V2 run rows were available.', + next_action: 'Run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --with-github-outcomes --json` or collect a new Copilot run through the local collector.', + evidence: { dashboards: [{ title: 'Today', url: dashboardUrl('agentops-v2-home', {}, links) }] }, + validation: ['Run `agentops dashboard kql-check --last 24h --json` after data is ingested.'], + rollback_condition: 'No rollback needed; this recommendation made no changes.' + }; + } + + const action = actionFromInsight(insight, run); + const healthy = action === 'keep_observing'; + const observedPattern = insight?.Summary + || (healthy ? 'No high-severity insight was found for this run.' : `Run status is ${run.OutcomeStatus || 'unknown'}.`); + const nextAction = insight?.SuggestedNextStep + || (healthy + ? 'Keep strict privacy mode enabled and compare the next similar run for cost, latency, eval, and outcome drift.' + : 'Open Run Story and inspect the failed span, blocked tool, eval score, and GitHub outcome.'); + + const benchmark = benchmarkEvidenceFromReport(benchmarkReport); + const metricMovement = metricMovementForRecommendation(run, insight, evaluation, benchmark); + const annotationRefs = changeAnnotations.map(changeRef).filter(Boolean); + const validation = [ + `agentops explain ${run.RunId} --runs --evals --insights `, + 'agentops dashboard kql-check --last 24h --json' + ]; + if (changeAnnotations.length) validation.push(`agentops annotation config-change --component --target --run-id ${run.RunId}`); + if (benchmark?.run_id) validation.push(`agentops experimental benchmark report ${benchmark.run_id}`); + + return { + ok: true, + action, + severity: insight?.Severity || (healthy ? 'low' : 'medium'), + run_id: run.RunId, + session_id: run.SessionId || '', + trace_id: run.TraceId || '', + observed_pattern: observedPattern, + next_action: nextAction, + evidence: { + dashboards: linkedDashboardsForRecommendation(run, insight, links), + eval: evaluation ? { + overall: evaluation.EvalOverall, + bucket: evaluation.EvalBucket || '', + reason: evaluation.EvalReason || '' + } : null, + pattern: insight?.PatternKey ? { + id: insight.PatternId || '', + key: insight.PatternKey, + runs: insight.PatternRuns ?? null, + dimension: insight.PatternDimension || '' + } : null, + benchmark, + metric_movement: metricMovement, + change_annotations: changeAnnotations, + file_refs: [...new Set([...fileRefsForRecommendation(action, insight, run), ...annotationRefs])] + }, + validation, + rollback_condition: 'Rollback the agent, skill, MCP, model, or instruction change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.' + }; +} + +module.exports = { + actionFromInsight, + buildRecommendation, + metricMovementForRecommendation +}; diff --git a/agentops-cli/src/lib/recommendation-files.js b/agentops-cli/src/lib/recommendation-files.js new file mode 100644 index 0000000..9d79399 --- /dev/null +++ b/agentops-cli/src/lib/recommendation-files.js @@ -0,0 +1,36 @@ +const legacy = require('../legacy'); +const { changeAnnotationsForRun } = require('./change-annotations'); +const { latestByTime } = require('./explain/v2-explain'); +const { readJson, readJsonl } = require('./json'); +const { buildRecommendation } = require('./recommendation-builder'); +const { + matchingPatternInsight, + topInsightForRun +} = require('./recommendation-links'); + +function pickRun(runs, runId) { + if (runId && runId !== 'latest') return runs.find(row => row.RunId === runId) || null; + return latestByTime(runs); +} + +function recommendFromFiles(options = {}) { + const runs = readJsonl(options.runsFile); + const evals = readJsonl(options.evalsFile); + const insights = readJsonl(options.insightsFile); + const events = readJsonl(options.eventsFile); + const benchmarkReport = options.benchmarkReportFile + ? readJson(options.benchmarkReportFile) + : options.benchmarkRunId + ? legacy.benchmarkReport(options.benchmarkRunId) + : null; + const run = pickRun(runs, options.runId); + const insight = run ? topInsightForRun(insights, run.RunId) || matchingPatternInsight(insights, run) : null; + const evaluation = run ? evals.find(row => row.RunId === run.RunId) || null : null; + const changeAnnotations = run ? changeAnnotationsForRun(events, run) : []; + return buildRecommendation({ run, insight, evaluation, links: options.links, benchmarkReport, changeAnnotations }); +} + +module.exports = { + pickRun, + recommendFromFiles +}; diff --git a/agentops-cli/src/lib/recommendation-links.js b/agentops-cli/src/lib/recommendation-links.js new file mode 100644 index 0000000..e4ae27c --- /dev/null +++ b/agentops-cli/src/lib/recommendation-links.js @@ -0,0 +1,129 @@ +const legacy = require('../legacy'); + +const severityRank = { + critical: 4, + high: 3, + medium: 2, + low: 1 +}; + +function dashboardBaseUrl(links = legacy.openLinksSummary()) { + const home = links.v2_home_url || '/d/agentops-v2-home'; + return home.replace(/\/d\/agentops-v2-home.*$/, ''); +} + +function dashboardUrl(uid, vars = {}, links = legacy.openLinksSummary()) { + const base = `${dashboardBaseUrl(links)}/d/${uid}`; + const pairs = Object.entries(vars).filter(([, value]) => value !== undefined && value !== null && value !== ''); + if (pairs.length === 0) return base; + return `${base}?${pairs.map(([key, value]) => `var-${key}=${encodeURIComponent(value)}`).join('&')}`; +} + +function replayUrl(run, links) { + if (!run) return dashboardUrl('agentops-v2-runs-explorer', {}, links); + if (run.RunId) return dashboardUrl('agentops-v2-run-replay', { run_id: run.RunId }, links); + if (run.SessionId) return dashboardUrl('agentops-v2-run-replay', { session_id: run.SessionId }, links); + return dashboardUrl('agentops-v2-run-replay', {}, links); +} + +function topInsightForRun(insights, runId) { + return insights + .filter(row => row.RunId === runId) + .sort((left, right) => { + const bySeverity = (severityRank[right.Severity] || 0) - (severityRank[left.Severity] || 0); + if (bySeverity !== 0) return bySeverity; + return String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || '')); + })[0] || null; +} + +function matchingPatternInsight(insights, run = {}) { + const task = run.TaskType || ''; + const model = run.ModelActual || ''; + const repo = run.RepoHash || ''; + const agent = run.AgentName || 'agent'; + const privacy = run.PrivacyMode || 'strict'; + const outcome = run.OutcomeReason || (run.OutcomeStatus && run.OutcomeStatus !== 'success' ? 'failed' : ''); + const candidates = insights.filter(row => row.PatternKey || String(row.InsightType || '').startsWith('recurring-')); + return candidates + .filter(row => { + const key = String(row.PatternKey || ''); + return (task && key.includes(`|${task}|`)) + || (model && key.includes(`|${model}|`)) + || (repo && key.includes(`|${repo}|`)) + || (agent && key.includes(`|${agent}`)) + || (privacy && key.endsWith(`|${privacy}`)) + || (outcome && key.endsWith(`|${outcome}`)); + }) + .sort((left, right) => { + const byRuns = Number(right.PatternRuns || 0) - Number(left.PatternRuns || 0); + if (byRuns !== 0) return byRuns; + const bySeverity = (severityRank[right.Severity] || 0) - (severityRank[left.Severity] || 0); + if (bySeverity !== 0) return bySeverity; + return String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || '')); + })[0] || null; +} + +function linkedDashboardsForRecommendation(run, insight, links = legacy.openLinksSummary()) { + const dashboards = [{ title: 'Run Story', url: replayUrl(run, links) }]; + if (insight?.ToolName || Number(run?.ToolFailureCount || 0) > 0 || Number(run?.ToolDeniedCount || 0) > 0) { + dashboards.push({ title: 'Tools & MCP Risk', url: dashboardUrl('agentops-v2-tools-mcp-risk', insight?.ToolName ? { tool_name: insight.ToolName } : {}, links) }); + } + if (Number(run?.EstimatedCostUsd || 0) > 0 || run?.ModelActual) { + dashboards.push({ title: 'Models, Cost & Tokens', url: dashboardUrl('agentops-v2-models-cost-tokens', run?.ModelActual ? { model: run.ModelActual } : {}, links) }); + } + if (Number(run?.ToolDeniedCount || 0) > 0 || insight?.InsightType === 'privacy-drop') { + dashboards.push({ title: 'Privacy', url: dashboardUrl('agentops-v2-safety-privacy-policy', {}, links) }); + } + if (run?.PrOpened || run?.CiStatus) { + dashboards.push({ title: 'Code Outcomes', url: dashboardUrl('agentops-v2-code-outcomes', run?.RepoHash ? { repo_hash: run.RepoHash } : {}, links) }); + } + dashboards.push({ + title: insight?.PatternKey ? 'Insights Pattern' : 'Insights & Regressions', + url: dashboardUrl('agentops-v2-insights-regressions', insight?.PatternKey ? { pattern_key: insight.PatternKey } : run?.RunId ? { run_id: run.RunId } : {}, links) + }); + return dashboards; +} + +function fileRefsForRecommendation(action, insight = {}, run = {}) { + insight = insight || {}; + run = run || {}; + const refs = new Set(); + if (action === 'run_validation') { + refs.add('tests_or_benchmark_suite'); + refs.add('agent_skill_validation_step'); + } + if (action === 'investigate_tool') { + refs.add('tool_policy_or_mcp_config'); + } + if (action === 'check_collector') { + refs.add('collector_config'); + } + if (action === 'review_policy') { + refs.add('agentops_policy_config'); + refs.add('mcp_server_config'); + } + if (action === 'reduce_context_or_cost' || action === 'reduce_context') { + refs.add('agent_instruction_or_skill_context_rules'); + } + if (action === 'fix_ci') { + refs.add('ci_workflow_or_test_command'); + } + if (action === 'compare_regression' || insight.ConfigHash || run.ConfigHash) { + refs.add('agent_instruction_config'); + refs.add('skill_definition'); + } + if (action === 'triage_recurring_pattern') { + refs.add('recurring_pattern_owner'); + } + return [...refs]; +} + +module.exports = { + dashboardBaseUrl, + dashboardUrl, + fileRefsForRecommendation, + linkedDashboardsForRecommendation, + matchingPatternInsight, + replayUrl, + topInsightForRun +}; diff --git a/agentops-cli/src/lib/recommendation-render.js b/agentops-cli/src/lib/recommendation-render.js new file mode 100644 index 0000000..9ecd665 --- /dev/null +++ b/agentops-cli/src/lib/recommendation-render.js @@ -0,0 +1,39 @@ +function renderRecommendationV2(recommendation) { + const lines = ['AgentOps recommendation', '']; + lines.push(`Action: ${recommendation.action}`); + lines.push(`Severity: ${recommendation.severity}`); + if (recommendation.run_id) lines.push(`Run: ${recommendation.run_id}`); + lines.push(`Observed pattern: ${recommendation.observed_pattern}`); + lines.push(`Next action: ${recommendation.next_action}`); + if (recommendation.evidence?.eval) { + const evaluation = recommendation.evidence.eval; + lines.push(`Eval: ${evaluation.overall} (${evaluation.bucket || 'unknown'})${evaluation.reason ? ` - ${evaluation.reason}` : ''}`); + } + if (recommendation.evidence?.pattern) { + const pattern = recommendation.evidence.pattern; + lines.push(`Pattern: ${pattern.key} (${pattern.runs ?? 'unknown'} run(s), ${pattern.dimension || 'unknown'})`); + } + if (recommendation.evidence?.benchmark) { + const benchmark = recommendation.evidence.benchmark; + lines.push(`Benchmark: ${benchmark.run_id || 'unknown'} (${benchmark.decision || 'unknown'}, score ${benchmark.average_score ?? 'unknown'}, pass ${benchmark.pass_rate_pct ?? 'unknown'}%)`); + } + if (recommendation.evidence?.change_annotations?.length) { + lines.push('Config changes:'); + for (const annotation of recommendation.evidence.change_annotations) { + lines.push(`- ${annotation.component || 'config'} ${annotation.target || 'unknown'} (${annotation.change_type || 'updated'}${annotation.version ? `, ${annotation.version}` : ''})`); + } + } + if (recommendation.evidence?.file_refs?.length) lines.push(`Change targets: ${recommendation.evidence.file_refs.join(', ')}`); + if (recommendation.evidence?.dashboards?.length) { + lines.push('Dashboards:'); + for (const dashboard of recommendation.evidence.dashboards) lines.push(`- ${dashboard.title}: ${dashboard.url}`); + } + lines.push('Validation:'); + for (const item of recommendation.validation || []) lines.push(`- ${item}`); + lines.push(`Rollback condition: ${recommendation.rollback_condition}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + renderRecommendationV2 +}; diff --git a/agentops-cli/src/lib/recommendation-store.js b/agentops-cli/src/lib/recommendation-store.js new file mode 100644 index 0000000..5dd2d53 --- /dev/null +++ b/agentops-cli/src/lib/recommendation-store.js @@ -0,0 +1,416 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { optionValue } = require('./args'); +const { appendJsonlFile, writeJsonFile, writeJsonlFile } = require('./command-output'); +const { latestByTime } = require('./explain/v2-explain'); +const { prefixedHash: stableId } = require('./hash'); +const { readJson, readJsonl } = require('./json'); +const { defaultUserAgentOpsPath } = require('./paths'); +const { AGENTOPS_SCHEMA_VERSION } = require('./schema/agentops-attributes'); +const { validateRecommendationRow } = require('./schema/recommendation-schema'); + +function stringValue(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + return String(value); +} + +function telemetrySnapshot(run = {}, evaluation = {}) { + return { + run_id: run.RunId || '', + eval_overall: evaluation?.EvalOverall ?? run.EvalOverall ?? null, + eval_bucket: evaluation?.EvalBucket || '', + estimated_cost_usd: run.EstimatedCostUsd ?? null, + input_tokens: run.InputTokens ?? null, + output_tokens: run.OutputTokens ?? null, + tool_failure_count: run.ToolFailureCount ?? null, + tool_denied_count: run.ToolDeniedCount ?? null, + risk_score: run.RiskScore ?? null, + outcome_status: run.OutcomeStatus || '' + }; +} + +function recommendationRow(recommendation, timeGenerated = new Date().toISOString()) { + const dashboards = recommendation.evidence?.dashboards || []; + const pattern = recommendation.evidence?.pattern || {}; + const evaluation = recommendation.evidence?.eval || {}; + const benchmark = recommendation.evidence?.benchmark || {}; + const metricMovement = recommendation.evidence?.metric_movement || {}; + const changeAnnotations = recommendation.evidence?.change_annotations || []; + return { + TimeGenerated: timeGenerated, + SchemaVersion: AGENTOPS_SCHEMA_VERSION, + RecommendationId: stableId([ + recommendation.run_id || 'none', + recommendation.action || 'none', + recommendation.severity || 'none', + recommendation.observed_pattern || '', + recommendation.next_action || '', + pattern.key || '' + ].join('|')), + RunId: recommendation.run_id || '', + SessionId: recommendation.session_id || '', + TraceId: recommendation.trace_id || '', + Action: recommendation.action || '', + Severity: recommendation.severity || '', + ObservedPattern: recommendation.observed_pattern || '', + NextAction: recommendation.next_action || '', + PatternId: pattern.id || '', + PatternKey: pattern.key || '', + PatternRuns: pattern.runs ?? null, + PatternDimension: pattern.dimension || '', + EvalOverall: evaluation.overall ?? null, + EvalBucket: evaluation.bucket || '', + BenchmarkRunId: benchmark.run_id || '', + BenchmarkDecision: benchmark.decision || '', + BenchmarkPassRatePct: benchmark.pass_rate_pct ?? null, + BenchmarkAverageScore: benchmark.average_score ?? null, + BenchmarkSafetyViolationCount: benchmark.safety_violation_count ?? null, + BenchmarkToolFailures: benchmark.tool_failures ?? null, + BenchmarkArtifactAdded: benchmark.artifact_diff?.added ?? null, + BenchmarkArtifactModified: benchmark.artifact_diff?.modified ?? null, + BenchmarkArtifactDeleted: benchmark.artifact_diff?.deleted ?? null, + BenchmarkArtifactTotalChanged: benchmark.artifact_diff?.total_changed ?? null, + BenchmarkArtifactFiles: benchmark.artifact_files || [], + BenchmarkArtifactContentDiffs: benchmark.artifact_content_diffs || [], + BenchmarkHiddenChecksPassed: benchmark.hidden_checks?.passed ?? null, + BenchmarkHiddenChecksFailed: benchmark.hidden_checks?.failed ?? null, + BenchmarkHiddenCheckPacks: benchmark.hidden_checks?.packs || [], + BenchmarkPolicyBlocks: benchmark.policy?.blocks ?? null, + BenchmarkPermissionProfiles: benchmark.policy?.permission_profiles || {}, + BenchmarkPolicyTasks: benchmark.policy?.tasks || [], + BenchmarkSemanticCheckCount: benchmark.semantic_checks?.count ?? null, + BenchmarkSemanticAverageScore: benchmark.semantic_checks?.average_score ?? null, + BenchmarkSemanticChecks: benchmark.semantic_checks?.checks || [], + BenchmarkApprovalStatus: benchmark.approval?.status || '', + BenchmarkApprovalCount: benchmark.approval?.approved_count ?? null, + BenchmarkRequiredApprovals: benchmark.approval?.required_count ?? null, + BenchmarkApprovalApprovedAt: benchmark.approval?.approved_at || '', + BenchmarkApprovalTicket: benchmark.approval?.ticket || '', + BenchmarkApprovalSource: benchmark.approval?.source || '', + ExpectedMetricMovement: metricMovement.expected || {}, + BeforeTelemetry: metricMovement.before || {}, + AfterTelemetry: metricMovement.after || {}, + ObservedMetricMovement: metricMovement.observed || {}, + ChangeAnnotations: changeAnnotations, + ChangeTargetRefs: recommendation.evidence?.file_refs || [], + DashboardTitles: dashboards.map(dashboard => dashboard.title), + DashboardCount: dashboards.length, + Validation: recommendation.validation || [], + RollbackCondition: recommendation.rollback_condition || '' + }; +} + +function writeRecommendation(recommendation, outDir) { + const absoluteDir = path.resolve(outDir); + const row = recommendationRow(recommendation); + const validation = validateRecommendationRow(row); + if (!validation.ok) throw new Error(`recommendation row failed schema validation: ${validation.errors.join('; ')}`); + const file = path.join(absoluteDir, 'AgentOpsRecommendations_CL.jsonl'); + appendJsonlFile(file, row); + const manifest = path.join(absoluteDir, 'recommendation-manifest.json'); + writeJsonFile(manifest, { + generated_at: row.TimeGenerated, + table: 'AgentOpsRecommendations_CL', + file, + rows_written: 1, + privacy: 'metadata-only; no prompts, responses, tool arguments, tool results, source code, or file contents' + }); + return { out_dir: absoluteDir, file, manifest, row }; +} + +function defaultRecommendationStorePath() { + return process.env.AGENTOPS_RECOMMENDATIONS_PATH || defaultUserAgentOpsPath('recommendations.json'); +} + +function readRecommendationStore(filePath = defaultRecommendationStorePath()) { + if (!fs.existsSync(filePath)) return { recommendations: [] }; + const payload = readJson(filePath); + return { + recommendations: Array.isArray(payload.recommendations) ? payload.recommendations : [] + }; +} + +function writeRecommendationStore(payload, filePath = defaultRecommendationStorePath()) { + writeJsonFile(filePath, payload); +} + +function saveRecommendation(recommendation, filePath = defaultRecommendationStorePath(), timeGenerated = new Date().toISOString()) { + const row = recommendationRow(recommendation, timeGenerated); + const validation = validateRecommendationRow(row); + if (!validation.ok) throw new Error(`recommendation row failed schema validation: ${validation.errors.join('; ')}`); + const payload = readRecommendationStore(filePath); + const next = payload.recommendations + .filter(item => item.RecommendationId !== row.RecommendationId) + .concat(row) + .sort((left, right) => String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || ''))); + writeRecommendationStore({ recommendations: next }, filePath); + return { path: filePath, saved: row, count: next.length }; +} + +function exportRecommendationStore({ storePath = defaultRecommendationStorePath(), outDir } = {}) { + const payload = readRecommendationStore(storePath); + const absoluteDir = path.resolve(outDir || path.join(path.dirname(storePath), 'recommendations-export')); + const rows = payload.recommendations; + const file = path.join(absoluteDir, 'AgentOpsRecommendations_CL.jsonl'); + writeJsonlFile(file, rows); + const manifest = path.join(absoluteDir, 'recommendations-manifest.json'); + writeJsonFile(manifest, { + generated_at: new Date().toISOString(), + table: 'AgentOpsRecommendations_CL', + file, + rows_written: rows.length, + privacy: 'metadata-only; no prompts, responses, tool arguments, tool results, source code, or file contents' + }); + return { out_dir: absoluteDir, file, manifest, rows_written: rows.length, rows }; +} + +function metricValue(snapshot = {}, metric) { + const key = { + EvalOverall: 'eval_overall', + EstimatedCostUsd: 'estimated_cost_usd', + ToolFailureCount: 'tool_failure_count', + ToolDeniedCount: 'tool_denied_count', + RiskScore: 'risk_score' + }[metric] || metric; + return snapshot[key]; +} + +function movementResults(row = {}, after = {}) { + const before = row.BeforeTelemetry || {}; + const expected = Array.isArray(row.ExpectedMetricMovement?.metrics) ? row.ExpectedMetricMovement.metrics : []; + return expected + .map(metric => { + const name = metric.metric; + const beforeValue = metricValue(before, name) ?? metric.current_value ?? metric.baseline_value ?? null; + const afterValue = metricValue(after, name); + if (typeof beforeValue !== 'number' || typeof afterValue !== 'number') return null; + const direction = metric.expected_direction || 'decrease'; + const delta = Number((afterValue - beforeValue).toFixed(6)); + const passed = direction === 'increase' ? delta >= 0 : delta <= 0; + return { + metric: name, + expected_direction: direction, + before_value: beforeValue, + after_value: afterValue, + delta, + passed + }; + }) + .filter(Boolean); +} + +function observedMovementStatus(results = []) { + if (results.length === 0) return 'no-comparable-metrics'; + if (results.every(result => result.passed)) return 'improved'; + if (results.every(result => !result.passed)) return 'regressed'; + return 'mixed'; +} + +function reviewDecision(row = {}) { + return stringValue(row.OperatorReview?.decision || row.OperatorReview?.status).toLowerCase(); +} + +function safeToken(value, fallback = 'recommendation') { + return stringValue(value || fallback) + .trim() + .replace(/[^A-Za-z0-9_.-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 80) || fallback; +} + +function recommendationActionPlanForRow(row = {}, options = {}) { + const validation = validateRecommendationRow(row); + if (!validation.ok) throw new Error(`recommendation row failed schema validation: ${validation.errors.join('; ')}`); + + const decision = reviewDecision(row); + const movementStatus = stringValue(row.ObservedMetricMovement?.status); + const benchmarkDecision = stringValue(row.BenchmarkDecision); + const approved = decision === 'approve' || decision === 'approved'; + const blockedReasons = [ + approved ? '' : 'operator review approval is required', + movementStatus === 'regressed' ? 'observed metric movement regressed' : '', + benchmarkDecision === 'reject' ? 'benchmark decision rejected the recommendation' : '', + Array.isArray(row.Validation) && row.Validation.length > 0 ? '' : 'validation steps are required', + row.RollbackCondition ? '' : 'rollback condition is required' + ].filter(Boolean); + const hypothesis = safeToken(options.hypothesis || row.RecommendationId || row.RunId, 'recommendation'); + const benchmarkSuite = safeToken(options.benchmarkSuite || 'starter', 'starter'); + const branch = `agentops/${hypothesis}`; + const targetRefs = Array.isArray(row.ChangeTargetRefs) ? row.ChangeTargetRefs : []; + const patchPrompt = [ + `Implement approved AgentOps recommendation ${row.RecommendationId}.`, + `Action: ${row.Action}.`, + `Observed pattern: ${row.ObservedPattern}.`, + `Next action: ${row.NextAction}.`, + targetRefs.length ? `Change targets: ${targetRefs.join(', ')}.` : 'Infer the smallest safe target from the recommendation metadata.', + row.BenchmarkRunId ? `Benchmark evidence: ${row.BenchmarkRunId} (${benchmarkDecision || 'unknown'}).` : '', + movementStatus ? `Observed metric movement: ${movementStatus}.` : '', + 'Keep prompts, responses, tool arguments, tool results, source code, file contents, request bodies, response bodies, and secrets out of telemetry artifacts.', + `Validation: ${(row.Validation || []).join(' | ') || 'run the benchmark/report commands in this plan'}.`, + `Rollback: ${row.RollbackCondition}.` + ].filter(Boolean).join('\n'); + + return { + schema_version: 'agentops.recommendation-action-plan.v1', + mode: 'metadata-only-recommendation-action-plan', + status: blockedReasons.length ? 'needs-review' : 'ready', + recommendation_id: row.RecommendationId || null, + run_id: row.RunId || null, + operator_review: row.OperatorReview || {}, + blocked_reasons: blockedReasons, + guardrails: [ + 'Create a branch before editing files.', + 'Make only the minimal patch described by the approved recommendation metadata.', + 'Run benchmark and recommendation comparison before promoting the change.', + 'Reject or rollback if validation fails, metric movement regresses, cost rises unexpectedly, or privacy signals appear.' + ], + commands: { + create_branch: `git checkout -b ${branch}`, + patch_prompt: patchPrompt, + benchmark_dry_run: `agentops benchmark run ${benchmarkSuite} --variant ${hypothesis} --repeat 1 --hypothesis ${hypothesis} --dry-run`, + benchmark_run: `agentops benchmark run ${benchmarkSuite} --variant ${hypothesis} --repeat 1 --hypothesis ${hypothesis}`, + benchmark_report: 'agentops benchmark report ', + compare_after_run: `agentops recommend compare --recommendation-id ${row.RecommendationId || ''} --after-runs --after-evals ` + }, + evidence: { + change_target_refs: targetRefs, + validation: row.Validation || [], + rollback_condition: row.RollbackCondition || '', + expected_metric_movement: row.ExpectedMetricMovement || {}, + observed_metric_movement: row.ObservedMetricMovement || {}, + before_telemetry: row.BeforeTelemetry || {}, + after_telemetry: row.AfterTelemetry || {} + }, + next: blockedReasons.length + ? ['Resolve blocked reasons, then regenerate this action plan.'] + : [ + 'Create the branch.', + 'Apply the minimal patch using the patch prompt.', + 'Run the benchmark dry-run, benchmark run, benchmark report, and recommendation compare commands.', + 'Promote only if benchmark and after-run movement pass.' + ] + }; +} + +function recommendationActionPlan({ + storePath = defaultRecommendationStorePath(), + recommendationId, + benchmarkSuite, + hypothesis +} = {}) { + if (!recommendationId) throw new Error('recommend action-plan requires --recommendation-id '); + const payload = readRecommendationStore(storePath); + const row = payload.recommendations.find(item => item.RecommendationId === recommendationId); + if (!row) throw new Error(`recommendation not found: ${recommendationId}`); + return { + path: storePath, + action_plan: recommendationActionPlanForRow(row, { benchmarkSuite, hypothesis }) + }; +} + +function compareRecommendationAfterRun({ + storePath = defaultRecommendationStorePath(), + recommendationId, + afterRunsFile, + afterEvalsFile, + afterRunId, + comparedAt = new Date().toISOString() +} = {}) { + if (!recommendationId) throw new Error('recommend compare requires --recommendation-id '); + if (!afterRunsFile) throw new Error('recommend compare requires --after-runs '); + + const payload = readRecommendationStore(storePath); + const row = payload.recommendations.find(item => item.RecommendationId === recommendationId); + if (!row) throw new Error(`recommendation not found: ${recommendationId}`); + + const afterRuns = readJsonl(afterRunsFile); + const afterRun = afterRunId + ? afterRuns.find(item => item.RunId === afterRunId) + : latestByTime(afterRuns); + if (!afterRun) throw new Error('no after-run rows were available'); + + const afterEval = readJsonl(afterEvalsFile).find(item => item.RunId === afterRun.RunId) || null; + const after = telemetrySnapshot(afterRun, afterEval); + const results = movementResults(row, after); + const updated = { + ...row, + AfterTelemetry: after, + ObservedMetricMovement: { + status: observedMovementStatus(results), + compared_at: comparedAt, + after_run_id: after.RunId || after.run_id || afterRun.RunId || '', + results + } + }; + const validation = validateRecommendationRow(updated); + if (!validation.ok) throw new Error(`updated recommendation row failed schema validation: ${validation.errors.join('; ')}`); + + const next = payload.recommendations + .map(item => item.RecommendationId === recommendationId ? updated : item) + .sort((left, right) => String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || ''))); + writeRecommendationStore({ recommendations: next }, storePath); + return { path: storePath, updated, after_run: afterRun.RunId || '', status: updated.ObservedMetricMovement.status }; +} + +function recommendationStoreCommand(args = []) { + const subcommand = args[0]; + const storePath = optionValue(args, '--store') || defaultRecommendationStorePath(); + if (subcommand === 'list') { + const payload = readRecommendationStore(storePath); + return { + path: storePath, + recommendations: payload.recommendations.map(row => ({ + RecommendationId: row.RecommendationId, + TimeGenerated: row.TimeGenerated, + Severity: row.Severity, + Action: row.Action, + RunId: row.RunId, + NextAction: row.NextAction, + ObservedMetricMovementStatus: row.ObservedMetricMovement?.status || '', + ChangeTargetRefs: row.ChangeTargetRefs || [] + })) + }; + } + if (subcommand === 'export') { + return { + path: storePath, + export: exportRecommendationStore({ storePath, outDir: optionValue(args, '--out') }) + }; + } + if (subcommand === 'compare') { + return compareRecommendationAfterRun({ + storePath, + recommendationId: optionValue(args, '--recommendation-id'), + afterRunsFile: optionValue(args, '--after-runs'), + afterEvalsFile: optionValue(args, '--after-evals'), + afterRunId: optionValue(args, '--after-run-id') + }); + } + if (subcommand === 'action-plan') { + return recommendationActionPlan({ + storePath, + recommendationId: optionValue(args, '--recommendation-id'), + benchmarkSuite: optionValue(args, '--benchmark-suite'), + hypothesis: optionValue(args, '--hypothesis') + }); + } + throw new Error('recommend requires latest|, list, export, compare, or action-plan'); +} + +module.exports = { + compareRecommendationAfterRun, + defaultRecommendationStorePath, + exportRecommendationStore, + readRecommendationStore, + recommendationActionPlan, + recommendationActionPlanForRow, + recommendationRow, + recommendationStoreCommand, + saveRecommendation, + telemetrySnapshot, + writeRecommendation, + writeRecommendationStore +}; diff --git a/agentops-cli/src/lib/rollup/span-to-agentops-tables.js b/agentops-cli/src/lib/rollup/span-to-agentops-tables.js index b1d1df6..20c14b4 100644 --- a/agentops-cli/src/lib/rollup/span-to-agentops-tables.js +++ b/agentops-cli/src/lib/rollup/span-to-agentops-tables.js @@ -1,18 +1,11 @@ -const crypto = require('node:crypto'); -const fs = require('node:fs'); const path = require('node:path'); +const { writeJsonFile, writeJsonlFile } = require('../command-output'); const { tableNames } = require('../demo/agentops-demo-data'); -const { sensitiveContentAttributes } = require('../schema/agentops-attributes'); - -function stableHash(value, prefix = 'h') { - return `${prefix}_${crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 16)}`; -} - -function readJsonlRows(filePath) { - const text = fs.readFileSync(filePath, 'utf8'); - return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); -} +const { prefixedHashOrEmpty: stableHash } = require('../hash'); +const { AGENTOPS_SCHEMA_VERSION, sensitiveContentAttributes } = require('../schema/agentops-attributes'); +const { readJsonlRows } = require('../json'); +const { isSpanTelemetryRow, operationFromRow, telemetryTime } = require('../session-row-utils'); function rowAttributes(row) { const attrs = row.attributes || row.Properties || row.properties || {}; @@ -43,20 +36,36 @@ function boolValue(value) { } function operation(row, attrs) { - return row.name || row.Name || row.operation || attr(attrs, ['gen_ai.operation.name', 'operation'], 'span'); + return operationFromRow(row, attrs); } function timestamp(row, index, baseTime) { - const value = row.TimeGenerated || row.timestamp || row.time || row.startTime; + const value = telemetryTime(row.TimeGenerated || row.timestamp || row.time || row.startTime); if (value && !Number.isNaN(new Date(value).getTime())) return new Date(value).toISOString(); return new Date(baseTime.getTime() + index * 1000).toISOString(); } +function endTimestamp(row, index, baseTime) { + const value = telemetryTime(row.endTime || row.EndTime || row.end_time); + if (value && !Number.isNaN(new Date(value).getTime())) return new Date(value).toISOString(); + return timestamp(row, index, baseTime); +} + +function rowDurationMs(row, index, baseTime) { + const explicit = numberValue(row.DurationMs ?? row.durationMs ?? row.duration_ms); + if (explicit > 0) return explicit; + const start = new Date(timestamp(row, index, baseTime)).getTime(); + const end = new Date(endTimestamp(row, index, baseTime)).getTime(); + return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0; +} + function failed(row, attrs) { const status = row.Status ?? row.status?.code ?? row.status ?? row.ResultCode ?? row.resultCode; const success = row.Success ?? row.success; if (success === false || String(success).toLowerCase() === 'false') return true; if (['failed', 'failure', 'error', 'blocked', 'degraded'].includes(String(status || '').toLowerCase())) return true; + const outcome = String(attr(attrs, ['agentops.outcome', 'agentops.outcome.status'], '')).toLowerCase(); + if (/failed|failure|error|blocked|denied|aborted/.test(outcome)) return true; return Boolean(attr(attrs, ['error.type', 'exception.type', 'error'], '')); } @@ -90,7 +99,7 @@ function mcpToolName(tool) { function isMcpSpan(op, attrs, tool) { return op === 'mcp.tools.call' - || Boolean(attr(attrs, ['mcp.method.name', 'mcp.session.id', 'mcp.server.name', 'agentops.mcp.server.hash'], '')) + || Boolean(attr(attrs, ['agentops.mcp.server', 'agentops.mcp.tool', 'mcp.method.name', 'mcp.session.id', 'mcp.server.name', 'agentops.mcp.server.hash'], '')) || /^mcp__[^_]+__/.test(String(tool || '')); } @@ -115,15 +124,33 @@ function contentSignals(attrs) { })); } +function sdkContentSignal(attrs) { + if (!boolValue(attr(attrs, ['agentops.content_capture.signal'], false))) return null; + return { + kind: String(attr(attrs, ['agentops.content.kind'], 'metadata')), + action: String(attr(attrs, ['agentops.content.action'], 'dropped')), + droppedBytes: numberValue(attr(attrs, ['agentops.content.dropped_bytes'], 0)), + secretLike: boolValue(attr(attrs, ['agentops.content.secret_like'], false)) + }; +} + function emptyTables() { return Object.fromEntries(tableNames.map(name => [name, []])); } -function addEvent(tables, run, row, attrs, index, baseTime) { +function addEvent(tables, run, row, attrs, index, baseTime, sequence) { const op = operation(row, attrs); const tool = attr(attrs, ['gen_ai.tool.name', 'tool'], ''); + const rawEventId = row.Id || row.id || row.SpanId || row.spanId || `${run.RunId}:${sequence}:${op}`; + const rawParentId = row.ParentId || row.parentId || row.ParentSpanId || row.parentSpanId || ''; + const explicitEventId = attr(attrs, ['agentops.custom_event_id'], ''); + const explicitParentId = attr(attrs, ['agentops.parent_event_id'], ''); + const safeSignal = sdkContentSignal(attrs); tables.AgentOpsEvents_CL.push({ TimeGenerated: timestamp(row, index, baseTime), + Sequence: numberValue(attr(attrs, ['agentops.event.sequence'], sequence)) || sequence, + EventId: String(explicitEventId || stableHash(rawEventId, 'event')), + ParentEventId: String(explicitParentId || (rawParentId ? stableHash(rawParentId, 'event') : '')), RunId: run.RunId, SessionId: run.SessionId, TraceId: run.TraceId, @@ -136,14 +163,40 @@ function addEvent(tables, run, row, attrs, index, baseTime) { SubAgentName: run.SubAgentName || '', DelegationId: run.DelegationId || '', ToolName: tool, + McpServerName: String(attr(attrs, ['agentops.mcp.server', 'mcp.server.name'], '')), + McpToolName: String(attr(attrs, ['agentops.mcp.tool'], '')), + CommandName: String(attr(attrs, ['agentops.command.name'], '')), + ScriptName: String(attr(attrs, ['agentops.script.name'], '')), ModelActual: run.ModelActual, Status: failed(row, attrs) ? 'failed' : 'success', - DurationMs: numberValue(row.DurationMs ?? row.durationMs ?? row.duration_ms), + DurationMs: rowDurationMs(row, index, baseTime), InputTokens: numberValue(attr(attrs, ['gen_ai.usage.input_tokens', 'InputTokens', 'input_tokens'], 0)), OutputTokens: numberValue(attr(attrs, ['gen_ai.usage.output_tokens', 'OutputTokens', 'output_tokens'], 0)), + ReasoningTokens: numberValue(attr(attrs, ['gen_ai.usage.reasoning.output_tokens'], 0)), + CacheReadTokens: numberValue(attr(attrs, ['gen_ai.usage.cache_read.input_tokens'], 0)), + CacheWriteTokens: numberValue(attr(attrs, ['gen_ai.usage.cache_creation.input_tokens'], 0)), + TotalTokens: numberValue(attr(attrs, ['gen_ai.usage.total_tokens'], 0)), + TotalToolCalls: numberValue(attr(attrs, ['agentops.tools.count'], 0)), + CopilotCost: numberValue(attr(attrs, ['github.copilot.cost'], 0)), EstimatedCostUsd: numberValue(attr(attrs, ['agentops.cost.estimated_usd'], 0)), + PermissionKind: String(attr(attrs, ['agentops.permission.kind'], '')), + PermissionDecision: String(attr(attrs, ['agentops.permission.decision'], '')), + ErrorType: String(attr(attrs, ['error.type', 'exception.type'], '')), + PremiumRequests: numberValue(attr(attrs, ['github.copilot.premium_requests'], 0)), + TotalNanoAiu: numberValue(attr(attrs, ['github.copilot.aiu.nano'], 0)), + ApiDurationMs: numberValue(attr(attrs, ['agentops.api.duration_ms'], 0)), + LinesAdded: numberValue(attr(attrs, ['agentops.lines.added'], 0)), + LinesRemoved: numberValue(attr(attrs, ['agentops.lines.removed'], 0)), + FilesModified: numberValue(attr(attrs, ['agentops.files.edited_count'], 0)), PrivacyMode: run.PrivacyMode, - ContentCaptureSignal: contentSignals(attrs).length > 0 + ContentCaptureSignal: Boolean(safeSignal || contentSignals(attrs).length > 0), + ContentDroppedBytes: safeSignal?.droppedBytes || 0, + ContentAction: safeSignal?.action || '', + SecretLike: safeSignal?.secretLike || false, + ContentCaptureMode: String(attr(attrs, ['agentops.content_capture.mode'], run.ContentCaptureMode)), + RepoHash: String(attr(attrs, ['agentops.repo.hash'], run.RepoHash)), + BranchHash: String(attr(attrs, ['agentops.branch.hash'], run.BranchHash)), + WorkingDirectoryHash: String(attr(attrs, ['agentops.workspace.hash'], '')) }); } @@ -153,7 +206,7 @@ function rollupSpanRows(rows, options = {}) { const sessions = new Map(); let currentSessionId = null; - rows.forEach((row, index) => { + rows.filter(isSpanTelemetryRow).forEach((row, index) => { const attrs = rowAttributes(row); let sessionId = row.SessionId || row.session @@ -166,19 +219,25 @@ function rollupSpanRows(rows, options = {}) { }); for (const [sessionId, items] of sessions.entries()) { + items.sort((left, right) => ( + new Date(timestamp(left.row, left.index, baseTime)).getTime() + - new Date(timestamp(right.row, right.index, baseTime)).getTime() + )); const first = items[0]; const runId = attr(first.attrs, ['agentops.run.id', 'agentops.wrapper.run_id'], stableHash(sessionId, 'run')); - const traceId = first.row.OperationId || first.row.TraceId || stableHash(`${sessionId}:trace`, 'trace'); - const repoHash = attr(first.attrs, ['agentops.repo.hash'], stableHash(options.repo || 'unknown-repo', 'repo')); - const branchHash = attr(first.attrs, ['agentops.branch.hash'], stableHash(options.branch || 'unknown-branch', 'branch')); - const agentName = attr(first.attrs, ['agentops.agent.name', 'gen_ai.agent.name'], ''); + const traceId = first.row.OperationId || first.row.TraceId || first.row.traceId || stableHash(`${sessionId}:trace`, 'trace'); + const repoHash = firstAttr(items, ['agentops.repo.hash'], stableHash(options.repo || 'unknown-repo', 'repo')); + const branchHash = firstAttr(items, ['agentops.branch.hash'], stableHash(options.branch || 'unknown-branch', 'branch')); + const agentName = firstAttr(items, ['agentops.agent.name', 'gen_ai.agent.name', 'gen_ai.agent.id']); const skillName = firstAttr(items, ['agentops.skill.name', 'github.copilot.skill.name']); const parentAgentName = firstAttr(items, ['agentops.parent_agent.name', 'agentops.parent.agent.name']); const subAgentName = firstAttr(items, ['agentops.sub_agent.name', 'agentops.child_agent.name']); const delegationId = firstAttr(items, ['agentops.delegation.id']); - const model = attr(first.attrs, ['agentops.model.actual', 'gen_ai.response.model', 'gen_ai.request.model'], ''); + const model = firstAttr(items, ['agentops.model.actual', 'gen_ai.response.model', 'gen_ai.request.model']); const started = timestamp(first.row, first.index, baseTime); - const ended = timestamp(items.at(-1).row, items.at(-1).index, baseTime); + const ended = items + .map(item => endTimestamp(item.row, item.index, baseTime)) + .sort((left, right) => new Date(right) - new Date(left))[0]; const durationMs = Math.max(0, new Date(ended).getTime() - new Date(started).getTime()); let inputTokens = 0; @@ -195,7 +254,17 @@ function rollupSpanRows(rows, options = {}) { let toolDeniedCount = 0; let failures = 0; let privacyDrops = 0; + let directFilesModified = 0; const tools = new Set(); + const chatItems = items.filter(item => operation(item.row, item.attrs) === 'chat'); + const usageItems = chatItems.length > 0 + ? chatItems + : (() => { + const invokeItems = items.filter(item => operation(item.row, item.attrs) === 'invoke_agent'); + return invokeItems.length > 0 + ? invokeItems + : items.filter(item => operation(item.row, item.attrs) === 'assistant.usage'); + })(); const run = { TimeGenerated: ended, @@ -218,31 +287,42 @@ function rollupSpanRows(rows, options = {}) { ContentCaptureSignal: false }; - for (const item of items) { + for (const [sequenceIndex, item] of items.entries()) { const { row, attrs, index } = item; const op = operation(row, attrs); const tool = attr(attrs, ['gen_ai.tool.name', 'tool'], ''); const rowFailed = failed(row, attrs); const signals = contentSignals(attrs); - - inputTokens += numberValue(attr(attrs, ['gen_ai.usage.input_tokens', 'InputTokens', 'input_tokens'], 0)); - outputTokens += numberValue(attr(attrs, ['gen_ai.usage.output_tokens', 'OutputTokens', 'output_tokens'], 0)); - reasoningTokens += numberValue(attr(attrs, ['gen_ai.usage.reasoning.output_tokens'], 0)); - cacheReadTokens += numberValue(attr(attrs, ['agentops.cache.read_input_tokens', 'gen_ai.usage.cache_read.input_tokens', 'CacheReadTokens', 'CacheRead'], 0)); - cacheCreationTokens += numberValue(attr(attrs, ['agentops.cache.creation_input_tokens', 'gen_ai.usage.cache_creation.input_tokens', 'CacheCreationTokens'], 0)); + const safeSignal = sdkContentSignal(attrs); + const eventName = String(attr(attrs, ['agentops.event.name'], op)); + const permissionDecision = String(attr(attrs, ['agentops.permission.decision'], '')).toLowerCase(); + const permissionDenied = /denied|reject|blocked/.test(permissionDecision); + const toolLifecycleStart = eventName === 'tool.execution_start'; + + if (usageItems.includes(item)) { + inputTokens += numberValue(attr(attrs, ['gen_ai.usage.input_tokens', 'InputTokens', 'input_tokens'], 0)); + outputTokens += numberValue(attr(attrs, ['gen_ai.usage.output_tokens', 'OutputTokens', 'output_tokens'], 0)); + reasoningTokens += numberValue(attr(attrs, ['gen_ai.usage.reasoning.output_tokens'], 0)); + cacheReadTokens += numberValue(attr(attrs, ['agentops.cache.read_input_tokens', 'gen_ai.usage.cache_read.input_tokens', 'CacheReadTokens', 'CacheRead'], 0)); + cacheCreationTokens += numberValue(attr(attrs, ['agentops.cache.creation_input_tokens', 'gen_ai.usage.cache_creation.input_tokens', 'CacheCreationTokens'], 0)); + const explicitCost = numberValue(attr(attrs, ['agentops.cost.estimated_usd'], 0)); + const credits = numberValue(attr(attrs, ['github.copilot.cost'], 0)); + estimatedCostUsd += explicitCost || credits * 0.01; + } tokensRemoved += numberValue(attr(attrs, ['agentops.context.tokens_removed', 'github.copilot.tokens_removed', 'TokensRemoved'], 0)); permissionWaitMs += numberValue(attr(attrs, ['agentops.permission.wait_ms', 'github.copilot.permission.wait_ms', 'PermissionWaitMs'], 0)); contextWindowPct = Math.max(contextWindowPct, numberValue(attr(attrs, ['agentops.context.window_pct', 'github.copilot.context.window_pct', 'ContextWindowPct'], 0))); - estimatedCostUsd += numberValue(attr(attrs, ['agentops.cost.estimated_usd'], 0)); + directFilesModified = Math.max(directFilesModified, numberValue(attr(attrs, ['agentops.files.edited_count'], 0))); if (rowFailed) failures += 1; + if (permissionDenied) toolDeniedCount += 1; - if (op === 'execute_tool' || tool) { + if ((op === 'execute_tool' || tool) && !toolLifecycleStart) { const risk = riskForTool(tool); - const allowed = boolValue(attr(attrs, ['agentops.mcp.allowed'], true)); + const allowed = !permissionDenied && boolValue(attr(attrs, ['agentops.mcp.allowed'], true)); toolCount += 1; tools.add(String(tool || 'tool')); if (rowFailed) toolFailureCount += 1; - if (allowed === false) toolDeniedCount += 1; + if (allowed === false && !permissionDenied) toolDeniedCount += 1; tables.AgentOpsToolCalls_CL.push({ TimeGenerated: timestamp(row, index, baseTime), RunId: runId, @@ -255,16 +335,16 @@ function rollupSpanRows(rows, options = {}) { Allowed: allowed, DeniedReason: allowed ? '' : String(attr(attrs, ['agentops.mcp.denied_reason'], 'policy_denied')), Status: rowFailed ? 'failed' : 'success', - DurationMs: numberValue(row.DurationMs ?? row.durationMs ?? row.duration_ms), + DurationMs: rowDurationMs(row, index, baseTime), ErrorType: String(attr(attrs, ['error.type', 'exception.type', 'error'], '')), - OutputSizeBytes: numberValue(attr(attrs, ['agentops.mcp.result_size_bytes'], 0)), + OutputSizeBytes: numberValue(attr(attrs, ['agentops.tool.result_size_bytes', 'agentops.mcp.result_size_bytes'], 0)), AgentName: run.AgentName, - ArgsSchemaHash: String(attr(attrs, ['agentops.mcp.args_schema_hash'], stableHash(`${tool}:schema`, 'schema'))) + ArgsSchemaHash: String(attr(attrs, ['agentops.tool.args_schema_hash', 'agentops.mcp.args_schema_hash'], stableHash(`${tool}:schema`, 'schema'))) }); if (isMcpSpan(op, attrs, tool)) { - const serverName = String(attr(attrs, ['mcp.server.name'], mcpServerFromTool(tool) || 'unknown-mcp')); - const resultSize = numberValue(attr(attrs, ['agentops.mcp.result_size_bytes'], 0)); + const serverName = String(attr(attrs, ['agentops.mcp.server', 'mcp.server.name'], mcpServerFromTool(tool) || 'unknown-mcp')); + const resultSize = numberValue(attr(attrs, ['agentops.tool.result_size_bytes', 'agentops.mcp.result_size_bytes'], 0)); tables.AgentOpsMcpCalls_CL.push({ TimeGenerated: timestamp(row, index, baseTime), RunId: runId, @@ -277,17 +357,17 @@ function rollupSpanRows(rows, options = {}) { McpTransport: String(attr(attrs, ['mcp.transport'], 'unknown')), Surface: run.Surface, AgentName: run.AgentName, - ToolName: String(mcpToolName(tool || attr(attrs, ['gen_ai.tool.name'], 'tool'))), + ToolName: String(attr(attrs, ['agentops.mcp.tool'], mcpToolName(tool || attr(attrs, ['gen_ai.tool.name'], 'tool')))), ToolType: risk, ToolRisk: String(attr(attrs, ['agentops.mcp.tool.risk'], risk)), Allowed: allowed, DeniedReason: allowed ? '' : String(attr(attrs, ['agentops.mcp.denied_reason'], 'policy_denied')), Sandboxed: boolValue(attr(attrs, ['agentops.mcp.sandboxed'], false)), Status: rowFailed ? 'failed' : 'success', - DurationMs: numberValue(row.DurationMs ?? row.durationMs ?? row.duration_ms), + DurationMs: rowDurationMs(row, index, baseTime), OutputSizeBytes: resultSize, ResultSizeBytes: resultSize, - ArgsSchemaHash: String(attr(attrs, ['agentops.mcp.args_schema_hash'], stableHash(`${serverName}:${tool}:schema`, 'schema'))) + ArgsSchemaHash: String(attr(attrs, ['agentops.tool.args_schema_hash', 'agentops.mcp.args_schema_hash'], stableHash(`${serverName}:${tool}:schema`, 'schema'))) }); } } @@ -307,8 +387,23 @@ function rollupSpanRows(rows, options = {}) { LeakDetected: false }); } + if (safeSignal) { + privacyDrops += 1; + tables.AgentOpsPrivacy_CL.push({ + TimeGenerated: timestamp(row, index, baseTime), + RunId: runId, + TraceId: traceId, + PrivacyMode: String(attr(attrs, ['agentops.privacy.mode'], 'strict')), + ContentKind: safeSignal.kind, + Observed: true, + Action: safeSignal.action, + DroppedCount: 1, + RedactedCount: safeSignal.action === 'redacted' ? 1 : 0, + LeakDetected: false + }); + } - addEvent(tables, run, row, attrs, index, baseTime); + addEvent(tables, run, row, attrs, index, baseTime, sequenceIndex + 1); } run.ContentCaptureMode = privacyDrops > 0 ? 'signal_only' : 'off'; @@ -331,7 +426,7 @@ function rollupSpanRows(rows, options = {}) { run.TestsRan = [...tools].some(tool => /test|lint|typecheck/i.test(tool)); run.TestsPassed = run.TestsRan && toolFailureCount === 0; run.FilesReadCount = [...tools].filter(tool => /read/i.test(tool)).length; - run.FilesEditedCount = [...tools].filter(tool => /edit|write|patch/i.test(tool)).length; + run.FilesEditedCount = directFilesModified || [...tools].filter(tool => /edit|write|patch/i.test(tool)).length; run.PrOpened = items.some(item => githubBool(item.attrs, ['agentops.pr.opened', 'github.pr.opened', 'github.pull_request.opened'])); run.PrNumberHash = String(attr(items.find(item => attr(item.attrs, ['agentops.pr.number_hash', 'github.pr.number_hash'], ''))?.attrs || {}, ['agentops.pr.number_hash', 'github.pr.number_hash'], '')); run.CiStatus = String(attr(items.find(item => attr(item.attrs, ['agentops.ci.status', 'github.ci.status'], ''))?.attrs || {}, ['agentops.ci.status', 'github.ci.status'], run.PrOpened ? 'unknown' : 'not_run')); @@ -401,6 +496,12 @@ function rollupSpanRows(rows, options = {}) { SchemaVersion: '2' }); + for (const tableRows of Object.values(tables)) { + for (const row of tableRows) { + if (row.SchemaVersion === undefined) row.SchemaVersion = AGENTOPS_SCHEMA_VERSION; + } + } + return { ok: true, generated_at: new Date().toISOString(), @@ -411,20 +512,19 @@ function rollupSpanRows(rows, options = {}) { } function writeTables(result, outDir) { - fs.mkdirSync(outDir, { recursive: true }); const files = {}; for (const table of tableNames) { const file = path.join(outDir, `${table}.jsonl`); - fs.writeFileSync(file, `${result.tables[table].map(row => JSON.stringify(row)).join('\n')}\n`); + writeJsonlFile(file, result.tables[table], { trailingNewline: true }); files[table] = file; } const manifest = path.join(outDir, 'manifest.json'); - fs.writeFileSync(manifest, `${JSON.stringify({ + writeJsonFile(manifest, { generated_at: result.generated_at, runs: result.runs, table_counts: result.table_counts, files - }, null, 2)}\n`); + }); return { out_dir: outDir, manifest, files }; } diff --git a/agentops-cli/src/lib/run-summary-command.js b/agentops-cli/src/lib/run-summary-command.js new file mode 100644 index 0000000..8083065 --- /dev/null +++ b/agentops-cli/src/lib/run-summary-command.js @@ -0,0 +1,49 @@ +const path = require('node:path'); + +const { hasFlag, optionValue } = require('./args'); +const { writeJson } = require('./command-output'); +const { readJsonlRows, rollupSpanRows, writeTables } = require('./rollup/span-to-agentops-tables'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +function runSummaryCommand(args = []) { + const [subcommand = 'generate'] = args; + if (subcommand !== 'generate') throw new Error('run-summary supports: generate'); + + const file = optionValue(args, ['--file', '--jsonl']); + if (!file) throw new Error('run-summary generate requires --file '); + + const input = path.resolve(file); + const outDir = path.resolve(optionValue(args, '--out', path.join(repoRoot, '.agentops', 'run-summary', 'latest'))); + const rows = readJsonlRows(input); + const result = rollupSpanRows(rows, { + surface: optionValue(args, '--surface', 'cli'), + repo: optionValue(args, '--repo', 'unknown-repo'), + branch: optionValue(args, '--branch', 'unknown-branch') + }); + const written = writeTables(result, outDir); + const payload = { + ok: result.ok, + input, + runs: result.runs, + out_dir: written.out_dir, + manifest: written.manifest, + table_counts: result.table_counts, + next: [ + `agentops latest --file ${written.files.AgentOpsRunSummary_CL}`, + `agentops replay latest --file ${written.files.AgentOpsEvents_CL}` + ] + }; + + if (hasFlag(args, '--json')) { + writeJson(payload); + } else { + process.stdout.write(`Generated ${payload.runs} AgentOps run summar${payload.runs === 1 ? 'y' : 'ies'}.\n`); + process.stdout.write(`Output: ${payload.out_dir}\n`); + process.stdout.write(`Next: ${payload.next[0]}\n`); + } +} + +module.exports = { + runSummaryCommand +}; diff --git a/agentops-cli/src/lib/schema-command.js b/agentops-cli/src/lib/schema-command.js new file mode 100644 index 0000000..0482d54 --- /dev/null +++ b/agentops-cli/src/lib/schema-command.js @@ -0,0 +1,28 @@ +const { writeJson } = require('./command-output'); +const { readJson } = require('./json'); +const { exampleAgentRunAttributes, schemaDocument, validateAgentRun } = require('./schema/agent-run-schema'); + +function readInputFile(args) { + const index = args.indexOf('--file'); + if (index === -1) return null; + if (!args[index + 1]) throw new Error('--file requires a path'); + return readJson(args[index + 1]); +} + +function schemaCommand(args = []) { + const [subcommand = 'validate'] = args; + if (subcommand === 'print') { + writeJson(schemaDocument()); + return; + } + if (subcommand !== 'validate') throw new Error('schema supports: validate|print'); + + const input = readInputFile(args) || { attributes: exampleAgentRunAttributes() }; + const result = validateAgentRun(input); + writeJson(result); + if (!result.ok) process.exitCode = 1; +} + +module.exports = { + schemaCommand +}; diff --git a/agentops-cli/src/lib/schema/agentops-attributes.js b/agentops-cli/src/lib/schema/agentops-attributes.js index 786872a..cd143ff 100644 --- a/agentops-cli/src/lib/schema/agentops-attributes.js +++ b/agentops-cli/src/lib/schema/agentops-attributes.js @@ -22,6 +22,9 @@ const optionalAgentOpsAttributes = [ 'agentops.parent_agent.name', 'agentops.sub_agent.name', 'agentops.delegation.id', + 'agentops.subagent.duration_ms', + 'agentops.subagent.total_tokens', + 'agentops.subagent.tool_count', 'agentops.skill.name', 'agentops.model.requested', 'agentops.model.actual', diff --git a/agentops-cli/src/lib/security-audit.js b/agentops-cli/src/lib/security-audit.js index e67bad6..314b6ee 100644 --- a/agentops-cli/src/lib/security-audit.js +++ b/agentops-cli/src/lib/security-audit.js @@ -4,7 +4,8 @@ const path = require('node:path'); const { validateCollectorArtifacts, validateOwaspFixtures } = require('./collector-artifacts'); const { validateDashboardContentGuardrails } = require('./dashboard-content-guardrails'); const { poisonCheck } = require('./privacy'); -const { repoRoot } = require('./paths'); +const { collectorHome, repoRoot } = require('./paths'); +const { readJson } = require('./json'); const { commandExists, run } = require('./shell'); function finding(name, ok, detail = null, severity = 'error', evidence = []) { @@ -31,7 +32,7 @@ function isSourceCheckout(root) { function isInstalledPackage(root) { try { - return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name === 'copilot-agentops-cli' + return readJson(path.join(root, 'package.json')).name === 'copilot-agentops-cli' && !isSourceCheckout(root); } catch { return false; @@ -72,9 +73,9 @@ const postureControls = [ status: 'covered', summary: 'Prompt-like content is dropped in strict mode and prompt-injection abuse fixtures must sanitize before export.', evidence: [ - evidenceItem('collector/tests/owasp-abuse-fixtures/injected-tool-instructions.json', 'injected tool instruction fixture'), - evidenceItem('collector/tests/owasp-abuse-fixtures/mcp-prompt-injection.json', 'MCP prompt injection fixture'), - evidenceItem('collector/tests/owasp-abuse-fixtures/prompt-injection.json', 'prompt injection abuse fixture'), + evidenceItem('collector/security-fixtures/owasp-abuse-fixtures/injected-tool-instructions.json', 'injected tool instruction fixture'), + evidenceItem('collector/security-fixtures/owasp-abuse-fixtures/mcp-prompt-injection.json', 'MCP prompt injection fixture'), + evidenceItem('collector/security-fixtures/owasp-abuse-fixtures/prompt-injection.json', 'prompt injection abuse fixture'), evidenceItem('collector/processors/content-signal.yaml', 'content-signal processor'), evidenceItem('agentops-cli/src/lib/privacy.js', 'strict sanitizer') ] @@ -86,8 +87,8 @@ const postureControls = [ status: 'covered', summary: 'Strict privacy mode drops content-like and secret-like fields; optional transcript capture requires explicit restricted-workspace, short-retention, RBAC-style guardrails.', evidence: [ - evidenceItem('collector/tests/privacy-poison-fixtures/content-poison.json', 'content poison fixture'), - evidenceItem('collector/tests/owasp-abuse-fixtures/secret-tool-result.json', 'secret-like tool result fixture'), + evidenceItem('collector/security-fixtures/privacy-poison-fixtures/content-poison.json', 'content poison fixture'), + evidenceItem('collector/security-fixtures/owasp-abuse-fixtures/secret-tool-result.json', 'secret-like tool result fixture'), evidenceItem('agentops-cli/src/lib/dashboard-content-guardrails.js', 'dashboard content guardrail'), evidenceItem('docs/privacy-modes.md', 'content capture restricted workspace guidance'), evidenceItem('docs/azure-production-hardening.md', 'retention and RBAC production hardening') @@ -136,7 +137,7 @@ const postureControls = [ status: 'covered', summary: 'Tool risk, denied calls, broad-permission modes, MCP metadata, and abuse fixtures are tracked without capturing raw tool content.', evidence: [ - evidenceItem('collector/tests/owasp-abuse-fixtures/broad-tool-permissions.json', 'broad permission abuse fixture'), + evidenceItem('collector/security-fixtures/owasp-abuse-fixtures/broad-tool-permissions.json', 'broad permission abuse fixture'), evidenceItem('agentops-cli/src/lib/mcp/risk-classifier.js', 'MCP and tool risk classifier'), evidenceItem('grafana/dashboards/v2/05-tools-mcp-risk.json', 'tool and MCP risk dashboard') ] @@ -182,7 +183,7 @@ const postureControls = [ status: 'covered', summary: 'Cost, token, latency, runaway loop, and Azure budget/alert posture are tested and surfaced in dashboards.', evidence: [ - evidenceItem('collector/tests/owasp-abuse-fixtures/runaway-tool-loop.json', 'runaway tool loop fixture'), + evidenceItem('collector/security-fixtures/owasp-abuse-fixtures/runaway-tool-loop.json', 'runaway tool loop fixture'), evidenceItem('grafana/dashboards/v2/04-models-cost-tokens.json', 'model cost and token dashboard'), evidenceItem('docs/azure-production-hardening.md', 'budget and alert hardening') ] @@ -419,7 +420,7 @@ function contentCaptureOperationalGuardrailsCheck(options = {}) { terms: ['--allow-content', 'access-controlled workspace/dashboard'] }, { - file: 'agentops-cli/src/commands/content.js', + file: 'agentops-cli/src/lib/content-status.js', terms: ['restricted to approved viewers', 'AgentOpsContent_CL can contain sensitive text'] } ]; @@ -463,6 +464,188 @@ function dashboardEvidenceDisclaimerCheck(options = {}) { ); } +const persistentQueueConfigs = [ + 'collector/otelcol.azuremonitor.strict.yaml', + 'collector/otelcol.azuremonitor.compat.yaml', + 'collector/otelcol.binary.strict.yaml', + 'collector/otelcol.binary.compat.yaml' +]; + +function pipelineProcessors(body, signal) { + const list = body.match(new RegExp(`^ ${signal}:\\s*\\n(?:^ .+\\n)*?^ processors:\\s*\\[([^\\]]*)\\]`, 'm'))?.[1] || ''; + return list.split(',').map(value => value.trim()).filter(Boolean); +} + +function persistentCollectorQueueCheck(options = {}) { + const root = options.root || repoRoot; + const errors = []; + const evidence = []; + + for (const file of persistentQueueConfigs) { + const resolved = sourceEvidencePath(root, file); + const absolute = path.join(root, resolved); + if (!fs.existsSync(absolute)) { + errors.push(`${resolved}: required persistent-queue config is missing`); + continue; + } + const body = fs.readFileSync(absolute, 'utf8').replace(/\r\n/g, '\n'); + const fileStorage = body.match(/^ file_storage:\s*\n([\s\S]*?)(?=^processors:)/m)?.[1] || ''; + const azureExporter = body.match(/^ azuremonitor:\s*\n([\s\S]*?)(?=^service:)/m)?.[1] || ''; + const queue = azureExporter.match(/^ sending_queue:\s*\n((?:^ .+\n?)*)/m)?.[1] || ''; + const size = Number(queue.match(/^ queue_size:\s*(\d+)\s*$/m)?.[1]); + const strict = file.includes('.strict.'); + + if (!/directory:\s*\$\{env:AGENTOPS_OTEL_STORAGE_DIR\}/.test(fileStorage)) errors.push(`${resolved}: file_storage must use AGENTOPS_OTEL_STORAGE_DIR`); + if (!/create_directory:\s*true/.test(fileStorage)) errors.push(`${resolved}: file_storage must create its configured directory`); + if (!/^ extensions:\s*\[[^\]]*file_storage[^\]]*\]/m.test(body)) errors.push(`${resolved}: service must enable file_storage`); + if (!/enabled:\s*true/.test(queue)) errors.push(`${resolved}: sending_queue must be enabled`); + if (!/storage:\s*file_storage/.test(queue)) errors.push(`${resolved}: sending_queue must persist through file_storage`); + if (!Number.isInteger(size) || size < 1 || size > 10000) errors.push(`${resolved}: sending_queue queue_size must be bounded between 1 and 10000`); + + if (strict) { + for (const signal of ['traces', 'metrics', 'logs']) { + const processors = pipelineProcessors(body, signal); + const privacyIndex = processors.indexOf('transform/privacy_strict'); + const batchIndex = processors.indexOf('batch'); + if (privacyIndex < 0) errors.push(`${resolved}: ${signal} pipeline must include transform/privacy_strict before export`); + if (batchIndex >= 0 && privacyIndex > batchIndex) errors.push(`${resolved}: ${signal} pipeline must sanitize before batch/queue/export`); + } + } + evidence.push({ file: resolved, queue_size: size || null, strict }); + } + + const queueDir = options.collectorQueueDir || path.join(collectorHome, 'queue'); + let runtime = { directory: queueDir, exists: false, permissions_checked: false }; + if (fs.existsSync(queueDir)) { + const mode = fs.statSync(queueDir).mode & 0o777; + runtime = { directory: queueDir, exists: true, permissions_checked: process.platform !== 'win32', mode: mode.toString(8).padStart(3, '0') }; + if (process.platform !== 'win32' && (mode & 0o077) !== 0) errors.push(`${queueDir}: persistent queue directory permissions must not grant group/other access`); + } + evidence.push(runtime); + + return finding( + 'collector-persistent-queue-security', + errors.length === 0, + errors.length === 0 + ? `${persistentQueueConfigs.length} persistent queue configs are bounded, privacy-first, and runtime permissions are safe when present` + : errors.join('; '), + 'error', + evidence + ); +} + +function localStrictCollectorSecurityCheck(options = {}) { + const root = options.root || repoRoot; + const file = sourceEvidencePath(root, 'collector/otelcol.local.strict.yaml'); + const absolute = path.join(root, file); + if (!fs.existsSync(absolute)) { + return finding('collector-local-strict-security', false, `${file}: strict local Collector config is missing`, 'error', [{ file }]); + } + + const body = fs.readFileSync(absolute, 'utf8').replace(/\r\n/g, '\n'); + const errors = []; + const requirePattern = (pattern, message) => { + if (!pattern.test(body)) errors.push(message); + }; + + requirePattern(/directory:\s*\$\{env:AGENTOPS_OTEL_STORAGE_DIR\}/, `${file}: local durable storage must use AGENTOPS_OTEL_STORAGE_DIR`); + requirePattern(/create_directory:\s*true/, `${file}: local durable storage must create its configured directory`); + requirePattern(/fsync:\s*true/, `${file}: local durable storage must enable fsync`); + requirePattern(/^ extensions:\s*\[[^\]]*file_storage[^\]]*\]/m, `${file}: local service must enable file_storage`); + requirePattern(/^ otlp_http\/local_receipt:\s*\n[\s\S]*?sending_queue:\s*\n[\s\S]*?enabled:\s*true/m, `${file}: local receipt relay must have a durable sending queue`); + requirePattern(/storage:\s*file_storage/, `${file}: local receipt relay must persist through file_storage`); + requirePattern(/queue_size:\s*(?:[1-9]\d{0,3}|10000)\s*$/m, `${file}: local receipt queue must be bounded`); + requirePattern(/error_mode:\s*propagate/, `${file}: strict privacy transforms must fail closed`); + requirePattern(/endpoint:\s*127\.0\.0\.1:4319/, `${file}: local receipt relay must remain loopback-only`); + + for (const signal of ['traces', 'metrics', 'logs']) { + const processors = pipelineProcessors(body, signal); + const privacyIndex = processors.indexOf('transform/privacy_strict'); + const batchIndex = processors.indexOf('batch'); + if (privacyIndex < 0) errors.push(`${file}: ${signal} pipeline must include transform/privacy_strict`); + if (batchIndex >= 0 && privacyIndex > batchIndex) errors.push(`${file}: ${signal} pipeline must sanitize before batch/queue/export`); + } + + return finding( + 'collector-local-strict-security', + errors.length === 0, + errors.length === 0 + ? 'strict local Collector uses loopback receivers, fail-closed privacy filtering, and a bounded private receipt queue' + : errors.join('; '), + 'error', + [{ file, controls: ['loopback-receivers', 'strict-transform', 'fail-closed-transform', 'private-file-storage', 'bounded-receipt-queue'] }] + ); +} + +function durableReceiptSecurityCheck(options = {}) { + const root = options.root || repoRoot; + const spoolFile = sourceEvidencePath(root, 'agentops-cli/src/lib/azure/durable-evidence-spool.js'); + const uploadFile = sourceEvidencePath(root, 'agentops-cli/src/lib/azure/logs-ingestion-upload.js'); + const subscriptionFile = sourceEvidencePath(root, 'agentops-cli/src/lib/azure/subscription-guard.js'); + const errors = []; + const evidence = []; + const read = file => { + const absolute = path.join(root, file); + if (!fs.existsSync(absolute)) { + errors.push(`${file}: required durable receipt security evidence is missing`); + return ''; + } + return fs.readFileSync(absolute, 'utf8'); + }; + const spool = read(spoolFile); + const upload = read(uploadFile); + const subscription = read(subscriptionFile); + const requirePattern = (body, pattern, message) => { + if (!pattern.test(body)) errors.push(message); + }; + + requirePattern(spool, /const allowedEvidenceTables = new Set\(\[\s*'AgentOpsEvents_CL'\s*\]\)/, + `${spoolFile}: durable receipts must target AgentOpsEvents_CL only`); + requirePattern(spool, /row\.PrivacyMode = 'strict'[\s\S]*row\.ContentCaptureMode = 'off'/, + `${spoolFile}: durable receipt canonicalizer must force strict privacy and content capture off`); + requirePattern(spool, /const maximumMaxBytes = \d+[\s\S]*const maximumTtlMs = \d+[\s\S]*const maximumRetryDelayMs = \d+[\s\S]*const maximumDrainAttempts = \d+/, + `${spoolFile}: bytes, TTL, retry delay, and attempt bounds must be explicit`); + requirePattern(spool, /boundedOption\(drainOptions\.maxAttempts, 3, maximumDrainAttempts/, + `${spoolFile}: drain attempts must use the bounded option validator`); + requirePattern(spool, /lstatSync\(directory\)[\s\S]*isSymbolicLink\(\)[\s\S]*lstatSync\(file\)[\s\S]*isSymbolicLink\(\)/, + `${spoolFile}: spool root and segments must reject symlinks`); + requirePattern(spool, /mkdirSync\(directory, \{ recursive: true, mode: 0o700 \}\)[\s\S]*openSync\(temporary, 'wx', 0o600\)/, + `${spoolFile}: spool directory and segments must be private`); + requirePattern(spool, /immutableEnvelopeHash[\s\S]*created_at[\s\S]*expires_at[\s\S]*row_hash/, + `${spoolFile}: immutable receipt envelope fields must be integrity checked`); + if (/['"](?:Reason|Error)['"]/.test(spool)) { + errors.push(`${spoolFile}: raw Reason/Error fields must never be allowlisted or persisted`); + } + + requirePattern(upload, /new URL\([\s\S]*\.ingest\.monitor\.azure\.com[\s\S]*endpoint\.username[\s\S]*endpoint\.password[\s\S]*endpoint\.hash[\s\S]*endpoint\.search/, + `${uploadFile}: uploader must validate the Azure public Monitor hostname and reject URL credential/query/fragment injection`); + requirePattern(upload, /endpoint\.pathname !== '\/'/, + `${uploadFile}: uploader must reject non-root endpoint paths`); + requirePattern(upload, /\^dcr-\[A-Za-z0-9-\]\+\$/, + `${uploadFile}: uploader must validate the DCR immutable ID`); + requirePattern(upload, /maximumRequestTimeoutMs[\s\S]*requestTimeoutMs\(options\.timeoutMs\)[\s\S]*AbortSignal\.timeout\(timeoutMs\)/, + `${uploadFile}: HTTP requests must use a bounded timeout`); + requirePattern(upload, /checkAzureSubscription\([\s\S]*expectedSubscriptionId: options\.expectedSubscriptionId[\s\S]*subscriptionId: subscription\.expected/, + `${uploadFile}: uploader and token request must use the exact guarded subscription`); + requirePattern(subscription, /APPROVED_AZURE_SUBSCRIPTION_IDS[\s\S]*approved\.includes\(expected\)[\s\S]*refused the write/, + `${subscriptionFile}: subscription guard must fail closed against the approved enterprise subscription`); + + evidence.push( + { file: spoolFile, controls: ['events-only-schema', 'strict-off', 'private-spool', 'symlink-rejection', 'bounded-storage-ttl-retries', 'envelope-integrity', 'no-raw-reason-error'] }, + { file: uploadFile, controls: ['monitor-hostname', 'dcr-validation', 'exact-subscription', 'bounded-http-timeout-response'] }, + { file: subscriptionFile, controls: ['enterprise-subscription-allowlist', 'fail-closed-write-guard'] } + ); + return finding( + 'durable-receipt-security', + errors.length === 0, + errors.length === 0 + ? 'durable AgentOpsEvents receipts are metadata-only, bounded, private, integrity-checked, and pinned to the guarded Azure Monitor destination' + : errors.join('; '), + 'error', + evidence + ); +} + function securityAudit(options = {}) { const runGitleaksCheck = options.runGitleaks || runGitleaks; const runStatic = options.runStaticCheck || runStaticCheck; @@ -476,7 +659,10 @@ function securityAudit(options = {}) { owaspFixtureCheck(options), dashboardContentGuardrailCheck(options), contentCaptureOperationalGuardrailsCheck(options), - dashboardEvidenceDisclaimerCheck(options) + dashboardEvidenceDisclaimerCheck(options), + localStrictCollectorSecurityCheck(options), + persistentCollectorQueueCheck(options), + durableReceiptSecurityCheck(options) ]; const blocking = checks.filter(check => !check.ok && check.severity === 'error'); const warnings = checks.filter(check => check.severity === 'warning'); @@ -504,8 +690,11 @@ module.exports = { dependencyAudit, dashboardEvidenceDisclaimerCheck, dashboardContentGuardrailCheck, + durableReceiptSecurityCheck, + localStrictCollectorSecurityCheck, owaspFixtureCheck, poisonRuntimeCheck, + persistentCollectorQueueCheck, runGitleaks, runStaticCheck, securityAudit, diff --git a/agentops-cli/src/lib/security-command.js b/agentops-cli/src/lib/security-command.js new file mode 100644 index 0000000..5ed0e08 --- /dev/null +++ b/agentops-cli/src/lib/security-command.js @@ -0,0 +1,45 @@ +const { writeJsonOrRender } = require('./command-output'); +const { securityAudit, securityPosture } = require('./security-audit'); + +function renderSecurityAudit(audit) { + const lines = ['AgentOps security audit']; + for (const check of audit.checks) { + const status = check.severity === 'warning' ? 'warn' : check.ok ? 'ok' : 'failed'; + lines.push(`- ${check.name}: ${status}${check.detail ? ` (${check.detail})` : ''}`); + } + lines.push('', audit.ok ? 'Security audit passed with no blocking issues.' : 'Security audit found blocking issues.'); + if (audit.next) lines.push(audit.next); + return `${lines.join('\n')}\n`; +} + +function renderSecurityPosture(posture) { + const lines = ['AgentOps security posture']; + for (const control of posture.controls) { + lines.push(`- ${control.id} ${control.risk}: ${control.status} (${control.summary})`); + } + lines.push('', posture.ok ? 'Security posture has no evidence gaps.' : 'Security posture has evidence gaps.'); + if (posture.next) lines.push(posture.next); + return `${lines.join('\n')}\n`; +} + +async function securityCommand(args = []) { + const [subcommand = 'audit'] = args; + const json = args.includes('--json'); + const failOnWarning = args.includes('--fail-on-warning'); + if (subcommand === 'posture') { + const posture = securityPosture(); + writeJsonOrRender(posture, json, renderSecurityPosture); + process.exitCode = posture.ok ? 0 : 1; + return; + } + if (subcommand !== 'audit') throw new Error('security supports: audit, posture'); + const audit = securityAudit(); + writeJsonOrRender(audit, json, renderSecurityAudit); + process.exitCode = audit.ok && (!failOnWarning || audit.summary.warnings === 0) ? 0 : 1; +} + +module.exports = { + renderSecurityAudit, + renderSecurityPosture, + securityCommand +}; diff --git a/agentops-cli/src/lib/session-command.js b/agentops-cli/src/lib/session-command.js new file mode 100644 index 0000000..eb59e8c --- /dev/null +++ b/agentops-cli/src/lib/session-command.js @@ -0,0 +1,102 @@ +const sessionCommandNames = Object.freeze(['latest', 'live', 'tail', 'replay', 'explain', 'recommend', 'open']); + +function createSessionCommand(dependencies = {}) { + const { + explainLatest, + latestSummaryFromArgs, + liveViewFromArgs, + openLinksSummary, + optionValue, + parseLastArg, + recommendationForExplanation, + renderExplanation, + renderLatest, + renderLive, + renderOpenLinks, + renderRecommendation, + renderReplay, + replayTimeline, + runAzureLogAnalyticsQuery, + sessionQuery, + sleep, + spanRowsFromSource, + stdout = process.stdout, + validateKqlDuration + } = dependencies; + + async function liveCommand(args) { + const intervalIndex = args.indexOf('--interval'); + const intervalSec = intervalIndex === -1 ? 5 : Number(args[intervalIndex + 1]); + if (intervalIndex !== -1 && (!Number.isFinite(intervalSec) || intervalSec <= 0)) { + throw new Error('--interval must be a positive number of seconds'); + } + const follow = args.includes('--follow'); + do { + stdout.write(renderLive(liveViewFromArgs(args))); + if (!follow) return; + await sleep(intervalSec * 1000); + } while (follow); + } + + function replayCommand(args) { + const sessionId = args[0]; + if (!sessionId) throw new Error('replay requires a session id or latest'); + const replayArgs = args.slice(1); + let source; + if (sessionId === 'latest' || optionValue(replayArgs, ['--file', '--jsonl'])) { + source = spanRowsFromSource(replayArgs, '7d'); + } else { + const last = validateKqlDuration(parseLastArg(replayArgs, '7d')); + const query = sessionQuery(sessionId, last); + const result = runAzureLogAnalyticsQuery(query); + source = { mode: 'azure', last, rows: result.ok ? result.rows : [], query, error: result.ok ? null : result.error }; + } + if (source.error) { + stdout.write(`Session replay: ${sessionId}\n\nCould not read telemetry: ${source.error}\n`); + return; + } + stdout.write(renderReplay(replayTimeline(source.rows, { sessionId, source: source.mode }))); + } + + async function sessionCommand(command, args) { + if (command === 'latest') { + stdout.write(renderLatest(latestSummaryFromArgs(args))); + return; + } + if (command === 'live' || command === 'tail') { + await liveCommand(args); + return; + } + if (command === 'replay') { + replayCommand(args); + return; + } + if (command === 'explain') { + if (args[0] !== 'latest') throw new Error('explain currently supports: explain latest'); + stdout.write(renderExplanation(explainLatest(latestSummaryFromArgs(args.slice(1))))); + return; + } + if (command === 'recommend') { + if (args[0] !== 'latest') throw new Error('recommend currently supports: recommend latest'); + const recommendArgs = args.slice(1); + const summary = latestSummaryFromArgs(recommendArgs); + const last = parseLastArg(recommendArgs, '7d'); + stdout.write(renderRecommendation(recommendationForExplanation(explainLatest(summary), { last }))); + return; + } + if (command === 'open') { + stdout.write(renderOpenLinks(openLinksSummary(latestSummaryFromArgs(args)))); + return; + } + throw new Error(`Unknown session command: ${command}`); + } + + return { + sessionCommand, + sessionCommandNames + }; +} + +module.exports = { + createSessionCommand +}; diff --git a/agentops-cli/src/lib/session-row-utils.js b/agentops-cli/src/lib/session-row-utils.js new file mode 100644 index 0000000..5afc58b --- /dev/null +++ b/agentops-cli/src/lib/session-row-utils.js @@ -0,0 +1,89 @@ +function rowAttributes(row) { + const attrs = row.attributes || row.Properties || row.properties || {}; + if (typeof attrs !== 'string') return attrs; + + try { + return JSON.parse(attrs); + } catch { + return {}; + } +} + +function attributeValue(attrs, keys) { + for (const key of keys) { + if (attrs[key] !== undefined && attrs[key] !== null && attrs[key] !== '') return attrs[key]; + } + return null; +} + +function numberAttribute(attrs, keys) { + const value = attributeValue(attrs, keys); + if (value === null) return 0; + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function booleanAttribute(attrs, keys) { + const value = attributeValue(attrs, keys); + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.toLowerCase() === 'true'; + return Boolean(value); +} + +function operationFromRow(row, attrs) { + return row.operation + || row.EventName + || row.SpanName + || attributeValue(attrs, ['gen_ai.operation.name', 'operation']) + || row.name + || 'unknown'; +} + +function telemetryTime(value) { + if (Array.isArray(value) && value.length >= 1) { + const seconds = Number(value[0]); + const nanos = Number(value[1] || 0); + if (Number.isFinite(seconds) && Number.isFinite(nanos)) { + return new Date((seconds * 1000) + (nanos / 1e6)).toISOString(); + } + } + return value || null; +} + +function isSpanTelemetryRow(row) { + return !row.type || row.type === 'span'; +} + +function sessionFromRow(row, attrs) { + return row.session + || row.SessionId + || row.session_id + || row.Session + || row.conversation + || attributeValue(attrs, ['gen_ai.conversation.id', 'github.copilot.interaction_id', 'conversation']) + || 'unknown-session'; +} + +function isFailedRow(row, attrs) { + const success = row.Success ?? row.success; + const status = row.Status ?? row.status ?? row.OutcomeStatus; + const statusCode = row.status?.code || row.statusCode || row.ResultCode || row.resultCode; + const error = attributeValue(attrs, ['error.type', 'exception.type', 'error']); + + if (success === false || (typeof success === 'string' && success.toLowerCase() === 'false')) return true; + if (['failed', 'failure', 'error', 'blocked', 'degraded'].includes(String(status || '').toLowerCase())) return true; + if (String(statusCode || '').toUpperCase() === 'ERROR') return true; + return Boolean(error); +} + +module.exports = { + attributeValue, + booleanAttribute, + isFailedRow, + isSpanTelemetryRow, + numberAttribute, + operationFromRow, + rowAttributes, + sessionFromRow, + telemetryTime +}; diff --git a/agentops-cli/src/lib/session-summary.js b/agentops-cli/src/lib/session-summary.js new file mode 100644 index 0000000..e0a775b --- /dev/null +++ b/agentops-cli/src/lib/session-summary.js @@ -0,0 +1,565 @@ +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { numberValue } = require('./benchmark-scoring'); +const { readJsonlRows } = require('./json'); +const { validateKqlDuration } = require('./kql'); +const { + baseFilter, + directSessionKey, + encodeGrafanaValue, + fallbackSessionKey +} = require('./observability-queries'); +const { + attributeValue, + booleanAttribute, + isFailedRow, + isSpanTelemetryRow, + numberAttribute, + operationFromRow, + rowAttributes, + sessionFromRow, + telemetryTime +} = require('./session-row-utils'); + +function createSessionSummary(config = {}) { + const { + buildLink, + appInsightsResourceUrl = '', + agentsViewUrl = '', + cloudVerified = false, + configuredWorkspaceId = '', + mainGrafanaDashboardUrl, + optionValue, + parseLastArg, + sessionsGrafanaDashboardUrl, + v2HomeGrafanaDashboardUrl, + v2ReplayGrafanaDashboardUrl, + v2RunsGrafanaDashboardUrl, + workspaceId + } = config; + + function summarizeSession(sessionId, spans, source = 'local') { + const spanRows = spans.filter(isSpanTelemetryRow); + const tools = new Set(); + const models = new Set(); + const agents = new Set(); + const skills = new Set(); + const subagents = new Set(); + const mcpServers = new Set(); + const cliTools = new Set(); + const scripts = new Set(); + const outcomes = new Set(); + const ciStatuses = new Set(); + const e2eIds = new Set(); + const allUsage = { inputTokens: 0, outputTokens: 0, credits: 0, count: 0 }; + const chatUsage = { inputTokens: 0, outputTokens: 0, credits: 0, count: 0 }; + let estimatedUsd = 0; + let toolCalls = 0; + let failedTools = 0; + let failures = 0; + let policyBlocks = 0; + let declaredDeniedTools = 0; + let observedDeniedTools = 0; + let privacyBlockedCount = 0; + let testsRan = false; + let testsPassed = null; + let prOpened = false; + let tokensRemoved = 0; + let contentCaptureWarning = false; + let contentDroppedSignal = false; + let runSummaryRows = 0; + let declaredDurationMs = 0; + let latestTime = null; + let earliestTime = null; + + for (const row of spanRows) { + const attrs = rowAttributes(row); + const operation = operationFromRow(row, attrs); + const tool = row.ToolName || attributeValue(attrs, ['gen_ai.tool.name', 'tool']); + const model = row.ModelActual || row.ModelRequested || attributeValue(attrs, ['gen_ai.request.model', 'gen_ai.response.model', 'model']); + const agent = row.AgentName || attributeValue(attrs, ['gen_ai.agent.name', 'gen_ai.agent.id', 'agent']); + const skill = row.SkillName || attributeValue(attrs, ['agentops.skill.name', 'github.copilot.skill.name']); + const subagent = row.SubAgentName || attributeValue(attrs, ['agentops.sub_agent.name', 'agentops.child_agent.name']); + const mcpServer = row.McpServerName || attributeValue(attrs, ['agentops.mcp.server', 'mcp.server.name']); + const cliTool = row.CliName || attributeValue(attrs, ['agentops.cli.name']); + const script = row.ScriptName || attributeValue(attrs, ['agentops.script.name']); + const outcome = row.OutcomeStatus || attributeValue(attrs, ['agentops.outcome']); + const ciStatus = row.CiStatus || attributeValue(attrs, ['agentops.ci.status', 'github.ci.status']); + const e2eId = attributeValue(attrs, ['agentops.e2e.id']); + const eventName = String(row.EventName || row.event || row.Name || row.name || ''); + const failed = isFailedRow(row, attrs); + const timeValue = telemetryTime(row.TimeGenerated || row.timestamp || row.time || row.startTime); + const time = timeValue ? new Date(timeValue) : null; + const contentCaptureMode = String(row.ContentCaptureMode || attributeValue(attrs, ['agentops.content_capture.mode']) || '').toLowerCase(); + const contentCaptureSignal = row.ContentCaptureSignal === true || String(row.ContentCaptureSignal || '').toLowerCase() === 'true'; + + if (tool) tools.add(String(tool)); + if (model) models.add(String(model)); + if (agent) agents.add(String(agent)); + if (skill) skills.add(String(skill)); + if (subagent) subagents.add(String(subagent)); + if (mcpServer) mcpServers.add(String(mcpServer)); + if (cliTool) cliTools.add(String(cliTool)); + if (script) scripts.add(String(script)); + if (outcome) outcomes.add(String(outcome)); + if (ciStatus) ciStatuses.add(String(ciStatus)); + if (e2eId) e2eIds.add(String(e2eId)); + if (operation === 'execute_tool' || tool) { + toolCalls += 1; + if (failed) failedTools += 1; + } + toolCalls += numberValue(row.ToolCount); + failedTools += numberValue(row.ToolFailureCount); + if (failed) failures += 1; + const policyBlocked = attributeValue(attrs, ['agentops.policy.blocked']); + const mcpAllowed = attributeValue(attrs, ['agentops.mcp.allowed']); + const rowDeniedCount = numberValue(row.ToolDeniedCount); + declaredDeniedTools += rowDeniedCount; + if (row.Allowed === false || String(row.Allowed).toLowerCase() === 'false' || mcpAllowed === false || String(mcpAllowed).toLowerCase() === 'false') { + observedDeniedTools += 1; + } + const policySignal = /^(?:preToolUse|permissionRequest|policy)(?:\.|$)/i.test(eventName) + || policyBlocked === true + || String(policyBlocked).toLowerCase() === 'true' + || mcpAllowed === false + || String(mcpAllowed).toLowerCase() === 'false' + || rowDeniedCount > 0; + if (policySignal) policyBlocks += rowDeniedCount || 1; + privacyBlockedCount += numberValue(row.DroppedCount || row.PrivacyBlockedCount); + if (row.TestsRan === true || String(row.TestsRan).toLowerCase() === 'true') testsRan = true; + if (row.TestsPassed === false || String(row.TestsPassed).toLowerCase() === 'false') testsPassed = false; + else if (row.TestsPassed === true || String(row.TestsPassed).toLowerCase() === 'true') testsPassed ??= true; + if (row.PrOpened === true || String(row.PrOpened).toLowerCase() === 'true') prOpened = true; + if (/truncation|compaction|too much context/i.test(eventName)) tokensRemoved += 1; + if (row.RunId && row.OutcomeStatus) runSummaryRows += 1; + declaredDurationMs = Math.max(declaredDurationMs, numberValue(row.DurationMs)); + if (booleanAttribute(attrs, ['content.capture.enabled', 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'])) contentCaptureWarning = true; + if (attributeValue(attrs, ['gen_ai.prompt', 'gen_ai.completion', 'prompt', 'completion'])) contentCaptureWarning = true; + if (contentCaptureSignal && ['signal_only', 'off'].includes(contentCaptureMode)) contentDroppedSignal = true; + if (contentCaptureSignal && !['signal_only', 'off'].includes(contentCaptureMode)) contentCaptureWarning = true; + + const inputTokenValue = numberValue(row.InputTokens) || numberAttribute(attrs, ['gen_ai.usage.input_tokens', 'InputTokens', 'input_tokens']); + const outputTokenValue = numberValue(row.OutputTokens) || numberAttribute(attrs, ['gen_ai.usage.output_tokens', 'OutputTokens', 'output_tokens']); + const creditValue = numberAttribute(attrs, ['github.copilot.cost', 'Credits', 'credits']); + const estimatedUsdValue = numberValue(row.EstimatedCostUsd); + if (estimatedUsdValue) estimatedUsd += estimatedUsdValue; + if (inputTokenValue || outputTokenValue || creditValue || estimatedUsdValue) { + allUsage.inputTokens += inputTokenValue; + allUsage.outputTokens += outputTokenValue; + allUsage.credits += creditValue; + allUsage.count += 1; + if (operation === 'chat') { + chatUsage.inputTokens += inputTokenValue; + chatUsage.outputTokens += outputTokenValue; + chatUsage.credits += creditValue; + chatUsage.count += 1; + } + } + tokensRemoved += numberAttribute(attrs, ['github.copilot.tokens_removed', 'tokens_removed']); + + if (time && !Number.isNaN(time.getTime())) { + if (!latestTime || time > latestTime) latestTime = time; + if (!earliestTime || time < earliestTime) earliestTime = time; + } + } + + const primaryUsage = chatUsage.count > 0 ? chatUsage : allUsage; + const inputTokens = primaryUsage.inputTokens; + const outputTokens = primaryUsage.outputTokens; + const credits = primaryUsage.credits; + + const dataMissing = []; + if (source === 'local') dataMissing.push('live Azure query'); + if (!latestTime) dataMissing.push('timestamps'); + if (inputTokens === 0 && outputTokens === 0) dataMissing.push('token totals'); + if (credits === 0 && estimatedUsd === 0) dataMissing.push('cost'); + const observedDurationMs = earliestTime && latestTime ? Math.max(0, latestTime - earliestTime) : null; + const durationMs = declaredDurationMs || observedDurationMs; + + return { + id: sessionId, + source, + started: earliestTime ? earliestTime.toISOString() : null, + ended: latestTime ? latestTime.toISOString() : null, + duration_ms: durationMs, + spans: spanRows.length, + run_summary_rows: runSummaryRows, + tool_calls: toolCalls, + failed_tools: failedTools, + failures, + tools: [...tools], + models: [...models], + agents: [...agents], + e2e_id: [...e2eIds][0] || null, + e2e_ids: [...e2eIds], + input_tokens: inputTokens, + output_tokens: outputTokens, + credits, + est_usd: estimatedUsd || credits * 0.01, + policy_blocks: policyBlocks, + denied_tool_calls: Math.max(declaredDeniedTools, observedDeniedTools), + privacy_blocked_count: privacyBlockedCount, + skills: [...skills], + subagents: [...subagents], + mcp_servers: [...mcpServers], + cli_tools: [...cliTools], + scripts: [...scripts], + outcomes: [...outcomes], + tests_ran: testsRan, + tests_passed: testsRan ? testsPassed : null, + pr_opened: prOpened, + ci_statuses: [...ciStatuses], + tokens_removed: tokensRemoved, + content_capture_warning: contentCaptureWarning, + content_dropped_signal: contentDroppedSignal, + grafana_url: sessionId === 'unknown-session' ? null : buildLink('session', sessionId).grafana_url, + data_missing: dataMissing + }; + } + + function latestSessionAzureQuery(last = '7d') { + const lookback = validateKqlDuration(last); + return `let base = AppDependencies + | where TimeGenerated > ago(${lookback}) + | where ${baseFilter} + | extend direct_session=${directSessionKey}, fallback_session=${fallbackSessionKey}; + let operation_sessions = base + | where isnotempty(direct_session) + | summarize operation_session=take_any(direct_session) by OperationId; + let enriched = base + | join kind=leftouter operation_sessions on OperationId + | extend conversation=iff(isnotempty(operation_session), operation_session, iff(isnotempty(direct_session), direct_session, fallback_session)); + let latest_session = toscalar(enriched | summarize Ended=max(TimeGenerated) by conversation | top 1 by Ended desc | project conversation); + enriched + | where conversation == latest_session + | project TimeGenerated, conversation, Name, Success, ResultCode, DurationMs, OperationId, ParentId, Id, Properties + | order by TimeGenerated asc`; + } + + function runAzureLogAnalyticsQuery(query, options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const effectiveWorkspaceId = options.workspaceId || workspaceId; + + if (!options.workspaceId && !configuredWorkspaceId && !options.spawnSync) { + return { + ok: false, + rows: [], + error: 'Set AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID or LOG_ANALYTICS_WORKSPACE_ID before running live Azure telemetry queries.' + }; + } + + const result = spawnSync('az', [ + 'monitor', + 'log-analytics', + 'query', + '--workspace', + effectiveWorkspaceId, + '--analytics-query', + query, + '-o', + 'json' + ], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }); + + if (result.error) return { ok: false, rows: [], error: result.error.message }; + if (result.status !== 0) { + return { + ok: false, + rows: [], + error: (result.stderr || result.stdout || `az exited with status ${result.status}`).trim() + }; + } + + try { + return { ok: true, rows: JSON.parse(result.stdout || '[]'), error: null }; + } catch (error) { + return { ok: false, rows: [], error: `Could not parse Azure query JSON: ${error.message}` }; + } + } + + function latestAzureSessionSummary(options = {}) { + const last = validateKqlDuration(options.last || '7d'); + const query = latestSessionAzureQuery(last); + const result = runAzureLogAnalyticsQuery(query, options); + + if (!result.ok) { + return { + mode: 'azure', + last, + query, + session: null, + error: result.error, + data_missing: ['live Azure query failed'] + }; + } + + if (!Array.isArray(result.rows) || result.rows.length === 0) { + return { + mode: 'azure', + last, + query, + session: null, + data_missing: [`no Copilot telemetry found in Azure for last ${last}`] + }; + } + + return { + ...latestSessionSummary({ rows: result.rows, source: 'azure' }), + last, + query + }; + } + + function latestSessionSummary({ filePath = null, rows = null, source = null } = {}) { + if (!filePath && !rows) { + return { + mode: 'missing-live', + session: null, + data_missing: ['live Azure query', 'local JSONL file', 'latest session id', 'token totals', 'cost'] + }; + } + + const sourceRows = rows || readJsonlRows(filePath); + const summarySource = source || (filePath ? 'local' : 'azure'); + const sessions = new Map(); + const order = []; + let currentSessionId = null; + + for (const row of sourceRows) { + const attrs = rowAttributes(row); + let sessionId = sessionFromRow(row, attrs); + if (sessionId === 'unknown-session' && currentSessionId) sessionId = currentSessionId; + if (sessionId !== 'unknown-session') currentSessionId = sessionId; + if (!sessions.has(sessionId)) { + sessions.set(sessionId, []); + order.push(sessionId); + } + sessions.get(sessionId).push(row); + } + + const summaries = order.map(sessionId => summarizeSession(sessionId, sessions.get(sessionId), summarySource)); + const withTime = summaries.filter(summary => summary.ended); + const session = withTime.length > 0 + ? withTime.sort((a, b) => new Date(b.ended) - new Date(a.ended))[0] + : summaries.at(-1) || null; + + return { + mode: summarySource, + file: filePath, + session, + data_missing: session ? session.data_missing : ['local JSONL rows'] + }; + } + + function listOrMissing(values, missing = 'not in this data') { + return values.length > 0 ? values.join(', ') : missing; + } + + function readableDuration(durationMs) { + if (!Number.isFinite(durationMs)) return 'not in this data'; + if (durationMs < 1000) return `${Math.round(durationMs)}ms`; + if (durationMs < 60000) return `${(durationMs / 1000).toFixed(durationMs < 10000 ? 1 : 0)}s`; + const minutes = Math.floor(durationMs / 60000); + const seconds = Math.round((durationMs % 60000) / 1000); + return `${minutes}m ${seconds}s`; + } + + function renderLatest(summary = latestSessionSummary()) { + const lines = ['AgentOps receipt', 'Latest Copilot session', '']; + + if (!summary.session) { + if (summary.mode === 'azure' && summary.error) { + lines.push('I could not read live Azure telemetry.'); + lines.push(`Azure error: ${summary.error}`); + lines.push('Use --file to summarize a local or fixture export.'); + } else if (summary.mode === 'azure') { + lines.push(`No Copilot sessions were found in Azure for the last ${summary.last || '7d'}.`); + lines.push('Run Copilot through AgentOps, then try again.'); + } else { + lines.push('Use --file to summarize a local or fixture export, or run with Azure CLI access for live telemetry.'); + } + lines.push(`Missing data: ${summary.data_missing.join(', ')}.`); + lines.push(`Main dashboard: ${mainGrafanaDashboardUrl}`); + return `${lines.join('\n')}\n`; + } + + const session = summary.session; + lines.push(`Result: ${session.failures > 0 ? 'Needs attention' : 'Completed'}`); + lines.push(`Run: ${session.id}`); + lines.push(`Time: ${readableDuration(session.duration_ms)}`); + const workUnit = session.run_summary_rows > 0 ? 'run summary' : 'recorded step'; + lines.push(`Work: ${session.spans} ${workUnit}${session.spans === 1 ? '' : workUnit === 'run summary' ? ' rows' : 's'}, ${session.tool_calls} tool call${session.tool_calls === 1 ? '' : 's'}, ${session.failures} failure${session.failures === 1 ? '' : 's'}`); + lines.push(`Used: agent ${listOrMissing(session.agents)}; model ${listOrMissing(session.models)}; tools ${listOrMissing(session.tools)}`); + lines.push(`Agent work: skills ${listOrMissing(session.skills)}; subagents ${listOrMissing(session.subagents)}; MCP ${listOrMissing(session.mcp_servers)}`); + lines.push(`Automation: CLI ${listOrMissing(session.cli_tools)}; scripts ${listOrMissing(session.scripts)}`); + lines.push(`Outcome: ${listOrMissing(session.outcomes)}; tests ${session.tests_ran ? session.tests_passed === false ? 'failed' : 'passed' : 'not run'}; PR ${session.pr_opened ? 'opened' : 'not opened'}; CI ${listOrMissing(session.ci_statuses, 'not run')}`); + lines.push(`Safety: ${session.denied_tool_calls} denied tool call${session.denied_tool_calls === 1 ? '' : 's'}; ${session.privacy_blocked_count} privacy field${session.privacy_blocked_count === 1 ? '' : 's'} blocked`); + lines.push(`Tokens: ${session.input_tokens.toLocaleString('en-US')} in, ${session.output_tokens.toLocaleString('en-US')} out`); + if (session.credits > 0) lines.push(`Copilot credits: ${session.credits.toLocaleString('en-US')}`); + lines.push(session.est_usd > 0 ? `Estimated cost: $${session.est_usd.toFixed(2)}` : 'Estimated cost: not in this data'); + if (session.source === 'azure') { + lines.push('Delivery: Visible in Azure'); + lines.push(`Coverage: ${session.run_summary_rows > 0 ? 'AgentOps managed' : 'Native best effort'}`); + } else { + lines.push('Delivery: Local evidence only · Azure not confirmed'); + lines.push('Coverage: Local export'); + } + lines.push(session.content_capture_warning + ? 'Privacy: content capture may be on. Do not share this export until reviewed.' + : session.content_dropped_signal + ? 'Privacy: sensitive fields were detected and dropped; prompts, answers, code, and tool arguments were not retained.' + : 'Privacy: prompts, answers, code, and tool arguments were not recorded.'); + if (session.grafana_url) lines.push(`Open team view: ${session.grafana_url}`); + if (session.data_missing.length > 0) lines.push(`Not available here: ${session.data_missing.join(', ')}.`); + + return `${lines.join('\n')}\n`; + } + + function explainLatest(summary = latestSessionSummary()) { + const session = summary.session; + if (!session) { + return { + classification: 'unknown', + headline: 'Not enough data yet', + detail: summary.mode === 'azure' && summary.error + ? `The Azure query failed: ${summary.error}` + : 'No local JSONL rows or live Azure rows were available.', + session: null + }; + } + + if (session.content_capture_warning) { + return { + classification: 'content_capture_warning', + headline: 'Content capture warning', + detail: 'Prompts or code may have been recorded. Review the export before sharing it.', + session + }; + } + + if (session.policy_blocks > 0) { + return { + classification: 'policy_blocked', + headline: 'No risky commands were allowed through', + detail: `${session.policy_blocks} policy signal${session.policy_blocks === 1 ? '' : 's'} appeared in this session.`, + session + }; + } + + if (session.failed_tools > 0) { + return { + classification: 'failed_tool', + headline: 'Tools kept failing', + detail: `${session.failed_tools} tool call${session.failed_tools === 1 ? '' : 's'} failed. Check the tool waterfall in Grafana.`, + session + }; + } + + if (session.input_tokens >= 30000 || session.tokens_removed > 0) { + return { + classification: 'too_much_context', + headline: 'Copilot had too much to remember', + detail: 'The session shows high context use or compaction/truncation signals.', + session + }; + } + + if (session.est_usd >= 1) { + return { + classification: 'high_cost', + headline: 'This session looked expensive', + detail: `Estimated cost was $${session.est_usd.toFixed(2)}.`, + session + }; + } + + if (session.spans > 0 && session.failures === 0) { + return { + classification: 'success', + headline: 'This session looks successful', + detail: 'No failed tools, policy blocks, high context, or high cost signals were found.', + session + }; + } + + return { + classification: 'unknown', + headline: 'The issue is unclear', + detail: 'The local data does not include enough signals to classify the session.', + session + }; + } + + function renderExplanation(explanation = explainLatest()) { + const lines = ['Likely issue', '', explanation.headline, explanation.detail]; + if (explanation.session?.grafana_url) lines.push(`Open in Grafana: ${explanation.session.grafana_url}`); + return `${lines.join('\n')}\n`; + } + + function openLinksSummary(summary = latestSessionSummary()) { + const latestSessionUrl = summary.session?.grafana_url || null; + const primaryInvestigationUrl = agentsViewUrl || appInsightsResourceUrl || v2HomeGrafanaDashboardUrl; + const primaryInvestigationLabel = agentsViewUrl + ? 'Azure Monitor Agents view' + : appInsightsResourceUrl + ? 'Application Insights (open Agents)' + : 'Grafana Today'; + return { + primary_investigation_url: primaryInvestigationUrl, + primary_investigation_label: primaryInvestigationLabel, + cloud_verified: cloudVerified, + azure_agents_view_url: agentsViewUrl || null, + application_insights_url: appInsightsResourceUrl || null, + main_dashboard_url: mainGrafanaDashboardUrl, + sessions_dashboard_url: sessionsGrafanaDashboardUrl, + v2_home_url: v2HomeGrafanaDashboardUrl, + v2_runs_url: v2RunsGrafanaDashboardUrl, + v2_replay_url: latestSessionUrl + ? `${v2ReplayGrafanaDashboardUrl}?var-session_id=${encodeGrafanaValue(summary.session.id)}` + : v2ReplayGrafanaDashboardUrl, + latest_session_url: latestSessionUrl, + missing_latest_reason: latestSessionUrl + ? null + : summary.session + ? 'that session did not include a usable session id' + : 'latest session was not found in the selected local file or Azure lookback window' + }; + } + + function latestSummaryFromArgs(args, fallbackLast = '7d') { + const filePath = optionValue(args, ['--file', '--jsonl']); + if (filePath) return latestSessionSummary({ filePath: path.resolve(filePath) }); + + return latestAzureSessionSummary({ last: parseLastArg(args, fallbackLast) }); + } + + + return { + attributeValue, + explainLatest, + isFailedRow, + isSpanTelemetryRow, + latestAzureSessionSummary, + latestSessionAzureQuery, + latestSessionSummary, + latestSummaryFromArgs, + numberAttribute, + openLinksSummary, + operationFromRow, + readJsonlRows, + renderExplanation, + renderLatest, + rowAttributes, + runAzureLogAnalyticsQuery, + sessionFromRow, + summarizeSession, + telemetryTime + }; +} + +module.exports = { + createSessionSummary +}; diff --git a/agentops-cli/src/lib/setup-guide.js b/agentops-cli/src/lib/setup-guide.js new file mode 100644 index 0000000..754e906 --- /dev/null +++ b/agentops-cli/src/lib/setup-guide.js @@ -0,0 +1,416 @@ +const childProcess = require('node:child_process'); + +function createSetupGuide(dependencies = {}) { + const { + commandCandidates, + configFromEnvValues, + configuredCloudValues, + defaultInstallDir, + grafanaDashboardImportCommand, + installedShimStatus, + isConfiguredValue, + parseEnvAssignments, + realCopilotSmokeCommand + } = dependencies; + + function setupToolStatus(name, options = {}) { + if (name === 'node') { + return { name, ok: true, path: process.execPath, version: process.version }; + } + + const availability = options.commandAvailability || {}; + if (Object.prototype.hasOwnProperty.call(availability, name)) { + return { + name, + ok: Boolean(availability[name]), + path: options.commandPaths?.[name] || null + }; + } + + const candidates = commandCandidates(name); + return { name, ok: candidates.length > 0, path: candidates[0] || null }; + } + + function azdEnvironmentStatus(options = {}, azdAvailable = true) { + if (!azdAvailable) { + return { checked: false, ok: false, values: {}, detail: 'azd is not available on PATH.' }; + } + + if (Object.prototype.hasOwnProperty.call(options, 'azdValues')) { + const values = configFromEnvValues(parseEnvAssignments(options.azdValues)); + return { + checked: true, + ok: Object.keys(values).length > 0, + values, + detail: Object.keys(values).length > 0 + ? 'azd environment contains AgentOps outputs.' + : 'azd environment does not contain AgentOps outputs yet.' + }; + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync('azd', ['env', 'get-values'], { + encoding: 'utf8', + maxBuffer: 1024 * 1024 + }); + + if (result.error) { + return { checked: true, ok: false, values: {}, detail: result.error.message }; + } + if (result.status !== 0) { + const rawDetail = (result.stderr || result.stdout || `azd exited with status ${result.status}`).trim(); + const detail = /out of date/i.test(rawDetail) && !/error|failed|not found/i.test(rawDetail) + ? 'azd env get-values did not return AgentOps outputs. Run azd provision or select the right azd environment.' + : rawDetail; + return { + checked: true, + ok: false, + values: {}, + detail + }; + } + + const values = configFromEnvValues(parseEnvAssignments(result.stdout)); + return { + checked: true, + ok: Object.keys(values).length > 0, + values, + detail: Object.keys(values).length > 0 + ? 'azd environment contains AgentOps outputs.' + : 'azd environment does not contain AgentOps outputs yet.' + }; + } + + function parseSetupArgs(args) { + return { + json: args.includes('--json') + }; + } + + function azureAccountStatus(options = {}, azAvailable = true) { + if (!azAvailable) return { checked: false, ok: false, id: null, name: null, detail: 'Azure CLI is not available.' }; + if (options.azureAccount) { + return { + checked: true, + ok: Boolean(options.azureAccount.id), + id: options.azureAccount.id || null, + name: options.azureAccount.name || null, + detail: options.azureAccount.id ? 'Active Azure subscription found.' : 'Azure CLI account is not signed in.' + }; + } + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync('az', ['account', 'show', '-o', 'json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + let account = null; + try { + account = result.status === 0 ? JSON.parse(result.stdout || '{}') : null; + } catch { + account = null; + } + return { + checked: true, + ok: Boolean(account?.id), + id: account?.id || null, + name: account?.name || null, + detail: account?.id ? 'Active Azure subscription found.' : (result.stderr || result.stdout || 'Run az login.').trim() + }; + } + + function azureResourceGroupStatus(options = {}, azAvailable = true) { + const resourceGroup = options.resourceGroup || ''; + if (!resourceGroup) { + return { + checked: false, + exists: null, + ok: false, + status: 'not-configured', + detail: 'No Azure resource group is configured.' + }; + } + if (!azAvailable) { + return { + checked: false, + exists: null, + ok: false, + status: 'not-checked', + detail: 'Azure CLI is not available.' + }; + } + if (options.resourceGroupExists !== undefined) { + const exists = Boolean(options.resourceGroupExists); + return { + checked: true, + exists, + ok: exists, + status: exists ? 'ready' : 'missing', + detail: exists + ? `Configured Azure resource group ${resourceGroup} exists.` + : `Configured Azure resource group ${resourceGroup} was not found.` + }; + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const args = ['group', 'exists', '--name', resourceGroup]; + if (options.subscriptionId) args.push('--subscription', options.subscriptionId); + const result = spawnSync('az', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + const raw = String(result.stdout || '').trim().toLowerCase(); + const failed = result.status !== 0 || result.error; + const exists = !failed && raw === 'true'; + return { + checked: !failed, + exists: failed ? null : exists, + ok: !failed && exists, + status: failed ? 'not-checked' : exists ? 'ready' : 'missing', + detail: failed + ? (result.stderr || result.stdout || result.error?.message || 'Azure resource group lookup failed.').trim() + : exists + ? `Configured Azure resource group ${resourceGroup} exists.` + : `Configured Azure resource group ${resourceGroup} was not found.` + }; + } + + function agentopsSetupGuide(options = {}) { + const tools = ['node', 'az', 'azd', 'docker', 'copilot'] + .map(name => setupToolStatus(name, options)); + const toolByName = Object.fromEntries(tools.map(tool => [tool.name, tool])); + const shim = installedShimStatus(options.installDir || defaultInstallDir); + const cloud = configuredCloudValues(options); + const azureAccount = azureAccountStatus(options, toolByName.az.ok); + const expectedSubscriptionId = cloud.subscriptionId || null; + const subscriptionMatch = Boolean(expectedSubscriptionId && azureAccount.id && expectedSubscriptionId.toLowerCase() === azureAccount.id.toLowerCase()); + const workspaceConfigured = isConfiguredValue(cloud.workspaceId, /^0{8}-0{4}-0{4}-0{4}-0{12}$/); + const grafanaConfigured = isConfiguredValue(cloud.grafanaBaseUrl, /your-grafana||^$/); + const agentsViewConfigured = isConfiguredValue(cloud.agentsViewUrl, /^$/); + const cloudConfigured = workspaceConfigured && (grafanaConfigured || agentsViewConfigured); + const resourceGroup = cloudConfigured + ? azureResourceGroupStatus({ + resourceGroup: cloud.resourceGroup, + subscriptionId: expectedSubscriptionId, + resourceGroupExists: options.resourceGroupExists, + spawnSync: options.spawnSync + }, toolByName.az.ok) + : { + checked: false, + exists: null, + ok: false, + status: 'not-configured', + detail: 'Cloud binding is incomplete; Azure target lookup is deferred.' + }; + const cloudTargetReady = cloudConfigured && resourceGroup.ok && subscriptionMatch; + const localReady = Boolean(toolByName.node.ok && shim.agentops_cli_installed && shim.copilot_agentops_installed); + const azd = azdEnvironmentStatus(options, toolByName.azd.ok); + const dashboardCloud = cloudConfigured ? cloud : { ...cloud, ...azd.values }; + const dashboardGrafanaConfigured = isConfiguredValue(dashboardCloud.grafanaBaseUrl, /your-grafana||^$/); + const dashboardImportCommand = dashboardGrafanaConfigured + ? grafanaDashboardImportCommand(dashboardCloud) + : 'Optional advanced path: agentops configure set --grafana-url then agentops dashboard import'; + + const phases = [ + { + name: '1. Provision Azure once', + status: cloudTargetReady ? 'done' : cloudConfigured ? 'needs-review' : (azd.ok ? 'ready-to-import' : 'needed'), + commands: cloudTargetReady + ? ['agentops configure show'] + : cloudConfigured + ? ['agentops validate-azure --last 24h', 'confirm the intended Azure resource group before any write'] + : ['az login', 'azd provision'], + verify: 'agentops configure import-azd' + }, + { + name: '2. Install local AgentOps utility', + status: shim.agentops_cli_installed && shim.copilot_agentops_installed ? 'done' : 'needed', + commands: [ + 'agentops install', + 'export PATH="$HOME/.local/bin:$PATH"' + ], + verify: 'agentops status' + }, + { + name: '3. Bind local CLI to Azure outputs', + status: cloudTargetReady ? 'done' : cloudConfigured ? 'needs-review' : (azd.ok ? 'needed' : 'blocked'), + commands: cloudTargetReady + ? ['agentops configure show'] + : cloudConfigured + ? ['agentops configure show', 'do not redirect to another resource group automatically'] + : azd.ok + ? ['agentops configure import-azd'] + : ['agentops configure set --resource-group --workspace-id --agents-url --app-insights-name '], + verify: 'agentops configure show' + }, + { + name: '4. Validate and smoke test', + status: cloudTargetReady ? 'ready' : 'blocked', + commands: [ + 'agentops validate-enterprise', + 'agentops validate-azure', + 'agentops collector smoke --privacy strict --poison' + ], + verify: 'agentops latest --last 2h' + }, + { + name: '5. Observe a real run', + status: cloudTargetReady ? 'ready' : 'blocked', + commands: [ + 'agentops copilot -p "Reply with exactly: agentops smoke."', + 'agentops latest --last 2h', + 'agentops open' + ], + verify: 'Open the newest run in Run Story.' + } + ]; + + const firstRun = { + name: 'First-run loop', + ready: cloudTargetReady && shim.agentops_cli_installed && shim.copilot_agentops_installed, + read_only: true, + setup_command: 'agentops setup', + guided_command: 'agentops init --full', + bind_command: cloudTargetReady + ? 'agentops configure show' + : cloudConfigured + ? 'agentops validate-azure --last 24h' + : azd.ok + ? 'agentops configure import-azd' + : 'az login && azd provision && agentops configure import-azd', + privacy_smoke_command: 'agentops collector smoke --privacy strict --poison --json', + smoke_command: 'agentops smoke --real-copilot --wait 2m --poll 10s --open-browser', + run_command: realCopilotSmokeCommand(), + latest_command: 'agentops latest --last 2h', + replay_command: 'agentops replay latest --last 2h', + open_command: 'agentops open latest --last 2h', + dashboard_import_command: dashboardImportCommand, + dashboard_verify_command: 'agentops dashboard verify --live --last 24h --json', + content_command: 'agentops content status --json', + privacy_note: 'Prompts and responses stay off by default. Use agentops content opt-in only when you intentionally want transcript rows.' + }; + + const next = []; + const missingTools = tools.filter(tool => !tool.ok).map(tool => tool.name); + if (missingTools.length > 0) { + next.push(`Install missing tools: ${missingTools.join(', ')}.`); + } + if (!cloudConfigured) { + if (azd.ok) { + next.push('agentops configure import-azd'); + } else { + next.push('az login'); + next.push('azd provision'); + next.push('agentops configure import-azd'); + } + } else if (!cloudTargetReady) { + next.push('agentops validate-azure --last 24h'); + next.push('Confirm the intended Azure resource group; AgentOps will not redirect to another group automatically.'); + } + if (!shim.agentops_cli_installed || !shim.copilot_agentops_installed) { + next.push('agentops install'); + } + if (!shim.plain_copilot_observed) { + next.push('export PATH="$HOME/.local/bin:$PATH"'); + } + next.push('agentops init --full'); + next.push('agentops validate-enterprise'); + next.push('agentops validate-azure'); + next.push('agentops collector smoke --privacy strict --poison'); + next.push('agentops copilot -p "Reply with exactly: agentops smoke."'); + next.push('agentops latest --last 2h'); + next.push('agentops open'); + + return { + ok: tools.every(tool => tool.ok) && + shim.agentops_cli_installed && + shim.copilot_agentops_installed && + cloudTargetReady, + mode: 'guide', + mutates: false, + local_ready: localReady, + cloud_ready: cloudTargetReady, + readiness: { + local: localReady ? 'ready' : 'needs-local-install', + cloud: cloudTargetReady ? 'ready' : cloudConfigured ? 'configured-but-unverified' : 'not-configured' + }, + tools, + azd, + shim, + first_run: firstRun, + cloud: { + expected_subscription_id: expectedSubscriptionId, + active_subscription_id: azureAccount.id, + active_subscription_name: azureAccount.name, + subscription_match: subscriptionMatch, + resource_group: cloud.resourceGroup, + resource_group_checked: resourceGroup.checked, + resource_group_exists: resourceGroup.exists, + resource_group_status: resourceGroup.status, + resource_group_detail: resourceGroup.detail, + binding_status: cloudTargetReady ? 'ready' : cloudConfigured ? 'configured-but-target-missing-or-unverified' : 'unconfigured', + workspace_id_configured: workspaceConfigured, + workspace_name: cloud.workspaceName || null, + grafana_url_configured: grafanaConfigured, + grafana_name: cloud.grafanaName || null, + agents_view_url_configured: agentsViewConfigured, + agents_view_url: cloud.agentsViewUrl || null, + app_insights_name: cloud.appInsightsName || null + }, + phases, + next + }; + } + + function renderSetupGuide(result) { + const lines = [ + 'AgentOps setup guide', + '', + 'This command is read-only. It does not create Azure resources or change local files.', + '', + 'Detected tools:' + ]; + + for (const tool of result.tools) { + const detail = tool.path ? ` (${tool.path})` : ''; + const version = tool.version ? ` ${tool.version}` : ''; + lines.push(`- ${tool.name}: ${tool.ok ? 'found' : 'missing'}${version}${detail}`); + } + + lines.push('', `azd environment: ${result.azd.ok ? 'AgentOps outputs found' : result.azd.detail}`); + lines.push(`Azure subscription: expected=${result.cloud.expected_subscription_id || 'not configured'}, active=${result.cloud.active_subscription_name || 'not signed in'} (${result.cloud.active_subscription_id || 'unknown'}), match=${result.cloud.subscription_match ? 'yes' : 'no'}.`); + lines.push(`Local shim: agentops=${result.shim.agentops_cli_installed ? 'installed' : 'missing'}, copilot-agentops=${result.shim.copilot_agentops_installed ? 'installed' : 'missing'}, transparent routing=${result.shim.plain_copilot_observed ? 'enabled' : 'disabled'}.`); + lines.push(`Cloud config: workspace=${result.cloud.workspace_id_configured ? 'set' : 'missing'}, Azure Monitor Agents view=${result.cloud.agents_view_url_configured ? 'set' : 'missing'}, Grafana advanced=${result.cloud.grafana_url_configured ? 'set' : 'missing'}.`); + lines.push(`Azure target: resource group=${result.cloud.resource_group || 'missing'} (${result.cloud.resource_group_status || 'not-checked'}); binding=${result.cloud.binding_status || 'unknown'}.`); + + lines.push('', 'One-minute first run:'); + lines.push(`1. Guided path: ${result.first_run.guided_command}`); + lines.push(' This is a zero-write preview. Execute the reviewed plan with: agentops init --full --yes'); + lines.push(`2. Setup/bind fallback: ${result.first_run.bind_command}`); + lines.push(`3. Privacy smoke fallback: ${result.first_run.privacy_smoke_command}`); + lines.push(`4. Real smoke fallback: ${result.first_run.smoke_command}`); + lines.push(`5. See it: the smoke opens Run Story, or run ${result.first_run.latest_command} && ${result.first_run.open_command}`); + lines.push(`6. Dashboards: ${result.first_run.dashboard_import_command} && ${result.first_run.dashboard_verify_command}`); + lines.push(`Privacy: ${result.first_run.privacy_note}`); + lines.push('Everyday observed use: agentops copilot ... (plain copilot stays unobserved unless transparent routing is enabled).'); + + lines.push('', 'Fastest path:'); + for (const phase of result.phases) { + lines.push('', `${phase.name} (${phase.status})`); + for (const command of phase.commands) lines.push(` ${command}`); + lines.push(` verify: ${phase.verify}`); + } + + lines.push('', 'Run next:'); + for (const command of result.next.slice(0, 3)) lines.push(`- ${command}`); + if (result.next.length > 3) lines.push(`- ${result.next.length - 3} more fallback commands are available in: agentops setup --json`); + return `${lines.join('\n')}\n`; + } + + return { + agentopsSetupGuide, + azureAccountStatus, + azureResourceGroupStatus, + azdEnvironmentStatus, + parseSetupArgs, + renderSetupGuide, + setupToolStatus + }; +} + +module.exports = { + createSetupGuide +}; diff --git a/agentops-cli/src/lib/setup-init.js b/agentops-cli/src/lib/setup-init.js new file mode 100644 index 0000000..e8f08ba --- /dev/null +++ b/agentops-cli/src/lib/setup-init.js @@ -0,0 +1,809 @@ +const childProcess = require('node:child_process'); +const path = require('node:path'); +const { createSetupGuide } = require('./setup-guide'); +const { checkAzureSubscription: defaultCheckAzureSubscription } = require('./azure/subscription-guard'); + +const LOCAL_NATIVE_SHELLS = new Set(['bash', 'zsh', 'fish', 'powershell', 'json']); +const LOCAL_NATIVE_OTEL_ENV = Object.freeze({ + COPILOT_OTEL_ENABLED: 'true', + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://127.0.0.1:4318', + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf', + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'false' +}); + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +function renderNativeOtelExports(env, shell) { + if (shell === 'json') return `${JSON.stringify(env, null, 2)}\n`; + const lines = []; + for (const [key, value] of Object.entries(env)) { + if (shell === 'powershell') { + lines.push(`$env:${key} = "${String(value).replace(/"/g, '`"')}"`); + } else if (shell === 'fish') { + lines.push(`set -gx ${key} ${shellQuote(value)}`); + } else { + lines.push(`export ${key}=${shellQuote(value)}`); + } + } + return `${lines.join('\n')}\n`; +} + +function assetPlan(result, key) { + if (!result) return null; + const inventoryKey = key === 'skills' ? 'skills' : 'agents'; + const installedKey = key === 'skills' ? 'installedSkills' : 'installedAgents'; + const inventory = Array.isArray(result[inventoryKey]) ? result[inventoryKey] : []; + const target = item => item?.target || item?.targetDir || null; + return { + target_dir: result.targetDir || null, + would_install: (result[installedKey] || []).map(target).filter(Boolean), + would_update: (result.updated || []).map(target).filter(Boolean), + would_skip: (result.skipped || []).map(target).filter(Boolean), + available: inventory.map(item => item.name || item.file || item.directory).filter(Boolean) + }; +} + +function createSetupInit(dependencies = {}) { + const { + agentopsConfigure, + agentopsStatusSummary, + checkAzureSubscription = defaultCheckAzureSubscription, + commandCandidates, + configFromEnvValues, + configuredCloudValues, + defaultInstallDir, + doctor, + durationToMs, + grafanaDashboardImportCommand, + installDefaultAgents, + installDefaultSkills, + installedShimStatus, + isConfiguredValue, + optionValue, + parseEnvAssignments, + plural, + realCopilotSmokeCommand, + validateAzure + } = dependencies; + const cliEntryPath = dependencies.cliEntryPath || path.join(__dirname, '..', 'index.js'); + const { + agentopsSetupGuide, + azureAccountStatus, + azureResourceGroupStatus, + azdEnvironmentStatus, + parseSetupArgs, + renderSetupGuide, + setupToolStatus + } = createSetupGuide({ + commandCandidates, + configFromEnvValues, + configuredCloudValues, + defaultInstallDir, + grafanaDashboardImportCommand, + installedShimStatus, + isConfiguredValue, + parseEnvAssignments, + realCopilotSmokeCommand + }); + + function parseInitArgs(args) { + const full = args.includes('--full'); + const localOnly = args.includes('--local-only'); + const yes = args.includes('--yes'); + const shellArgPresent = args.includes('--shell') || args.some(arg => arg.startsWith('--shell=')); + const shell = optionValue(args, ['--shell']); + if (shellArgPresent && !shell) throw new Error('--shell requires bash, zsh, fish, powershell, or json'); + if (shellArgPresent && !LOCAL_NATIVE_SHELLS.has(shell)) { + throw new Error('--shell must be bash, zsh, fish, powershell, or json'); + } + const localOnlyConflicts = ['--full', '--provision-cloud', '--import-dashboards', '--run-smoke', '--triage-latest'] + .filter(flag => args.includes(flag)); + if (localOnly && localOnlyConflicts.length > 0) { + throw new Error(`--local-only cannot be combined with ${localOnlyConflicts.join(', ')}`); + } + if (shellArgPresent && !localOnly) throw new Error('--shell is only supported with --local-only'); + const requestedWrites = full || ['--provision-cloud', '--import-dashboards', '--run-smoke', '--triage-latest'] + .some(flag => args.includes(flag)); + const explicitDryRun = args.includes('--dry-run'); + const localPreview = localOnly && !yes && !explicitDryRun; + return { + dryRun: explicitDryRun || localPreview || (requestedWrites && !yes), + full, + localOnly, + yes, + shell: localOnly ? (shell || 'bash') : null, + confirmationRequired: (localOnly || requestedWrites) && !yes && !explicitDryRun, + confirmationCommand: localOnly + ? `agentops init --local-only --yes --shell ${shell || 'bash'}` + : full ? 'agentops init --full --yes' : null, + forceSkills: args.includes('--force-skills') || args.includes('--force'), + json: args.includes('--json'), + importDashboards: full || args.includes('--import-dashboards'), + noSkills: args.includes('--no-skills') || args.includes('--no-plugin'), + provisionCloud: full || args.includes('--provision-cloud'), + forceProvisionCloud: args.includes('--provision-cloud'), + runSmoke: full || args.includes('--run-smoke'), + triageLatest: full || args.includes('--triage-latest'), + copilotHome: optionValue(args, ['--copilot-home', '--home']), + checkAzureAccount: true + }; + } + + function agentopsInitLocal(options = {}) { + const dryRun = options.dryRun === undefined ? options.yes !== true : options.dryRun !== false; + const confirmationRequired = options.confirmationRequired === undefined + ? dryRun && options.yes !== true + : Boolean(options.confirmationRequired); + const confirmationCommand = options.confirmationCommand + || `agentops init --local-only --yes --shell ${options.shell || 'bash'}`; + const status = doctor({ localOnly: true }); + const rawLocalStatus = agentopsStatusSummary({ checks: status }); + const localStatus = { + ok: rawLocalStatus.ok, + required_files: rawLocalStatus.required_files, + content_capture_off: rawLocalStatus.content_capture_off, + collector_localhost: rawLocalStatus.collector_localhost + }; + const skillsResult = options.noSkills + ? null + : installDefaultSkills({ + copilotHome: options.copilotHome, + force: options.forceSkills, + dryRun + }); + const agentsResult = options.noSkills + ? null + : installDefaultAgents({ + copilotHome: options.copilotHome, + force: options.forceSkills, + dryRun + }); + const skills = assetPlan(skillsResult, 'skills'); + const agents = assetPlan(agentsResult, 'agents'); + const wouldWrite = [ + ...(skills?.would_install || []), + ...(skills?.would_update || []), + ...(agents?.would_install || []), + ...(agents?.would_update || []) + ]; + const nativeOtel = { + endpoint: LOCAL_NATIVE_OTEL_ENV.OTEL_EXPORTER_OTLP_ENDPOINT, + protocol: LOCAL_NATIVE_OTEL_ENV.OTEL_EXPORTER_OTLP_PROTOCOL, + capture_content: false, + wrapper_required: false, + session_export: { + remote_export: false, + verified: false, + enforcement: 'per-invocation-cli-flag', + cli_flag: '--no-remote-export', + settings: { remoteExport: false } + }, + exports: { ...LOCAL_NATIVE_OTEL_ENV }, + shell: options.shell || 'bash' + }; + const collectorCommand = 'agentops collector start --mode local --privacy strict'; + const result = { + ok: true, + mode: dryRun ? 'local-preview' : 'local-applied', + local_only: true, + dry_run: dryRun, + mutates: !dryRun, + confirmation_required: confirmationRequired, + confirmation_command: confirmationRequired + ? confirmationCommand + : null, + local_status: localStatus, + local_ready: Boolean(localStatus.required_files?.missing?.length === 0 && localStatus.content_capture_off), + cloud: { + checked: false, + required: false, + ready: false, + state: 'not_checked' + }, + wrapper_required: false, + native_otel: nativeOtel, + shell_exports: renderNativeOtelExports(nativeOtel.exports, nativeOtel.shell), + would_install: { + skills: skills?.would_install || [], + agents: agents?.would_install || [] + }, + would_update: { + skills: skills?.would_update || [], + agents: agents?.would_update || [] + }, + would_write: dryRun ? wouldWrite : [], + would_start: dryRun ? [collectorCommand] : [], + applied: dryRun ? null : { + skills: skills?.would_install || [], + agents: agents?.would_install || [], + updated_skills: skills?.would_update || [], + updated_agents: agents?.would_update || [], + collector: 'not_started_by_init' + }, + next: dryRun + ? [options.confirmationCommand || 'agentops init --local-only --yes --shell bash', 'agentops smoke --local'] + : [collectorCommand, 'agentops smoke --local', 'copilot --no-remote-export'] + }; + result.summary = { + status: dryRun ? 'preview' : 'applied', + detail: dryRun + ? 'No local writes or process starts were made. Review the planned AgentOps-owned setup, then confirm explicitly.' + : 'AgentOps-owned local setup was applied. Native Copilot OTel exports are ready for the current shell.' + }; + return result; + } + + function runInitCloudProvision(options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const command = options.azdCommand || 'azd'; + const provisionArgs = options.azdProvisionArgs || ['provision']; + const commandText = `${command} ${provisionArgs.join(' ')}`; + if (options.dryRun) { + return { + requested: true, + dry_run: true, + ok: true, + command: commandText, + import_result: { dryRun: true, action: 'import-azd' }, + failing_stage: null, + next: [] + }; + } + + const subscriptionGuard = checkAzureSubscription({ + env: options.env, + expectedSubscriptionId: options.expectedSubscriptionId, + approvedSubscriptionIds: options.approvedSubscriptionIds, + spawnSync + }); + if (!subscriptionGuard.ok) { + return { + requested: true, + dry_run: false, + ok: false, + command: commandText, + failing_stage: 'subscription guard', + subscription_guard: subscriptionGuard, + import_result: null, + next: [ + 'export AGENTOPS_AZURE_SUBSCRIPTION_ID=""', + 'export AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS=""', + 'az account show --query "{name:name,id:id}" -o table', + 'agentops init --dry-run --provision-cloud' + ] + }; + } + + const provision = spawnSync(command, provisionArgs, { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }); + const provisionOk = provision.status === 0 && !provision.error; + const importResult = provisionOk + ? agentopsConfigure({ + subcommand: 'import-azd', + configPath: options.configPath, + dryRun: false, + spawnSync + }) + : null; + const importOk = importResult?.ok === true; + const failingStage = !provisionOk ? 'azd provision' : importOk ? null : 'agentops configure import-azd'; + const next = !provisionOk + ? [ + 'az login', + 'azd env list', + commandText, + 'agentops init --dry-run --provision-cloud' + ] + : importOk + ? [] + : [ + 'azd env get-values', + 'agentops configure import-azd', + 'agentops configure set --workspace-id ""', + 'agentops configure set --agents-url ""' + ]; + + return { + requested: true, + dry_run: false, + ok: provisionOk && importOk, + command: commandText, + failing_stage: failingStage, + provision: { + ok: provisionOk, + status: provision.status, + stdout: provision.stdout || '', + stderr: provision.stderr || '', + error: provision.error?.message || null + }, + import_result: importResult, + next + }; + } + + function runInitDashboardImport(options = {}) { + const command = 'agentops validate-azure --import-dashboards --last 24h'; + if (!options.importDashboards) { + return { + requested: false, + dry_run: Boolean(options.dryRun), + ok: null, + command + }; + } + + if (options.dryRun) { + return { + requested: true, + dry_run: true, + ok: true, + command, + validation: null, + next: [] + }; + } + + const validate = options.validateAzure || validateAzure; + const validation = validate({ + ...options, + last: '24h', + importDashboards: true, + production: false, + remediationPlan: false + }); + + return { + requested: true, + dry_run: false, + ok: validation.ok === true, + command, + validation, + next: validation.ok === true + ? ['node agentops-cli/src/index.js collector smoke --privacy strict --poison --json'] + : (Array.isArray(validation.next) && validation.next.length ? validation.next : [command]) + }; + } + + function runInitRealSmoke(options = {}) { + const args = ['smoke', '--real-copilot', '--wait', '2m', '--poll', '10s', '--open-browser', '--json']; + const command = 'agentops smoke --real-copilot --wait 2m --poll 10s --open-browser --json'; + if (!options.runSmoke) { + return { + requested: false, + dry_run: Boolean(options.dryRun), + ok: null, + command + }; + } + + if (options.dryRun) { + return { + requested: true, + dry_run: true, + ok: true, + command, + status: null, + next: [] + }; + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync(process.execPath, [cliEntryPath, ...args], { + cwd: options.cwd || process.cwd(), + env: { ...process.env, ...(options.env || {}) }, + encoding: 'utf8', + timeout: durationToMs(options.smokeTimeoutMs ?? options.timeout, 180000), + maxBuffer: 20 * 1024 * 1024 + }); + const status = result.status === null || result.status === undefined ? 1 : result.status; + let smoke = null; + try { + smoke = result.stdout ? JSON.parse(result.stdout) : null; + } catch { + smoke = null; + } + + const ok = status === 0 && !result.error && smoke?.ok !== false; + return { + requested: true, + dry_run: false, + ok, + command, + status, + stdout: result.stdout || '', + stderr: result.stderr || '', + error: result.error?.message || null, + smoke, + next: ok + ? ['agentops open latest --last 2h'] + : [command, 'agentops latest --last 2h', 'agentops open latest --last 2h'] + }; + } + + function runInitLatestTriage(options = {}) { + const args = ['triage', 'latest', '--out', '.agentops/triage/latest', '--json']; + const command = 'agentops triage latest --out .agentops/triage/latest --json'; + if (!options.triageLatest) { + return { + requested: false, + dry_run: Boolean(options.dryRun), + ok: null, + command + }; + } + + if (options.dryRun) { + return { + requested: true, + dry_run: true, + ok: true, + command, + status: null, + next: [] + }; + } + + const spawnSync = options.spawnSync || childProcess.spawnSync; + const result = spawnSync(process.execPath, [cliEntryPath, ...args], { + cwd: options.cwd || process.cwd(), + env: { ...process.env, ...(options.env || {}) }, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }); + const status = result.status === null || result.status === undefined ? 1 : result.status; + let triage = null; + try { + triage = result.stdout ? JSON.parse(result.stdout) : null; + } catch { + triage = null; + } + + const ok = status === 0 && !result.error && triage?.ok !== false; + return { + requested: true, + dry_run: false, + ok, + command, + status, + stdout: result.stdout || '', + stderr: result.stderr || '', + error: result.error?.message || null, + triage, + next: ok + ? [] + : [command, 'agentops latest --last 2h', 'agentops open latest --last 2h'] + }; + } + + function buildInitSummary(result) { + const dryRun = result.mode === 'dry-run'; + const cloudTargetBlocked = result.cloud?.binding_status === 'configured-but-target-missing-or-unverified' + && result.cloud_provision?.requested !== true; + const stages = [ + ['cloud_provision', result.cloud_provision], + ['dashboard_import', result.dashboard_import], + ['real_smoke', result.real_smoke], + ['triage_latest', result.triage_latest] + ] + .filter(([, stage]) => stage?.requested) + .map(([name, stage]) => ({ + name, + status: cloudTargetBlocked + ? 'blocked' + : result.confirmation_required || dryRun ? 'planned' : stage.ok === true ? 'ready' : 'needs_review', + command: stage.command + })); + const nextAction = result.next?.[0] || 'node agentops-cli/src/index.js open latest --last 2h'; + if (cloudTargetBlocked) { + const targetDetail = String(result.cloud.resource_group_detail || 'verify or restore the intended target before continuing').replace(/[.!?]+$/, ''); + return { + status: 'blocked', + next_action: 'agentops init --dry-run --provision-cloud', + detail: `No cloud or workflow writes were made. The configured Azure target ${result.cloud.resource_group || ''} is ${result.cloud.resource_group_status || 'not verified'}: ${targetDetail}. Review the reprovision preview, then run: agentops init --provision-cloud --yes. AgentOps will not redirect to a different resource group automatically.`, + stages + }; + } + if (result.confirmation_required || dryRun) { + const command = result.confirmation_required + ? result.confirmation_command + : stages.length > 0 ? 'agentops init --full --yes' : nextAction; + return { + status: 'preview', + next_action: command, + detail: `No cloud or workflow writes were made. Review the preview, verify the active subscription, then run: ${command}`, + stages + }; + } + const ready = result.ok === true; + return { + status: ready ? 'ready' : 'needs_action', + next_action: ready ? 'node agentops-cli/src/index.js open latest --last 2h' : nextAction, + detail: ready + ? 'AgentOps first-run path is ready; open the latest run or continue with normal Copilot work.' + : `Run next: ${nextAction}`, + stages + }; + } + + function agentopsInit(options = {}) { + if (options.localOnly) return agentopsInitLocal(options); + const checks = doctor({ localOnly: true }); + const status = agentopsStatusSummary({ checks }); + const cloud = configuredCloudValues(options); + const shim = installedShimStatus(options.installDir || defaultInstallDir); + const skills = options.noSkills + ? null + : installDefaultSkills({ + copilotHome: options.copilotHome, + force: options.forceSkills, + dryRun: options.dryRun + }); + const agents = options.noSkills + ? null + : installDefaultAgents({ + copilotHome: options.copilotHome, + force: options.forceSkills, + dryRun: options.dryRun + }); + const workspaceConfigured = isConfiguredValue(cloud.workspaceId, /^0{8}-0{4}-0{4}-0{4}-0{12}$/); + const grafanaConfigured = isConfiguredValue(cloud.grafanaBaseUrl, /your-grafana||^$/); + const agentsViewConfigured = isConfiguredValue(cloud.agentsViewUrl, /^$/); + const cloudConfigured = workspaceConfigured && (grafanaConfigured || agentsViewConfigured); + const azdTool = setupToolStatus('azd', options); + const azd = azdEnvironmentStatus(options, azdTool.ok); + const azTool = setupToolStatus('az', options); + const azureAccount = options.checkAzureAccount + ? azureAccountStatus(options, azTool.ok) + : { checked: false, ok: false, id: null, name: null }; + const expectedSubscriptionId = cloud.subscriptionId || null; + const subscriptionMatch = Boolean(expectedSubscriptionId && azureAccount.id + && expectedSubscriptionId.toLowerCase() === azureAccount.id.toLowerCase()); + const resourceGroup = cloudConfigured && options.checkAzureAccount !== false + ? azureResourceGroupStatus({ + resourceGroup: cloud.resourceGroup, + subscriptionId: expectedSubscriptionId, + resourceGroupExists: options.resourceGroupExists, + spawnSync: options.spawnSync + }, azTool.ok) + : { + checked: false, + exists: null, + ok: false, + status: 'not-configured', + detail: 'Cloud binding is incomplete or Azure account checking is disabled.' + }; + const cloudTargetReady = cloudConfigured && resourceGroup.ok && subscriptionMatch; + const shouldProvisionCloud = options.provisionCloud + && (options.forceProvisionCloud || !cloudConfigured); + const cloudProvision = shouldProvisionCloud + ? runInitCloudProvision(options) + : { + requested: false, + dry_run: Boolean(options.dryRun), + ok: null, + command: 'agentops init --provision-cloud', + skipped_reason: options.provisionCloud ? 'cloud_already_configured' : null + }; + const dashboardImport = runInitDashboardImport(options); + const realSmoke = runInitRealSmoke(options); + const latestTriage = runInitLatestTriage(options); + const next = []; + + if (!shim.agentops_cli_installed) { + next.push('agentops install'); + } + if (!shim.plain_copilot_observed) next.push('agentops copilot'); + if (!cloudConfigured) { + if (azd.ok) { + next.push('agentops configure import-azd'); + } else if (!options.provisionCloud) { + next.push('agentops init --provision-cloud'); + } else { + if (!workspaceConfigured) { + next.push('agentops configure set --workspace-id ""'); + } + if (!grafanaConfigured) { + next.push('agentops configure set --agents-url ""'); + } + } + } else if (!cloudTargetReady) { + next.push('agentops init --dry-run --provision-cloud'); + next.push('agentops init --provision-cloud --yes'); + } + if (dashboardImport.requested && dashboardImport.ok !== true) { + for (const command of dashboardImport.next || []) next.push(command); + } else if (!dashboardImport.requested) { + next.push('agentops validate-azure --import-dashboards --last 24h'); + } + next.push('agentops collector smoke --privacy strict --poison --json'); + if (realSmoke.requested && realSmoke.ok !== true) { + for (const command of realSmoke.next || []) next.push(command); + } else if (!realSmoke.requested) { + next.push('agentops smoke --real-copilot --wait 2m --poll 10s'); + next.push('agentops latest --last 2h'); + next.push('agentops open latest --last 2h'); + } else { + next.push('agentops open latest --last 2h'); + } + if (latestTriage.requested && latestTriage.ok !== true) { + for (const command of latestTriage.next || []) next.push(command); + } else if (!latestTriage.requested) { + next.push('agentops triage latest --out .agentops/triage/latest --json'); + } + next.push('agentops plugin uninstall'); + + const initOk = status.ok && Boolean(shim.agentops_cli_installed) && Boolean(shim.copilot_agentops_installed) && cloudTargetReady && (!cloudProvision.requested || cloudProvision.ok === true) && (!dashboardImport.requested || dashboardImport.ok === true) && (!realSmoke.requested || realSmoke.ok === true) && (!latestTriage.requested || latestTriage.ok === true); + const result = { + ok: initOk, + mode: options.confirmationRequired ? 'preview-awaiting-confirmation' : options.dryRun ? 'dry-run' : 'local-init', + confirmation_required: Boolean(options.confirmationRequired), + confirmation_command: options.confirmationRequired + ? (options.confirmationCommand || 'agentops init --full --yes') + : null, + local_status: status, + azd, + cloud_provision: cloudProvision, + dashboard_import: dashboardImport, + real_smoke: realSmoke, + triage_latest: latestTriage, + skills, + agents, + shim, + cloud: { + resource_group: cloud.resourceGroup, + resource_group_checked: resourceGroup.checked, + resource_group_exists: resourceGroup.exists, + resource_group_status: resourceGroup.status, + resource_group_detail: resourceGroup.detail, + binding_status: cloudTargetReady ? 'ready' : cloudConfigured ? 'configured-but-target-missing-or-unverified' : 'unconfigured', + workspace_id_configured: workspaceConfigured, + grafana_url_configured: grafanaConfigured, + grafana_name_configured: Boolean(cloud.grafanaName), + agents_view_url_configured: agentsViewConfigured, + app_insights_name: cloud.appInsightsName, + expected_subscription_id: expectedSubscriptionId, + active_subscription_id: azureAccount.id, + active_subscription_name: azureAccount.name, + subscription_match: subscriptionMatch + }, + next + }; + result.summary = buildInitSummary(result); + return result; + } + + function renderInit(result) { + if (result.local_only) { + if (result.native_otel.shell === 'json') { + return `${JSON.stringify({ + mode: result.mode, + local_only: true, + dry_run: result.dry_run, + wrapper_required: false, + would_install: result.would_install, + would_update: result.would_update, + would_write: result.would_write, + would_start: result.would_start, + applied: result.applied, + native_otel: result.native_otel, + shell_exports: result.native_otel.exports, + next: result.next + }, null, 2)}\n`; + } + if (!result.dry_run) return result.shell_exports; + const lines = [ + 'AgentOps init --local-only', + '', + 'Mode: preview. No local writes or process starts were made.', + 'Native Copilot OTel: planned; wrapper required: no; Azure: not checked and not required.', + '', + 'would_install:', + `- AgentOps skills: ${result.would_install.skills.length}`, + `- AgentOps agents: ${result.would_install.agents.length}`, + 'would_write:', + ...(result.would_write.length ? result.would_write.map(target => `- ${target}`) : ['- none']), + 'would_start:', + ...result.would_start.map(command => `- ${command}`), + '', + 'would_emit (native Copilot OTel exports):', + ...result.shell_exports.trim().split('\n').map(line => `- ${line}`), + '', + `Next: ${result.confirmation_command || 'agentops init --local-only --yes --shell bash'}`, + 'Then: agentops smoke --local', + 'Execution remains the plain `copilot` command.' + ]; + return `${lines.join('\n')}\n`; + } + const lines = [ + 'AgentOps init', + '', + `Mode: ${result.mode}.`, + `Local files: ${result.local_status.required_files.found} of ${result.local_status.required_files.total} found.`, + result.local_status.content_capture_off + ? 'Content capture: off.' + : 'Content capture: on; turn it off before sharing telemetry.', + result.local_status.collector_localhost + ? 'Collector config: localhost confirmed.' + : 'Collector config: localhost not confirmed.', + `Shim: agentops=${result.shim.agentops_cli_installed ? 'installed' : 'missing'}, copilot-agentops=${result.shim.copilot_agentops_installed ? 'installed' : 'missing'}, transparent routing=${result.shim.plain_copilot_observed ? 'enabled' : 'disabled'}.`, + `Cloud config: workspace=${result.cloud.workspace_id_configured ? 'set' : 'missing'}, Azure Monitor Agents view=${result.cloud.agents_view_url_configured ? 'set' : 'missing'}, Grafana advanced=${result.cloud.grafana_url_configured ? 'set' : 'missing'}.`, + `Azure subscription: expected=${result.cloud.expected_subscription_id || 'not configured'}, active=${result.cloud.active_subscription_name || 'not signed in'} (${result.cloud.active_subscription_id || 'unknown'}), match=${result.cloud.subscription_match ? 'yes' : 'no'}.`, + `azd environment: ${result.azd.ok ? 'AgentOps outputs found.' : result.azd.detail}` + ]; + + if (result.cloud_provision.requested) { + lines.push(`Cloud provision: ${result.cloud_provision.ok ? 'ready' : 'needs review'} (${result.cloud_provision.command}).`); + if (!result.cloud_provision.ok && result.cloud_provision.failing_stage) { + lines.push(`Cloud provision failed at: ${result.cloud_provision.failing_stage}.`); + } + if (!result.cloud_provision.ok && result.cloud_provision.next?.length) { + lines.push('Cloud provision next:'); + for (const command of result.cloud_provision.next) lines.push(`- ${command}`); + } + } else if (result.cloud_provision.skipped_reason === 'cloud_already_configured' && result.cloud.binding_status === 'ready') { + lines.push('Cloud provision: skipped; the existing workspace and Grafana binding will be reused. Use `--provision-cloud` explicitly to reprovision.'); + } else if (result.cloud_provision.skipped_reason === 'cloud_already_configured') { + lines.push(`Cloud provision: blocked; configured target is ${result.cloud.resource_group_status || 'not verified'} (${result.cloud.resource_group_detail || 'run validate-azure before any write'}).`); + } + if (result.dashboard_import?.requested) { + lines.push(`Dashboard import: ${result.confirmation_required ? 'planned; not run' : result.dashboard_import.ok ? 'ready' : 'needs review'} (${result.dashboard_import.command}).`); + if (!result.dashboard_import.ok && result.dashboard_import.next?.length) { + lines.push('Dashboard import next:'); + for (const command of result.dashboard_import.next) lines.push(`- ${command}`); + } + } + if (result.real_smoke?.requested) { + lines.push(`Real smoke: ${result.confirmation_required ? 'planned; not run' : result.real_smoke.ok ? 'ready' : 'needs review'} (${result.real_smoke.command}).`); + if (!result.real_smoke.ok && result.real_smoke.next?.length) { + lines.push('Real smoke next:'); + for (const command of result.real_smoke.next) lines.push(`- ${command}`); + } + } + if (result.triage_latest?.requested) { + lines.push(`Latest triage: ${result.confirmation_required ? 'planned; not run' : result.triage_latest.ok ? 'ready' : 'needs review'} (${result.triage_latest.command}).`); + if (!result.triage_latest.ok && result.triage_latest.next?.length) { + lines.push('Latest triage next:'); + for (const command of result.triage_latest.next) lines.push(`- ${command}`); + } + } + + if (result.skills) { + lines.push(`Skills: ${plural(result.skills.installed, 'new skill')}; ${plural(result.skills.updated.length, 'updated skill')}; skipped ${plural(result.skills.skipped.length, 'existing skill')}.`); + } + if (result.agents) { + lines.push(`Agents: ${plural(result.agents.installed, 'new agent')}; ${plural(result.agents.updated.length, 'updated agent')}; skipped ${plural(result.agents.skipped.length, 'existing agent')}.`); + } + + if (result.summary) { + const stages = result.summary.stages?.length + ? ` Stages: ${result.summary.stages.map(stage => `${stage.name}=${stage.status}`).join(', ')}.` + : ''; + lines.push(`Summary: ${result.summary.status}. ${result.summary.detail}${stages}`); + } + + lines.push('', 'Next commands:'); + for (const command of result.next) lines.push(`- ${command}`); + lines.push('', 'Everyday observed use: agentops copilot'); + lines.push('See the latest result: agentops open latest'); + lines.push('Check local health: agentops status'); + lines.push('', 'Plugin files are reversible: run `agentops plugin uninstall` to remove only the bundled AgentOps agents and skills from Copilot home.'); + return `${lines.join('\n')}\n`; + } + + return { + agentopsInit, + agentopsSetupGuide, + parseInitArgs, + parseSetupArgs, + renderInit, + renderSetupGuide + }; +} + +module.exports = { + createSetupInit +}; diff --git a/agentops-cli/src/lib/smoke-cli.js b/agentops-cli/src/lib/smoke-cli.js new file mode 100644 index 0000000..d000d7a --- /dev/null +++ b/agentops-cli/src/lib/smoke-cli.js @@ -0,0 +1,56 @@ +const { durationToMs, optionValue, parseLastArg } = require('./cli-options'); + +function parseSmokeArgs(args) { + const parsed = { + dryRun: args.includes('--dry-run'), + endpoint: optionValue(args, ['--endpoint']), + id: optionValue(args, ['--id']), + last: parseLastArg(args, '2h'), + realCopilot: args.includes('--real-copilot') || args.includes('--copilot'), + openBrowser: args.includes('--open-browser'), + copilotTimeoutMs: durationToMs(optionValue(args, ['--timeout']), 120000), + verify: !args.includes('--local') && !args.includes('--no-verify'), + // Azure Monitor native OTLP is eventually consistent. Give the default + // verification window enough time for the first row to reach Log Analytics; + // local receipt checks remain quick and explicit --no-verify by default. + waitMs: durationToMs(optionValue(args, ['--wait']), args.includes('--local') || args.includes('--no-verify') ? 60000 : 300000), + pollMs: durationToMs(optionValue(args, ['--poll']), 10000), + json: args.includes('--json') + }; + if (args.includes('--local')) parsed.local = true; + return parsed; +} + +function realCopilotSmokeArgs() { + return [ + '--no-ask-user', + '--no-remote', + '--no-remote-export', + '--add-dir', + '.', + "--allow-tool=shell(pwd)", + "--allow-tool=shell(ls:*)", + '-p', + 'Do not edit files. Run pwd and ls docs | head, then summarize.' + ]; +} + +function commandShellQuote(value) { + const text = String(value); + if (/^[A-Za-z0-9_./:=+-]+$/.test(text)) return text; + return `'${text.replace(/'/g, `'\\''`)}'`; +} + +function realCopilotSmokeCommand() { + return `copilot ${realCopilotSmokeArgs().map(commandShellQuote).join(' ')}`; +} + +module.exports = { + commandShellQuote, + durationToMs, + optionValue, + parseLastArg, + parseSmokeArgs, + realCopilotSmokeArgs, + realCopilotSmokeCommand +}; diff --git a/agentops-cli/src/lib/smoke-command.js b/agentops-cli/src/lib/smoke-command.js new file mode 100644 index 0000000..0326bd0 --- /dev/null +++ b/agentops-cli/src/lib/smoke-command.js @@ -0,0 +1,48 @@ +const { writeJsonOrRender } = require('./command-output'); + +function writeSmokeResult(result, options, renderSmoke, stdout, setExitCode) { + writeJsonOrRender(result, options.json, renderSmoke, stdout); + setExitCode(result.ok ? 0 : 1); +} + +const smokeCommandNames = Object.freeze(['smoke', 'attribution-smoke', 'live-replay-smoke']); + +function createSmokeCommand(dependencies = {}) { + const { + agentopsAttributionSmoke, + agentopsLiveReplaySmoke, + agentopsSmoke, + parseSmokeArgs, + renderSmoke, + setExitCode = code => { + process.exitCode = code; + }, + stdout = process.stdout + } = dependencies; + + async function smokeCommand(command, args) { + const options = parseSmokeArgs(args); + if (command === 'smoke') { + writeSmokeResult(await agentopsSmoke(options), options, renderSmoke, stdout, setExitCode); + return; + } + if (command === 'attribution-smoke') { + writeSmokeResult(await agentopsAttributionSmoke(options), options, renderSmoke, stdout, setExitCode); + return; + } + if (command === 'live-replay-smoke') { + writeSmokeResult(await agentopsLiveReplaySmoke(options), options, renderSmoke, stdout, setExitCode); + return; + } + throw new Error(`Unknown smoke command: ${command}`); + } + + return { + smokeCommand, + smokeCommandNames + }; +} + +module.exports = { + createSmokeCommand +}; diff --git a/agentops-cli/src/lib/smoke-payloads.js b/agentops-cli/src/lib/smoke-payloads.js new file mode 100644 index 0000000..d4cc91c --- /dev/null +++ b/agentops-cli/src/lib/smoke-payloads.js @@ -0,0 +1,288 @@ +const crypto = require('node:crypto'); + +const { escapeKqlString, validateKqlDuration } = require('./kql'); + +function smokeId(now = new Date()) { + const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + return `agentops-smoke-${stamp}-${crypto.randomBytes(3).toString('hex')}`; +} + +function attributionSmokeId(now = new Date()) { + const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + return `agentops-attribution-smoke-${stamp}-${crypto.randomBytes(3).toString('hex')}`; +} + +function liveReplaySmokeId(now = new Date()) { + const stamp = now.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + return `agentops-live-replay-smoke-${stamp}-${crypto.randomBytes(3).toString('hex')}`; +} + +function encodeGrafanaValue(value) { + return encodeURIComponent(value).replace(/%2F/g, '/'); +} + +function liveReplayGrafanaUrl(id, last = '2h', options = {}) { + const grafanaBaseUrl = options.grafanaBaseUrl || 'http://localhost:3000'; + return `${grafanaBaseUrl}/d/agentops-live-replay/agentops-live-replay?from=now-${encodeGrafanaValue(validateKqlDuration(last))}&to=now&timezone=browser&refresh=30s&var-conversation=${encodeGrafanaValue(id)}&var-agentops_agent=__all&var-mcp_server=__all&var-tool=__all`; +} + +function smokeAzureQuery(id, last = '2h') { + const lookback = validateKqlDuration(last); + const escapedId = escapeKqlString(id); + return `union isfuzzy=true\n(\n OTelSpans\n | where TimeGenerated > ago(${lookback})\n | where tostring(Attributes) has "${escapedId}" or tostring(ResourceAttributes) has "${escapedId}"\n | extend OperationId=TraceId, Id=SpanId, Success=true, ResultCode="", Properties=Attributes\n | project TimeGenerated, Name, OperationId, Id, Success, ResultCode, Properties\n),\n(\n AppDependencies\n | where TimeGenerated > ago(${lookback})\n | where Properties has "${escapedId}" or Name has "${escapedId}"\n | project TimeGenerated, Name, OperationId, Id, Success, ResultCode, Properties\n)\n| order by TimeGenerated desc\n| take 20`; +} + +function otlpSmokeTracePayload(id, nowMs = Date.now()) { + const traceId = crypto.randomBytes(16).toString('hex'); + const spanId = crypto.randomBytes(8).toString('hex'); + const start = BigInt(nowMs) * 1000000n; + const end = start + 100000000n; + const attr = (key, stringValue) => ({ key, value: { stringValue } }); + + return { + resourceSpans: [ + { + resource: { + attributes: [ + attr('service.name', 'github-copilot-cli'), + attr('service.namespace', 'copilot-agentops'), + attr('agent.framework', 'github-copilot'), + attr('agent.runtime', 'github-copilot-cli'), + attr('agentops.profile', 'safe-default'), + attr('agentops.e2e.id', id), + attr('agentops.smoke_id', id) + ] + }, + scopeSpans: [ + { + scope: { name: 'agentops.smoke', version: '0.1.0' }, + spans: [ + { + traceId, + spanId, + name: `agentops.smoke.${id}`, + kind: 1, + startTimeUnixNano: start.toString(), + endTimeUnixNano: end.toString(), + attributes: [ + attr('agentops.custom_event_id', id), + attr('agentops.smoke_id', id), + attr('gen_ai.operation.name', 'smoke_test'), + { key: 'content.capture.enabled', value: { boolValue: false } } + ], + status: { code: 1 } + } + ] + } + ] + } + ] + }; +} + +function otlpAttributionSmokeTracePayload(id, nowMs = Date.now()) { + const traceId = crypto.randomBytes(16).toString('hex'); + const start = BigInt(nowMs) * 1000000n; + const attr = (key, stringValue) => ({ key, value: { stringValue } }); + const boolAttr = (key, boolValue) => ({ key, value: { boolValue } }); + const intAttr = (key, intValue) => ({ key, value: { intValue: String(intValue) } }); + const span = (name, offsetMs, durationMs, attributes) => { + const spanStart = start + BigInt(offsetMs) * 1000000n; + const spanEnd = spanStart + BigInt(durationMs) * 1000000n; + return { + traceId, + spanId: crypto.randomBytes(8).toString('hex'), + name, + kind: 1, + startTimeUnixNano: spanStart.toString(), + endTimeUnixNano: spanEnd.toString(), + attributes: [ + attr('agentops.smoke_id', id), + attr('agentops.test.kind', 'attribution'), + attr('gen_ai.conversation.id', id), + boolAttr('content.capture.enabled', false), + ...attributes + ], + status: { code: 1 } + }; + }; + + return { + resourceSpans: [ + { + resource: { + attributes: [ + attr('service.name', 'github-copilot-cli'), + attr('service.namespace', 'copilot-agentops'), + attr('agent.framework', 'github-copilot'), + attr('agent.runtime', 'github-copilot-cli'), + attr('agentops.profile', 'attribution-smoke'), + attr('agentops.smoke_id', id) + ] + }, + scopeSpans: [ + { + scope: { name: 'agentops.attribution-smoke', version: '0.1.0' }, + spans: [ + span(`agentops.attribution.${id}.agent`, 0, 100, [ + attr('gen_ai.operation.name', 'invoke_agent'), + attr('gen_ai.agent.name', 'agentops-kitchen-sink-smoke'), + attr('agentops.agent.name', 'agentops-kitchen-sink-smoke'), + attr('agentops.agent.file', 'agentops-kitchen-sink-smoke.agent.md'), + intAttr('gen_ai.usage.input_tokens', 1200), + intAttr('gen_ai.usage.output_tokens', 80), + attr('github.copilot.cost', '0.2') + ]), + span(`agentops.attribution.${id}.skill`, 110, 40, [ + attr('gen_ai.operation.name', 'skill.invoke'), + attr('agentops.skill.name', 'agentops-attribution'), + attr('agentops.skill.file', 'agentops-attribution/SKILL.md') + ]), + span(`agentops.attribution.${id}.mcp`, 160, 60, [ + attr('gen_ai.operation.name', 'execute_tool'), + attr('gen_ai.tool.name', 'azure-mcp/monitor_query'), + attr('agentops.mcp.server', 'azure-mcp'), + attr('agentops.mcp.tool', 'monitor_query') + ]), + span(`agentops.attribution.${id}.script`, 230, 30, [ + attr('gen_ai.operation.name', 'hook.execute'), + attr('agentops.script.name', 'pre-tool-policy'), + attr('agentops.script.file', 'plugin/scripts/pre-tool-policy.js'), + attr('agentops.hook.name', 'preToolUse') + ]) + ] + } + ] + } + ] + }; +} + +function otlpLiveReplaySmokeTracePayload(id, nowMs = Date.now()) { + const traceId = crypto.randomBytes(16).toString('hex'); + const orchestratorSpanId = crypto.randomBytes(8).toString('hex'); + const delegationSpanId = crypto.randomBytes(8).toString('hex'); + const subagentSpanId = crypto.randomBytes(8).toString('hex'); + const start = BigInt(nowMs) * 1000000n; + const attr = (key, stringValue) => ({ key, value: { stringValue } }); + const boolAttr = (key, boolValue) => ({ key, value: { boolValue } }); + const intAttr = (key, intValue) => ({ key, value: { intValue: String(intValue) } }); + const span = (spanId, name, offsetMs, durationMs, attributes, parentSpanId = undefined) => { + const spanStart = start + BigInt(offsetMs) * 1000000n; + const spanEnd = spanStart + BigInt(durationMs) * 1000000n; + return { + traceId, + spanId, + ...(parentSpanId ? { parentSpanId } : {}), + name, + kind: 1, + startTimeUnixNano: spanStart.toString(), + endTimeUnixNano: spanEnd.toString(), + attributes: [ + attr('agentops.smoke_id', id), + attr('agentops.test.kind', 'live-replay'), + attr('gen_ai.conversation.id', id), + boolAttr('content.capture.enabled', false), + ...attributes + ], + status: { code: 1 } + }; + }; + + return { + resourceSpans: [ + { + resource: { + attributes: [ + attr('service.name', 'github-copilot-cli'), + attr('service.namespace', 'copilot-agentops'), + attr('agent.framework', 'github-copilot'), + attr('agent.runtime', 'github-copilot-cli'), + attr('agentops.profile', 'live-replay-smoke'), + attr('agentops.smoke_id', id) + ] + }, + scopeSpans: [ + { + scope: { name: 'agentops.live-replay-smoke', version: '0.1.0' }, + spans: [ + span(orchestratorSpanId, `agentops.live_replay.${id}.orchestrator`, 0, 380, [ + attr('gen_ai.operation.name', 'invoke_agent'), + attr('gen_ai.agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.agent.file', 'agentops-orchestrator.agent.md'), + intAttr('gen_ai.usage.input_tokens', 900), + intAttr('gen_ai.usage.output_tokens', 120), + attr('github.copilot.cost', '0.12') + ]), + span(delegationSpanId, `agentops.live_replay.${id}.delegation.started`, 70, 40, [ + attr('gen_ai.operation.name', 'agent.delegation.started'), + attr('agentops.event.name', 'agent.delegation.started'), + attr('agentops.agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.delegation.id', `${id}-delegation-1`), + attr('agentops.workflow.name', 'live-replay-e2e'), + attr('agentops.step.name', 'delegate-investigation') + ], orchestratorSpanId), + span(subagentSpanId, `agentops.live_replay.${id}.subagent`, 120, 180, [ + attr('gen_ai.operation.name', 'invoke_agent'), + attr('gen_ai.agent.name', 'agentops-investigator-smoke'), + attr('agentops.agent.name', 'agentops-investigator-smoke'), + attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.delegation.id', `${id}-delegation-1`), + attr('agentops.workflow.name', 'live-replay-e2e'), + intAttr('gen_ai.usage.input_tokens', 400), + intAttr('gen_ai.usage.output_tokens', 60), + attr('github.copilot.cost', '0.08') + ], delegationSpanId), + span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.skill`, 160, 35, [ + attr('gen_ai.operation.name', 'skill.invoke'), + attr('agentops.agent.name', 'agentops-investigator-smoke'), + attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.delegation.id', `${id}-delegation-1`), + attr('agentops.skill.name', 'agentops-live-triage'), + attr('agentops.skill.file', 'agentops-live-triage/SKILL.md') + ], subagentSpanId), + span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.mcp`, 210, 70, [ + attr('gen_ai.operation.name', 'execute_tool'), + attr('agentops.agent.name', 'agentops-investigator-smoke'), + attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.delegation.id', `${id}-delegation-1`), + attr('gen_ai.tool.name', 'azure-mcp/monitor_query'), + attr('agentops.mcp.server', 'azure-mcp'), + attr('agentops.mcp.tool', 'monitor_query') + ], subagentSpanId), + span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.script`, 300, 30, [ + attr('gen_ai.operation.name', 'hook.execute'), + attr('agentops.agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.script.name', 'pre-tool-policy'), + attr('agentops.script.file', 'plugin/scripts/pre-tool-policy.js'), + attr('agentops.hook.name', 'preToolUse') + ], orchestratorSpanId), + span(crypto.randomBytes(8).toString('hex'), `agentops.live_replay.${id}.delegation.completed`, 340, 30, [ + attr('gen_ai.operation.name', 'agent.delegation.completed'), + attr('agentops.event.name', 'agent.delegation.completed'), + attr('agentops.agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.parent_agent.name', 'agentops-orchestrator-smoke'), + attr('agentops.delegation.id', `${id}-delegation-1`), + attr('agentops.workflow.name', 'live-replay-e2e'), + attr('agentops.outcome', 'completed') + ], delegationSpanId) + ] + } + ] + } + ] + }; +} + +module.exports = { + attributionSmokeId, + liveReplayGrafanaUrl, + liveReplaySmokeId, + otlpAttributionSmokeTracePayload, + otlpLiveReplaySmokeTracePayload, + otlpSmokeTracePayload, + smokeAzureQuery, + smokeId +}; diff --git a/agentops-cli/src/lib/smoke-runtime.js b/agentops-cli/src/lib/smoke-runtime.js new file mode 100644 index 0000000..f80f579 --- /dev/null +++ b/agentops-cli/src/lib/smoke-runtime.js @@ -0,0 +1,213 @@ +const childProcess = require('node:child_process'); +const http = require('node:http'); +const https = require('node:https'); + +const { otlpHttpEndpoint } = require('./collector-endpoints'); +const { durationToMs, realCopilotSmokeArgs, realCopilotSmokeCommand } = require('./smoke-cli'); +const { validateKqlDuration } = require('./kql'); +const { smokeAzureQuery } = require('./smoke-payloads'); +const { sleep } = require('./timing'); + +function postJson(url, payload, options = {}) { + return new Promise(resolve => { + const parsed = new URL(url); + const body = JSON.stringify(payload); + const client = parsed.protocol === 'https:' ? https : http; + const req = client.request(parsed, { + method: 'POST', + timeout: options.timeoutMs || 2500, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + } + }, res => { + let responseBody = ''; + res.setEncoding('utf8'); + res.on('data', chunk => responseBody += chunk); + res.on('end', () => resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + statusCode: res.statusCode, + body: responseBody + })); + }); + req.on('timeout', () => { + req.destroy(); + resolve({ ok: false, error: 'timeout' }); + }); + req.on('error', error => resolve({ ok: false, error: error.message })); + req.end(body); + }); +} + +function runRealCopilotSmoke(options = {}) { + const spawnSync = options.spawnSync || childProcess.spawnSync; + const command = options.copilotCommand || 'copilot'; + const args = realCopilotSmokeArgs(); + const started = Date.now(); + const result = spawnSync(command, args, { + cwd: options.cwd || process.cwd(), + env: { + ...process.env, + AGENTOPS_PRIVACY_MODE: 'strict', + AGENTOPS_CAPTURE_CONTENT: 'false', + COPILOT_OTEL_ENABLED: 'true', + COPILOT_OTEL_EXPORTER_TYPE: 'otlp-http', + COPILOT_OTEL_SOURCE_NAME: 'github.copilot', + COPILOT_OTEL_CAPTURE_CONTENT: 'false', + OTEL_EXPORTER_OTLP_ENDPOINT: options.endpoint || otlpHttpEndpoint, + OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf', + OTEL_SERVICE_NAME: 'github-copilot', + OTEL_RESOURCE_ATTRIBUTES: 'agent.framework=github-copilot,agent.runtime=github-copilot-cli,agentops.profile=native-smoke' + }, + encoding: 'utf8', + timeout: durationToMs(options.copilotTimeoutMs ?? options.timeout, 120000), + maxBuffer: 1024 * 1024 + }); + const status = result.status === null || result.status === undefined ? 1 : result.status; + return { + ok: status === 0 && !result.error, + status, + signal: result.signal || null, + error: result.error?.message || null, + duration_ms: Date.now() - started, + command: realCopilotSmokeCommand(), + cwd: options.cwd || process.cwd() + }; +} + +function openUrlInBrowser(url, options = {}) { + if (!url) return { ok: false, reason: 'missing-url' }; + if (options.openUrl) return options.openUrl(url); + const spawnSync = options.spawnSync || childProcess.spawnSync; + const platform = options.platform || process.platform; + const command = platform === 'darwin' ? 'open' : (platform === 'win32' ? 'cmd' : 'xdg-open'); + const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]; + const result = spawnSync(command, args, { + encoding: 'utf8', + timeout: durationToMs(options.openTimeoutMs, 5000), + maxBuffer: 1024 * 1024 + }); + const status = result.status === null || result.status === undefined ? 1 : result.status; + return { + ok: status === 0 && !result.error, + command: [command, ...args].join(' '), + status, + error: result.error?.message || null, + url + }; +} + +async function waitForLatestRunSummary(options = {}) { + const last = validateKqlDuration(options.last || '2h'); + const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); + const pollMs = Math.max(1, durationToMs(options.pollMs ?? options.poll, 10000)); + const latestFn = options.latestSummary || options.latestSummaryFromArgs; + if (!latestFn) throw new Error('latestSummaryFromArgs is required'); + const sleepFn = options.sleep || sleep; + const started = Date.now(); + const attempts = []; + + while (true) { + let summary; + try { + const value = latestFn({ last }); + summary = typeof value?.then === 'function' ? await value : value; + } catch (error) { + summary = { session: null, error: error.message }; + } + const visible = Boolean(summary?.session?.grafana_url); + attempts.push({ + visible, + session_id: summary?.session?.id || null, + error: summary?.error || null + }); + if (visible) { + return { + ok: true, + status: 'found', + summary, + attempts, + elapsed_ms: Date.now() - started + }; + } + + const elapsed = Date.now() - started; + if (waitMs === 0 || elapsed >= waitMs) break; + await sleepFn(Math.min(pollMs, waitMs - elapsed)); + } + + return { + ok: false, + status: attempts.some(attempt => !attempt.error) ? 'not_found' : 'query_failed', + summary: null, + attempts, + elapsed_ms: Date.now() - started + }; +} + +async function verifySmokeInAzure(id, options = {}) { + const last = validateKqlDuration(options.last || '2h'); + const query = smokeAzureQuery(id, last); + const workspace = options.workspaceId || options.defaultWorkspaceId; + const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); + const pollMs = Math.max(1, durationToMs(options.pollMs ?? options.poll, 10000)); + const sleepFn = options.sleep || sleep; + const queryFn = options.runQuery || options.runAzureLogAnalyticsQuery; + if (!queryFn) throw new Error('runAzureLogAnalyticsQuery is required'); + const started = Date.now(); + const attempts = []; + + while (true) { + let resolved; + try { + const queryResult = queryFn(query, { + workspaceId: workspace, + spawnSync: options.spawnSync + }); + resolved = typeof queryResult?.then === 'function' ? await queryResult : queryResult; + } catch (error) { + resolved = { ok: false, rows: [], error: error.message }; + } + const rows = Array.isArray(resolved?.rows) ? resolved.rows : []; + const attempt = { + ok: Boolean(resolved?.ok), + rows: rows.length, + error: resolved?.error || null + }; + attempts.push(attempt); + + if (attempt.ok && rows.length > 0) { + return { + ok: true, + status: 'found', + workspace_id: workspace, + query, + rows: rows.length, + attempts, + elapsed_ms: Date.now() - started + }; + } + + const elapsed = Date.now() - started; + if (waitMs === 0 || elapsed >= waitMs) break; + await sleepFn(Math.min(pollMs, waitMs - elapsed)); + } + + return { + ok: false, + status: attempts.some(attempt => attempt.ok) ? 'not_found' : 'query_failed', + workspace_id: workspace, + query, + rows: 0, + attempts, + elapsed_ms: Date.now() - started + }; +} + +module.exports = { + openUrlInBrowser, + postJson, + runRealCopilotSmoke, + verifySmokeInAzure, + waitForLatestRunSummary +}; diff --git a/agentops-cli/src/lib/smoke.js b/agentops-cli/src/lib/smoke.js new file mode 100644 index 0000000..541f4d9 --- /dev/null +++ b/agentops-cli/src/lib/smoke.js @@ -0,0 +1,507 @@ +const fs = require('node:fs'); + +const { otlpHttpEndpoint } = require('./collector-endpoints'); +const { findCollectorBinary } = require('./collector-discovery'); +const { startLocalCollector } = require('./collector-binary-runtime'); +const { readNativeReceiptFile } = require('./native-receipt'); +const { sleep } = require('./timing'); +const { validateKqlDuration } = require('./kql'); +const { + commandShellQuote, + durationToMs, + parseSmokeArgs, + realCopilotSmokeArgs, + realCopilotSmokeCommand +} = require('./smoke-cli'); +const { + attributionSmokeId, + liveReplayGrafanaUrl, + liveReplaySmokeId, + otlpAttributionSmokeTracePayload, + otlpLiveReplaySmokeTracePayload, + otlpSmokeTracePayload, + smokeAzureQuery, + smokeId +} = require('./smoke-payloads'); +const { + openUrlInBrowser, + postJson, + runRealCopilotSmoke, + verifySmokeInAzure, + waitForLatestRunSummary +} = require('./smoke-runtime'); + +async function readLocalReceiptAfterPost(filePath, baselineBytes, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + let bytes = 0; + let readback = readNativeReceiptFile(filePath); + while (Date.now() < deadline) { + try { bytes = fs.statSync(filePath).size; } catch { bytes = 0; } + if (bytes > baselineBytes) { + readback = readNativeReceiptFile(filePath); + if (readback.ok) { + return { ok: true, bytes, ...readback }; + } + } + await sleep(100); + } + return { ok: false, bytes, ...readback, reason: readback.reason || 'LOCAL_RECEIPT_READBACK_TIMEOUT' }; +} + +async function agentopsSmoke(options = {}) { + const endpoint = (options.endpoint || otlpHttpEndpoint).replace(/\/$/, ''); + const id = options.id || smokeId(options.now); + const last = validateKqlDuration(options.last || '2h'); + const query = smokeAzureQuery(id, last); + const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); + const pollMs = durationToMs(options.pollMs ?? options.poll, 10000); + const realCopilot = Boolean(options.realCopilot); + const verify = options.verify !== false; + const result = { + smoke_kind: options.local ? 'local-collector' : 'collector', + local_only: Boolean(options.local), + smoke_id: id, + endpoint, + dry_run: Boolean(options.dryRun), + real_copilot: realCopilot, + verify, + wait_ms: waitMs, + poll_ms: pollMs, + workspace_id: options.local ? null : (options.workspaceId || options.defaultWorkspaceId), + azure_query: options.local ? null : query, + payload_preview: { + service: 'github-copilot-cli', + operation: 'smoke_test', + content_capture_enabled: false + } + }; + + if (options.dryRun) { + const next = [ + `POST ${endpoint}/v1/traces`, + 'node agentops-cli/src/index.js validate-azure', + verify + ? `node agentops-cli/src/index.js smoke --id ${id} --wait ${Math.ceil(waitMs / 1000)}s --poll ${Math.ceil(pollMs / 1000)}s${realCopilot ? ' --real-copilot' : ''}${options.openBrowser ? ' --open-browser' : ''}` + : options.local + ? `node agentops-cli/src/index.js smoke --local --id ${id} --no-verify` + : `az monitor log-analytics query --workspace "${result.workspace_id}" --analytics-query ""` + ]; + if (realCopilot) { + next.push(realCopilotSmokeCommand()); + next.push(options.openBrowser + ? 'The successful real-Copilot smoke opens Run Story after latest-run visibility is verified.' + : `node agentops-cli/src/index.js open latest --last ${last}`); + } + return { + ...result, + ok: true, + copilot_command: realCopilot ? realCopilotSmokeCommand() : null, + next + }; + } + + let localCollector = null; + let localReceiptBaselineBytes = 0; + let localReceiptPath = null; + let realCopilotReceiptBaselineBytes = 0; + let realCopilotReceipt = null; + if (options.local) { + localCollector = await startLocalCollector({ + privacy: 'strict', + findCollectorBinary: options.findCollectorBinary || findCollectorBinary + }); + if (!localCollector.ok) { + return { + ...result, + ok: false, + local_collector: localCollector, + next: ['Install or expose otelcol-contrib, then rerun `agentops smoke --local`.'] + }; + } + localReceiptPath = localCollector.receiptPath || null; + try { localReceiptBaselineBytes = fs.statSync(localReceiptPath).size; } catch { localReceiptBaselineBytes = 0; } + } + + const post = options.postJson || postJson; + const response = await post(`${endpoint}/v1/traces`, otlpSmokeTracePayload(id, options.nowMs), options); + const localReceipt = options.local && response.ok && localReceiptPath + ? await readLocalReceiptAfterPost(localReceiptPath, localReceiptBaselineBytes) + : null; + let copilotRun = null; + let verification = null; + let latestVisibility = null; + let links = null; + let browserOpen = null; + if (response.ok && realCopilot) { + if (options.local && localReceiptPath) { + try { realCopilotReceiptBaselineBytes = fs.statSync(localReceiptPath).size; } catch { realCopilotReceiptBaselineBytes = 0; } + } + copilotRun = runRealCopilotSmoke({ ...options, endpoint }); + if (options.local && copilotRun.ok && localReceiptPath) { + realCopilotReceipt = await readLocalReceiptAfterPost(localReceiptPath, realCopilotReceiptBaselineBytes, 8000); + } + if (copilotRun.ok && !options.local) { + latestVisibility = await waitForLatestRunSummary({ + ...options, + last, + waitMs, + pollMs + }); + if (latestVisibility.ok && options.openLinksSummary) links = options.openLinksSummary(latestVisibility.summary); + const investigationUrl = links?.azure_agents_view_url || links?.v2_replay_url; + if (options.openBrowser && investigationUrl) { + browserOpen = openUrlInBrowser(investigationUrl, options); + } + } + } + if (response.ok && verify) { + verification = await verifySmokeInAzure(id, { + ...options, + last, + waitMs, + pollMs + }); + } + const ok = response.ok + && (!options.local || localReceipt?.ok === true) + && (!realCopilot || copilotRun?.ok === true) + && (!options.local || !realCopilot || realCopilotReceipt?.ok === true) + && (!verify || verification?.ok === true); + const investigationUrl = links?.azure_agents_view_url || links?.v2_replay_url; + const investigationLabel = links?.azure_agents_view_url ? 'Azure Monitor Agents view' : 'Run Story'; + return { + ...result, + ok, + collector_response: response, + copilot_run: copilotRun, + latest_visibility: latestVisibility, + verification, + local_receipt: localReceipt, + real_copilot_receipt: realCopilotReceipt, + links, + browser_open: browserOpen, + local_collector: localCollector, + next: response.ok + ? (verification?.ok + ? [ + `Verified ${verification.rows} smoke row${verification.rows === 1 ? '' : 's'} in Log Analytics.`, + realCopilot && investigationUrl + ? `${browserOpen?.ok ? 'Opened' : 'Open'} ${investigationLabel}: ${investigationUrl}` + : 'node agentops-cli/src/index.js latest --last 2h', + realCopilot && investigationUrl ? 'node agentops-cli/src/index.js triage latest --out .agentops/triage/latest --json' : 'node agentops-cli/src/index.js latest --last 2h' + ] + : [ + verify + ? `Smoke was sent, but Log Analytics did not return ${id} before the wait expired.` + : options.local + ? localReceipt?.ok + ? `Local Collector accepted the privacy-safe OTLP smoke payload and appended a ${localReceipt.status} native receipt.` + : 'Local Collector accepted the OTLP request, but the native receipt sink was not read back before the timeout.' + : options.local + ? 'Local Collector accepted the privacy-safe OTLP smoke payload; inspect the native receipt with `agentops open latest --json`.' + : `Run this Azure query after ingestion latency settles: ${query}`, + realCopilot && investigationUrl + ? `Open ${investigationLabel}: ${investigationUrl}` + : options.local + ? 'agentops open latest --json' + : 'node agentops-cli/src/index.js validate-azure' + ]) + : [options.local + ? 'Start the local strict collector with `agentops collector start --mode local --privacy strict`.' + : 'Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] + }; +} + +async function agentopsAttributionSmoke(options = {}) { + const endpoint = (options.endpoint || otlpHttpEndpoint).replace(/\/$/, ''); + const id = options.id || attributionSmokeId(options.now); + const last = validateKqlDuration(options.last || '2h'); + const query = smokeAzureQuery(id, last); + const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); + const pollMs = durationToMs(options.pollMs ?? options.poll, 10000); + const verify = options.verify !== false; + const result = { + smoke_kind: 'attribution', + smoke_id: id, + endpoint, + dry_run: Boolean(options.dryRun), + verify, + wait_ms: waitMs, + poll_ms: pollMs, + workspace_id: options.workspaceId || options.defaultWorkspaceId, + azure_query: query, + payload_preview: { + service: 'github-copilot-cli', + operation: 'attribution_smoke', + agent: 'agentops-kitchen-sink-smoke', + skill: 'agentops-attribution', + mcp_server: 'azure-mcp', + script: 'pre-tool-policy', + content_capture_enabled: false + } + }; + + if (options.dryRun) { + return { + ...result, + ok: true, + next: [ + `POST ${endpoint}/v1/traces`, + 'node agentops-cli/src/index.js attribution --last 2h', + verify + ? `node agentops-cli/src/index.js attribution-smoke --id ${id} --wait ${Math.ceil(waitMs / 1000)}s --poll ${Math.ceil(pollMs / 1000)}s` + : `az monitor log-analytics query --workspace "${result.workspace_id}" --analytics-query ""` + ] + }; + } + + const post = options.postJson || postJson; + const response = await post(`${endpoint}/v1/traces`, otlpAttributionSmokeTracePayload(id, options.nowMs), options); + let verification = null; + if (response.ok && verify) { + verification = await verifySmokeInAzure(id, { + ...options, + last, + waitMs, + pollMs + }); + } + const ok = response.ok && (!verify || verification?.ok === true); + return { + ...result, + ok, + collector_response: response, + verification, + next: response.ok + ? (verification?.ok + ? [ + `Verified ${verification.rows} attribution smoke row${verification.rows === 1 ? '' : 's'} in Log Analytics.`, + 'node agentops-cli/src/index.js attribution --last 2h', + 'node agentops-cli/src/index.js mcp --last 2h', + 'node agentops-cli/src/index.js lineage --last 2h' + ] + : [ + verify + ? `Attribution smoke was sent, but Log Analytics did not return ${id} before the wait expired.` + : `Run this Azure query after ingestion latency settles: ${query}`, + 'node agentops-cli/src/index.js validate-azure' + ]) + : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] + }; +} + +async function agentopsLiveReplaySmoke(options = {}) { + const endpoint = (options.endpoint || otlpHttpEndpoint).replace(/\/$/, ''); + const id = options.id || liveReplaySmokeId(options.now); + const last = validateKqlDuration(options.last || '2h'); + const query = smokeAzureQuery(id, last); + const waitMs = durationToMs(options.waitMs ?? options.wait, 60000); + const pollMs = durationToMs(options.pollMs ?? options.poll, 10000); + const verify = options.verify !== false; + const grafanaUrl = liveReplayGrafanaUrl(id, last, options); + const result = { + smoke_kind: 'live-replay', + smoke_id: id, + endpoint, + dry_run: Boolean(options.dryRun), + verify, + wait_ms: waitMs, + poll_ms: pollMs, + workspace_id: options.workspaceId || options.defaultWorkspaceId, + azure_query: query, + grafana_url: grafanaUrl, + payload_preview: { + service: 'github-copilot-cli', + operation: 'live_replay_smoke', + agent: 'agentops-orchestrator-smoke', + subagent: 'agentops-investigator-smoke', + delegation_id: `${id}-delegation-1`, + skill: 'agentops-live-triage', + mcp_server: 'azure-mcp', + script: 'pre-tool-policy', + content_capture_enabled: false + } + }; + + if (options.dryRun) { + return { + ...result, + ok: true, + next: [ + `POST ${endpoint}/v1/traces`, + grafanaUrl, + verify + ? `node agentops-cli/src/index.js live-replay-smoke --id ${id} --wait ${Math.ceil(waitMs / 1000)}s --poll ${Math.ceil(pollMs / 1000)}s` + : `az monitor log-analytics query --workspace "${result.workspace_id}" --analytics-query ""` + ] + }; + } + + const post = options.postJson || postJson; + const response = await post(`${endpoint}/v1/traces`, otlpLiveReplaySmokeTracePayload(id, options.nowMs), options); + let verification = null; + if (response.ok && verify) { + verification = await verifySmokeInAzure(id, { + ...options, + last, + waitMs, + pollMs + }); + } + const ok = response.ok && (!verify || verification?.ok === true); + return { + ...result, + ok, + collector_response: response, + verification, + next: response.ok + ? (verification?.ok + ? [ + `Verified ${verification.rows} live replay smoke rows in Log Analytics.`, + grafanaUrl, + 'node agentops-cli/src/index.js lineage --last 2h' + ] + : [ + verify + ? `Live replay smoke was sent, but Log Analytics did not return ${id} before the wait expired.` + : `Run this Azure query after ingestion latency settles: ${query}`, + grafanaUrl + ]) + : ['Start the collector with `node agentops-cli/src/index.js collector start` or `./scripts/collector-azuremonitor-up.sh`.'] + }; +} + +function createSmokeContext(dependencies = {}) { + const { + defaultWorkspaceId, + grafanaBaseUrl, + latestSummaryFromArgs, + openLinksSummary, + runAzureLogAnalyticsQuery, + sleep + } = dependencies; + const smoke = agentopsSmoke; + const attributionSmoke = agentopsAttributionSmoke; + const liveReplaySmoke = agentopsLiveReplaySmoke; + const azureSmokeVerification = verifySmokeInAzure; + + return { + agentopsSmoke(options = {}) { + return smoke({ + defaultWorkspaceId, + runAzureLogAnalyticsQuery, + latestSummaryFromArgs: latestSummaryFromArgs ? ({ last }) => latestSummaryFromArgs(['--last', last], last) : undefined, + openLinksSummary, + sleep, + ...options + }); + }, + agentopsAttributionSmoke(options = {}) { + return attributionSmoke({ + defaultWorkspaceId, + runAzureLogAnalyticsQuery, + ...options + }); + }, + agentopsLiveReplaySmoke(options = {}) { + return liveReplaySmoke({ + defaultWorkspaceId, + grafanaBaseUrl, + runAzureLogAnalyticsQuery, + ...options + }); + }, + verifySmokeInAzure(id, options = {}) { + return azureSmokeVerification(id, { + defaultWorkspaceId, + runAzureLogAnalyticsQuery, + ...options + }); + } + }; +} + +function renderSmoke(result) { + const lines = [ + result.smoke_kind === 'live-replay' + ? 'AgentOps live replay smoke' + : (result.smoke_kind === 'attribution' + ? 'AgentOps attribution smoke' + : 'AgentOps smoke'), + '', + `Smoke id: ${result.smoke_id}`, + `Endpoint: ${result.endpoint}`, + `Mode: ${result.dry_run ? 'dry-run' : 'sent'}` + ]; + + if (result.collector_response) { + lines.push(result.collector_response.ok + ? `Collector response: ${result.collector_response.statusCode || 'ok'}.` + : `Collector response: failed (${result.collector_response.error || result.collector_response.statusCode || 'unknown'}).`); + } + + if (result.copilot_run) { + lines.push(result.copilot_run.ok + ? `Real Copilot smoke: completed in ${result.copilot_run.duration_ms}ms.` + : `Real Copilot smoke: failed (${result.copilot_run.error || result.copilot_run.signal || `exit ${result.copilot_run.status}`}).`); + } else if (result.real_copilot && result.dry_run) { + lines.push(`Real Copilot smoke: planned (${result.copilot_command}).`); + } + + if (result.latest_visibility) { + lines.push(result.latest_visibility.ok + ? `Latest Copilot run: visible after ${result.latest_visibility.attempts.length} attempt${result.latest_visibility.attempts.length === 1 ? '' : 's'}.` + : `Latest Copilot run: ${result.latest_visibility.status.replace(/_/g, ' ')} after ${result.latest_visibility.attempts.length} attempt${result.latest_visibility.attempts.length === 1 ? '' : 's'}.`); + } + + if (result.verification) { + lines.push(result.verification.ok + ? `Azure verification: found ${result.verification.rows} row${result.verification.rows === 1 ? '' : 's'} after ${result.verification.attempts.length} attempt${result.verification.attempts.length === 1 ? '' : 's'}.` + : `Azure verification: ${result.verification.status.replace(/_/g, ' ')} after ${result.verification.attempts.length} attempt${result.verification.attempts.length === 1 ? '' : 's'}.`); + } else if (!result.dry_run && result.verify === false) { + lines.push('Azure verification: skipped.'); + } + + if (result.links?.primary_investigation_url) { + lines.push(`${result.links.primary_investigation_label || 'Primary investigation'}: ${result.links.primary_investigation_url}`); + } + if (result.grafana_url) { + lines.push(`Grafana Live Replay: ${result.grafana_url}`); + } + if (result.links?.v2_replay_url && result.links.v2_replay_url !== result.links.primary_investigation_url) { + lines.push(`Run Story: ${result.links.v2_replay_url}`); + } + if (result.browser_open) { + lines.push(result.browser_open.ok + ? `Browser open: opened Run Story.` + : `Browser open: failed (${result.browser_open.error || result.browser_open.status || result.browser_open.reason || 'unknown'}).`); + } + lines.push('', 'Azure verification query:', result.azure_query, '', 'Next:'); + for (const item of result.next || []) lines.push(`- ${item}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + agentopsAttributionSmoke, + agentopsLiveReplaySmoke, + agentopsSmoke, + attributionSmokeId, + commandShellQuote, + createSmokeContext, + liveReplayGrafanaUrl, + liveReplaySmokeId, + openUrlInBrowser, + otlpAttributionSmokeTracePayload, + otlpLiveReplaySmokeTracePayload, + otlpSmokeTracePayload, + parseSmokeArgs, + postJson, + realCopilotSmokeArgs, + realCopilotSmokeCommand, + renderSmoke, + runRealCopilotSmoke, + smokeAzureQuery, + smokeId, + verifySmokeInAzure, + waitForLatestRunSummary +}; diff --git a/agentops-cli/src/lib/status-command.js b/agentops-cli/src/lib/status-command.js new file mode 100644 index 0000000..e453af0 --- /dev/null +++ b/agentops-cli/src/lib/status-command.js @@ -0,0 +1,15 @@ +const { writeJsonOrRender } = require('./command-output'); +const { renderStatus, statusSummary } = require('./status-summary'); + +async function statusCommand(args = []) { + const json = args.includes('--json'); + const summary = await statusSummary(); + writeJsonOrRender(summary, json, renderStatus); + process.exitCode = summary.ok ? 0 : 1; +} + +module.exports = { + renderStatus, + statusCommand, + statusSummary +}; diff --git a/agentops-cli/src/lib/status-summary.js b/agentops-cli/src/lib/status-summary.js new file mode 100644 index 0000000..29c61fc --- /dev/null +++ b/agentops-cli/src/lib/status-summary.js @@ -0,0 +1,66 @@ +const legacy = require('../legacy'); +const fs = require('node:fs'); +const path = require('node:path'); +const collector = require('./collector-manager'); +const resolver = require('./copilot-resolver'); +const { agentopsHome } = require('./paths'); +const { createDurableEvidenceSpool } = require('./azure/durable-evidence-spool'); +const { summarizeDeliveryStatus } = require('./delivery-state'); + +function checkByName(checks, name) { + return checks.find(check => check.name === name); +} + +function durableDeliveryStatus(options = {}) { + const env = options.env || process.env; + const directory = path.resolve(options.directory || env.AGENTOPS_DURABLE_SPOOL_DIR || path.join(agentopsHome, 'delivery-spool')); + if (!fs.existsSync(directory)) return { directory, exists: false, ...summarizeDeliveryStatus() }; + try { + const status = createDurableEvidenceSpool({ directory }).status(); + return { directory, exists: true, raw: status, ...summarizeDeliveryStatus(status) }; + } catch (error) { + return { directory, exists: true, state: 'quarantined', headline: 'Local delivery queue needs review.', error: error.message }; + } +} + +async function statusSummary() { + const checks = legacy.doctor({ localOnly: true }); + const summary = legacy.agentopsStatusSummary({ checks }); + const collectorStatus = await collector.status(); + const copilot = resolver.resolveCopilotBinary(); + const delivery = durableDeliveryStatus(); + return { + ...summary, + collector: collectorStatus, + delivery, + copilot: { + ok: copilot.ok, + path: copilot.path, + source: copilot.source, + error: copilot.error, + candidates: copilot.candidates + }, + content_capture_off: Boolean(checkByName(checks, 'content-capture-disabled')?.ok) + }; +} + +function renderStatus(summary) { + return [ + 'AgentOps status', + '', + `Required files: ${summary.required_files.found} of ${summary.required_files.total} found.`, + `Content capture: ${summary.content_capture_off ? 'off' : 'enabled or unknown'}.`, + `Collector: ${summary.collector.running ? 'running' : 'not running'} (${summary.collector.effectiveMode || summary.collector.mode}, ${summary.collector.privacyMode}).`, + `Collector binding: ${summary.collector.safeLocalhostBinding ? 'localhost-only' : 'needs review'}.`, + `Delivery: ${summary.delivery?.headline || summarizeDeliveryStatus().headline}`, + `Copilot binary: ${summary.copilot.ok ? summary.copilot.path : summary.copilot.error}.`, + `Shim: agentops is ${summary.shim.agentops_cli}; copilot-agentops is ${summary.shim.agentops_command}; transparent routing is ${summary.shim.shadow}.`, + 'Everyday observed use: agentops copilot ...' + ].join('\n') + '\n'; +} + +module.exports = { + durableDeliveryStatus, + renderStatus, + statusSummary +}; diff --git a/agentops-cli/src/lib/timing.js b/agentops-cli/src/lib/timing.js new file mode 100644 index 0000000..df1b14c --- /dev/null +++ b/agentops-cli/src/lib/timing.js @@ -0,0 +1,7 @@ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +module.exports = { + sleep +}; diff --git a/agentops-cli/src/lib/triage-command.js b/agentops-cli/src/lib/triage-command.js new file mode 100644 index 0000000..4a3e69a --- /dev/null +++ b/agentops-cli/src/lib/triage-command.js @@ -0,0 +1,65 @@ +const path = require('node:path'); + +const { firstPositional, hasFlag, optionValue } = require('./args'); +const { writeJsonOrRender } = require('./command-output'); +const { writeRecommendation } = require('./recommendation-store'); +const { buildTriage, renderTriage, writeTriage } = require('./triage-packet'); + +function resolveOption(args, name) { + const value = optionValue(args, name); + return value ? path.resolve(value) : null; +} + +function triageCommand(args = []) { + const runId = firstPositional(args); + const runsFile = resolveOption(args, '--runs'); + const result = buildTriage({ + runId, + runsFile, + eventsFile: resolveOption(args, '--events'), + toolsFile: resolveOption(args, '--tools'), + privacyFile: resolveOption(args, '--privacy'), + githubFile: resolveOption(args, '--github'), + evalsFile: resolveOption(args, '--evals'), + insightsFile: resolveOption(args, '--insights'), + benchmarkReportFile: resolveOption(args, '--benchmark-report'), + benchmarkRunId: optionValue(args, '--benchmark-run') + }); + const outDir = optionValue(args, '--out'); + if (result.ok && outDir) { + const triageArtifact = writeTriage(result, outDir); + const recommendationArtifact = writeRecommendation({ + ok: true, + action: result.recommendation.action, + severity: result.recommendation.severity, + run_id: result.run_id, + session_id: result.session_id, + trace_id: result.trace_id, + observed_pattern: result.recommendation.observed_pattern, + next_action: result.recommendation.next_action, + evidence: { + dashboards: result.recommendation.dashboards, + pattern: result.recommendation.pattern, + benchmark: result.recommendation.benchmark, + change_annotations: result.recommendation.change_annotations, + file_refs: result.recommendation.change_targets + }, + validation: [], + rollback_condition: 'Rollback the agent, skill, MCP, model, or instruction change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.' + }, outDir); + result.artifacts = { + triage: triageArtifact.file, + recommendation: recommendationArtifact.file + }; + } + + writeJsonOrRender(result, hasFlag(args, '--json'), renderTriage); + if (!result.ok) process.exitCode = 1; +} + +module.exports = { + buildTriage, + renderTriage, + triageCommand, + writeTriage +}; diff --git a/agentops-cli/src/lib/triage-packet.js b/agentops-cli/src/lib/triage-packet.js new file mode 100644 index 0000000..073ffa2 --- /dev/null +++ b/agentops-cli/src/lib/triage-packet.js @@ -0,0 +1,112 @@ +const path = require('node:path'); + +const { writeJsonFile } = require('./command-output'); +const { recommendFromFiles } = require('./recommendation-files'); +const { buildV2AskContext } = require('./v2-ask-context'); +const { openV2FromFiles } = require('./v2-open-links'); + +function writeTriage(result, outDir) { + const absoluteDir = path.resolve(outDir); + const file = path.join(absoluteDir, 'agentops-triage.json'); + writeJsonFile(file, result); + return { file }; +} + +function buildTriage(options = {}) { + if (!options.runsFile) throw new Error('triage requires --runs '); + + const open = openV2FromFiles({ runId: options.runId, runsFile: options.runsFile }); + if (!open.ok) { + return { + ok: false, + run_id: options.runId || 'latest', + error: open.missing_latest_reason || 'no V2 run row was found' + }; + } + + const ask = buildV2AskContext({ + runId: open.run_id, + runsFile: options.runsFile, + eventsFile: options.eventsFile, + toolsFile: options.toolsFile, + privacyFile: options.privacyFile, + githubFile: options.githubFile, + evalsFile: options.evalsFile, + insightsFile: options.insightsFile + }); + const recommendation = recommendFromFiles({ + runId: open.run_id, + runsFile: options.runsFile, + eventsFile: options.eventsFile, + evalsFile: options.evalsFile, + insightsFile: options.insightsFile, + benchmarkReportFile: options.benchmarkReportFile, + benchmarkRunId: options.benchmarkRunId + }); + + return { + ok: true, + run_id: open.run_id, + session_id: open.session_id, + trace_id: open.trace_id, + status: open.status, + links: open.links, + evidence_counts: ask.counts || {}, + recommendation: { + action: recommendation.action, + severity: recommendation.severity, + observed_pattern: recommendation.observed_pattern, + next_action: recommendation.next_action, + pattern: recommendation.evidence?.pattern || null, + benchmark: recommendation.evidence?.benchmark || null, + change_annotations: recommendation.evidence?.change_annotations || [], + change_targets: recommendation.evidence?.file_refs || [], + dashboards: recommendation.evidence?.dashboards || [] + }, + ask_agentops: { + prompt: ask.prompt, + replay_url: ask.replay_url + }, + privacy: { + mode: ask.run?.PrivacyMode || 'strict', + content_capture_mode: ask.run?.ContentCaptureMode || 'off', + note: 'Metadata-only triage packet. Do not include prompts, responses, tool args, tool results, source code, file contents, URLs, request bodies, response bodies, or secrets unless content capture is explicitly approved.' + }, + next: [ + `agentops open ${open.run_id} --runs `, + `agentops ask-context ${open.run_id} --runs --events --tools --evals --insights `, + `agentops recommend ${open.run_id} --runs --events --evals --insights --out ` + ] + }; +} + +function renderTriage(result) { + if (!result.ok) return `AgentOps triage\n\n${result.error}\n`; + const lines = [ + 'AgentOps triage', + '', + `Run: ${result.run_id}`, + `Status: ${result.status || 'unknown'}`, + `Run Story: ${result.links.replay}`, + `Ask AgentOps prompt: ready`, + `Recommendation: ${result.recommendation.action} (${result.recommendation.severity})`, + `Next action: ${result.recommendation.next_action}`, + `Evidence: ${result.evidence_counts.events || 0} events, ${result.evidence_counts.failed_tools || 0} failed/denied tools, ${result.evidence_counts.insights || 0} insights`, + `Privacy: ${result.privacy.mode}, content capture ${result.privacy.content_capture_mode}`, + '' + ]; + if (result.recommendation.pattern) lines.push(`Pattern: ${result.recommendation.pattern.key}`); + if (result.recommendation.benchmark) lines.push(`Benchmark: ${result.recommendation.benchmark.run_id} (${result.recommendation.benchmark.decision || 'unknown'})`); + if (result.recommendation.change_annotations?.length) lines.push(`Config changes: ${result.recommendation.change_annotations.map(annotation => [annotation.component, annotation.target].filter(Boolean).join(':')).filter(Boolean).join(', ')}`); + if (result.recommendation.change_targets.length) lines.push(`Change targets: ${result.recommendation.change_targets.join(', ')}`); + lines.push(''); + lines.push('Prompt:'); + lines.push(result.ask_agentops.prompt); + return `${lines.join('\n')}\n`; +} + +module.exports = { + buildTriage, + renderTriage, + writeTriage +}; diff --git a/agentops-cli/src/lib/type-predicates.js b/agentops-cli/src/lib/type-predicates.js new file mode 100644 index 0000000..bed6d5c --- /dev/null +++ b/agentops-cli/src/lib/type-predicates.js @@ -0,0 +1,17 @@ +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isStringArray(value) { + return Array.isArray(value) && value.every(item => typeof item === 'string'); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +module.exports = { + asArray, + isPlainObject, + isStringArray +}; diff --git a/agentops-cli/src/lib/usage.js b/agentops-cli/src/lib/usage.js new file mode 100644 index 0000000..ef126bc --- /dev/null +++ b/agentops-cli/src/lib/usage.js @@ -0,0 +1,88 @@ +function usage() { + const commands = [ + 'setup [--json]', + 'status', + 'latest [--file ] [--last ]', + 'live|tail [--file ] [--last ] [--follow] [--interval ]', + 'replay [--file ] [--last ]', + 'explain latest [--file ] [--last ]', + 'recommend latest [--file ] [--last ]', + 'open [--file ] [--last ]', + 'workflows list|show [--json]', + 'plugin install|uninstall [--copilot-home ] [--force] [--json]', + 'agents list|path|install|uninstall [--copilot-home ] [--force] [--json]', + 'skills list|path|install|uninstall [--copilot-home ] [--force] [--json]', + 'doctor [--local-only]', + 'scan [--json]', + 'primitives [--last ] [--root ]', + 'import-jsonl ', + 'custom emit --event --agent [--parent-agent ] [--delegation-id ] [--workflow ] [--step ] [--outcome ] [--risk ] [--score ] [--tag ] [--custom key=value] [--attribute key=value] [--dry-run] [--json]', + 'custom import [--agent ] [--workflow ] [--dry-run] [--json]', + 'annotation config-change --component --target [--change-type ] [--change-id ] [--version ] [--run-id ] [--session ] [--trace-id ] [--dry-run] [--json]', + 'configure show|set|import-azd [--agents-url ] [--json]', + 'install [--shadow-copilot]', + 'otel-setup [--endpoint ] [--service-name ] [--shell bash|powershell|json]', + 'start|stop', + 'copilot [copilot-args...]', + 'codex [codex-args...]', + 'compat-check [--last ]', + 'validate-collector [endpoint]', + 'validate-azure [--last ] [--profile personal|team|internal] [--verify-dashboard-content] [--production] [--remediation-plan] [--json]', + 'init --local-only [--yes] [--shell bash|zsh|fish|powershell|json] [--force-skills] [--no-skills] [--json]', + 'init [--dry-run] --full [--yes] [--provision-cloud] [--import-dashboards] [--run-smoke] [--triage-latest] [--force-skills] [--no-skills] [--json]', + 'smoke [--local] [--dry-run] [--endpoint ] [--id ] [--last ] [--wait ] [--poll ] [--open-browser] [--no-verify] [--json]', + 'attribution-smoke [--dry-run] [--endpoint ] [--id ] [--last ] [--wait ] [--poll ] [--no-verify] [--json]', + 'live-replay-smoke [--dry-run] [--endpoint ] [--id ] [--last ] [--wait ] [--poll ] [--no-verify] [--json]', + 'validate-enterprise [--json]', + 'ask-context [--file ] [--last ] [--json]', + 'enable-shadow', + 'disable-shadow', + 'uninstall', + 'collector start|stop', + 'saved-view add --url [--query-file ] [--description ] [--tag ] [--events ]', + 'saved-view list|show|open|export [name] [--events ] [--out ]', + 'link session ', + 'link trace ', + 'fields [--last ]', + 'context [--last ]', + 'token-rollup-audit [--last ]', + 'collector-health [--last ]', + 'attribution [--last ]', + 'permission-friction [--last ]', + 'alert recommend [--last ]', + 'alert tune-plan [--last ] [--rule ] [--owner ]', + 'alert threshold-simulate --rule --threshold --owner [--last ]', + 'alert threshold-patch --rule --threshold --owner [--last ]', + 'alert policy [--owner ] [--service ] [--timezone ]', + 'alert resources [--resource-group ]', + 'alert history --rule [--last ]', + 'alert detail --rule --session [--last ]', + 'alert open --rule --session [--last ]', + 'alert review --rule --session [--owner ] [--last ]', + 'alert action-plan --rule --session [--last ]', + 'alert export --rule --session --output [--last ]', + 'alert handoff --rule --session [--owner ] [--events ] [--output ] [--last ]', + 'alert route-plan --rule --session [--owner ] [--events ] [--target ] [--output ]', + 'alert route-github --repo --rule --session --owner [--yes]', + 'alert route-azure-devops --org --project --rule --session --owner [--yes]', + 'alert action-group-plan --resource-group --name --short-name --owner [--email
] [--webhook ]', + 'alert route-action-group --resource-group --scheduled-query --action-group --rule --session --owner [--yes]', + 'incident timeline --artifact [--artifact ...] --output [--incident ]', + 'lineage [--last ]', + 'policy [--last ]', + 'mcp [--last ]', + 'benchmark list', + 'benchmark fixture-pack --id [--fixture ] [--title ] [--output <file>] [--sign-key-id <id> --sign-private-key <pem>]', + 'benchmark judge-provider [--json]', + 'benchmark run <suite> --variant <name> --repeat <n> [--hypothesis <id>] [--dry-run]', + 'benchmark approve <run-id> --by <name> [--ticket <id>] [--output <json>]', + 'benchmark artifacts <run-id> [--task <task-id>] [--include-content]', + 'benchmark report <run-id> [--azure] [--last <duration>] [--approval-file <json>] [--verify-external-review]', + 'benchmark compare <before-run-id> <after-run-id> [--azure] [--last <duration>] [--approval-file <json>] [--verify-external-review]' + ]; + return `agentops <command>\n\nCommands:\n ${commands.join('\n ')}\n`; +} + +module.exports = { + usage +}; diff --git a/agentops-cli/src/lib/utility-command.js b/agentops-cli/src/lib/utility-command.js new file mode 100644 index 0000000..6ae5999 --- /dev/null +++ b/agentops-cli/src/lib/utility-command.js @@ -0,0 +1,57 @@ +const path = require('node:path'); +const { writeJson } = require('./command-output'); + +const utilityCommandNames = Object.freeze(['scan', 'primitives', 'doctor', 'import-jsonl', 'saved-view']); + +function createUtilityCommand(dependencies = {}) { + const { + copilotPrimitivesInventory, + doctor, + importJsonl, + parseSavedViewArgs, + savedViewCommand, + scan, + setExitCode = code => { + process.exitCode = code; + }, + stdout = process.stdout + } = dependencies; + + function utilityCommand(command, args) { + if (command === 'scan') { + writeJson(scan(), stdout); + return; + } + if (command === 'primitives') { + writeJson(copilotPrimitivesInventory(args), stdout); + return; + } + if (command === 'doctor') { + const checks = doctor({ localOnly: args.includes('--local-only') }); + const ok = checks.every(check => check.ok); + writeJson({ checks, ok }, stdout); + setExitCode(ok ? 0 : 1); + return; + } + if (command === 'import-jsonl') { + const filePath = args[0]; + if (!filePath) throw new Error('import-jsonl requires a file path'); + writeJson(importJsonl(path.resolve(filePath)), stdout); + return; + } + if (command === 'saved-view') { + writeJson(savedViewCommand(parseSavedViewArgs(args)), stdout); + return; + } + throw new Error(`Unknown utility command: ${command}`); + } + + return { + utilityCommand, + utilityCommandNames + }; +} + +module.exports = { + createUtilityCommand +}; diff --git a/agentops-cli/src/lib/v2-ask-context.js b/agentops-cli/src/lib/v2-ask-context.js new file mode 100644 index 0000000..a25e8b0 --- /dev/null +++ b/agentops-cli/src/lib/v2-ask-context.js @@ -0,0 +1,213 @@ +const legacy = require('../legacy'); +const { optionValue } = require('./args'); +const { latestByTime } = require('./explain/v2-explain'); +const { readJsonl } = require('./json'); + +function hasV2AskArgs(args = []) { + return Boolean(optionValue(args, '--runs')); +} + +function filterByRun(rows = [], runId) { + return rows.filter(row => row.RunId === runId); +} + +function topRows(rows = [], count = 8) { + return rows.slice(0, count); +} + +function escapeKqlString(value) { + return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function v2RunReplayUrl(run) { + const links = legacy.openLinksSummary({ session: { id: run.SessionId || run.RunId, grafana_url: null } }); + const base = links.v2_replay_url || ''; + const separator = base.includes('?') ? '&' : '?'; + return `${base}${separator}var-run_id=${encodeURIComponent(run.RunId)}&var-session_id=${encodeURIComponent(run.SessionId || '__all')}&var-trace_id=${encodeURIComponent(run.TraceId || '__all')}`; +} + +function investigationKql(run, last = '2h') { + const runId = escapeKqlString(run.RunId); + const sessionId = escapeKqlString(run.SessionId || run.RunId); + return [ + 'union isfuzzy=true AppDependencies, AppTraces, AppEvents', + `| where TimeGenerated > ago(${last})`, + `| where tostring(Properties) has_any ("${runId}", "${sessionId}")`, + '| extend Event=coalesce(tostring(Properties["agentops.event.name"]), tostring(Properties["github.copilot.event.name"]), Name)', + '| extend Tool=coalesce(tostring(Properties["gen_ai.tool.name"]), tostring(Properties["agentops.tool.name"]))', + '| extend Agent=coalesce(tostring(Properties["agentops.agent.name"]), tostring(Properties["gen_ai.agent.name"]))', + '| project TimeGenerated, Event, Name, OperationId, Id, ParentId, Agent, Tool, Success, DurationMs, Properties', + '| order by TimeGenerated asc', + '| take 200' + ].join('\n'); +} + +function latestRecommendation(rows = [], run = {}) { + const matches = rows.filter(row => { + return row.RunId === run.RunId || + (run.SessionId && row.SessionId === run.SessionId) || + (run.TraceId && row.TraceId === run.TraceId); + }); + matches.sort((left, right) => String(right.TimeGenerated || '').localeCompare(String(left.TimeGenerated || ''))); + const row = matches[0] || null; + if (!row) return null; + return { + time: row.TimeGenerated || '', + action: row.Action || '', + severity: row.Severity || '', + observed_pattern: row.ObservedPattern || '', + next_action: row.NextAction || '', + validation: Array.isArray(row.Validation) ? row.Validation : [], + rollback_condition: row.RollbackCondition || '', + benchmark_run_id: row.BenchmarkRunId || '', + benchmark_decision: row.BenchmarkDecision || '', + dashboard_titles: Array.isArray(row.DashboardTitles) ? row.DashboardTitles : [] + }; +} + +function buildV2AskContext(options = {}) { + const runs = readJsonl(options.runsFile); + const run = options.runId && options.runId !== 'latest' + ? runs.find(row => row.RunId === options.runId) + : latestByTime(runs); + + if (!run) { + return { + ok: false, + run_id: options.runId || 'latest', + error: 'No V2 AgentOps run rows were available.' + }; + } + + const runId = run.RunId; + const events = filterByRun(readJsonl(options.eventsFile), runId); + const tools = filterByRun(readJsonl(options.toolsFile), runId); + const privacy = filterByRun(readJsonl(options.privacyFile), runId); + const github = filterByRun(readJsonl(options.githubFile), runId); + const evals = filterByRun(readJsonl(options.evalsFile), runId); + const insights = filterByRun(readJsonl(options.insightsFile), runId); + const recommendation = latestRecommendation(readJsonl(options.recommendationsFile), run); + const replayUrl = v2RunReplayUrl(run); + const last = legacy.validateKqlDuration(options.last || '2h'); + const kql = investigationKql(run, last); + + const failedTools = tools.filter(row => row.Status !== 'success' || row.Allowed === false); + const timeline = topRows(events, 20).map(row => ({ + time: row.TimeGenerated, + event: row.EventName, + status: row.Status, + tool: row.ToolName || '', + agent: row.AgentName || '', + skill: row.SkillName || '', + sub_agent: row.SubAgentName || '' + })); + + const prompt = [ + 'Use the telemetry-investigator or AgentOps triage skill.', + '', + `Investigate AgentOps run ${runId}.`, + `Run Story: ${replayUrl}`, + `Time range: ${last}`, + `Session: ${run.SessionId || 'unknown'}`, + `Trace: ${run.TraceId || 'unknown'}`, + `Status: ${run.OutcomeStatus || 'unknown'}${run.OutcomeReason ? ` (${run.OutcomeReason})` : ''}`, + recommendation ? `Last recommendation: ${recommendation.action} (${recommendation.severity}) - ${recommendation.next_action}` : 'Last recommendation: none in this bundle', + recommendation?.benchmark_run_id ? `Benchmark run: ${recommendation.benchmark_run_id} (${recommendation.benchmark_decision || 'unknown'})` : 'Benchmark run: none in this bundle', + '', + 'Use only the metadata in this bundle and read-only Azure/Grafana MCP if available.', + 'Start with this KQL if Azure Monitor is available:', + kql, + '', + 'Return: what happened, why it matters, the most likely failure/cost/safety/context pattern, and one evidence-backed next action.', + 'Do not request or enable prompt, response, source code, file content, tool argument, tool result, URL, request body, response body, or secret capture.' + ].join('\n'); + + return { + ok: true, + run_id: runId, + session_id: run.SessionId || '', + trace_id: run.TraceId || '', + status: run.OutcomeStatus || 'unknown', + replay_url: replayUrl, + time_range: last, + kql_query: kql, + grafana_url: replayUrl, + last_recommendation: recommendation, + benchmark_run_id: recommendation?.benchmark_run_id || '', + run: { + TimeGenerated: run.TimeGenerated, + Surface: run.Surface, + RepoHash: run.RepoHash, + BranchHash: run.BranchHash, + TaskType: run.TaskType, + AgentName: run.AgentName, + SkillName: run.SkillName || '', + ParentAgentName: run.ParentAgentName || '', + SubAgentName: run.SubAgentName || '', + ModelActual: run.ModelActual, + DurationMs: run.DurationMs, + InputTokens: run.InputTokens, + OutputTokens: run.OutputTokens, + ReasoningTokens: run.ReasoningTokens, + CacheReadTokens: run.CacheReadTokens || 0, + ContextWindowPct: run.ContextWindowPct || 0, + TokensRemoved: run.TokensRemoved || 0, + PermissionWaitMs: run.PermissionWaitMs || 0, + EstimatedCostUsd: run.EstimatedCostUsd, + ToolCount: run.ToolCount, + ToolFailureCount: run.ToolFailureCount, + ToolDeniedCount: run.ToolDeniedCount, + TestsRan: run.TestsRan, + TestsPassed: run.TestsPassed, + PrOpened: run.PrOpened, + CiStatus: run.CiStatus, + EvalOverall: run.EvalOverall, + RiskScore: run.RiskScore, + PrivacyMode: run.PrivacyMode, + ContentCaptureMode: run.ContentCaptureMode + }, + evidence: { + timeline, + failed_tools: topRows(failedTools, 10), + privacy_signals: topRows(privacy, 10), + github_outcomes: topRows(github, 5), + evals: topRows(evals, 5), + insights: topRows(insights, 10), + recommendation: recommendation ? [recommendation] : [] + }, + counts: { + events: events.length, + tools: tools.length, + failed_tools: failedTools.length, + privacy_signals: privacy.length, + github_outcomes: github.length, + evals: evals.length, + insights: insights.length, + recommendations: recommendation ? 1 : 0 + }, + prompt + }; +} + +function renderV2AskContext(result) { + if (!result.ok) return `AgentOps ask context\n\n${result.error}\n`; + const lines = [ + 'AgentOps ask context', + '', + `Run: ${result.run_id}`, + `Status: ${result.status}`, + `Time range: ${result.time_range}`, + `Replay: ${result.replay_url}`, + `Evidence: ${result.counts.events} events, ${result.counts.failed_tools} failed/denied tools, ${result.counts.insights} insights, ${result.counts.recommendations} recommendation`, + '', + 'Prompt:', + result.prompt + ]; + return `${lines.join('\n')}\n`; +} + +module.exports = { + buildV2AskContext, + hasV2AskArgs, + renderV2AskContext +}; diff --git a/agentops-cli/src/lib/v2-open-links.js b/agentops-cli/src/lib/v2-open-links.js new file mode 100644 index 0000000..cfd7d60 --- /dev/null +++ b/agentops-cli/src/lib/v2-open-links.js @@ -0,0 +1,85 @@ +const legacy = require('../legacy'); +const { latestByTime } = require('./explain/v2-explain'); +const { readJsonl } = require('./json'); + +function withVars(baseUrl, vars = {}) { + const entries = Object.entries(vars).filter(([, value]) => value !== undefined && value !== null && value !== ''); + if (entries.length === 0) return baseUrl; + const separator = baseUrl.includes('?') ? '&' : '?'; + return `${baseUrl}${separator}${entries.map(([key, value]) => `var-${key}=${encodeURIComponent(value)}`).join('&')}`; +} + +function v2OpenLinksForRun(run, legacyLinks = legacy.openLinksSummary()) { + const runVars = run ? { + run_id: run.RunId || '__all', + session_id: run.SessionId || '__all', + trace_id: run.TraceId || '__all' + } : {}; + const modelVars = run?.ModelActual ? { model: run.ModelActual } : {}; + const repoVars = run?.RepoHash ? { repo_hash: run.RepoHash } : {}; + const agentVars = run?.AgentName ? { agent_name: run.AgentName } : {}; + + return { + ok: Boolean(run), + run_id: run?.RunId || '', + session_id: run?.SessionId || '', + trace_id: run?.TraceId || '', + status: run?.OutcomeStatus || '', + missing_latest_reason: run ? null : 'no V2 run row was found', + links: { + primary: legacyLinks.primary_investigation_url || legacyLinks.v2_home_url, + primary_label: legacyLinks.primary_investigation_label || 'Grafana Today', + azure_agents_view: legacyLinks.azure_agents_view_url || null, + application_insights: legacyLinks.application_insights_url || null, + home: legacyLinks.v2_home_url, + runs: withVars(legacyLinks.v2_runs_url, { ...repoVars, ...agentVars }), + replay: withVars(legacyLinks.v2_replay_url, runVars), + content_viewer: withVars(`${legacyLinks.v2_replay_url}?viewPanel=26`, runVars), + models: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-models-cost-tokens`, modelVars), + tools: `${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-tools-mcp-risk`, + privacy: `${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-safety-privacy-policy`, + outcomes: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-code-outcomes`, repoVars), + evals: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-evals-quality`, runVars), + insights: withVars(`${legacyLinks.v2_home_url.replace(/\/d\/agentops-v2-home$/, '')}/d/agentops-v2-insights-regressions`, runVars) + } + }; +} + +function openV2FromFiles(options = {}) { + const runs = readJsonl(options.runsFile); + const run = options.runId && options.runId !== 'latest' + ? runs.find(row => row.RunId === options.runId || row.SessionId === options.runId || row.TraceId === options.runId) + : latestByTime(runs); + return v2OpenLinksForRun(run, options.legacyLinks || legacy.openLinksSummary()); +} + +function renderOpenV2(result) { + const lines = ['AgentOps V2 links', '']; + if (!result.ok) { + lines.push(`Latest run: unknown. ${result.missing_latest_reason}.`); + return `${lines.join('\n')}\n`; + } + + lines.push(`Run: ${result.run_id}`); + lines.push(`Status: ${result.status || 'unknown'}`); + lines.push(`${result.links.primary_label}: ${result.links.primary}`); + if (result.links.azure_agents_view) lines.push(`Azure Monitor Agents view: ${result.links.azure_agents_view}`); + if (result.links.application_insights) lines.push(`Application Insights (open Agents): ${result.links.application_insights}`); + lines.push(`Home: ${result.links.home}`); + lines.push(`Runs: ${result.links.runs}`); + lines.push(`Run Story: ${result.links.replay}`); + lines.push(`Prompt/response viewer (explicit opt-in): ${result.links.content_viewer}`); + lines.push(`Models: ${result.links.models}`); + lines.push(`Tools & MCP: ${result.links.tools}`); + lines.push(`Safety & Privacy: ${result.links.privacy}`); + lines.push(`Code Outcomes: ${result.links.outcomes}`); + lines.push(`Evals: ${result.links.evals}`); + lines.push(`Insights: ${result.links.insights}`); + return `${lines.join('\n')}\n`; +} + +module.exports = { + openV2FromFiles, + renderOpenV2, + v2OpenLinksForRun +}; diff --git a/agentops-cli/src/lib/validation-command.js b/agentops-cli/src/lib/validation-command.js new file mode 100644 index 0000000..16529a4 --- /dev/null +++ b/agentops-cli/src/lib/validation-command.js @@ -0,0 +1,61 @@ +const { writeJson, writeJsonOrRender } = require('./command-output'); +const { optionValue } = require('./args'); + +function writeValidationResult(result, json, render, stdout, setExitCode) { + writeJsonOrRender(result, json, render, stdout); + setExitCode(result.ok ? 0 : 1); +} + +const validationCommandNames = Object.freeze(['validate-collector', 'validate-azure', 'validate-enterprise']); + +function createValidationCommand(dependencies = {}) { + const { + parseLastArg, + renderValidateAzure, + renderValidateEnterprise, + setExitCode = code => { + process.exitCode = code; + }, + stdout = process.stdout, + validateAzure, + validateCollector, + validateEnterprise + } = dependencies; + + async function validationCommand(command, args) { + if (command === 'validate-collector') { + writeJson(await validateCollector(args[0]), stdout); + return; + } + + if (command === 'validate-azure') { + const result = validateAzure({ + last: parseLastArg(args, '2h'), + importDashboards: args.includes('--import-dashboards'), + verifyDashboardContent: args.includes('--verify-dashboard-content'), + production: args.includes('--production'), + readinessProfile: optionValue(args, '--profile', ''), + remediationPlan: args.includes('--remediation-plan') + }); + writeValidationResult(result, args.includes('--json'), renderValidateAzure, stdout, setExitCode); + return; + } + + if (command === 'validate-enterprise') { + const result = validateEnterprise(); + writeValidationResult(result, args.includes('--json'), renderValidateEnterprise, stdout, setExitCode); + return; + } + + throw new Error(`Unknown validation command: ${command}`); + } + + return { + validationCommand, + validationCommandNames + }; +} + +module.exports = { + createValidationCommand +}; diff --git a/agentops-cli/src/lib/workflow-command.js b/agentops-cli/src/lib/workflow-command.js new file mode 100644 index 0000000..fc85ab7 --- /dev/null +++ b/agentops-cli/src/lib/workflow-command.js @@ -0,0 +1,36 @@ +const { writeJsonOrRender } = require('./command-output'); + +function createWorkflowCommand(dependencies = {}) { + const { + agentopsWorkflows, + parseWorkflowsArgs, + renderWorkflow, + renderWorkflowsList, + stdout = process.stdout + } = dependencies; + + function workflowCommand(args) { + const options = parseWorkflowsArgs(args); + const workflows = agentopsWorkflows(); + if (options.subcommand === 'list') { + writeJsonOrRender({ workflows }, options.json, value => renderWorkflowsList(value.workflows), stdout); + return; + } + if (options.subcommand === 'show') { + if (!options.name) throw new Error('workflows show requires a workflow name'); + const workflow = workflows.find(item => item.name === options.name); + if (!workflow) throw new Error(`Unknown workflow: ${options.name}`); + writeJsonOrRender(workflow, options.json, renderWorkflow, stdout); + return; + } + throw new Error('workflows requires list or show'); + } + + return { + workflowCommand + }; +} + +module.exports = { + createWorkflowCommand +}; diff --git a/agentops-cli/src/lib/workflows.js b/agentops-cli/src/lib/workflows.js new file mode 100644 index 0000000..09ca2f6 --- /dev/null +++ b/agentops-cli/src/lib/workflows.js @@ -0,0 +1,212 @@ +function agentopsWorkflows() { + const cli = 'node agentops-cli/src/index.js'; + return [ + { + name: 'setup', + skill: 'agentops-setup', + description: 'Install the local Collector binary, local shim, and safe defaults.', + prompt: 'Use agentops-setup to check my AgentOps install and tell me the next command to run.', + commands: [ + `${cli} setup`, + `${cli} init --full`, + `${cli} validate-enterprise`, + 'az login', + 'azd provision', + `${cli} install`, + './setup-agentops.sh', + './setup-agentops.ps1', + `${cli} configure show`, + `${cli} configure import-azd`, + `${cli} init --dry-run`, + `${cli} validate-azure`, + `${cli} collector smoke --privacy strict --poison`, + `${cli} smoke --real-copilot --wait 2m --poll 10s --open-browser`, + `${cli} plugin install`, + `${cli} status`, + `${cli} doctor --local-only` + ] + }, + { + name: 'orchestrate', + skill: 'agentops-orchestrator', + description: 'Route setup, triage, attribution, dashboard, benchmark, and operations questions to the right AgentOps skill.', + prompt: 'Use agentops-orchestrator to figure out which AgentOps workflow I need and run the first read-only check.', + commands: [ + `${cli} workflows list`, + `${cli} workflows show setup`, + `${cli} workflows show latest-run`, + `${cli} workflows show attribution`, + `${cli} workflows show dashboard`, + `${cli} workflows show science-mode`, + `${cli} workflows show operations` + ] + }, + { + name: 'latest-run', + skill: 'agentops-latest-run', + description: 'Find, open, and inspect the latest observed Copilot CLI run.', + prompt: 'Use agentops-latest-run to find my latest AgentOps run, open the Run Story link, explain it, and recommend one next action.', + commands: [ + 'copilot -p "Reply with exactly: agentops smoke."', + `${cli} ask-context latest --last 2h`, + `${cli} open latest --last 2h`, + `${cli} latest --last 7d`, + `${cli} explain latest --last 7d`, + `${cli} recommend latest --last 7d`, + `${cli} live --last 2h`, + `${cli} replay latest --last 7d` + ] + }, + { + name: 'attribution', + skill: 'agentops-attribution', + description: 'Filter telemetry by custom agent, skill, MCP server/tool, script, or hook.', + prompt: 'Use agentops-attribution to show usage, failures, cost, and tools for my custom agents, skills, MCP servers, and hooks.', + commands: [ + `${cli} attribution --last 7d`, + `${cli} primitives --last 7d`, + `${cli} mcp --last 7d`, + `${cli} lineage --last 24h`, + `${cli} link session <conversation-id>` + ] + }, + { + name: 'dashboard', + skill: 'agentops-dashboard-ops', + description: 'Open, rebuild, import, and deep-link Grafana dashboards.', + prompt: 'Use agentops-dashboard-ops to open the AgentOps dashboard and create a link for this session.', + commands: [ + `${cli} open`, + `${cli} link session <conversation>`, + `${cli} link trace <operationId>`, + 'node scripts/build-grafana-dashboard-pack.js', + 'AZURE_RESOURCE_GROUP=rg-agentops-dev GRAFANA_NAME=graf-agentops-dev ./scripts/grafana-import-dashboard.sh' + ] + }, + { + name: 'science-mode', + skill: 'agentops-benchmark-gate', + description: 'Run repeatable benchmark checks before keeping agent changes.', + prompt: 'Use agentops-benchmark-gate to compare my baseline and candidate benchmark runs.', + commands: [ + `${cli} benchmark list`, + `${cli} benchmark fixture-pack benchmarks/starter/fixtures/tiny-repo --id tiny-repo-sealed --fixture fixtures/tiny-repo --output benchmarks/starter/fixture-packs/tiny-repo.json`, + `${cli} benchmark fixture-pack benchmarks/starter/fixtures/tiny-repo --id tiny-repo-sealed --fixture fixtures/tiny-repo --sign-key-id eval-fixtures-v1 --sign-private-key keys/eval-fixtures-v1.pem --output benchmarks/starter/fixture-packs/tiny-repo.json`, + `${cli} benchmark judge-provider`, + `${cli} benchmark run starter --variant baseline --repeat 1 --hypothesis safer-tool-policy --dry-run`, + `${cli} benchmark run starter --variant baseline --repeat 1 --hypothesis safer-tool-policy`, + `${cli} benchmark approve <run-id> --by alice@example.com --ticket CHG-123 --output approvals/<run-id>.json`, + `${cli} benchmark artifacts <run-id> --task create-note --include-content`, + `${cli} benchmark report <run-id>`, + `${cli} benchmark compare <baseline-run-id> <variant-run-id> --azure --last 24h` + ] + }, + { + name: 'judge-provider', + skill: 'agentops-benchmark-gate', + description: 'Wire a hosted LLM judge CLI into benchmark semantic checks without storing prompts or secrets.', + prompt: 'Use agentops-benchmark-gate to configure a hosted llm-judge provider for my benchmark suite.', + commands: [ + `${cli} benchmark judge-provider`, + `${cli} benchmark judge-provider --json`, + 'AGENTOPS_JUDGE_ENDPOINT=https://judge.example.com AGENTOPS_JUDGE_TOKEN=... benchmark-judges/hosted-judge.sh notes/hello.txt note-quality' + ] + }, + { + name: 'offline-test', + skill: 'agentops-live-triage', + description: 'Use local JSONL fixtures when Azure telemetry is not available.', + prompt: 'Use agentops-live-triage with the sample JSONL fixture to explain a local tool failure.', + commands: [ + `${cli} latest --file fixtures/sample-otel/tool-failure.ndjson.fixture`, + `${cli} explain latest --file fixtures/sample-otel/tool-failure.ndjson.fixture`, + `${cli} recommend latest --file fixtures/sample-otel/tool-failure.ndjson.fixture`, + `${cli} live --file fixtures/sample-otel/tool-failure.ndjson.fixture`, + `${cli} replay latest --file fixtures/sample-otel/tool-failure.ndjson.fixture` + ] + }, + { + name: 'analyst-mode', + skill: 'agentops-evidence-prompts', + description: 'Generate read-only KQL, links, saved views, and investigation prompts.', + prompt: 'Use agentops-evidence-prompts to investigate the last 24 hours and propose one safe improvement.', + commands: [ + `${cli} fields --last 7d`, + `${cli} context --last 7d`, + `${cli} token-rollup-audit --last 14d`, + `${cli} collector-health --last 24h`, + `${cli} policy --last 7d`, + `${cli} mcp --last 7d`, + `${cli} lineage --last 24h`, + `${cli} permission-friction --last 7d`, + `${cli} alert recommend --last 14d`, + `${cli} ask-context latest --last 24h`, + `${cli} saved-view add latest-risk --session <conversation-id> --tag risk`, + `${cli} saved-view list` + ] + }, + { + name: 'primitive-inventory', + skill: 'agentops-primitive-inventory', + description: 'Show which agents, skills, hooks, MCP tools, and other primitives are configured or observed.', + prompt: 'Use agentops-primitive-inventory to inventory this repo and explain any missing runtime signals.', + commands: [ + `${cli} primitives --last 7d`, + `${cli} primitives --root /path/to/awesome-copilot --last 7d` + ] + }, + { + name: 'operations', + skill: 'agentops-operations', + description: 'Check health, stop collector, disable shadowing, or uninstall safely.', + prompt: 'Use agentops-operations to check health and choose the safest cleanup command.', + commands: [ + `${cli} status`, + `${cli} validate-collector`, + `${cli} collector-health --last 24h`, + `${cli} disable-shadow`, + `${cli} collector stop`, + `${cli} plugin uninstall`, + `${cli} uninstall` + ] + } + ]; +} + +function parseWorkflowsArgs(args) { + return { + subcommand: args[0] || 'list', + name: args[1], + json: args.includes('--json') + }; +} + +function renderWorkflow(workflow) { + const lines = [ + `${workflow.name}: ${workflow.description}`, + `Skill: ${workflow.skill}`, + `Ask Copilot: ${workflow.prompt}`, + '', + 'Commands:' + ]; + for (const command of workflow.commands) lines.push(`- ${command}`); + return `${lines.join('\n')}\n`; +} + +function renderWorkflowsList(workflows = agentopsWorkflows()) { + const lines = ['AgentOps workflows', '']; + for (const workflow of workflows) { + lines.push(`- ${workflow.name}: ${workflow.description}`); + lines.push(` Skill: ${workflow.skill}`); + lines.push(` Ask: ${workflow.prompt}`); + } + lines.push('', 'Run `agentops workflows show <name>` to print the commands for one workflow.'); + return `${lines.join('\n')}\n`; +} + +module.exports = { + agentopsWorkflows, + parseWorkflowsArgs, + renderWorkflow, + renderWorkflowsList +}; diff --git a/agentops-cli/src/saved-views.js b/agentops-cli/src/saved-views.js index 0701e67..d165872 100644 --- a/agentops-cli/src/saved-views.js +++ b/agentops-cli/src/saved-views.js @@ -1,7 +1,11 @@ const fs = require('node:fs'); -const crypto = require('node:crypto'); const path = require('node:path'); +const { changeRef, configChangeAnnotationsForSession } = require('./lib/change-annotations'); +const { writeJsonFile, writeJsonlFile } = require('./lib/command-output'); +const { prefixedHash } = require('./lib/hash'); +const { readJsonl } = require('./lib/json'); + function createSavedViews({ savedViewsPath, readJson, buildLink }) { function readSavedViews(filePath = savedViewsPath) { if (!fs.existsSync(filePath)) return { views: [] }; @@ -12,80 +16,16 @@ function createSavedViews({ savedViewsPath, readJson, buildLink }) { } function writeSavedViews(payload, filePath = savedViewsPath) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`); - } - - function readJsonl(filePath) { - if (!filePath) return []; - const text = fs.readFileSync(filePath, 'utf8'); - return text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); - } - - function stringValue(value) { - if (value === undefined || value === null) return ''; - if (typeof value === 'string') return value; - return String(value); - } - - function propertyValue(row = {}, key) { - const props = row.Properties && typeof row.Properties === 'object' - ? row.Properties - : {}; - return row[key] - ?? row[`agentops.custom.${key}`] - ?? row[`agentops.${key}`] - ?? props[key] - ?? props[`agentops.custom.${key}`] - ?? props[`agentops.${key}`] - ?? ''; - } - - function parseDetailsValue(details, key) { - const text = stringValue(details); - if (!text) return ''; - const pattern = new RegExp(`${key}[=: ]+([A-Za-z0-9_.@/-]+)`); - return pattern.exec(text)?.[1] || ''; - } - - function normalizeConfigChangeAnnotation(row = {}) { - const props = row.Properties && typeof row.Properties === 'object' ? row.Properties : {}; - const eventName = stringValue(row.EventName || row.Event || row.event || props['agentops.event.name'] || props['event.name']); - const eventType = stringValue(row.EventType || row.Type || row.type); - const details = stringValue(row.Details || row.ResultCode || row.details || ''); - const annotationType = stringValue(propertyValue(row, 'annotation_type') || row.AnnotationType || parseDetailsValue(details, 'annotation_type')); - const isConfigAnnotation = eventName === 'agentops.config.changed' - || annotationType === 'config_change' - || eventType === 'annotation' - || details.includes('config_change'); - if (!isConfigAnnotation) return null; - - return { - time_generated: stringValue(row.TimeGenerated || row.time || row.timestamp), - component: stringValue(row.ChangeComponent || propertyValue(row, 'component') || propertyValue(row, 'entity.type') || row.EntityType || parseDetailsValue(details, 'component')), - target: stringValue(row.ChangeTarget || propertyValue(row, 'target') || propertyValue(row, 'entity.id_hash') || row.EntityIdHash || parseDetailsValue(details, 'target')), - change_type: stringValue(row.ChangeType || propertyValue(row, 'change_type') || parseDetailsValue(details, 'change_type') || 'updated'), - change_id: stringValue(row.ChangeId || propertyValue(row, 'change_id') || parseDetailsValue(details, 'change_id')), - version: stringValue(row.Version || propertyValue(row, 'version') || parseDetailsValue(details, 'version')), - run_id: stringValue(row.RunId || propertyValue(row, 'run.id')), - session_id: stringValue(row.SessionId || propertyValue(row, 'session.id') || props['gen_ai.conversation.id']), - trace_id: stringValue(row.TraceId || propertyValue(row, 'trace.id')), - event_name: eventName || 'agentops.config.changed' - }; + writeJsonFile(filePath, payload); } function annotationsForSession(events = [], session) { - const normalizedSession = String(session || '').trim(); - if (!normalizedSession) return []; - return events - .map(normalizeConfigChangeAnnotation) - .filter(annotation => annotation && annotation.session_id === normalizedSession) - .slice(0, 10); + return configChangeAnnotationsForSession(events, session); } function annotationRefs(annotations = []) { return annotations - .map(annotation => [annotation.component, annotation.target].filter(Boolean).join(':')) + .map(changeRef) .filter(Boolean); } @@ -140,18 +80,14 @@ function createSavedViews({ savedViewsPath, readJson, buildLink }) { return options; } - function stableId(value, prefix = 'view') { - return `${prefix}_${crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16)}`; - } - function savedViewRow(view, timeGenerated = new Date().toISOString()) { return { TimeGenerated: timeGenerated, - SavedViewId: stableId([view.name, view.url, view.session || '', view.createdAt || ''].join('|')), + SavedViewId: prefixedHash([view.name, view.url, view.session || '', view.createdAt || ''].join('|'), 'view'), Name: view.name || '', Description: view.description || '', Url: view.url || '', - QueryHash: view.query ? stableId(view.query, 'query') : '', + QueryHash: view.query ? prefixedHash(view.query, 'query') : '', Tags: Array.isArray(view.tags) ? view.tags : [], SessionId: view.session || '', CreatedAt: view.createdAt || '', @@ -173,18 +109,17 @@ function createSavedViews({ savedViewsPath, readJson, buildLink }) { function exportSavedViews(views, outDir, options = {}) { const absoluteDir = path.resolve(outDir); - fs.mkdirSync(absoluteDir, { recursive: true }); const rows = views.map(view => savedViewRow(viewWithAnnotations(view, options.events || []))); const file = path.join(absoluteDir, 'AgentOpsSavedViews_CL.jsonl'); - fs.writeFileSync(file, `${rows.map(row => JSON.stringify(row)).join('\n')}${rows.length ? '\n' : ''}`); + writeJsonlFile(file, rows); const manifest = path.join(absoluteDir, 'saved-views-manifest.json'); - fs.writeFileSync(manifest, `${JSON.stringify({ + writeJsonFile(manifest, { generated_at: new Date().toISOString(), table: 'AgentOpsSavedViews_CL', file, rows_written: rows.length, privacy: 'metadata-only; query text is represented by QueryHash and is not exported' - }, null, 2)}\n`); + }); return { out_dir: absoluteDir, file, manifest, rows_written: rows.length, rows }; } diff --git a/agentops-cli/src/telemetry.js b/agentops-cli/src/telemetry.js index 2c184c1..aa33598 100644 --- a/agentops-cli/src/telemetry.js +++ b/agentops-cli/src/telemetry.js @@ -1,5 +1,7 @@ const path = require('node:path'); +const { sleep } = require('./lib/timing'); + function createTelemetry({ optionValue, parseLastArg, @@ -12,7 +14,9 @@ function createTelemetry({ attributeValue, numberAttribute, isFailedRow, + isSpanTelemetryRow, sessionFromRow, + telemetryTime, numberValue, roundNumber }) { @@ -46,15 +50,21 @@ function createTelemetry({ const tool = row.ToolName || attributeValue(attrs, ['gen_ai.tool.name', 'tool']); const model = row.ModelActual || row.ModelRequested || attributeValue(attrs, ['gen_ai.request.model', 'gen_ai.response.model', 'model']); const error = attributeValue(attrs, ['error.type', 'exception.type', 'error']); - const message = `${row.Message || row.message || row.Name || row.name || ''} ${JSON.stringify(attrs)}`; + const eventName = String(row.EventName || row.event || row.Name || row.name || ''); const failed = isFailedRow(row, attrs); const inputTokens = numberValue(row.InputTokens) || numberAttribute(attrs, ['gen_ai.usage.input_tokens', 'InputTokens', 'input_tokens']); const outputTokens = numberValue(row.OutputTokens) || numberAttribute(attrs, ['gen_ai.usage.output_tokens', 'OutputTokens', 'output_tokens']); const credits = numberAttribute(attrs, ['github.copilot.cost', 'Credits', 'credits']); const estUsd = numberValue(row.EstimatedCostUsd) || roundNumber(credits * 0.01, 4); const tokensRemoved = numberAttribute(attrs, ['github.copilot.tokens_removed', 'tokens_removed']); - const policy = /preToolUse|policy|blocked|denied/i.test(message); - const context = /truncation|compaction|too much context/i.test(message) || tokensRemoved > 0; + const explicitAllowed = attributeValue(attrs, ['agentops.mcp.allowed']); + const explicitBlocked = attributeValue(attrs, ['agentops.policy.blocked']); + const policy = /^(?:preToolUse|permissionRequest|policy)(?:\.|$)/i.test(eventName) + || explicitBlocked === true + || String(explicitBlocked).toLowerCase() === 'true' + || explicitAllowed === false + || String(explicitAllowed).toLowerCase() === 'false'; + const context = /truncation|compaction|too much context/i.test(eventName) || tokensRemoved > 0; const eventType = policy ? 'policy' @@ -71,7 +81,7 @@ function createTelemetry({ : 'span'; return { - time: row.TimeGenerated || row.timestamp || row.time || row.startTime || null, + time: telemetryTime(row.TimeGenerated || row.timestamp || row.time || row.startTime), session: sessionFromRow(row, attrs), type: eventType, event: operation, @@ -96,6 +106,7 @@ function createTelemetry({ const sessionId = options.sessionId || null; let currentSessionId = null; const events = rows + .filter(isSpanTelemetryRow) .map(row => { const event = timelineEventFromRow(row); if (event.session === 'unknown-session' && currentSessionId) { @@ -198,10 +209,6 @@ function createTelemetry({ return `${lines.join('\n')}\n`; } - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - return { liveViewFromArgs, replayTimeline, diff --git a/agentops-cli/test/alert-action-group-actions.test.js b/agentops-cli/test/alert-action-group-actions.test.js new file mode 100644 index 0000000..e088325 --- /dev/null +++ b/agentops-cli/test/alert-action-group-actions.test.js @@ -0,0 +1,103 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { createAlertActionGroupActions } = require('../src/lib/alert-action-group-actions'); +const TEST_APPROVED_SUBSCRIPTION_ID = '11111111-1111-4111-8111-111111111111'; + +function createActions() { + return createAlertActionGroupActions({ + alertHandoff: ({ rule, session, last = '24h', owners = [], resourceGroup }) => ({ + schema_version: 'agentops.alert-handoff.v1', + alert: { + rule, + session, + last, + owner: owners[0] || null + }, + resource_group: resourceGroup, + evidence: { + detail: { + session_link: { + grafana_url: `https://grafana.example/d/session?var-session_id=${session}` + }, + history_query: `AlertHistory | where Conversation == "${session}"` + } + } + }) + }); +} + +test('alert action group module previews action group creation', () => { + const { alertActionGroupPlan } = createActions(); + + const plan = alertActionGroupPlan({ + resourceGroup: 'rg-agentops-dev', + name: 'ag-agentops-oncall', + shortName: 'agentops', + owners: ['agentops-oncall'], + emails: ['ops@example.com'], + webhooks: ['https://example.com/agentops-webhook'] + }); + + assert.equal(plan.schema_version, 'agentops.alert-action-group-plan.v1'); + assert.equal(plan.mode, 'preview-only-action-group-plan'); + assert.equal(plan.command.executable, 'az'); + assert.deepEqual(plan.receivers.email, [{ name: 'email-1', email_address: 'ops@example.com' }]); + assert.deepEqual(plan.receivers.webhook, [{ name: 'webhook-1', service_uri: 'https://example.com/agentops-webhook' }]); + assert.match(plan.follow_up_route_command, /alert route-action-group/); + + assert.throws(() => alertActionGroupPlan({ + resourceGroup: 'rg-agentops-dev', + name: 'ag-agentops-oncall', + shortName: 'agentops', + owners: ['agentops-oncall'] + }), /requires at least one --email/); +}); + +test('alert action group module dry-runs and posts scheduled query routing', () => { + const { alertActionGroupRoute } = createActions(); + const actionGroupId = '/subscriptions/sub-123/resourceGroups/rg-agentops-dev/providers/microsoft.insights/actionGroups/ag-agentops'; + + const dryRun = alertActionGroupRoute({ + rule: 'failed-spans', + session: 'session-123', + last: '6h', + owners: ['agentops-oncall'], + resourceGroup: 'rg-agentops-dev', + scheduledQuery: 'sqr-agentops-failed-spans', + actionGroups: [actionGroupId] + }); + + assert.equal(dryRun.schema_version, 'agentops.alert-action-group-route.v1'); + assert.equal(dryRun.mode, 'dry-run-action-group-route'); + assert.equal(dryRun.command.executable, 'az'); + assert.ok(dryRun.command.args.includes(actionGroupId)); + assert.equal(dryRun.enable_alert, false); + assert.match(dryRun.evidence.history_query, /Conversation == "session-123"/); + + let invoked = null; + const posted = alertActionGroupRoute({ + rule: 'failed-spans', + session: 'session-456', + owners: ['agentops-oncall'], + resourceGroup: 'rg-agentops-dev', + scheduledQuery: 'sqr-agentops-failed-spans', + actionGroups: [actionGroupId], + enableAlert: true, + yes: true, + expectedSubscriptionId: TEST_APPROVED_SUBSCRIPTION_ID, + approvedSubscriptionIds: [TEST_APPROVED_SUBSCRIPTION_ID], + spawnSync: (command, args, options) => { + invoked = { command, args, options }; + if (args[0] === 'account') return { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' }; + return { status: 0, stdout: '{"name":"sqr-agentops-failed-spans"}', stderr: '' }; + } + }); + + assert.equal(posted.mode, 'routed-action-group'); + assert.equal(posted.status, 0); + assert.equal(invoked.command, 'az'); + assert.ok(invoked.args.includes('--disabled')); + assert.ok(invoked.args.includes('false')); + assert.equal(invoked.options.encoding, 'utf8'); +}); diff --git a/agentops-cli/test/alert-model.test.js b/agentops-cli/test/alert-model.test.js new file mode 100644 index 0000000..0ecba4a --- /dev/null +++ b/agentops-cli/test/alert-model.test.js @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + alertPolicy, + alertResourceState, + alertRules, + configChangeAnnotationsForSession, + normalizeConfigChangeAnnotation, + requireAlertRule +} = require('../src/lib/alert-model'); + +test('alert model helpers summarize rules resources policy and config annotations', () => { + const row = { + EventName: 'agentops.config.changed', + TimeGenerated: '2026-01-01T00:00:00Z', + Properties: { + 'agentops.custom.component': 'mcp', + 'agentops.custom.target': 'server_hash', + 'agentops.custom.change_type': 'updated', + 'agentops.custom.change_id': 'change-123', + 'gen_ai.conversation.id': 'session-123' + } + }; + const annotation = normalizeConfigChangeAnnotation(row); + + assert.equal(annotation.session_id, 'session-123'); + assert.equal(annotation.component, 'mcp'); + assert.equal(configChangeAnnotationsForSession([row, { EventName: 'noise' }], 'session-123').length, 1); + assert.equal(requireAlertRule('content-capture', 'test').name, 'content-capture'); + assert.equal(alertRules('7d').find(rule => rule.name === 'failed-spans').last, '7d'); + + const resources = alertResourceState({ + workspaceId: 'workspace-123', + resourceGroup: 'rg-agentops', + resources: [{ + name: 'rule-one', + properties: { + enabled: 'true', + actions: { actionGroups: ['/actionGroups/oncall'] }, + evaluationFrequency: 'PT5M' + } + }] + }); + assert.deepEqual(resources.summary, { total: 1, enabled: 1, disabled: 0, routed: 1 }); + + const policy = alertPolicy({ + workspaceId: 'workspace-123', + owners: ['oncall@example.com'], + service: 'agentops' + }); + assert.equal(policy.ownership.state, 'assigned'); + assert.equal(policy.escalation.requires_manual_review, true); +}); diff --git a/agentops-cli/test/alert-timeline.test.js b/agentops-cli/test/alert-timeline.test.js new file mode 100644 index 0000000..b876985 --- /dev/null +++ b/agentops-cli/test/alert-timeline.test.js @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { incidentTimelineFromArtifacts } = require('../src/lib/alert-timeline'); + +function artifact({ rule, session, createdAt, ticket = null, extraExcluded = [] }) { + return { + schema_version: 'agentops.alert-artifact.v1', + created_at: createdAt, + rule, + session, + last: '4h', + privacy: { + mode: 'metadata-only', + excluded: ['prompts', ...extraExcluded] + }, + evidence: { + history_query: `history ${rule}`, + session_link: { conversation: session, workspace_id: 'workspace-from-link' }, + threshold_evidence_query: `threshold ${rule}` + }, + action_plan: { + title: `AgentOps alert: ${rule}`, + severity: rule === 'content-capture' ? 'critical' : 'review', + safe_metadata: { signal: rule }, + guardrails: ['metadata-only'] + }, + status: { + state: 'review', + ticket, + notes: [] + } + }; +} + +test('alert timeline normalizes artifacts chronologically without content fields', () => { + const timeline = incidentTimelineFromArtifacts({ + artifacts: [ + artifact({ rule: 'failed-spans', session: 'session-b', createdAt: '2026-06-03T12:10:00.000Z', ticket: 'INC-2', extraExcluded: ['tool results'] }), + artifact({ rule: 'content-capture', session: 'session-a', createdAt: '2026-06-03T12:00:00.000Z', ticket: 'INC-1', extraExcluded: ['responses'] }) + ], + createdAt: '2026-06-03T12:30:00.000Z', + incidentId: 'incident-123', + workspaceId: 'workspace-default' + }); + + assert.equal(timeline.schema_version, 'agentops.incident-timeline.v1'); + assert.equal(timeline.workspace_id, 'workspace-from-link'); + assert.equal(timeline.incident_id, 'incident-123'); + assert.deepEqual(timeline.status.tickets, ['INC-1', 'INC-2']); + assert.equal(timeline.timeline[0].rule, 'content-capture'); + assert.equal(timeline.timeline[1].rule, 'failed-spans'); + assert.ok(timeline.privacy.excluded.includes('tool results')); + assert.ok(timeline.privacy.excluded.includes('responses')); + assert.ok(timeline.next.some(step => step.includes('assign an owner'))); + assert.doesNotMatch(JSON.stringify(timeline), /SECRET_FAKE_TEST_VALUE|raw transcript/); +}); + +test('alert timeline validates input artifacts', () => { + assert.throws( + () => incidentTimelineFromArtifacts({ artifacts: [], createdAt: '2026-06-03T12:30:00.000Z' }), + /requires at least one alert artifact/ + ); + assert.throws( + () => incidentTimelineFromArtifacts({ artifacts: [{ schema_version: 'other' }] }), + /must be an agentops.alert-artifact.v1 JSON file/ + ); +}); diff --git a/agentops-cli/test/azure-ingest-command.test.js b/agentops-cli/test/azure-ingest-command.test.js new file mode 100644 index 0000000..e3274ef --- /dev/null +++ b/agentops-cli/test/azure-ingest-command.test.js @@ -0,0 +1,13 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + azureIngestCommand, + runLogsIngestionUpload +} = require('../src/lib/azure-ingest-command'); + +test('azure ingest command library preserves command and upload exports', () => { + assert.equal(typeof azureIngestCommand, 'function'); + assert.equal(typeof runLogsIngestionUpload, 'function'); + assert.throws(() => azureIngestCommand(['unknown']), /azure-ingest supports/); +}); diff --git a/agentops-cli/test/azure-ingest-render.test.js b/agentops-cli/test/azure-ingest-render.test.js new file mode 100644 index 0000000..59206bc --- /dev/null +++ b/agentops-cli/test/azure-ingest-render.test.js @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + renderAzureIngestPlan, + renderLogsIngestionUploadPlan, + renderSharedStorageUploadPlan +} = require('../src/lib/azure/v2-ingest-render'); + +test('azure ingest render helpers format plan status tables commands and next steps', () => { + const azure = renderAzureIngestPlan({ + ok: false, + dir: '/tmp/agentops', + privacy: { ok: false }, + content_capture: { rows: 2, allowed: true }, + schema_migration_policy: { migration_required: true, current_version: '2' }, + tables: { + AgentOpsRunSummary_CL: { + rows: 1, + columns: ['RunId', 'TimeGenerated'], + stream_name: 'Custom-AgentOpsRunSummary_CL' + } + }, + errors: ['missing DCR stream'], + warnings: ['schema migration required'], + next: ['agentops dashboard validate'] + }); + + assert.match(azure, /AgentOps V2 Azure ingestion plan/); + assert.match(azure, /Status: not ready/); + assert.match(azure, /AgentOpsRunSummary_CL: 1 row\(s\), 2 column\(s\), stream Custom-AgentOpsRunSummary_CL/); + assert.match(azure, /missing DCR stream/); + + const logs = renderLogsIngestionUploadPlan({ + ok: true, + dir: '/tmp/agentops', + endpoint: 'https://dce.example', + dcr_immutable_id: 'dcr-abc', + privacy: { ok: true }, + uploads: [{ + table: 'AgentOpsRunSummary_CL', + rows: 1, + stream: 'Custom-AgentOpsRunSummary_CL', + command: ['az', 'rest', '--uri', 'https://dce.example/streams/Custom-AgentOpsRunSummary_CL'] + }], + errors: [], + warnings: [], + next: ['agentops product audit --live'] + }); + + assert.match(logs, /AgentOps Logs Ingestion upload plan/); + assert.match(logs, /Endpoint: https:\/\/dce\.example/); + assert.match(logs, /az rest --uri https:\/\/dce\.example\/streams\/Custom-AgentOpsRunSummary_CL/); + + const shared = renderSharedStorageUploadPlan({ + ok: true, + dir: '/tmp/agentops', + storage: { account: 'acct', container: 'agentops-shared' }, + privacy: { ok: true }, + artifacts: [{ + table: 'AgentOpsSavedViews_CL', + rows: 1, + blob: 'team/latest/AgentOpsSavedViews_CL/AgentOpsSavedViews_CL.jsonl', + command: ['az', 'storage', 'blob', 'upload'] + }], + errors: [], + warnings: [], + next: ['Review the artifact list and privacy scan.'] + }); + + assert.match(shared, /AgentOps shared storage upload plan/); + assert.match(shared, /Storage: acct\/agentops-shared/); + assert.match(shared, /team\/latest\/AgentOpsSavedViews_CL\/AgentOpsSavedViews_CL\.jsonl/); +}); diff --git a/agentops-cli/test/azure-ingest-schema-versioning.test.js b/agentops-cli/test/azure-ingest-schema-versioning.test.js new file mode 100644 index 0000000..fef99ea --- /dev/null +++ b/agentops-cli/test/azure-ingest-schema-versioning.test.js @@ -0,0 +1,50 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + schemaMigrationSummary, + schemaVersionFor, + schemaVersioningSummary +} = require('../src/lib/azure/v2-schema-versioning'); + +test('azure ingest schema versioning detects current missing legacy and unsupported rows', () => { + const current = schemaVersionFor('AgentOpsRunSummary_CL', [ + { SchemaVersion: '2' }, + { SchemaVersion: '2' } + ]); + assert.equal(current.ok, true); + assert.deepEqual(current.versions, ['2']); + assert.equal(current.migration.status, 'current'); + + const migration = schemaVersionFor('AgentOpsRunSummary_CL', [ + {}, + { SchemaVersion: '1' }, + { SchemaVersion: '3' } + ]); + assert.equal(migration.ok, false); + assert.equal(migration.missing_rows, 1); + assert.deepEqual(migration.mismatched_versions, ['1', '3']); + assert.equal(migration.migration.status, 'unsupported-newer'); + assert.equal(migration.migration.compatible_for_ingest, false); + assert.deepEqual(migration.migration.legacy_versions, ['1']); + assert.deepEqual(migration.migration.unsupported_versions, ['3']); + assert.match(migration.migration.actions.join('\n'), /missing-version row/); + assert.match(migration.migration.actions.join('\n'), /version\(s\) 1 to 2/); + assert.match(migration.migration.actions.join('\n'), /unsupported newer schema version\(s\) 3/); + + const unchecked = schemaVersionFor('AgentOpsAlertHandoffs', [{ schema_version: 'agentops.alert-handoff.v1' }]); + assert.equal(unchecked.checked, false); + assert.equal(unchecked.ok, true); + + const tables = { + AgentOpsRunSummary_CL: { schema_version: migration }, + AgentOpsEvents_CL: { schema_version: current }, + AgentOpsAlertHandoffs: { schema_version: unchecked } + }; + assert.deepEqual(schemaVersioningSummary(tables).mismatched_tables, [ + { table: 'AgentOpsRunSummary_CL', versions: ['1', '3'] } + ]); + assert.deepEqual(schemaMigrationSummary(tables).unsupported_tables, [ + { table: 'AgentOpsRunSummary_CL', versions: ['3'] } + ]); +}); diff --git a/agentops-cli/test/azure-posture.test.js b/agentops-cli/test/azure-posture.test.js new file mode 100644 index 0000000..e165870 --- /dev/null +++ b/agentops-cli/test/azure-posture.test.js @@ -0,0 +1,65 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + agentOpsContentTables, + azureProductionRemediationPlan, + azureRoleIds, + logAnalyticsTablesFromResult, + roleAssignmentSummary +} = require('../src/lib/azure-posture'); + +test('azure posture helpers parse tables, summarize RBAC, and plan remediation', () => { + const tables = logAnalyticsTablesFromResult({ + value: [{ + name: 'AgentOpsContent_CL', + properties: { + retentionInDays: 90, + totalRetentionInDays: 90 + } + }] + }); + const rbac = roleAssignmentSummary([ + { + roleDefinitionId: `/providers/Microsoft.Authorization/roleDefinitions/${azureRoleIds.logAnalyticsDataReader}`, + roleDefinitionName: 'Log Analytics Reader', + principalType: 'Group' + }, + { + roleDefinitionId: `/providers/Microsoft.Authorization/roleDefinitions/${azureRoleIds.contributor}`, + roleDefinitionName: 'Contributor', + principalType: 'User' + } + ], [azureRoleIds.logAnalyticsDataReader]); + const plan = azureProductionRemediationPlan({ + last: '24h', + config: { + resource_group: 'rg-agentops-prod', + workspace_name: 'law-agentops-prod', + grafana_name: 'graf-agentops-prod' + }, + checks: [ + { name: 'log-analytics-posture', ok: false }, + { name: 'alert-routing-posture', ok: false, rule_names: ['sqr-agentops-cost'] } + ] + }); + + assert.equal(tables[0].name, 'AgentOpsContent_CL'); + assert.equal(tables[0].retention_days, 90); + assert.equal(agentOpsContentTables(tables).length, 1); + assert.equal(rbac.matching, 1); + assert.equal(rbac.group_assignments, 1); + assert.equal(rbac.broad_assignments, 1); + assert.deepEqual(plan.actions.map(action => action.name), [ + 'set-log-analytics-daily-cap', + 'route-agentops-alerts-to-action-groups' + ]); + assert.ok(plan.actions[1].commands.some(command => command.includes('sqr-agentops-cost'))); +}); + +test('azure posture uses shared array type helper', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', 'azure-posture.js'), 'utf8'); + assert.doesNotMatch(source, /function asArray\(/); +}); diff --git a/agentops-cli/test/azure-validation-render.test.js b/agentops-cli/test/azure-validation-render.test.js new file mode 100644 index 0000000..a976f5a --- /dev/null +++ b/agentops-cli/test/azure-validation-render.test.js @@ -0,0 +1,42 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { renderValidateAzure } = require('../src/lib/azure-validation-render'); + +test('azure validation renderer formats checks remediations and next steps', () => { + const output = renderValidateAzure({ + ok: false, + checks: [ + { name: 'az-cli', ok: true, detail: 'logged in' }, + { + name: 'grafana-dashboards', + ok: false, + detail: '2 expected dashboards missing', + missing: ['agentops-home', 'agentops-runs'] + }, + { name: 'grafana-resource', ok: true, skipped: true, detail: 'resource name is not configured' } + ], + remediation_plan: { + note: 'Review these commands before running them.', + actions: [{ + name: 'import-dashboards', + risk: 'low', + reason: 'Dashboards are missing.', + review: 'Confirm Grafana target first.', + commands: ['agentops dashboard import --yes'] + }] + }, + next: ['agentops validate-azure --import-dashboards --last 24h'] + }); + + assert.match(output, /^AgentOps Azure validation/); + assert.match(output, /- az-cli: ok \(logged in\)/); + assert.match(output, /- grafana-dashboards: failed \(2 expected dashboards missing\)/); + assert.match(output, /missing: agentops-home, agentops-runs/); + assert.match(output, /fix: agentops validate-azure --import-dashboards --last 24h/); + assert.match(output, /- grafana-resource: ok skipped \(resource name is not configured\)/); + assert.match(output, /Azure validation is incomplete\./); + assert.match(output, /Remediation plan:\nReview these commands before running them\./); + assert.match(output, /command: agentops dashboard import --yes/); + assert.match(output, /Next:\n- agentops validate-azure --import-dashboards --last 24h/); +}); diff --git a/agentops-cli/test/azure-validation-runtime.test.js b/agentops-cli/test/azure-validation-runtime.test.js new file mode 100644 index 0000000..d8ff64b --- /dev/null +++ b/agentops-cli/test/azure-validation-runtime.test.js @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { setEnvForTest } = require('./support/env'); + +const { + azAvailable, + azErrorDetail, + checkResult, + commandCandidates, + parseJsonOutput, + runAz +} = require('../src/lib/azure-validation-runtime'); + +test('commandCandidates finds commands on PATH once', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-az-runtime-')); + const commandName = `agentops-az-${process.pid}`; + const commandPath = path.join(dir, process.platform === 'win32' ? `${commandName}.cmd` : commandName); + const restoreEnv = setEnvForTest({ + PATH: [dir, dir, process.env.PATH || ''].filter(Boolean).join(path.delimiter) + }); + + fs.writeFileSync(commandPath, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n'); + + try { + assert.deepEqual(commandCandidates(commandName), [commandPath]); + } finally { + restoreEnv(); + } +}); + +test('azAvailable honors injected availability and command lookup', () => { + assert.equal(azAvailable({ azAvailable: 0 }), false); + assert.equal(azAvailable({ azAvailable: 'yes' }), true); + assert.equal(azAvailable({ spawnSync: () => ({ status: 0 }) }), true); + assert.equal(azAvailable({ commandCandidates: name => name === 'az' ? ['/usr/local/bin/az'] : [] }), true); + assert.equal(azAvailable({ commandCandidates: () => [] }), false); +}); + +test('runAz shells out to az with json-safe defaults', () => { + let observed = null; + const result = runAz(['account', 'show', '-o', 'json'], { + spawnSync: (command, args, options) => { + observed = { command, args, options }; + return { status: 0, stdout: '{"id":"sub"}' }; + } + }); + + assert.deepEqual(result, { status: 0, stdout: '{"id":"sub"}' }); + assert.equal(observed.command, 'az'); + assert.deepEqual(observed.args, ['account', 'show', '-o', 'json']); + assert.equal(observed.options.encoding, 'utf8'); + assert.equal(observed.options.maxBuffer, 10 * 1024 * 1024); +}); + +test('parseJsonOutput returns parsed JSON or null', () => { + assert.deepEqual(parseJsonOutput({ stdout: '{"ok":true}' }), { ok: true }); + assert.deepEqual(parseJsonOutput({ stdout: '' }), {}); + assert.equal(parseJsonOutput({ stdout: '{bad json' }), null); +}); + +test('checkResult and azErrorDetail normalize validation output', () => { + assert.deepEqual(checkResult('azure-account', 1, { detail: 'logged in' }), { + name: 'azure-account', + ok: true, + detail: 'logged in' + }); + assert.equal(azErrorDetail({ stderr: ' failed\n', stdout: 'ignored', status: 1 }, 'fallback'), 'failed'); + assert.equal(azErrorDetail({ stderr: '', stdout: ' output\n', status: 1 }, 'fallback'), 'output'); + assert.equal(azErrorDetail({ stderr: '', stdout: '', status: 7 }, 'fallback'), 'fallback'); + assert.equal(azErrorDetail({ stderr: '', stdout: '', status: 7 }), 'az exited with status 7'); +}); diff --git a/agentops-cli/test/benchmark-fixtures.test.js b/agentops-cli/test/benchmark-fixtures.test.js new file mode 100644 index 0000000..0df05f5 --- /dev/null +++ b/agentops-cli/test/benchmark-fixtures.test.js @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + benchmarkFixtureFiles, + benchmarkFixturePack, + validateBenchmarkFixtureSealPack, + validateBenchmarkFixtureTrustRevocations, + validateBenchmarkFixtureTrustRoots +} = require('../src/lib/benchmark-fixtures'); + +test('benchmark fixture helpers seal, sign, and validate trusted fixture packs', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-bench-fixtures-')); + const suiteDir = path.join(tempDir, 'suite'); + const fixtureDir = path.join(suiteDir, 'fixtures', 'tiny-repo'); + const keyPath = path.join(suiteDir, 'keys', 'fixture-signing-key.pem'); + + try { + fs.mkdirSync(path.join(fixtureDir, 'docs'), { recursive: true }); + fs.mkdirSync(path.dirname(keyPath), { recursive: true }); + fs.writeFileSync(path.join(fixtureDir, 'README.md'), '# Fixture\r\n'); + fs.writeFileSync(path.join(fixtureDir, 'docs', 'note.txt'), 'hello\n'); + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + fs.writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' })); + + const pack = benchmarkFixturePack({ + cwd: suiteDir, + fixtureDir: 'fixtures/tiny-repo', + id: 'tiny-repo-sealed', + fixture: 'fixtures/tiny-repo', + signKeyId: 'eval-fixtures-v1', + signPrivateKey: 'keys/fixture-signing-key.pem' + }); + const trustRoots = validateBenchmarkFixtureTrustRoots([{ + keyId: 'eval-fixtures-v1', + publicKey: publicKey.export({ type: 'spki', format: 'pem' }) + }], 'test suite'); + const trustRevocations = validateBenchmarkFixtureTrustRevocations([], 'test suite'); + + assert.deepEqual(benchmarkFixtureFiles(fixtureDir), ['README.md', 'docs/note.txt']); + assert.equal(pack.files['README.md'], crypto.createHash('sha256').update('# Fixture\n').digest('hex')); + assert.deepEqual( + validateBenchmarkFixtureSealPack(pack, fixtureDir, 'test pack', { + fixtureTrustRoots: trustRoots, + fixtureTrustRevocations: trustRevocations + }).signature, + { + algorithm: 'ed25519', + keyId: 'eval-fixtures-v1', + trusted: true + } + ); + + assert.throws(() => { + validateBenchmarkFixtureSealPack({ ...pack, title: 'Tampered pack' }, fixtureDir, 'test pack', { + fixtureTrustRoots: trustRoots, + fixtureTrustRevocations: trustRevocations + }); + }, /signature verification failed/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/benchmark-invocation.test.js b/agentops-cli/test/benchmark-invocation.test.js new file mode 100644 index 0000000..7d7a367 --- /dev/null +++ b/agentops-cli/test/benchmark-invocation.test.js @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + benchmarkCopilotInvocation, + benchmarkSandboxProfile, + mergeResourceAttributes +} = require('../src/lib/benchmark-invocation'); + +function benchmarkRun(osSandbox = null) { + return { + copilot: { + command: 'copilot', + args: ['--allow-tool=read_file'], + prompt: 'Inspect the fixture.' + }, + copilotHome: '/tmp/agentops/copilot-home', + osSandbox + }; +} + +test('benchmark invocation merges escaped resource attributes', () => { + const merged = mergeResourceAttributes('existing=true', { + 'agentops.benchmark.task_id': 'task,one', + 'agentops.benchmark.workspace': 'fixtures\\tiny' + }); + + assert.equal(merged, 'existing=true,agentops.benchmark.task_id=task\\,one,agentops.benchmark.workspace=fixtures\\\\tiny'); +}); + +test('benchmark invocation builds container sandbox command', () => { + const invocation = benchmarkCopilotInvocation( + benchmarkRun({ mode: 'container-network-blocked', image: 'agentops/copilot-runner:test' }), + '/tmp/agentops/workspace', + { containerRuntimeCommand: 'podman' } + ); + + assert.equal(invocation.command, 'podman'); + assert.deepEqual(invocation.args.slice(0, 5), ['run', '--rm', '--network', 'none', '-v']); + assert.ok(invocation.args.includes('/tmp/agentops/workspace:/workspace')); + assert.ok(invocation.args.includes('COPILOT_HOME=/copilot-home')); + assert.deepEqual(invocation.args.slice(-4), ['copilot', '--allow-tool=read_file', '-p', 'Inspect the fixture.']); + assert.deepEqual(invocation.sandbox, { + mode: 'container-network-blocked', + active: true, + command: 'podman', + image: 'agentops/copilot-runner:test', + network: 'blocked' + }); +}); + +test('benchmark invocation builds macOS sandbox profiles and fails closed elsewhere', () => { + const run = benchmarkRun({ mode: 'macos-network-blocked' }); + const profile = benchmarkSandboxProfile(run, '/tmp/agentops/workspace'); + assert.match(profile, /\(deny network\*\)/); + assert.match(profile, /\/tmp\/agentops\/workspace/); + + const darwin = benchmarkCopilotInvocation(run, '/tmp/agentops/workspace', { platform: 'darwin' }); + assert.equal(darwin.command, 'sandbox-exec'); + assert.equal(darwin.args[0], '-p'); + assert.equal(darwin.args[2], 'copilot'); + assert.deepEqual(darwin.sandbox, { + mode: 'macos-network-blocked', + active: true, + command: 'sandbox-exec' + }); + + const linux = benchmarkCopilotInvocation(run, '/tmp/agentops/workspace', { platform: 'linux' }); + assert.equal(linux.command, 'copilot'); + assert.deepEqual(linux.sandbox, { + mode: 'macos-network-blocked', + active: false, + error: 'macos-network-blocked requires macOS sandbox-exec' + }); +}); diff --git a/agentops-cli/test/benchmark-policy.test.js b/agentops-cli/test/benchmark-policy.test.js new file mode 100644 index 0000000..96e8d2e --- /dev/null +++ b/agentops-cli/test/benchmark-policy.test.js @@ -0,0 +1,55 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + benchmarkAllowedToolPolicyViolations, + benchmarkProfileAllowsBroadArgs, + hasBroadPermissionArg, + normalizeBenchmarkPermissionProfile, + validateBenchmarkToolPolicy +} = require('../src/lib/benchmark-policy'); + +test('benchmark permission helpers normalize broad-arg policy', () => { + assert.equal(normalizeBenchmarkPermissionProfile(), 'least-privilege'); + assert.equal(normalizeBenchmarkPermissionProfile(null), 'least-privilege'); + assert.equal(normalizeBenchmarkPermissionProfile(''), 'least-privilege'); + assert.equal(normalizeBenchmarkPermissionProfile('read-only'), 'read-only'); + + assert.equal(benchmarkProfileAllowsBroadArgs('allow-all-isolated'), true); + assert.equal(benchmarkProfileAllowsBroadArgs('least-privilege'), false); + assert.equal(hasBroadPermissionArg(['--foo', '--allow-all']), true); + assert.equal(hasBroadPermissionArg(['--foo', '--yolo']), true); + assert.equal(hasBroadPermissionArg(['--foo']), false); +}); + +test('validateBenchmarkToolPolicy normalizes blocked risks', () => { + assert.equal(validateBenchmarkToolPolicy(undefined), null); + assert.equal(validateBenchmarkToolPolicy({}), null); + assert.deepEqual(validateBenchmarkToolPolicy({ blockedRisks: ['network', 'browser-control', 'network', ' '] }), { + blockedRisks: ['browser-control', 'network'] + }); +}); + +test('validateBenchmarkToolPolicy rejects invalid policies', () => { + assert.throws( + () => validateBenchmarkToolPolicy(null, 'task.json'), + /toolPolicy must be an object/ + ); + assert.throws( + () => validateBenchmarkToolPolicy({ blockedRisks: ['network', 'unknown-risk'] }, 'task.json'), + /toolPolicy\.blockedRisks must use known risks/ + ); +}); + +test('benchmarkAllowedToolPolicyViolations deduplicates blocked allowed tools by risk', () => { + const violations = benchmarkAllowedToolPolicyViolations( + ['--allow-tool', 'web_fetch', '--allow-tool=write_file', '--allow-tool=web_fetch', '--allow-tool=read_file'], + { blockedRisks: ['network', 'write-file'] } + ); + + assert.deepEqual(violations, [ + { name: 'web_fetch', risk: 'network' }, + { name: 'write_file', risk: 'write-file' } + ]); + assert.deepEqual(benchmarkAllowedToolPolicyViolations(['--allow-tool=web_fetch'], null), []); +}); diff --git a/agentops-cli/test/browser-options.test.js b/agentops-cli/test/browser-options.test.js new file mode 100644 index 0000000..780bb80 --- /dev/null +++ b/agentops-cli/test/browser-options.test.js @@ -0,0 +1,48 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { browserProfileOptionsFromArgs, browserProfileRuntimeDefaults } = require('../src/lib/browser-options'); + +test('browser options helper parses profile args and env defaults', () => { + const env = { + AGENTOPS_BROWSER_EXECUTABLE: '/env/chrome', + AGENTOPS_BROWSER_USER_DATA_DIR: '/env/profile', + AGENTOPS_BROWSER_STORAGE_STATE: '/env/storage.json', + AGENTOPS_BROWSER_HEADED: '0' + }; + + assert.deepEqual(browserProfileOptionsFromArgs([], env), { + browserExecutable: '/env/chrome', + browserUserDataDir: '/env/profile', + storageState: '/env/storage.json', + headed: false, + azureCliGrafanaAuth: false + }); + assert.deepEqual(browserProfileOptionsFromArgs([ + '--browser-executable', '/arg/chrome', + '--browser-user-data-dir', '/arg/profile', + '--storage-state', '/arg/storage.json', + '--headed', + '--azure-cli-grafana-auth' + ], env), { + browserExecutable: '/arg/chrome', + browserUserDataDir: '/arg/profile', + storageState: '/arg/storage.json', + headed: true, + azureCliGrafanaAuth: true + }); + assert.deepEqual(browserProfileRuntimeDefaults(env), { + browserExecutable: '/env/chrome', + browserUserDataDir: '/env/profile', + storageState: '/env/storage.json', + headed: false, + azureCliGrafanaAuth: false + }); + + for (const file of ['e2e-grafana.js', 'e2e-playwright.js', 'product-command.js']) { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', file), 'utf8'); + assert.doesNotMatch(source, /AGENTOPS_BROWSER_EXECUTABLE|AGENTOPS_BROWSER_USER_DATA_DIR|AGENTOPS_BROWSER_STORAGE_STATE|AGENTOPS_BROWSER_HEADED/, file); + } +}); diff --git a/agentops-cli/test/change-annotations.test.js b/agentops-cli/test/change-annotations.test.js new file mode 100644 index 0000000..6aba2ba --- /dev/null +++ b/agentops-cli/test/change-annotations.test.js @@ -0,0 +1,60 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + changeAnnotationsForRun, + changeRef, + configChangeAnnotationsForSession, + normalizeChangeAnnotation, + normalizeConfigChangeAnnotation, + parseDetailsValue, + propertyValue +} = require('../src/lib/change-annotations'); + +test('change annotations normalize config-change rows from property aliases and details', () => { + const row = { + TimeGenerated: '2026-06-03T11:59:55Z', + Details: 'annotation_type=config_change component=skill target=agentops-latest-run change_type=updated change_id=change-123 version=2026.06.03', + Properties: { + 'agentops.custom.component': 'hook', + 'agentops.run.id': 'run-regression', + 'gen_ai.conversation.id': 'session-regression' + } + }; + const annotation = normalizeConfigChangeAnnotation(row); + + assert.equal(propertyValue(row, 'component'), 'hook'); + assert.equal(parseDetailsValue(row.Details, 'target'), 'agentops-latest-run'); + assert.equal(annotation.component, 'hook'); + assert.equal(annotation.target, 'agentops-latest-run'); + assert.equal(annotation.change_id, 'change-123'); + assert.equal(annotation.run_id, 'run-regression'); + assert.equal(annotation.session_id, 'session-regression'); + assert.deepEqual(normalizeChangeAnnotation(row), annotation); + assert.equal(normalizeConfigChangeAnnotation({ EventName: 'noise' }), null); +}); + +test('change annotations filter by run or session and render change refs', () => { + const rows = [ + { + EventName: 'agentops.config.changed', + RunId: 'run-1', + SessionId: 'session-1', + ChangeComponent: 'skill', + ChangeTarget: 'agentops-latest-run' + }, + { + EventName: 'agentops.config.changed', + RunId: 'run-2', + SessionId: 'session-2', + ChangeComponent: 'model', + ChangeTarget: 'SECRET_SHOULD_NOT_ATTACH' + } + ]; + const runAnnotations = changeAnnotationsForRun(rows, { RunId: 'run-1' }); + const sessionAnnotations = configChangeAnnotationsForSession(rows, 'session-1'); + + assert.equal(runAnnotations.length, 1); + assert.equal(sessionAnnotations.length, 1); + assert.equal(changeRef(runAnnotations[0]), 'skill:agentops-latest-run'); +}); diff --git a/agentops-cli/test/cli-dispatch.test.js b/agentops-cli/test/cli-dispatch.test.js new file mode 100644 index 0000000..51b1a95 --- /dev/null +++ b/agentops-cli/test/cli-dispatch.test.js @@ -0,0 +1,118 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { commandSuggestion, createCliMain } = require('../src/lib/cli-dispatch'); + +function createHarness(overrides = {}) { + const calls = []; + let stdout = ''; + let stderr = ''; + const legacy = { + main(args) { + calls.push(['legacy', args]); + return 'legacy-result'; + }, + latestSummaryFromArgs(args) { + calls.push(['latestSummaryFromArgs', args]); + return { ok: true, args }; + } + }; + const commands = { + statusCommand(args) { + calls.push(['status', args]); + return 'status-result'; + }, + collectorCommand(args) { + calls.push(['collector', args]); + return 'collector-result'; + }, + recommendCommand(args) { + calls.push(['recommend', args]); + return 'recommend-result'; + } + }; + const main = createCliMain({ + commands: { ...commands, ...overrides.commands }, + coreCommands: ['setup', 'smoke'], + experimentalCommands: new Set(['benchmark']), + legacy: { ...legacy, ...overrides.legacy }, + stderr: { write(chunk) { stderr += String(chunk); } }, + stdout: { write(chunk) { stdout += String(chunk); } }, + usage: overrides.usage || (() => 'usage text\n') + }); + + return { + calls, + main, + stderr: () => stderr, + stdout: () => stdout + }; +} + +test('createCliMain writes help through injected stdout', async () => { + const harness = createHarness(); + + await harness.main(['--help']); + + assert.equal(harness.stdout(), 'usage text\n'); + assert.deepEqual(harness.calls, []); +}); + +test('createCliMain exposes focused command help without dispatching the command', async () => { + const calls = []; + const harness = createHarness({ + usage(command) { + calls.push(command); + return `help for ${command || 'all'}\n`; + } + }); + + await harness.main(['help', 'status']); + + assert.equal(harness.stdout(), 'help for status\n'); + assert.deepEqual(calls, ['status']); + assert.deepEqual(harness.calls, []); +}); + +test('createCliMain routes direct commands and collector aliases', async () => { + const harness = createHarness(); + + assert.equal(await harness.main(['status', '--json']), 'status-result'); + assert.equal(await harness.main(['start', '--json']), 'collector-result'); + + assert.deepEqual(harness.calls, [ + ['status', ['--json']], + ['collector', ['start', '--json']] + ]); +}); + +test('createCliMain keeps recommend V2 routing and legacy fallback', async () => { + const harness = createHarness(); + + assert.equal(await harness.main(['recommend', 'latest', '--runs', 'runs.jsonl']), 'recommend-result'); + assert.equal(await harness.main(['recommend', 'latest']), 'legacy-result'); + + assert.deepEqual(harness.calls, [ + ['recommend', ['latest', '--runs', 'runs.jsonl']], + ['legacy', ['recommend', 'latest']] + ]); +}); + +test('createCliMain routes experimental commands with a migration warning', async () => { + const harness = createHarness(); + + assert.equal(await harness.main(['benchmark', 'list']), 'legacy-result'); + + assert.match(harness.stderr(), /agentops benchmark is experimental now/); + assert.deepEqual(harness.calls, [ + ['legacy', ['benchmark', 'list']] + ]); +}); + +test('unknown commands suggest a close useful command and always point to help', async () => { + const harness = createHarness(); + + await assert.rejects(harness.main(['setpu']), /Did you mean "agentops setup"\? Run "agentops --help"/); + await assert.rejects(harness.main(['definitely-unrelated']), /Run "agentops --help" to see the core commands/); + assert.equal(commandSuggestion('statsu', ['setup', 'status']), 'status'); +}); diff --git a/agentops-cli/test/cli-surface.test.js b/agentops-cli/test/cli-surface.test.js new file mode 100644 index 0000000..9113980 --- /dev/null +++ b/agentops-cli/test/cli-surface.test.js @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('CLI surface exposes public command lists and help text', () => { + const { coreCommands, experimentalCommands, usage } = require('../src/lib/cli-surface'); + + assert.ok(coreCommands.includes('collector')); + assert.ok(coreCommands.includes('triage')); + assert.ok(experimentalCommands.has('benchmark')); + assert.ok(experimentalCommands.has('saved-view')); + + const help = usage(); + assert.match(help, /collector start\|stop\|status\|validate\|smoke\|install-binary\|uninstall-binary/); + assert.match(help, /agentops experimental <old-command>/); + assert.doesNotMatch(help, /benchmark list/); +}); diff --git a/agentops-cli/test/collector-benchmark.test.js b/agentops-cli/test/collector-benchmark.test.js new file mode 100644 index 0000000..e7cec7e --- /dev/null +++ b/agentops-cli/test/collector-benchmark.test.js @@ -0,0 +1,54 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const test = require('node:test'); +const { + MAX_EVENTS, + benchmarkConfig, + boundedCount, + collectorBinaryPath, + percentile, + runCollectorBenchmark +} = require('../src/lib/collector-benchmark'); + +const binary = collectorBinaryPath(); + +test('collector benchmark helpers bound workloads and report nearest-rank percentiles', () => { + assert.equal(boundedCount('25', 10), 25); + assert.equal(boundedCount('bad', 10), 10); + assert.equal(boundedCount('99999', 10), MAX_EVENTS); + assert.equal(percentile([5, 1, 4, 2, 3], 0.50), 3); + assert.equal(percentile([5, 1, 4, 2, 3], 0.95), 5); +}); + +test('collector benchmark config reuses strict processors and has only a loopback sink', () => { + const config = benchmarkConfig({ + canonicalConfigPath: require('node:path').resolve(__dirname, '../../collector/otelcol.local.strict.yaml'), + receiverPort: 14318, + healthPort: 13133, + sinkPort: 24318 + }); + assert.match(config, /transform\/privacy_strict:/); + assert.match(config, /keep_keys\(attributes/); + assert.match(config, /endpoint: http:\/\/127\.0\.0\.1:24318/); + assert.doesNotMatch(config, /azuremonitor|APPLICATIONINSIGHTS|0\.0\.0\.0/); +}); + +test('real collector benchmark measures strict local receiver to sink path', { + skip: !fs.existsSync(binary), + timeout: 30000 +}, async () => { + const report = await runCollectorBenchmark({ events: 8, warmup: 2, collectorBinary: binary }); + assert.equal(report.methodology.events, 8); + assert.equal(report.methodology.warmup_events, 2); + assert.match(report.methodology.transport, /no Azure or external network writes/); + assert.equal(report.receiver_acknowledgement.samples, 8); + assert.equal(report.local_sink_acknowledgement.samples, 8); + assert.equal(report.privacy_check.sink_requests, 8); + assert.equal(report.privacy_check.allowlisted_event_id_present, true); + assert.equal(report.privacy_check.disallowed_metadata_removed, true); + assert.ok(report.receiver_acknowledgement.p50_ms >= 0); + assert.ok(report.receiver_acknowledgement.p95_ms >= report.receiver_acknowledgement.p50_ms); + assert.ok(report.local_sink_acknowledgement.p95_ms >= report.local_sink_acknowledgement.p50_ms); +}); diff --git a/agentops-cli/test/collector-binary-install.test.js b/agentops-cli/test/collector-binary-install.test.js new file mode 100644 index 0000000..00391dc --- /dev/null +++ b/agentops-cli/test/collector-binary-install.test.js @@ -0,0 +1,94 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { setEnvForTest } = require('./support/env'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function modulePath(relativePath) { + return path.join(repoRoot, 'agentops-cli', relativePath); +} + +function clearCollectorInstallModules() { + for (const relativePath of [ + 'src/lib/paths.js', + 'src/lib/collector-runtime.js', + 'src/lib/collector-binary-release.js', + 'src/lib/collector-binary-install.js' + ]) { + const absolutePath = modulePath(relativePath); + delete require.cache[require.resolve(absolutePath)]; + } +} + +function withCollectorHome(fn) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-binary-install-')); + const collectorHome = path.join(tempDir, 'collector-home'); + const restoreEnv = setEnvForTest({ AGENTOPS_COLLECTOR_HOME: collectorHome }); + clearCollectorInstallModules(); + try { + return fn(collectorHome); + } finally { + restoreEnv(); + clearCollectorInstallModules(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function writeExecutable(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '#!/usr/bin/env bash\nexit 0\n'); + if (process.platform !== 'win32') fs.chmodSync(filePath, 0o755); +} + +test('collector binary install helper validates an already installed binary', { + skip: process.platform === 'win32' ? 'test fixture is a POSIX shell script, not a Windows executable' : false +}, async () => { + await withCollectorHome(async () => { + const { installedCollectorBinaryPath } = require(modulePath('src/lib/collector-binary-release.js')); + const { installBinary } = require(modulePath('src/lib/collector-binary-install.js')); + const binaryPath = installedCollectorBinaryPath(); + writeExecutable(binaryPath); + + const result = await installBinary({ privacy: 'strict' }); + + assert.equal(result.ok, true); + assert.equal(result.action, 'install-binary'); + assert.equal(result.alreadyInstalled, true); + assert.equal(result.path, binaryPath); + assert.equal(result.validation.ok, true); + assert.match(result.validation.command, /validate --config/); + }); +}); + +test('collector binary install helper uninstalls binaries and purges runtime files', () => { + withCollectorHome(collectorHome => { + const { installedCollectorBinaryPath } = require(modulePath('src/lib/collector-binary-release.js')); + const { logFile, pidFile } = require(modulePath('src/lib/collector-runtime.js')); + const { uninstallBinary } = require(modulePath('src/lib/collector-binary-install.js')); + const binaryPath = installedCollectorBinaryPath(); + let stoppedOptions = null; + writeExecutable(binaryPath); + fs.writeFileSync(pidFile(), '123\n'); + fs.writeFileSync(logFile(), 'collector logs'); + + const result = uninstallBinary({ privacy: 'relaxed', purge: true }, { + stopCollector(options) { + stoppedOptions = options; + return { ok: true, mode: 'binary', stopped: false }; + } + }); + + assert.deepEqual(stoppedOptions, { mode: 'binary', privacy: 'relaxed' }); + assert.equal(result.ok, true); + assert.equal(result.action, 'uninstall-binary'); + assert.deepEqual(result.removed, [binaryPath]); + assert.equal(result.collectorHome, collectorHome); + assert.equal(fs.existsSync(binaryPath), false); + assert.equal(fs.existsSync(pidFile()), false); + assert.equal(fs.existsSync(logFile()), false); + }); +}); diff --git a/agentops-cli/test/collector-binary-release.test.js b/agentops-cli/test/collector-binary-release.test.js new file mode 100644 index 0000000..b37beb0 --- /dev/null +++ b/agentops-cli/test/collector-binary-release.test.js @@ -0,0 +1,31 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + collectorPackageInfo, + parseChecksumFile, + verifyChecksum +} = require('../src/lib/collector-binary-release'); + +test('collector binary release helpers map packages and verify checksums', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-collector-release-')); + try { + const mac = collectorPackageInfo({ version: 'v0.151.0', platform: 'darwin', arch: 'arm64' }); + const archive = path.join(tempDir, 'otelcol-contrib_0.151.0_darwin_arm64.tar.gz'); + fs.writeFileSync(archive, 'expected archive'); + const hash = crypto.createHash('sha256').update('expected archive').digest('hex'); + const checksums = `${hash} ${mac.fileName}\n`; + + assert.equal(mac.fileName, 'otelcol-contrib_0.151.0_darwin_arm64.tar.gz'); + assert.equal(mac.binaryName, 'otelcol-contrib'); + assert.match(mac.url, /download\/v0\.151\.0/); + assert.equal(parseChecksumFile(checksums, mac.fileName), hash); + assert.equal(verifyChecksum({ archive, checksumsText: checksums, fileName: mac.fileName }).ok, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/collector-binary-runtime.test.js b/agentops-cli/test/collector-binary-runtime.test.js new file mode 100644 index 0000000..d26cc12 --- /dev/null +++ b/agentops-cli/test/collector-binary-runtime.test.js @@ -0,0 +1,161 @@ +const assert = require('node:assert/strict'); +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { setEnvForTest } = require('./support/env'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function modulePath(relativePath) { + return path.join(repoRoot, 'agentops-cli', relativePath); +} + +function clearCollectorRuntimeModules() { + for (const relativePath of [ + 'src/lib/paths.js', + 'src/lib/collector-runtime.js', + 'src/lib/collector-binary-runtime.js' + ]) { + const absolutePath = modulePath(relativePath); + delete require.cache[require.resolve(absolutePath)]; + } +} + +function patch(object, property, value) { + const original = object[property]; + object[property] = value; + return () => { + object[property] = original; + }; +} + +test('collector binary runtime starts a managed process with strict config', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-binary-runtime-start-')); + const fakeBinary = path.join(tempDir, 'otelcol-contrib'); + const restoreEnv = setEnvForTest({ + AGENTOPS_COLLECTOR_HOME: path.join(tempDir, 'collector-home'), + APPLICATIONINSIGHTS_CONNECTION_STRING: 'InstrumentationKey=test' + }); + const spawnCalls = []; + let unrefCalled = false; + + try { + fs.writeFileSync(fakeBinary, '#!/usr/bin/env bash\nexit 0\n'); + fs.chmodSync(fakeBinary, 0o755); + + clearCollectorRuntimeModules(); + const runtime = require(modulePath('src/lib/collector-runtime.js')); + const restore = [ + patch(runtime, 'healthCheck', async () => ({ ok: false })), + patch(runtime, 'readPid', () => null), + patch(runtime, 'findManagedCollectorProcess', () => null), + patch(runtime, 'waitForHealth', async () => ({ ok: true, statusCode: 200 })), + patch(childProcess, 'spawn', (command, args, options) => { + spawnCalls.push({ command, args, options }); + return { + pid: 4321, + unref() { + unrefCalled = true; + } + }; + }) + ]; + + try { + const { startBinaryCollector } = require(modulePath('src/lib/collector-binary-runtime.js')); + const result = await startBinaryCollector({ + privacy: 'strict', + findCollectorBinary: () => ({ ok: true, path: fakeBinary }) + }); + + assert.equal(result.ok, true); + assert.equal(result.pid, 4321); + assert.equal(result.mode, 'binary'); + assert.match(result.config, /otelcol\.binary\.strict\.yaml$/); + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0].command, fakeBinary); + assert.deepEqual(spawnCalls[0].args, ['--config', result.config]); + assert.equal(spawnCalls[0].options.env.APPLICATIONINSIGHTS_CONNECTION_STRING, 'InstrumentationKey=test'); + assert.equal(spawnCalls[0].options.env.AGENTOPS_OTEL_STORAGE_DIR, path.join(tempDir, 'collector-home', 'queue')); + assert.equal(fs.existsSync(spawnCalls[0].options.env.AGENTOPS_OTEL_STORAGE_DIR), true); + if (process.platform !== 'win32') { + assert.equal(fs.statSync(spawnCalls[0].options.env.AGENTOPS_OTEL_STORAGE_DIR).mode & 0o777, 0o700); + } + assert.equal(unrefCalled, true); + } finally { + restore.reverse().forEach(fn => fn()); + clearCollectorRuntimeModules(); + } + } finally { + restoreEnv(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('collector binary runtime refuses to stop an unverified pid file', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-binary-runtime-stop-')); + const restoreEnv = setEnvForTest({ + AGENTOPS_COLLECTOR_HOME: path.join(tempDir, 'collector-home') + }); + const killed = []; + + try { + clearCollectorRuntimeModules(); + const runtime = require(modulePath('src/lib/collector-runtime.js')); + fs.mkdirSync(path.dirname(runtime.pidFile()), { recursive: true }); + fs.writeFileSync(runtime.pidFile(), '1234\n'); + + const restore = [ + patch(runtime, 'readPid', () => 1234), + patch(runtime, 'processAlive', pid => pid === 1234), + patch(runtime, 'findManagedCollectorProcess', () => null), + patch(process, 'kill', (pid, signal) => { + killed.push({ pid, signal }); + }) + ]; + + try { + const { stopBinaryCollector } = require(modulePath('src/lib/collector-binary-runtime.js')); + const result = stopBinaryCollector({ + privacy: 'strict', + findCollectorBinary: () => ({ ok: true, path: '/tmp/otelcol-contrib' }) + }); + + assert.deepEqual(killed, []); + assert.equal(result.ok, true); + assert.equal(result.stopped, false); + assert.equal(result.pid, undefined); + assert.equal(fs.existsSync(runtime.pidFile()), true); + } finally { + restore.reverse().forEach(fn => fn()); + clearCollectorRuntimeModules(); + } + } finally { + restoreEnv(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('native Azure endpoint guard requires explicit approval and Azure OTLP URL shapes', () => { + clearCollectorRuntimeModules(); + const { nativeEndpointEnvironment } = require(modulePath('src/lib/collector-binary-runtime.js')); + const endpoints = { + AGENTOPS_AZURE_OTLP_DCR_RESOURCE_ID: '/subscriptions/11111111-1111-4111-8111-111111111111/resourceGroups/rg-copilot-agentops-dev/providers/Microsoft.Insights/dataCollectionRules/dcr-copilot-agentops-dev', + AZURE_MONITOR_OTLP_TRACES_ENDPOINT: 'https://logs.ingest.monitor.azure.com/datacollectionRules/dcr/streams/Microsoft-OTLP-Traces/otlp/v1/traces', + AZURE_MONITOR_OTLP_LOGS_ENDPOINT: 'https://logs.ingest.monitor.azure.com/datacollectionRules/dcr/streams/Microsoft-OTLP-Logs/otlp/v1/logs', + AZURE_MONITOR_OTLP_METRICS_ENDPOINT: 'https://metrics.metrics.ingest.monitor.azure.com/datacollectionRules/dcr/streams/microsoft-otelmetrics/otlp/v1/metrics' + }; + + assert.equal(nativeEndpointEnvironment(endpoints).ok, false); + assert.equal(nativeEndpointEnvironment({ ...endpoints, AGENTOPS_APPROVE_NATIVE_OTLP: 'yes', AGENTOPS_AZURE_SUBSCRIPTION_ID: '11111111-1111-4111-8111-111111111111' }).ok, true); + assert.equal(nativeEndpointEnvironment({ + ...endpoints, + AGENTOPS_APPROVE_NATIVE_OTLP: 'yes', + AGENTOPS_AZURE_SUBSCRIPTION_ID: '11111111-1111-4111-8111-111111111111', + AZURE_MONITOR_OTLP_LOGS_ENDPOINT: 'https://example.com/v1/logs' + }).ok, false); + clearCollectorRuntimeModules(); +}); diff --git a/agentops-cli/test/collector-config-validation.test.js b/agentops-cli/test/collector-config-validation.test.js new file mode 100644 index 0000000..4d79149 --- /dev/null +++ b/agentops-cli/test/collector-config-validation.test.js @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function modulePath(relativePath) { + return path.join(repoRoot, 'agentops-cli', relativePath); +} + +function clearCollectorValidationModules() { + for (const relativePath of [ + 'src/lib/collector-artifacts.js', + 'src/lib/collector-config-validation.js', + 'src/lib/shell.js' + ]) { + const absolutePath = modulePath(relativePath); + delete require.cache[require.resolve(absolutePath)]; + } +} + +function patch(object, property, value) { + const original = object[property]; + object[property] = value; + return () => { + object[property] = original; + }; +} + +test('collector config validation carries artifact failures through binary validation', () => { + clearCollectorValidationModules(); + const artifacts = require(modulePath('src/lib/collector-artifacts.js')); + const shell = require(modulePath('src/lib/shell.js')); + const config = path.join(repoRoot, 'collector', 'otelcol.binary.strict.yaml'); + const calls = []; + const restore = [ + patch(artifacts, 'validateCollectorArtifacts', () => ({ + ok: false, + errors: ['processor missing allowlist'] + })), + patch(shell, 'run', (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: '', stderr: '' }; + }) + ]; + + try { + const { validateCollectorConfig } = require(modulePath('src/lib/collector-config-validation.js')); + const result = validateCollectorConfig({ + options: { mode: 'binary', privacy: 'strict' }, + findCollectorBinary: () => ({ ok: true, path: '/tmp/otelcol-contrib' }), + resolveAutoMode: () => ({ mode: 'binary', reason: 'explicit mode' }), + configPathFor: () => config + }); + + assert.equal(result.ok, false); + assert.equal(result.mode, 'binary'); + assert.equal(result.privacyMode, 'strict'); + assert.equal(result.config, config); + assert.equal(result.error, 'processor missing allowlist'); + assert.deepEqual(calls, [{ + command: '/tmp/otelcol-contrib', + args: ['validate', '--config', config], + options: { + timeout: 30000, + env: { AGENTOPS_OTEL_STORAGE_DIR: path.join(os.tmpdir(), 'agentops-otel-queue') } + } + }]); + } finally { + restore.reverse().forEach(fn => fn()); + clearCollectorValidationModules(); + } +}); diff --git a/agentops-cli/test/collector-connection.test.js b/agentops-cli/test/collector-connection.test.js new file mode 100644 index 0000000..a4a8338 --- /dev/null +++ b/agentops-cli/test/collector-connection.test.js @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { resolveConnectionString } = require('../src/lib/collector-connection'); + +test('collector connection string resolver prefers explicit environment value', () => { + let called = false; + const result = resolveConnectionString({ + APPLICATIONINSIGHTS_CONNECTION_STRING: 'InstrumentationKey=env' + }, { + run() { + called = true; + return { status: 1 }; + } + }); + + assert.equal(result.ok, true); + assert.equal(result.value, 'InstrumentationKey=env'); + assert.equal(result.source, 'APPLICATIONINSIGHTS_CONNECTION_STRING'); + assert.equal(called, false); +}); + +test('collector connection string resolver builds Azure CLI lookup from env and config', () => { + const calls = []; + const result = resolveConnectionString({ + AZURE_RESOURCE_GROUP: '', + APPLICATIONINSIGHTS_NAME: '' + }, { + readConfig: () => ({ + resourceGroup: 'rg-from-config', + appInsightsName: 'appi-from-config', + subscriptionId: 'sub-from-config' + }), + run(command, args, options) { + calls.push({ command, args, options }); + return { status: 0, stdout: ' InstrumentationKey=from-az \n' }; + } + }); + + assert.equal(result.ok, true); + assert.equal(result.value, 'InstrumentationKey=from-az'); + assert.equal(result.source, 'az monitor app-insights component show'); + assert.equal(calls[0].command, 'az'); + assert.deepEqual(calls[0].args, [ + 'monitor', + 'app-insights', + 'component', + 'show', + '--resource-group', + 'rg-from-config', + '--app', + 'appi-from-config', + '--query', + 'connectionString', + '-o', + 'tsv', + '--subscription', + 'sub-from-config' + ]); + assert.equal(calls[0].options.timeout, 15000); +}); + +test('collector connection string resolver reports Azure CLI failures and empty output', () => { + const failed = resolveConnectionString({}, { + readConfig: () => ({}), + run: () => ({ status: 1, stderr: 'az failed' }) + }); + const empty = resolveConnectionString({}, { + readConfig: () => ({}), + run: () => ({ status: 0, stdout: '\n' }) + }); + + assert.deepEqual(failed, { ok: false, error: 'az failed' }); + assert.deepEqual(empty, { ok: false, error: 'Application Insights connection string lookup returned an empty value.' }); +}); diff --git a/agentops-cli/test/collector-discovery.test.js b/agentops-cli/test/collector-discovery.test.js new file mode 100644 index 0000000..f9f34b8 --- /dev/null +++ b/agentops-cli/test/collector-discovery.test.js @@ -0,0 +1,27 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { findCollectorBinary, resolveAutoMode } = require('../src/lib/collector-discovery'); + +test('collector discovery resolves explicit executable binary for auto mode', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-collector-discovery-')); + const fakeBinary = path.join(tempDir, 'otelcol-contrib'); + fs.writeFileSync(fakeBinary, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(fakeBinary, 0o755); + + const env = { AGENTOPS_OTELCOL_BIN: fakeBinary }; + + assert.deepEqual(findCollectorBinary(env), { + path: fakeBinary, + source: 'AGENTOPS_OTELCOL_BIN', + ok: true, + error: null + }); + assert.deepEqual(resolveAutoMode(env), { + mode: 'binary', + reason: `using ${fakeBinary}` + }); +}); diff --git a/agentops-cli/test/collector-docker-runtime.test.js b/agentops-cli/test/collector-docker-runtime.test.js new file mode 100644 index 0000000..0af1c98 --- /dev/null +++ b/agentops-cli/test/collector-docker-runtime.test.js @@ -0,0 +1,71 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function modulePath(relativePath) { + return path.join(repoRoot, 'agentops-cli', relativePath); +} + +function clearDockerRuntimeModules() { + for (const relativePath of [ + 'src/lib/collector-connection.js', + 'src/lib/collector-docker.js', + 'src/lib/collector-docker-runtime.js', + 'src/lib/shell.js' + ]) { + const absolutePath = modulePath(relativePath); + delete require.cache[require.resolve(absolutePath)]; + } +} + +function patch(object, property, value) { + const original = object[property]; + object[property] = value; + return () => { + object[property] = original; + }; +} + +test('collector Docker runtime starts and stops compose with expected env and args', () => { + clearDockerRuntimeModules(); + const connection = require(modulePath('src/lib/collector-connection.js')); + const docker = require(modulePath('src/lib/collector-docker.js')); + const shell = require(modulePath('src/lib/shell.js')); + const calls = []; + const restore = [ + patch(connection, 'resolveConnectionString', () => ({ ok: true, value: 'InstrumentationKey=test' })), + patch(docker, 'dockerDaemonAvailable', () => true), + patch(shell, 'run', (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: '', stderr: '' }; + }) + ]; + + try { + const { + startDockerCollector, + stopDockerCollector + } = require(modulePath('src/lib/collector-docker-runtime.js')); + + const started = startDockerCollector({ privacy: 'compat' }); + const stopped = stopDockerCollector({ privacy: 'compat' }); + + assert.equal(started.ok, true); + assert.equal(started.mode, 'docker'); + assert.equal(started.privacyMode, 'compat'); + assert.equal(stopped.ok, true); + assert.equal(stopped.mode, 'docker'); + assert.equal(calls.length, 2); + assert.deepEqual(calls[0].args.slice(-3), ['up', '-d', '--force-recreate']); + assert.equal(calls[0].options.timeout, 60000); + assert.equal(calls[0].options.env.APPLICATIONINSIGHTS_CONNECTION_STRING, 'InstrumentationKey=test'); + assert.equal(calls[0].options.env.AGENTOPS_PRIVACY_MODE, 'compat'); + assert.deepEqual(calls[1].args.slice(-1), ['down']); + assert.equal(calls[1].options.timeout, 60000); + } finally { + restore.reverse().forEach(fn => fn()); + clearDockerRuntimeModules(); + } +}); diff --git a/agentops-cli/test/collector-docker.test.js b/agentops-cli/test/collector-docker.test.js new file mode 100644 index 0000000..a43be71 --- /dev/null +++ b/agentops-cli/test/collector-docker.test.js @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + composeFile, + composeHasLocalhostBindings, + dockerComposeArgs, + dockerProjectName +} = require('../src/lib/collector-docker'); + +test('collector Docker helpers build stable compose command arguments', () => { + const args = dockerComposeArgs(['config']); + + assert.equal(args[0], 'compose'); + assert.equal(args[args.indexOf('--project-name') + 1], dockerProjectName); + assert.equal(args[args.indexOf('-f') + 1], composeFile); + assert.equal(path.isAbsolute(composeFile), true); + assert.deepEqual(args.slice(-1), ['config']); +}); + +test('collector Docker helpers require localhost-only compose bindings', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-compose-bindings-')); + const good = path.join(tempDir, 'good.yaml'); + const bad = path.join(tempDir, 'bad.yaml'); + + try { + fs.writeFileSync(good, [ + 'ports:', + ' - "127.0.0.1:4318:4318"', + ' - "127.0.0.1:4317:4317"', + ' - "127.0.0.1:13133:13133"' + ].join('\n')); + fs.writeFileSync(bad, [ + 'ports:', + ' - "0.0.0.0:4318:4318"', + ' - "127.0.0.1:4317:4317"', + ' - "127.0.0.1:13133:13133"' + ].join('\n')); + + assert.equal(composeHasLocalhostBindings(good), true); + assert.equal(composeHasLocalhostBindings(bad), false); + assert.equal(composeHasLocalhostBindings(path.join(tempDir, 'missing.yaml')), false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/collector-manager-binary-start.test.js b/agentops-cli/test/collector-manager-binary-start.test.js new file mode 100644 index 0000000..cff9206 --- /dev/null +++ b/agentops-cli/test/collector-manager-binary-start.test.js @@ -0,0 +1,94 @@ +const assert = require('node:assert/strict'); +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function modulePath(relativePath) { + return path.join(repoRoot, 'agentops-cli', relativePath); +} + +function clearCollectorModules() { + for (const relativePath of [ + 'src/lib/paths.js', + 'src/lib/collector-runtime.js', + 'src/lib/collector-binary-runtime.js', + 'src/lib/collector-start.js', + 'src/lib/collector-manager.js' + ]) { + const absolutePath = modulePath(relativePath); + delete require.cache[require.resolve(absolutePath)]; + } +} + +function patch(object, property, value) { + const original = object[property]; + object[property] = value; + return () => { + object[property] = original; + }; +} + +test('collector binary start spawns the configured collector with strict config and connection string', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-collector-start-')); + const fakeBinary = path.join(tempDir, 'otelcol-contrib'); + const originalEnv = { + AGENTOPS_COLLECTOR_HOME: process.env.AGENTOPS_COLLECTOR_HOME, + AGENTOPS_OTELCOL_BIN: process.env.AGENTOPS_OTELCOL_BIN, + APPLICATIONINSIGHTS_CONNECTION_STRING: process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + }; + const spawnCalls = []; + let unrefCalled = false; + + try { + fs.writeFileSync(fakeBinary, '#!/usr/bin/env bash\nexit 0\n'); + fs.chmodSync(fakeBinary, 0o755); + process.env.AGENTOPS_COLLECTOR_HOME = path.join(tempDir, 'collector-home'); + process.env.AGENTOPS_OTELCOL_BIN = fakeBinary; + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = 'InstrumentationKey=test'; + + clearCollectorModules(); + const runtime = require(modulePath('src/lib/collector-runtime.js')); + const restore = [ + patch(runtime, 'healthCheck', async () => ({ ok: false })), + patch(runtime, 'readPid', () => null), + patch(runtime, 'findManagedCollectorProcess', () => null), + patch(runtime, 'waitForHealth', async () => ({ ok: true, statusCode: 200 })), + patch(childProcess, 'spawn', (command, args, options) => { + spawnCalls.push({ command, args, options }); + return { + pid: 4321, + unref() { + unrefCalled = true; + } + }; + }) + ]; + + try { + const collectorManager = require(modulePath('src/lib/collector-manager.js')); + const result = await collectorManager.start({ mode: 'binary', privacy: 'strict' }); + const config = collectorManager.configPathFor('binary', 'strict'); + + assert.equal(result.ok, true); + assert.equal(result.pid, 4321); + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0].command, fakeBinary); + assert.deepEqual(spawnCalls[0].args, ['--config', config]); + assert.equal(spawnCalls[0].options.env.APPLICATIONINSIGHTS_CONNECTION_STRING, 'InstrumentationKey=test'); + assert.equal(unrefCalled, true); + } finally { + restore.reverse().forEach(fn => fn()); + clearCollectorModules(); + } + } finally { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/collector-options.test.js b/agentops-cli/test/collector-options.test.js new file mode 100644 index 0000000..f3acc18 --- /dev/null +++ b/agentops-cli/test/collector-options.test.js @@ -0,0 +1,82 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + collectorModes, + collectorConfigPathsFor, + defaultConfigPathFor, + normalizeMode, + normalizePrivacy, + parseCollectorOptions, + privacyModes +} = require('../src/lib/collector-options'); +const { collectorConfigPath } = require('../src/lib/paths'); + +test('collector option helpers normalize modes privacy and CLI flags', () => { + assert.deepEqual(collectorModes, ['auto', 'local', 'docker', 'binary', 'azure-native', 'none']); + assert.deepEqual(privacyModes, ['strict', 'compat']); + assert.equal(normalizeMode('DOCKER'), 'docker'); + assert.equal(normalizeMode(''), 'auto'); + assert.equal(normalizePrivacy('COMPAT'), 'compat'); + assert.equal(normalizePrivacy(''), 'strict'); + assert.throws(() => normalizeMode('podman'), /Unsupported collector mode: podman/); + assert.throws(() => normalizePrivacy('loose'), /Unsupported privacy mode: loose/); + + assert.deepEqual(parseCollectorOptions([ + '--mode', + 'binary', + '--privacy', + 'compat', + '--json', + '--poison', + '--force', + '--purge', + '--version', + 'v0.151.0', + '--unsafe-no-collector' + ], {}), { + mode: 'binary', + privacy: 'compat', + json: true, + poison: true, + force: true, + purge: true, + version: 'v0.151.0', + unsafeNoCollector: true + }); + + assert.deepEqual(parseCollectorOptions([], { + AGENTOPS_COLLECTOR_MODE: 'none', + AGENTOPS_PRIVACY_MODE: 'compat', + AGENTOPS_OTELCOL_VERSION: 'v0.150.0', + AGENTOPS_ALLOW_NO_COLLECTOR: '1' + }), { + mode: 'none', + privacy: 'compat', + json: false, + poison: false, + force: false, + purge: false, + version: 'v0.150.0', + unsafeNoCollector: true + }); +}); + +test('collector option helpers own default config path resolution', () => { + assert.equal(defaultConfigPathFor('binary', 'strict'), collectorConfigPath({ target: 'binary', privacy: 'strict' })); + assert.equal(defaultConfigPathFor('docker', 'compat'), collectorConfigPath({ target: 'azuremonitor', privacy: 'compat' })); + assert.equal(defaultConfigPathFor('azure-native', 'strict'), collectorConfigPath({ target: 'azuremonitor.native', privacy: 'strict' })); + assert.deepEqual(collectorConfigPathsFor('azure-native', 'strict'), [ + collectorConfigPath({ target: 'local', privacy: 'strict' }), + collectorConfigPath({ target: 'azuremonitor.native', privacy: 'strict' }) + ]); + assert.throws(() => collectorConfigPathsFor('azure-native', 'compat'), /only supports strict/); + + for (const file of ['collector-config-validation.js', 'collector-manager.js', 'collector-status.js']) { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', file), 'utf8'); + assert.doesNotMatch(source, /function defaultConfigPathFor\(/); + assert.doesNotMatch(source, /function configTargetForMode\(/); + } +}); diff --git a/agentops-cli/test/collector-persistent-queue.integration.test.js b/agentops-cli/test/collector-persistent-queue.integration.test.js new file mode 100644 index 0000000..e8739f9 --- /dev/null +++ b/agentops-cli/test/collector-persistent-queue.integration.test.js @@ -0,0 +1,158 @@ +const assert = require('node:assert/strict'); +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const collectorBinary = process.env.AGENTOPS_OTELCOL_BIN + || path.join(os.homedir(), '.agentops', 'collector', 'bin', 'otelcol-contrib'); + +async function freePort() { + const server = http.createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +async function waitFor(check, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) return true; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; +} + +function startCollector(config, env) { + const child = childProcess.spawn(collectorBinary, ['--config', config], { + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + child.output = ''; + child.stdout.on('data', chunk => { child.output += chunk; }); + child.stderr.on('data', chunk => { child.output += chunk; }); + return child; +} + +async function stopCollector(child) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await Promise.race([ + new Promise(resolve => child.once('exit', resolve)), + new Promise(resolve => setTimeout(resolve, 5000)) + ]); + if (child.exitCode === null) child.kill('SIGKILL'); +} + +test('persistent collector queue survives a process crash with an in-flight batch', { + skip: !fs.existsSync(collectorBinary), + timeout: 30000 +}, async () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-persistent-queue-')); + const queueDir = path.join(temp, 'queue'); + const otlpPort = await freePort(); + const healthPort = await freePort(); + const backendPort = await freePort(); + const config = path.join(temp, 'collector.yaml'); + fs.writeFileSync(config, `receivers: + otlp: + protocols: + http: + endpoint: 127.0.0.1:${otlpPort} +extensions: + health_check: + endpoint: 127.0.0.1:${healthPort} + file_storage: + directory: ${queueDir} + create_directory: true +exporters: + otlphttp: + endpoint: http://127.0.0.1:${backendPort} + sending_queue: + enabled: true + storage: file_storage + queue_size: 10 + num_consumers: 1 + retry_on_failure: + enabled: true + initial_interval: 100ms + max_interval: 1s + max_elapsed_time: 0s +service: + extensions: [health_check, file_storage] + telemetry: + metrics: + level: none + pipelines: + traces: + receivers: [otlp] + exporters: [otlphttp] +`); + const env = {}; + let collector; + let backend; + let holdResponse = true; + const heldResponses = []; + const received = []; + try { + backend = http.createServer((request, response) => { + const chunks = []; + request.on('data', chunk => chunks.push(chunk)); + request.on('end', () => { + received.push(Buffer.concat(chunks).toString('utf8')); + if (holdResponse) { + heldResponses.push(response); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{}'); + }); + }); + await new Promise(resolve => backend.listen(backendPort, '127.0.0.1', resolve)); + collector = startCollector(config, env); + const healthy = await waitFor(async () => { + try { return (await fetch(`http://127.0.0.1:${healthPort}`)).ok; } catch { return false; } + }); + assert.equal(healthy, true, `collector should become healthy: ${collector.output}`); + + const response = await fetch(`http://127.0.0.1:${otlpPort}/v1/traces`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ resourceSpans: [{ + resource: { attributes: [{ key: 'service.name', value: { stringValue: 'agentops-restart-test' } }] }, + scopeSpans: [{ spans: [{ + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + name: 'agentops.restart.proof', + kind: 1, + startTimeUnixNano: '1785780000000000000', + endTimeUnixNano: '1785780000001000000', + status: { code: 1 } + }] }] + }] }) + }); + assert.equal(response.ok, true); + const firstAttempt = await waitFor(() => received.length >= 1); + assert.equal(firstAttempt, true, `backend should receive the first in-flight attempt: ${collector.output}`); + collector.kill('SIGKILL'); + await new Promise(resolve => collector.once('exit', resolve)); + collector = null; + assert.equal(fs.existsSync(queueDir), true); + assert.ok(fs.readdirSync(queueDir).length > 0, 'persistent queue should leave restart state on disk'); + + holdResponse = false; + for (const response of heldResponses) response.destroy(); + collector = startCollector(config, env); + + assert.equal(await waitFor(() => received.length >= 2, 15000), true, + 'queued span should reach the backend after collector restart'); + assert.equal(received[1], received[0], 'restart delivery should replay the same queued OTLP batch'); + } finally { + await stopCollector(collector); + if (backend) await new Promise(resolve => backend.close(resolve)); + fs.rmSync(temp, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/collector-persistent-queue.test.js b/agentops-cli/test/collector-persistent-queue.test.js new file mode 100644 index 0000000..a7d3fce --- /dev/null +++ b/agentops-cli/test/collector-persistent-queue.test.js @@ -0,0 +1,52 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const azureConfigs = [ + 'collector/otelcol.binary.strict.yaml', + 'collector/otelcol.binary.compat.yaml', + 'collector/otelcol.azuremonitor.strict.yaml', + 'collector/otelcol.azuremonitor.compat.yaml', + 'collector/otelcol.azuremonitor.yaml' +]; + +test('every Azure Monitor collector path uses a bounded persistent sending queue', () => { + for (const relative of azureConfigs) { + const config = fs.readFileSync(path.join(root, relative), 'utf8').replace(/\r\n/g, '\n'); + assert.match(config, /file_storage:/, `${relative} needs file storage`); + assert.match(config, /directory: \$\{env:AGENTOPS_OTEL_STORAGE_DIR\}/, `${relative} needs explicit storage location`); + assert.match(config, /sending_queue:\n\s+enabled: true\n\s+storage: file_storage\n\s+queue_size: 1000/, `${relative} needs bounded persistent queue`); + assert.match(config, /extensions: \[health_check, file_storage\]/, `${relative} must start file storage`); + } +}); + +test('Docker Azure collector persists the queue outside the container', () => { + const compose = fs.readFileSync(path.join(root, 'collector', 'docker-compose.azuremonitor.yaml'), 'utf8').replace(/\r\n/g, '\n'); + assert.match(compose, /AGENTOPS_OTEL_STORAGE_DIR: \/var\/lib\/agentops\/queue/); + assert.match(compose, /agentops-otel-queue:\/var\/lib\/agentops\/queue/); +}); + +test('Native Azure preview overlays the strict local privacy config without duplicating it', () => { + const base = fs.readFileSync(path.join(root, 'collector', 'otelcol.local.strict.yaml'), 'utf8').replace(/\r\n/g, '\n'); + const overlay = fs.readFileSync(path.join(root, 'collector', 'otelcol.azuremonitor.native.strict.yaml'), 'utf8').replace(/\r\n/g, '\n'); + assert.match(base, /error_mode: propagate/); + assert.match(base, /sending_queue:\n\s+enabled: true\n\s+storage: file_storage\n\s+queue_size: 1000/); + assert.match(overlay, /azure_auth:/); + assert.match(overlay, /use_default: true/); + assert.match(overlay, /otlp_http\/azuremonitor:/); + assert.match(overlay, /AZURE_MONITOR_OTLP_TRACES_ENDPOINT/); + assert.match(overlay, /AZURE_MONITOR_OTLP_LOGS_ENDPOINT/); + assert.match(overlay, /AZURE_MONITOR_OTLP_METRICS_ENDPOINT/); + assert.match(overlay, /storage: file_storage/); + assert.match(overlay, /otelcol\.local\.strict\.yaml/); +}); + +test('strict local privacy config clears signal-level content fields before export', () => { + const config = fs.readFileSync(path.join(root, 'collector', 'otelcol.local.strict.yaml'), 'utf8').replace(/\r\n/g, '\n'); + assert.match(config, /set\(name, "agentops\.span"\) where name != nil/); + assert.match(config, /set\(status\.message, "redacted by AgentOps strict privacy mode"\)/); + assert.match(config, /set\(name, "agentops\.event"\) where name != nil/); + assert.match(config, /set\(name, "agentops\.metric"\) where name != nil/); +}); diff --git a/agentops-cli/test/collector-runtime.test.js b/agentops-cli/test/collector-runtime.test.js new file mode 100644 index 0000000..463d2b0 --- /dev/null +++ b/agentops-cli/test/collector-runtime.test.js @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + healthCheck, + logFile, + otlpAttributes, + pidFile, + processAlive +} = require('../src/lib/collector-runtime'); +const { + collectorHealthUrl, + collectorHealthUrlWithSlash, + otlpHttpEndpoint +} = require('../src/lib/collector-endpoints'); + +test('collector runtime helpers expose local process and OTLP primitives', async () => { + const server = http.createServer((request, response) => { + response.writeHead(204); + response.end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const health = await healthCheck(`http://127.0.0.1:${port}`, 500); + const attrs = otlpAttributes({ + 'content.capture.enabled': false, + 'agentops.retry_count': 2, + 'agentops.score': 98.5, + 'service.name': 'agentops-test' + }); + + assert.equal(health.ok, true); + assert.equal(health.statusCode, 204); + assert.equal(processAlive(process.pid), true); + assert.match(pidFile(), /otelcol\.pid$/); + assert.match(logFile(), /otelcol\.log$/); + assert.equal(path.basename(pidFile()), 'otelcol.pid'); + assert.deepEqual(attrs, [ + { key: 'content.capture.enabled', value: { boolValue: false } }, + { key: 'agentops.retry_count', value: { intValue: '2' } }, + { key: 'agentops.score', value: { doubleValue: 98.5 } }, + { key: 'service.name', value: { stringValue: 'agentops-test' } } + ]); + } finally { + await new Promise(resolve => server.close(resolve)); + } +}); + +test('collector endpoint helpers own localhost defaults', () => { + assert.equal(otlpHttpEndpoint, 'http://127.0.0.1:4318'); + assert.equal(collectorHealthUrl, 'http://127.0.0.1:13133'); + assert.equal(collectorHealthUrlWithSlash, 'http://127.0.0.1:13133/'); + + for (const file of [ + 'collector-runtime.js', + 'collector-status.js', + 'collector-validation.js', + 'copilot/session-command.js', + 'custom-telemetry.js', + 'otel-setup.js', + 'smoke-runtime.js', + 'smoke.js' + ]) { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', file), 'utf8'); + assert.doesNotMatch(source, /http:\/\/127\.0\.0\.1:4318/); + if (file.startsWith('collector-')) { + assert.doesNotMatch(source, /http:\/\/127\.0\.0\.1:13133/); + } + } +}); diff --git a/agentops-cli/test/collector-sdk-attribute-parity.test.js b/agentops-cli/test/collector-sdk-attribute-parity.test.js new file mode 100644 index 0000000..2491a99 --- /dev/null +++ b/agentops-cli/test/collector-sdk-attribute-parity.test.js @@ -0,0 +1,33 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + attributesForContext, + canonicalSdkAttributes, + forbiddenContentAttributes, + strictCollectorFiles, + syncStrictCollectorFiles +} = require('../../scripts/lib/strict-collector-attributes'); + +test('every strict collector path preserves the canonical safe SDK event attributes', () => { + for (const file of strictCollectorFiles()) { + const text = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); + const contexts = ['span', ...(/- context: log\n/.test(text) ? ['log'] : [])]; + for (const context of contexts) { + const allowed = attributesForContext(text, context); + assert.ok(allowed.length > 0, `${path.basename(file)} ${context}`); + for (const attribute of canonicalSdkAttributes) { + assert.ok(allowed.includes(attribute), `${path.basename(file)} ${context} drops ${attribute}`); + } + for (const forbidden of forbiddenContentAttributes) { + assert.equal(allowed.includes(forbidden), false, `${path.basename(file)} ${context} allows ${forbidden}`); + } + } + } +}); + +test('strict collector attribute synchronization is idempotent', () => { + assert.deepEqual(syncStrictCollectorFiles({ write: false }).changed, []); +}); diff --git a/agentops-cli/test/collector-smoke.test.js b/agentops-cli/test/collector-smoke.test.js new file mode 100644 index 0000000..8e19d54 --- /dev/null +++ b/agentops-cli/test/collector-smoke.test.js @@ -0,0 +1,35 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { smokeCollector } = require('../src/lib/collector-smoke'); + +test('collector smoke skips poison checks when disabled and reports running health', async () => { + const calls = []; + + const result = await smokeCollector({ + options: { mode: 'binary', privacy: 'compat', poison: false }, + findCollectorBinary: () => ({ ok: true, path: '/tmp/otelcol-contrib' }), + status: async (options) => { + calls.push({ status: options }); + return { running: true, health: { ok: true } }; + }, + runPoisonCheck: () => { + calls.push('poison'); + }, + runRuntimePoisonSmoke: () => { + calls.push('runtime'); + } + }); + + assert.deepEqual(result, { + ok: true, + privacyMode: 'compat', + poison: null, + runtime_validation: { + status: 'collector-running', + health: { ok: true }, + debug_exporter: null + } + }); + assert.deepEqual(calls, [{ status: { mode: 'binary', privacy: 'compat' } }]); +}); diff --git a/agentops-cli/test/collector-start.test.js b/agentops-cli/test/collector-start.test.js new file mode 100644 index 0000000..c1f292a --- /dev/null +++ b/agentops-cli/test/collector-start.test.js @@ -0,0 +1,46 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function modulePath(relativePath) { + return path.join(repoRoot, 'agentops-cli', relativePath); +} + +function clearCollectorStartModules() { + for (const relativePath of [ + 'src/lib/collector-start.js' + ]) { + const absolutePath = modulePath(relativePath); + delete require.cache[require.resolve(absolutePath)]; + } +} + +test('collector start orchestration reports already-running health when auto cannot resolve a runtime', async () => { + clearCollectorStartModules(); + + try { + const { startCollector } = require(modulePath('src/lib/collector-start.js')); + const health = { ok: true, statusCode: 200 }; + const calls = []; + const result = await startCollector({ + options: { mode: 'auto', privacy: 'compat' }, + resolveAutoMode: () => ({ mode: null, reason: 'no local runtime' }), + findCollectorBinary: () => ({ ok: false, error: 'test resolver: no local runtime' }), + checkHealth: async () => health, + startDocker: () => calls.push('docker'), + startBinary: () => calls.push('binary') + }); + + assert.equal(result.ok, true); + assert.equal(result.mode, 'auto'); + assert.equal(result.privacyMode, 'compat'); + assert.equal(result.alreadyRunning, true); + assert.equal(result.health, health); + assert.match(result.warning, /health endpoint is already responding/); + assert.deepEqual(calls, []); + } finally { + clearCollectorStartModules(); + } +}); diff --git a/agentops-cli/test/collector-status.test.js b/agentops-cli/test/collector-status.test.js new file mode 100644 index 0000000..a6a09b0 --- /dev/null +++ b/agentops-cli/test/collector-status.test.js @@ -0,0 +1,112 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { setEnvForTest } = require('./support/env'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function modulePath(relativePath) { + return path.join(repoRoot, 'agentops-cli', relativePath); +} + +function clearCollectorStatusModules() { + for (const relativePath of [ + 'src/lib/paths.js', + 'src/lib/collector-docker.js', + 'src/lib/collector-runtime.js', + 'src/lib/collector-binary-runtime.js', + 'src/lib/collector-status.js' + ]) { + const absolutePath = modulePath(relativePath); + delete require.cache[require.resolve(absolutePath)]; + } +} + +function patch(object, property, value) { + const original = object[property]; + object[property] = value; + return () => { + object[property] = original; + }; +} + +test('collector status helper composes auto binary status with stale pid details', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-collector-status-')); + const fakeBinary = path.join(tempDir, 'otelcol-contrib'); + const restoreEnv = setEnvForTest({ + AGENTOPS_COLLECTOR_HOME: path.join(tempDir, 'collector-home') + }); + + try { + fs.writeFileSync(fakeBinary, '#!/usr/bin/env bash\nexit 0\n'); + fs.chmodSync(fakeBinary, 0o755); + + clearCollectorStatusModules(); + const docker = require(modulePath('src/lib/collector-docker.js')); + const runtime = require(modulePath('src/lib/collector-runtime.js')); + const binaryRuntime = require(modulePath('src/lib/collector-binary-runtime.js')); + const config = path.join(repoRoot, 'collector', 'otelcol.binary.strict.yaml'); + const restore = [ + patch(docker, 'dockerCliAvailable', () => true), + patch(docker, 'dockerComposeAvailable', () => true), + patch(docker, 'dockerDaemonAvailable', () => false), + patch(docker, 'composeHasLocalhostBindings', () => true), + patch(runtime, 'healthCheck', async () => ({ ok: true, statusCode: 200 })), + patch(runtime, 'readPid', () => 1111), + patch(runtime, 'processAlive', pid => pid === 2222), + patch(runtime, 'findManagedCollectorProcess', () => 2222), + patch(runtime, 'findCollectorProcessByConfig', () => 2222), + patch(binaryRuntime, 'findRunningBinaryCollector', () => ({ pid: 2222, privacy: 'strict', config })) + ]; + + try { + const { collectorStatus } = require(modulePath('src/lib/collector-status.js')); + const result = await collectorStatus({ + findCollectorBinary: () => ({ + ok: true, + path: fakeBinary, + source: 'AGENTOPS_OTELCOL_BIN', + error: null + }), + resolveAutoMode: () => ({ mode: 'binary', reason: `using ${fakeBinary}` }) + }); + + assert.equal(result.mode, 'auto'); + assert.equal(result.effectiveMode, 'binary'); + assert.equal(result.running, true); + assert.equal(result.privacyMode, 'strict'); + assert.equal(result.config, config); + assert.equal(result.binary.pid, 2222); + assert.equal(result.binary.discoveredPid, 2222); + assert.equal(result.binary.running, true); + assert.equal(result.docker.daemon, false); + assert.match(result.details.join('\n'), /auto: using/); + assert.match(result.details.join('\n'), /Binary PID file was stale; found running collector PID 2222/); + assert.match(result.endpoint, /4318$/); + assert.match(result.healthUrl, /13133$/); + + const explicit = await collectorStatus({ + options: { mode: 'binary' }, + env: { AGENTOPS_PRIVACY_MODE: 'compat' }, + findCollectorBinary: () => ({ + ok: true, + path: fakeBinary, + source: 'AGENTOPS_OTELCOL_BIN', + error: null + }), + resolveAutoMode: () => ({ mode: 'binary', reason: `using ${fakeBinary}` }) + }); + assert.equal(explicit.privacyMode, 'strict', 'running config must win over a stale privacy environment value'); + assert.equal(explicit.config, config); + } finally { + restore.reverse().forEach(fn => fn()); + clearCollectorStatusModules(); + } + } finally { + restoreEnv(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/collector-stop.test.js b/agentops-cli/test/collector-stop.test.js new file mode 100644 index 0000000..90c7c01 --- /dev/null +++ b/agentops-cli/test/collector-stop.test.js @@ -0,0 +1,27 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { stopCollector } = require('../src/lib/collector-stop'); + +test('collector stop orchestration dispatches binary mode with env privacy', () => { + const calls = []; + const result = stopCollector({ + options: { mode: 'auto' }, + env: { + AGENTOPS_COLLECTOR_MODE: 'auto', + AGENTOPS_PRIVACY_MODE: 'strict' + }, + resolveAutoMode: () => ({ mode: 'binary' }), + findCollectorBinary: () => ({ ok: true, path: '/tmp/otelcol-contrib' }), + stopDocker: () => { + calls.push('docker'); + }, + stopBinary: ({ privacy, findCollectorBinary }) => { + calls.push({ privacy, binary: findCollectorBinary().path }); + return { ok: true, mode: 'binary', stopped: true }; + } + }); + + assert.deepEqual(result, { ok: true, mode: 'binary', stopped: true }); + assert.deepEqual(calls, [{ privacy: 'strict', binary: '/tmp/otelcol-contrib' }]); +}); diff --git a/agentops-cli/test/command-args.test.js b/agentops-cli/test/command-args.test.js new file mode 100644 index 0000000..3bc6f27 --- /dev/null +++ b/agentops-cli/test/command-args.test.js @@ -0,0 +1,40 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { firstPositional, requiredOptionValue, optionValues: sharedOptionValues } = require('../src/lib/args'); +const { durationToMs, optionValue, optionValues, parseLastArg } = require('../src/lib/cli-options'); + +test('firstPositional skips flag values and defaults to latest', () => { + assert.equal(firstPositional(['--runs', 'runs.jsonl', '--json']), 'latest'); + assert.equal(firstPositional(['run-123', '--runs', 'runs.jsonl']), 'run-123'); + assert.equal(firstPositional(['--runs=runs.jsonl', 'run-456']), 'run-456'); +}); + +test('shared CLI option helpers parse required values and durations', () => { + const args = ['--name', 'demo', '--tag', 'one', '--tag', 'two', '--last', '24h']; + + assert.equal(optionValue(args, ['--name']), 'demo'); + assert.deepEqual(optionValues(args, '--tag'), ['one', 'two']); + assert.equal(parseLastArg(args, '7d'), '24h'); + assert.equal(durationToMs('2m'), 120000); + assert.equal(durationToMs('', 42), 42); + + assert.throws(() => optionValue(['--name'], ['--name']), /--name requires a value/); + assert.throws(() => optionValues(['--tag'], '--tag'), /--tag requires a value/); + assert.throws(() => parseLastArg(['--last'], '7d'), /--last requires a duration/); + assert.throws(() => durationToMs('7d'), /duration must look like/); +}); + +test('args module owns required option parsing helpers', () => { + assert.equal(requiredOptionValue(['--name', 'demo'], ['--name']), 'demo'); + assert.deepEqual(sharedOptionValues(['--tag', 'one', '--tag', 'two'], '--tag'), ['one', 'two']); + assert.throws(() => requiredOptionValue(['--name'], ['--name']), /--name requires a value/); + assert.throws(() => sharedOptionValues(['--tag'], '--tag'), /--tag requires a value/); +}); + +test('parseLastArg delegates required value lookup to shared args helper', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', 'cli-options.js'), 'utf8'); + assert.doesNotMatch(source, /args\.indexOf\('--last'\)/); +}); diff --git a/agentops-cli/test/command-lib-surface.test.js b/agentops-cli/test/command-lib-surface.test.js new file mode 100644 index 0000000..34d56b6 --- /dev/null +++ b/agentops-cli/test/command-lib-surface.test.js @@ -0,0 +1,51 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +test('remaining command libraries expose command entrypoints and helpers', () => { + const { collectorCommand, renderCollector } = require('../src/lib/collector-command'); + const { e2eCommand } = require('../src/lib/e2e-command'); + const { runSummaryCommand } = require('../src/lib/run-summary-command'); + const { dashboardCommand } = require('../src/lib/dashboard-command'); + const { productCommand, productAuditWithVisual } = require('../src/lib/product-command'); + const { securityCommand, renderSecurityAudit } = require('../src/lib/security-command'); + const { explainCommand, hasV2Args } = require('../src/lib/explain-command'); + const { githubEnrichCommand } = require('../src/lib/github-enrich-command'); + const { askContextCommand, legacyAskContext } = require('../src/lib/ask-context-command'); + const { contentCommand } = require('../src/lib/content-command'); + const { mcpProxyCommand, splitCommand } = require('../src/lib/mcp-proxy-command'); + const { openCommand } = require('../src/lib/open-command'); + const { schemaCommand } = require('../src/lib/schema-command'); + const { doctorCommand } = require('../src/lib/doctor-command'); + const { healthCommand } = require('../src/lib/health-command'); + const { statusCommand } = require('../src/lib/status-command'); + + assert.equal(typeof collectorCommand, 'function'); + assert.equal(typeof renderCollector, 'function'); + assert.equal(typeof e2eCommand, 'function'); + assert.equal(typeof runSummaryCommand, 'function'); + assert.equal(typeof dashboardCommand, 'function'); + assert.equal(typeof productCommand, 'function'); + assert.equal(typeof productAuditWithVisual, 'function'); + assert.equal(typeof securityCommand, 'function'); + assert.equal(typeof renderSecurityAudit, 'function'); + assert.equal(typeof explainCommand, 'function'); + assert.equal(typeof hasV2Args, 'function'); + assert.equal(typeof githubEnrichCommand, 'function'); + assert.equal(typeof askContextCommand, 'function'); + assert.equal(typeof legacyAskContext, 'function'); + assert.equal(typeof contentCommand, 'function'); + assert.equal(typeof mcpProxyCommand, 'function'); + assert.equal(typeof splitCommand, 'function'); + assert.equal(typeof openCommand, 'function'); + assert.equal(typeof schemaCommand, 'function'); + assert.equal(typeof doctorCommand, 'function'); + assert.equal(typeof healthCommand, 'function'); + assert.equal(typeof statusCommand, 'function'); +}); + +test('doctor command uses shared option parsing helper', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', 'doctor-command.js'), 'utf8'); + assert.doesNotMatch(source, /function valueAfter\(/); +}); diff --git a/agentops-cli/test/command-output.test.js b/agentops-cli/test/command-output.test.js new file mode 100644 index 0000000..e2bdc81 --- /dev/null +++ b/agentops-cli/test/command-output.test.js @@ -0,0 +1,84 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + appendJsonlFile, + jsonOutput, + jsonlOutput, + writeJson, + writeJsonFile, + writeJsonlFile, + writeJsonOrRender +} = require('../src/lib/command-output'); + +function createOutput() { + const chunks = []; + return { + chunks, + stdout: { + write(chunk) { + chunks.push(chunk); + } + } + }; +} + +test('command output helpers write pretty JSON with a trailing newline', () => { + const output = createOutput(); + + assert.equal(jsonOutput({ ok: true }), '{\n "ok": true\n}\n'); + writeJson({ count: 2 }, output.stdout); + + assert.deepEqual(output.chunks, ['{\n "count": 2\n}\n']); +}); + +test('command output helpers choose JSON or rendered text', () => { + const json = createOutput(); + const text = createOutput(); + + writeJsonOrRender({ ok: true }, true, value => `rendered ${value.ok}\n`, json.stdout); + writeJsonOrRender({ ok: true }, false, value => `rendered ${value.ok}\n`, text.stdout); + + assert.deepEqual(json.chunks, ['{\n "ok": true\n}\n']); + assert.deepEqual(text.chunks, ['rendered true\n']); +}); + +test('command output helpers write pretty JSON files and create parent directories', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-command-output-')); + const file = path.join(dir, 'nested', 'output.json'); + + const written = writeJsonFile(file, { ok: true }); + + assert.equal(written, file); + assert.equal(fs.readFileSync(file, 'utf8'), '{\n "ok": true\n}\n'); +}); + +test('command output helpers format JSONL rows with predictable newline behavior', () => { + assert.equal(jsonlOutput([{ id: 1 }, { id: 2 }]), '{"id":1}\n{"id":2}\n'); + assert.equal(jsonlOutput([]), ''); + assert.equal(jsonlOutput([], { trailingNewline: true }), '\n'); +}); + +test('command output helpers write JSONL files and create parent directories', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-command-output-')); + const file = path.join(dir, 'nested', 'output.jsonl'); + + const written = writeJsonlFile(file, [{ ok: true }]); + + assert.equal(written, file); + assert.equal(fs.readFileSync(file, 'utf8'), '{"ok":true}\n'); +}); + +test('command output helpers append JSONL rows and create parent directories', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-command-output-')); + const file = path.join(dir, 'nested', 'output.jsonl'); + + appendJsonlFile(file, { id: 1 }); + const written = appendJsonlFile(file, { id: 2 }); + + assert.equal(written, file); + assert.equal(fs.readFileSync(file, 'utf8'), '{"id":1}\n{"id":2}\n'); +}); diff --git a/agentops-cli/test/command-wrappers.test.js b/agentops-cli/test/command-wrappers.test.js new file mode 100644 index 0000000..02e705d --- /dev/null +++ b/agentops-cli/test/command-wrappers.test.js @@ -0,0 +1,432 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createAlertCommand } = require('../src/lib/alert-command'); +const { createBenchmarkCommand } = require('../src/lib/benchmark-command'); +const { createCustomTelemetryCommand } = require('../src/lib/custom-telemetry-command'); +const { createObservabilityQueryCommand } = require('../src/lib/observability-query-command'); +const { createPluginAssetCommand } = require('../src/lib/plugin-asset-command'); +const { createSmokeCommand } = require('../src/lib/smoke-command'); +const { createValidationCommand } = require('../src/lib/validation-command'); +const { optionValue, optionValues } = require('../src/lib/cli-options'); + +function createOutput() { + let text = ''; + return { + stdout: { + write(chunk) { + text += String(chunk); + return true; + } + }, + text() { + return text; + } + }; +} + +test('command wrapper tests use shared option helpers', () => { + const source = fs.readFileSync(__filename, 'utf8'); + assert.doesNotMatch(source, /^function optionValue\(/m); + assert.doesNotMatch(source, /^function optionValues\(/m); +}); + +test('smoke command writes rendered output and exit code through injected dependencies', async () => { + const output = createOutput(); + let exitCode = null; + const { smokeCommand, smokeCommandNames } = createSmokeCommand({ + stdout: output.stdout, + setExitCode(code) { + exitCode = code; + }, + parseSmokeArgs(args) { + return { json: false, args }; + }, + async agentopsSmoke(options) { + return { ok: false, checked: options.args }; + }, + renderSmoke(result) { + return `smoke:${result.checked.join(',')}`; + } + }); + + assert.ok(smokeCommandNames.includes('smoke')); + + await smokeCommand('smoke', ['--real-copilot']); + + assert.equal(output.text(), 'smoke:--real-copilot'); + assert.equal(exitCode, 1); +}); + +test('validation command writes JSON and exit code through injected dependencies', async () => { + const output = createOutput(); + let exitCode = null; + const { validationCommand, validationCommandNames } = createValidationCommand({ + stdout: output.stdout, + setExitCode(code) { + exitCode = code; + }, + parseLastArg(args, fallback) { + assert.deepEqual(args, ['--json', '--last', '3h']); + assert.equal(fallback, '2h'); + return '3h'; + }, + validateAzure(options) { + assert.deepEqual(options, { + last: '3h', + importDashboards: false, + verifyDashboardContent: false, + production: false, + readinessProfile: '', + remediationPlan: false + }); + return { ok: true, command: 'validate-azure' }; + }, + renderValidateAzure() { + throw new Error('JSON validation output should not call renderer'); + } + }); + + assert.ok(validationCommandNames.includes('validate-azure')); + + await validationCommand('validate-azure', ['--json', '--last', '3h']); + + assert.deepEqual(JSON.parse(output.text()), { ok: true, command: 'validate-azure' }); + assert.equal(exitCode, 0); +}); + +test('custom telemetry command resolves import file and writes injected exit code', async () => { + const output = createOutput(); + let exitCode = null; + const { customTelemetryCommand, customTelemetryCommandNames } = createCustomTelemetryCommand({ + stdout: output.stdout, + setExitCode(code) { + exitCode = code; + }, + parseCustomArgs(args) { + assert.deepEqual(args, ['import', '--file', 'events.jsonl']); + return { subcommand: 'import', file: 'events.jsonl', json: false }; + }, + async agentopsCustomImport(file, options) { + assert.equal(path.basename(file), 'events.jsonl'); + return { ok: false, file, subcommand: options.subcommand }; + }, + renderCustom(result) { + return `custom:${result.subcommand}`; + } + }); + + assert.ok(customTelemetryCommandNames.includes('custom')); + + await customTelemetryCommand('custom', ['import', '--file', 'events.jsonl']); + + assert.equal(output.text(), 'custom:import'); + assert.equal(exitCode, 1); +}); + +test('plugin asset command writes list output through injected stdout', () => { + const output = createOutput(); + const { pluginAssetCommand, pluginAssetCommandNames } = createPluginAssetCommand({ + stdout: output.stdout, + parseSkillsArgs(args) { + assert.deepEqual(args, ['list']); + return { subcommand: 'list' }; + }, + listDefaultAgents() { + return [{ name: 'agentops-orchestrator' }]; + } + }); + + assert.ok(pluginAssetCommandNames.includes('agents')); + + pluginAssetCommand('agents', ['list']); + + assert.deepEqual(JSON.parse(output.text()), { + agents: [{ name: 'agentops-orchestrator' }] + }); +}); + +test('benchmark command writes list output through injected stdout', () => { + const output = createOutput(); + const { benchmarkCommand } = createBenchmarkCommand({ + stdout: output.stdout, + listBenchmarks() { + return [{ id: 'quickstart' }]; + } + }); + + benchmarkCommand(['list']); + + assert.deepEqual(JSON.parse(output.text()), [{ id: 'quickstart' }]); +}); + +test('observability query command writes links through injected stdout', () => { + const output = createOutput(); + const { queryCommand, queryCommandNames } = createObservabilityQueryCommand({ + stdout: output.stdout, + parseLastArg(args, fallback) { + assert.deepEqual(args, ['--last', '1h']); + assert.equal(fallback, '24h'); + return '1h'; + }, + buildLink(kind, id, options) { + return { kind, id, options }; + } + }); + + assert.ok(queryCommandNames.includes('link')); + + queryCommand('link', ['session', 'abc123', '--last', '1h']); + + assert.deepEqual(JSON.parse(output.text()), { + kind: 'session', + id: 'abc123', + options: { last: '1h' } + }); +}); + +test('alert command writes recommendation output through injected stdout', () => { + const output = createOutput(); + const { alertCommand } = createAlertCommand({ + stdout: output.stdout, + parseLastArg(args, fallback) { + assert.deepEqual(args, ['--last', '4h']); + assert.equal(fallback, '14d'); + return '4h'; + }, + alertRecommendations(last) { + return { last, rules: ['failed-spans'] }; + } + }); + + alertCommand(['recommend', '--last', '4h']); + + assert.deepEqual(JSON.parse(output.text()), { + last: '4h', + rules: ['failed-spans'] + }); +}); + +test('alert command resolves route-plan events from injected cwd', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `agentops-alert-${process.pid}-`)); + fs.writeFileSync(path.join(dir, 'events.jsonl'), '{"event":"fired"}\n'); + const output = createOutput(); + const { alertCommand } = createAlertCommand({ + stdout: output.stdout, + cwd: dir, + optionValue, + optionValues, + parseLastArg(args, fallback) { + assert.equal(fallback, '24h'); + return optionValue(args, ['--last']) || fallback; + }, + configuredCloudValues() { + return { resourceGroup: 'rg-agentops' }; + }, + readJsonlRows(file) { + assert.equal(file, path.join(dir, 'events.jsonl')); + return [{ event: 'loaded' }]; + }, + alertRoutePlan(options) { + return options; + } + }); + + alertCommand([ + 'route-plan', + '--rule', 'failed-spans', + '--conversation', 'session-1', + '--owner', 'alice@example.com', + '--target', 'github', + '--service', 'agentops', + '--tz', 'Europe/Dublin', + '--events', 'events.jsonl', + '--last', '6h' + ]); + + assert.deepEqual(JSON.parse(output.text()), { + rule: 'failed-spans', + session: 'session-1', + last: '6h', + owners: ['alice@example.com'], + service: 'agentops', + timezone: 'Europe/Dublin', + targets: ['github'], + resourceGroup: 'rg-agentops', + events: [{ event: 'loaded' }] + }); +}); + +test('workflow command renders selected workflow through injected stdout', () => { + const { createWorkflowCommand } = require('../src/lib/workflow-command'); + const output = createOutput(); + const { workflowCommand } = createWorkflowCommand({ + stdout: output.stdout, + parseWorkflowsArgs(args) { + assert.deepEqual(args, ['show', 'setup']); + return { subcommand: 'show', name: 'setup', json: false }; + }, + agentopsWorkflows() { + return [{ name: 'setup', prompt: 'Set up AgentOps' }]; + }, + renderWorkflow(workflow) { + return `workflow:${workflow.name}`; + } + }); + + workflowCommand(['show', 'setup']); + + assert.equal(output.text(), 'workflow:setup'); +}); + +test('workflow command writes list JSON through injected stdout', () => { + const { createWorkflowCommand } = require('../src/lib/workflow-command'); + const output = createOutput(); + const { workflowCommand } = createWorkflowCommand({ + stdout: output.stdout, + parseWorkflowsArgs(args) { + assert.deepEqual(args, ['list', '--json']); + return { subcommand: 'list', json: true }; + }, + agentopsWorkflows() { + return [{ name: 'setup' }, { name: 'operations' }]; + }, + renderWorkflowsList() { + throw new Error('JSON workflow output should not call renderer'); + } + }); + + workflowCommand(['list', '--json']); + + assert.deepEqual(JSON.parse(output.text()), { + workflows: [{ name: 'setup' }, { name: 'operations' }] + }); +}); + +test('utility command writes doctor checks and exit code through injected dependencies', () => { + const { createUtilityCommand } = require('../src/lib/utility-command'); + const output = createOutput(); + let exitCode = null; + const { utilityCommand, utilityCommandNames } = createUtilityCommand({ + stdout: output.stdout, + setExitCode(code) { + exitCode = code; + }, + doctor(options) { + assert.deepEqual(options, { localOnly: true }); + return [{ name: 'azure-login', ok: false }]; + } + }); + + assert.ok(utilityCommandNames.includes('doctor')); + + utilityCommand('doctor', ['--local-only']); + + assert.deepEqual(JSON.parse(output.text()), { + checks: [{ name: 'azure-login', ok: false }], + ok: false + }); + assert.equal(exitCode, 1); +}); + +test('utility command resolves import-jsonl path and writes JSON through injected stdout', () => { + const { createUtilityCommand } = require('../src/lib/utility-command'); + const output = createOutput(); + const { utilityCommand } = createUtilityCommand({ + stdout: output.stdout, + importJsonl(file) { + assert.equal(path.basename(file), 'events.jsonl'); + return { imported: 2, file }; + } + }); + + utilityCommand('import-jsonl', ['events.jsonl']); + + const result = JSON.parse(output.text()); + assert.equal(result.imported, 2); + assert.equal(path.basename(result.file), 'events.jsonl'); +}); + +test('core command writes setup JSON and exit code through injected dependencies', () => { + const { createCoreCommand } = require('../src/lib/core-command'); + const output = createOutput(); + let exitCode = null; + const { coreCommand, coreCommandNames } = createCoreCommand({ + stdout: output.stdout, + setExitCode(code) { + exitCode = code; + }, + parseSetupArgs(args) { + assert.deepEqual(args, ['--json']); + return { json: true }; + }, + agentopsSetupGuide(options) { + return { ok: true, command: 'setup', json: options.json }; + }, + renderSetupGuide() { + throw new Error('JSON setup output should not call renderer'); + } + }); + + assert.ok(coreCommandNames.includes('setup')); + + coreCommand('setup', ['--json']); + + assert.deepEqual(JSON.parse(output.text()), { ok: true, command: 'setup', json: true }); + assert.equal(exitCode, 0); +}); + +test('core command preserves configure alias exit-code behavior', () => { + const { createCoreCommand } = require('../src/lib/core-command'); + const output = createOutput(); + let exitCode = null; + const { coreCommand, coreCommandNames } = createCoreCommand({ + stdout: output.stdout, + setExitCode(code) { + exitCode = code; + }, + parseConfigureArgs(args) { + assert.deepEqual(args, ['set']); + return { json: false }; + }, + agentopsConfigure() { + return { ok: false, reason: 'missing value' }; + }, + renderConfigure(result) { + return `configure:${result.reason}`; + } + }); + + assert.ok(coreCommandNames.includes('config')); + + coreCommand('config', ['set']); + + assert.equal(output.text(), 'configure:missing value'); + assert.equal(exitCode, 1); +}); + +test('planned command delegates lifecycle plans through injected runner', () => { + const { createPlannedCommand } = require('../src/lib/planned-command'); + const calls = []; + const { plannedCommand, plannedCommandNames } = createPlannedCommand({ + commandPlan(command, args) { + calls.push(['commandPlan', command, args]); + return { command, args, steps: ['run'] }; + }, + runPlannedCommand(plan) { + calls.push(['runPlannedCommand', plan]); + } + }); + + assert.ok(plannedCommandNames.includes('collector')); + + plannedCommand('collector', ['start']); + + assert.deepEqual(calls, [ + ['commandPlan', 'collector', ['start']], + ['runPlannedCommand', { command: 'collector', args: ['start'], steps: ['run'] }] + ]); +}); diff --git a/agentops-cli/test/commands.test.js b/agentops-cli/test/commands.test.js index c9f9f91..48ae2c7 100644 --- a/agentops-cli/test/commands.test.js +++ b/agentops-cli/test/commands.test.js @@ -5,6 +5,8 @@ const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); +const { setEnvForTest } = require('./support/env'); + const repoRoot = path.resolve(__dirname, '..', '..'); function tmpDir(name) { @@ -116,8 +118,7 @@ test('doctorSummary flags stored connection strings and renderDoctor reports blo const legacy = require('../src/legacy'); const collector = require('../src/lib/collector-manager'); const resolver = require('../src/lib/copilot-resolver'); - const originalConfigPath = process.env.AGENTOPS_CONFIG_PATH; - process.env.AGENTOPS_CONFIG_PATH = configPath; + const restoreEnv = setEnvForTest({ AGENTOPS_CONFIG_PATH: configPath }); const restore = [ patch(legacy, 'doctor', () => [{ name: 'base-check', ok: true }]), patch(collector, 'status', async () => ({ @@ -141,8 +142,7 @@ test('doctorSummary flags stored connection strings and renderDoctor reports blo assert.match(renderDoctor(summary), /Doctor found blocking issues/); } finally { restore.reverse().forEach(fn => fn()); - if (originalConfigPath === undefined) delete process.env.AGENTOPS_CONFIG_PATH; - else process.env.AGENTOPS_CONFIG_PATH = originalConfigPath; + restoreEnv(); } }); @@ -207,10 +207,12 @@ test('copilotCommand records fallback envelope when unobserved fallback is allow const resolver = require('../src/lib/copilot-resolver'); const dir = tmpDir('wrapper-envelope'); const eventFile = path.join(dir, 'wrapper-events.jsonl'); - const originalFallback = process.env.AGENTOPS_ALLOW_UNOBSERVED_FALLBACK; - const originalEventsPath = process.env.AGENTOPS_WRAPPER_EVENTS_PATH; - process.env.AGENTOPS_ALLOW_UNOBSERVED_FALLBACK = '1'; - process.env.AGENTOPS_WRAPPER_EVENTS_PATH = eventFile; + const restoreEnv = setEnvForTest({ + AGENTOPS_ALLOW_UNOBSERVED_FALLBACK: '1', + AGENTOPS_WRAPPER_EVENTS_PATH: eventFile, + AGENTOPS_DURABLE_SPOOL_DIR: path.join(dir, 'delivery-spool'), + AGENTOPS_CONFIG_PATH: path.join(dir, 'missing-config.json') + }); let spawned = null; const restore = [ patch(collector, 'status', async () => ({ running: false })), @@ -234,17 +236,15 @@ test('copilotCommand records fallback envelope when unobserved fallback is allow 'agentops.wrapper.fallback_unobserved', 'agentops.run.end' ]); - assert.equal(byName['agentops.wrapper.fallback_unobserved'].Reason, 'collector unavailable in test'); + assert.equal(byName['agentops.wrapper.fallback_unobserved'].ReasonCategory, 'collector_start_failed'); + assert.doesNotMatch(JSON.stringify(events), /collector unavailable in test/); assert.equal(byName['agentops.run.end'].FallbackUnobserved, true); assert.equal(spawned.env.AGENTOPS_WRAPPER_FALLBACK_UNOBSERVED, 'true'); assert.equal(spawned.env.AGENTOPS_WRAPPER_RUN_ID, byName['agentops.run.start'].RunId); assert.equal(spawned.env.AGENTOPS_WRAPPER_SESSION_ID, byName['agentops.run.start'].SessionId); } finally { restore.reverse().forEach(fn => fn()); - if (originalFallback === undefined) delete process.env.AGENTOPS_ALLOW_UNOBSERVED_FALLBACK; - else process.env.AGENTOPS_ALLOW_UNOBSERVED_FALLBACK = originalFallback; - if (originalEventsPath === undefined) delete process.env.AGENTOPS_WRAPPER_EVENTS_PATH; - else process.env.AGENTOPS_WRAPPER_EVENTS_PATH = originalEventsPath; + restoreEnv(); } }); @@ -415,6 +415,15 @@ test('static-check script validates repo syntax and local docs links', () => { assert.ok(summary.checked.markdown > 0); }); +test('native OTLP trace smoke uses strict-allowlisted correlation fields', () => { + const script = fs.readFileSync(path.join(repoRoot, 'scripts', 'otlp-smoke-trace.sh'), 'utf8'); + + assert.match(script, /agentops\.e2e\.id/); + assert.match(script, /agentops\.custom_event_id/); + assert.match(script, /OTelSpans/); + assert.doesNotMatch(script, /agentops\.smoke_id/); +}); + test('security audit reports production readiness checks as JSON', () => { const result = childProcess.spawnSync(process.execPath, [ path.join(repoRoot, 'agentops-cli', 'src', 'index.js'), @@ -436,6 +445,7 @@ test('security audit reports production readiness checks as JSON', () => { assert.ok(audit.checks.some(check => check.name === 'dashboard-content-guardrails' && check.ok)); assert.ok(audit.checks.some(check => check.name === 'content-capture-operational-guardrails' && check.ok)); assert.ok(audit.checks.some(check => check.name === 'dashboard-evidence-disclaimer' && check.ok)); + assert.ok(audit.checks.some(check => check.name === 'collector-persistent-queue-security' && check.ok)); }); test('security posture reports OWASP and ASVS control coverage as JSON', () => { diff --git a/agentops-cli/test/content-status.test.js b/agentops-cli/test/content-status.test.js new file mode 100644 index 0000000..95ea213 --- /dev/null +++ b/agentops-cli/test/content-status.test.js @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { generateDemoData, writeDemoData } = require('../src/lib/demo/agentops-demo-data'); +const { buildContentStatus, captureModeSummary, renderContentStatus, renderOptInGuide } = require('../src/lib/content-status'); + +test('content status library builds opt-in status and renderer output', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-content-lib-')); + try { + const demo = generateDemoData({ runs: 2, withContent: true }); + writeDemoData(demo, tempDir); + + const blocked = buildContentStatus({ dir: tempDir, allowContent: false }); + assert.equal(blocked.ok, false); + assert.equal(blocked.content_rows, 4); + assert.deepEqual(blocked.capture_modes, { redacted: 4 }); + assert.match(blocked.transcript_viewer_url, /viewPanel=26/); + + const rendered = renderContentStatus(blocked); + assert.match(rendered, /AgentOps content capture status/); + assert.match(rendered, /Allowed for ingest: no/); + + assert.deepEqual(captureModeSummary([{ CaptureMode: 'full' }, {}, { CaptureMode: 'full' }]), { + full: 2, + unknown: 1 + }); + assert.match(renderOptInGuide(), /restricted to approved viewers/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/copilot-command.test.js b/agentops-cli/test/copilot-command.test.js new file mode 100644 index 0000000..453aa7e --- /dev/null +++ b/agentops-cli/test/copilot-command.test.js @@ -0,0 +1,83 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + removeAgentOpsCopilotFlags, + receiptDeliveryText, + renderCopilotReceipt, + safeReceiptName, + wrapperReplayUrl +} = require('../src/lib/copilot/command'); + +test('copilot command helpers strip wrapper flags and build replay links', () => { + assert.deepEqual( + removeAgentOpsCopilotFlags(['--collector-mode', 'none', '--privacy=strict', '--unsafe-no-collector', '-p', 'hello']), + ['-p', 'hello'] + ); + assert.equal( + wrapperReplayUrl( + { runId: 'wrapper run,=id', sessionId: 'wrapper session' }, + { v2_replay_url: 'https://grafana.example/d/agentops-v2-run-replay?var-session_id=old' } + ), + 'https://grafana.example/d/agentops-v2-run-replay?var-run_id=wrapper+run%2C%3Did&var-session_id=wrapper+session' + ); +}); + +test('normal Copilot receipt is immediate, privacy-safe, and honest about ingestion', () => { + const envelope = { runId: 'run-123', sessionId: 'session-123' }; + const observed = renderCopilotReceipt({ + envelope, + exitCode: 0, + privacy: 'strict', + replayUrl: 'https://grafana.example/d/run-story', + wallDurationMs: 7100, + agent: 'agentops-kitchen-sink-smoke', + summary: { + sessionId: 'native-session', + model: 'gpt-5.6-sol', + inputTokens: 20226, + outputTokens: 8, + aiCredits: 12.664, + apiDurationMs: 2012, + tools: ['read_file'], + filesModified: 1, + linesAdded: 4, + linesRemoved: 2 + } + }); + const unobserved = renderCopilotReceipt({ envelope, exitCode: 2, fallbackUnobserved: true }); + + assert.match(observed, /AgentOps receipt/); + assert.match(observed, /Completed · exit 0/); + assert.match(observed, /Best effort · delivery not yet confirmed/); + assert.match(observed, /Coverage\s+Detailed Copilot activity is best effort; use agentops latest to check what arrived/); + assert.doesNotMatch(observed, /Recorded locally|Visible in Azure/); + assert.match(observed, /AgentOps did not record prompts, answers, code, or tool payloads/); + assert.match(observed, /Details\s+agentops latest/); + assert.match(observed, /Run Story\s+https:\/\/grafana\.example/); + assert.match(observed, /Copilot\s+native-session/); + assert.match(observed, /Details\s+agentops latest · run run-123 · wrapper session session-123/); + assert.match(observed, /Agent\s+agentops-kitchen-sink-smoke/); + assert.match(observed, /Tokens\s+20,226 in · 8 out/); + assert.match(observed, /AI credits\s+12\.7/); + assert.match(observed, /Time\s+7\.1s wall · 2\.0s API/); + assert.match(observed, /Tools\s+1 · read_file/); + assert.match(observed, /Code\s+1 files · \+4 \/ -2/); + assert.match(renderCopilotReceipt({ + envelope, + exitCode: 0, + privacy: 'strict', + requestedPrivacy: 'compat' + }), /Privacy\s+strict effective · compat requested/); + assert.match(unobserved, /Needs attention · exit 2/); + assert.match(unobserved, /Not observed · collector unavailable/); + assert.equal(safeReceiptName('unsafe\nagent'), ''); +}); + +test('receipt delivery wording separates local receipt evidence from native detail coverage', () => { + assert.match(receiptDeliveryText('local_pending'), /Run receipt saved locally/); + assert.match(receiptDeliveryText('azure_acknowledged'), /accepted by Azure/); + assert.match(receiptDeliveryText('overflow'), /NOT SAVED/); + assert.match(receiptDeliveryText('expired'), /held locally/); + assert.match(receiptDeliveryText('quarantined'), /needs review/); +}); diff --git a/agentops-cli/test/copilot-receipt-session.test.js b/agentops-cli/test/copilot-receipt-session.test.js new file mode 100644 index 0000000..51f17cc --- /dev/null +++ b/agentops-cli/test/copilot-receipt-session.test.js @@ -0,0 +1,55 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + changedCopilotSession, + snapshotCopilotSessions, + summarizeSessionEvents +} = require('../src/lib/copilot/receipt-session'); + +test('receipt session summary extracts only safe operational metadata', () => { + const summary = summarizeSessionEvents([ + { type: 'session.start', data: { sessionId: 'session-safe', context: { cwd: '/private/repo' } } }, + { type: 'session.model_change', data: { newModel: 'gpt-5.6-sol' } }, + { type: 'assistant.message', data: { content: 'SECRET_FAKE_VALUE', toolRequests: [{ name: 'skill', arguments: { name: 'private' } }, { name: 'read_file', arguments: { path: '/secret' } }] } }, + { type: 'session.shutdown', data: { + totalNanoAiu: 12600000000, + totalApiDurationMs: 2012, + tokenDetails: { input: { tokenCount: 3 }, cache_read: { tokenCount: 5 }, cache_write: { tokenCount: 20 }, output: { tokenCount: 8 } }, + codeChanges: { filesModified: ['secret.js'], linesAdded: 4, linesRemoved: 2 } + } } + ]); + + assert.deepEqual(summary, { + sessionId: 'session-safe', + model: 'gpt-5.6-sol', + inputTokens: 28, + outputTokens: 8, + aiCredits: 12.6, + apiDurationMs: 2012, + tools: ['skill', 'read_file'], + filesModified: 1, + linesAdded: 4, + linesRemoved: 2 + }); + assert.doesNotMatch(JSON.stringify(summary), /SECRET|private|secret\.js|\/secret/); +}); + +test('changed session detection selects the newest created or updated event stream', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-receipt-session-')); + const root = path.join(home, '.copilot', 'session-state'); + fs.mkdirSync(path.join(root, 'session-a'), { recursive: true }); + const file = path.join(root, 'session-a', 'events.jsonl'); + fs.writeFileSync(file, `${JSON.stringify({ type: 'session.start', data: { sessionId: 'session-a' } })}\n`); + const before = snapshotCopilotSessions(root); + fs.appendFileSync(file, `${JSON.stringify({ type: 'session.model_change', data: { newModel: 'gpt-5.6-sol' } })}\n`); + const now = new Date(Date.now() + 1000); + fs.utimesSync(file, now, now); + + const summary = changedCopilotSession(before, root); + assert.equal(summary.sessionId, 'session-a'); + assert.equal(summary.model, 'gpt-5.6-sol'); +}); diff --git a/agentops-cli/test/copilot-session-command.test.js b/agentops-cli/test/copilot-session-command.test.js new file mode 100644 index 0000000..4a8fe56 --- /dev/null +++ b/agentops-cli/test/copilot-session-command.test.js @@ -0,0 +1,42 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + parseCopilotSessionArgs, + renderCopilotSessionEnrichment +} = require('../src/lib/copilot/session-command'); + +test('copilot session command library parses args and renders enrichment summary', () => { + assert.deepEqual(parseCopilotSessionArgs([ + 'enrich', + 'session-1', + '--file', + 'events.jsonl', + '--sidecar', + 'sidecar-events.jsonl', + '--endpoint', + 'http://127.0.0.1:4319', + '--id', + 'import-1', + '--dry-run', + '--json' + ]), { + subcommand: 'enrich', + sessionId: 'session-1', + file: 'events.jsonl', + sidecarFile: 'sidecar-events.jsonl', + endpoint: 'http://127.0.0.1:4319', + id: 'import-1', + dryRun: true, + json: true + }); + assert.match(renderCopilotSessionEnrichment({ + session_id: 'session-1', + source_file: 'events.jsonl', + enriched_rows: 2, + dry_run: true, + ok: true, + event_counts: { 'agent.selected': 1, 'mcp.tools.call': 1 }, + next: ['agentops open latest'] + }), /- mcp\.tools\.call: 1/); +}); diff --git a/agentops-cli/test/core-helpers.test.js b/agentops-cli/test/core-helpers.test.js index 487f0bb..36d6eae 100644 --- a/agentops-cli/test/core-helpers.test.js +++ b/agentops-cli/test/core-helpers.test.js @@ -10,6 +10,7 @@ const { inferMcpServer, inferMcpTool, readCopilotSessionEvents, + readScriptSidecarEvents, safeName, toolRisk } = require('../src/lib/copilot/session-enricher'); @@ -35,6 +36,7 @@ const { validateProcessorFragment } = require('../src/lib/collector-artifacts'); const shell = require('../src/lib/shell'); +const { writeJsonlFixture } = require('./support/json-fixtures'); function withTempDir(fn) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-core-helpers-')); @@ -45,10 +47,6 @@ function withTempDir(fn) { } } -function writeJsonl(filePath, rows) { - fs.writeFileSync(filePath, rows.map(row => JSON.stringify(row)).join('\n') + '\n'); -} - test('copilot session paths reject unsafe session ids and preserve safe ids', () => { assert.equal(safeName(' agent-01_./:@* '), 'agent-01_./:@*'); assert.equal(safeName('../bad session', 'fallback'), 'fallback'); @@ -65,7 +63,7 @@ test('copilot session paths reject unsafe session ids and preserve safe ids', () test('copilot session reader parses jsonl and surfaces invalid file/json paths', () => { withTempDir((dir) => { const file = path.join(dir, 'events.jsonl'); - writeJsonl(file, [{ type: 'skill.invoked', data: { name: 'review' } }]); + writeJsonlFixture(file, [{ type: 'skill.invoked', data: { name: 'review' } }]); assert.deepEqual(readCopilotSessionEvents(file), [ { type: 'skill.invoked', data: { name: 'review' } } ]); @@ -76,6 +74,40 @@ test('copilot session reader parses jsonl and surfaces invalid file/json paths', }); }); +test('script sidecar reader keeps only safe exact-session execution metadata', () => { + withTempDir((dir) => { + const file = path.join(dir, 'sidecar-events.jsonl'); + writeJsonlFixture(file, [ + { + timestamp: '2026-08-03T17:30:00.000Z', + type: 'agentops.script.executed', + data: { + sessionId: 'session-one', + scriptName: 'pre-tool-policy', + hookType: 'preToolUse', + outcome: 'allowed', + contentCapture: false + } + }, + { type: 'agentops.script.executed', data: { sessionId: 'session-two', scriptName: 'other' } }, + { type: 'unrelated', data: { sessionId: 'session-one' } } + ]); + + const events = readScriptSidecarEvents(file, 'session-one'); + assert.equal(events.length, 1); + assert.equal(events[0].data.scriptName, 'pre-tool-policy'); + assert.deepEqual(readScriptSidecarEvents(path.join(dir, 'missing.jsonl'), 'session-one'), []); + + const rows = enrichCopilotSessionEvents(events, { sessionId: 'session-one' }); + assert.equal(rows.length, 1); + assert.equal(rows[0].event, 'script.executed'); + assert.equal(rows[0].attributes['agentops.script.name'], 'pre-tool-policy'); + assert.equal(rows[0].attributes['github.copilot.hook.type'], 'preToolUse'); + assert.equal(rows[0].attributes['content.capture.enabled'], false); + assert.doesNotMatch(JSON.stringify(rows), /prompt|toolArgs|command/); + }); +}); + test('copilot MCP inference covers explicit, Azure, mcp namespace, and slash forms', () => { assert.equal(inferMcpServer({ mcp_server_name: 'github' }), 'github'); assert.equal(inferMcpTool({ mcp_tool_name: 'search_issues' }), 'search_issues'); @@ -116,6 +148,34 @@ test('copilot session enrichment emits MCP metadata, skill requests, and failed assert.equal(rows[5].event, 'hook.started'); }); +test('copilot session enrichment preserves ordered fleet subagent lifecycle metadata', () => { + const rows = enrichCopilotSessionEvents([ + { id: 'start-1', type: 'subagent.started', data: { agentName: 'explore', model: 'claude-haiku-4.5', toolCallId: 'call-one' } }, + { id: 'start-2', type: 'subagent.started', data: { agentName: 'explore', model: 'claude-haiku-4.5', toolCallId: 'call-two' } }, + { id: 'done-1', type: 'subagent.completed', data: { agentName: 'explore', model: 'claude-haiku-4.5', toolCallId: 'call-one', durationMs: 4000, totalTokens: 1234, totalToolCalls: 1 } }, + { id: 'done-2', type: 'subagent.completed', data: { agentName: 'explore', model: 'claude-haiku-4.5', toolCallId: 'call-two', durationMs: 7000, totalTokens: 2345, totalToolCalls: 1 } } + ], { sessionId: 'fleet-session' }); + + assert.deepEqual(rows.map(row => row.event), [ + 'subagent.started', + 'subagent.started', + 'subagent.completed', + 'subagent.completed' + ]); + assert.equal(rows[0].parentAgent, 'github-copilot-cli'); + assert.equal(rows[0].attributes['agentops.sub_agent.name'], 'explore'); + assert.equal(rows[0].attributes['gen_ai.request.model'], 'claude-haiku-4.5'); + assert.equal(rows[0].delegationId, rows[2].delegationId); + assert.equal(rows[1].delegationId, rows[3].delegationId); + assert.notEqual(rows[0].delegationId, rows[1].delegationId); + assert.equal(rows[2].custom['agentops.custom.duration_ms'], 4000); + assert.equal(rows[2].custom['agentops.custom.total_tokens'], 1234); + assert.equal(rows[2].attributes['agentops.subagent.duration_ms'], 4000); + assert.equal(rows[2].attributes['agentops.subagent.total_tokens'], 1234); + assert.equal(rows[2].attributes['agentops.subagent.tool_count'], 1); + assert.doesNotMatch(JSON.stringify(rows), /call-one|call-two/); +}); + test('tool risk classifies edge cases without content capture', () => { assert.equal(toolRisk('write_file'), 'write-file'); assert.equal(toolRisk('get_secret'), 'secret-access'); @@ -179,9 +239,9 @@ test('explain helpers pick latest runs and handle missing or invalid file inputs const runsFile = path.join(dir, 'runs.jsonl'); const evalsFile = path.join(dir, 'evals.jsonl'); const insightsFile = path.join(dir, 'insights.jsonl'); - writeJsonl(runsFile, [older, newer]); - writeJsonl(evalsFile, [{ RunId: 'new', EvalOverall: 55, EvalBucket: 'weak', EvalReason: 'low score' }]); - writeJsonl(insightsFile, [{ RunId: 'new', Severity: 'high', InsightType: 'failure', Summary: 'High risk', SuggestedNextStep: 'Fix it' }]); + writeJsonlFixture(runsFile, [older, newer]); + writeJsonlFixture(evalsFile, [{ RunId: 'new', EvalOverall: 55, EvalBucket: 'weak', EvalReason: 'low score' }]); + writeJsonlFixture(insightsFile, [{ RunId: 'new', Severity: 'high', InsightType: 'failure', Summary: 'High risk', SuggestedNextStep: 'Fix it' }]); const latest = explainFromFiles({ runsFile, evalsFile, insightsFile, runId: 'latest' }); assert.equal(latest.run.RunId, 'new'); @@ -205,7 +265,7 @@ test('privacy helpers redact secret-like env values and drop unsafe content attr AZURE_CLIENT_SECRET: 'secret-value', GITHUB_TOKEN: 'token-value', OPENAI_API_KEY: 'key-value', - HOME: '/Users/example' + HOME: '/home/example' }); assert.deepEqual(summary, { @@ -257,6 +317,31 @@ test('collector artifact helpers validate processor fragments and poison fixture }); }); +test('collector compatibility configs hash Copilot identity and repository metadata', () => { + const repoRoot = path.resolve(__dirname, '../..'); + const configNames = [ + 'otelcol.local.compat.yaml', + 'otelcol.azuremonitor.compat.yaml', + 'otelcol.local.yaml', + 'otelcol.azuremonitor.yaml', + 'otelcol.binary.compat.yaml' + ]; + const sensitiveMetadata = [ + 'enduser.pseudo.id', + 'github.copilot.git.repository', + 'github.copilot.github.org', + 'github.copilot.git.branch', + 'github.copilot.git.commit_sha' + ]; + + for (const configName of configNames) { + const body = fs.readFileSync(path.join(repoRoot, 'collector', configName), 'utf8').replace(/\r\n/g, '\n'); + for (const key of sensitiveMetadata) { + assert.ok(body.includes(`- key: ${key}\n action: hash`)); + } + } +}); + test('shell helpers find candidates, check executability, and merge env for local commands', () => { withTempDir((dir) => { const binA = path.join(dir, 'a'); diff --git a/agentops-cli/test/dashboard-import.test.js b/agentops-cli/test/dashboard-import.test.js new file mode 100644 index 0000000..bede336 --- /dev/null +++ b/agentops-cli/test/dashboard-import.test.js @@ -0,0 +1,55 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); + +const { dashboardImportPlan, runDashboardImport } = require('../src/lib/dashboard-import'); +const TEST_APPROVED_SUBSCRIPTION_ID = '11111111-1111-4111-8111-111111111111'; + +test('dashboard import library plans and runs managed Grafana imports', () => { + const plan = dashboardImportPlan([], { + env: { + AZURE_RESOURCE_GROUP: 'rg-agentops-dev', + GRAFANA_NAME: 'graf-agentops-dev' + } + }); + + assert.equal(plan.ok, true); + assert.equal(plan.dry_run, true); + assert.equal(plan.v2_only, true); + assert.equal(plan.folder, 'AgentOps for Azure'); + assert.ok(plan.files.every(file => file.includes(`${path.sep}dashboards${path.sep}v2${path.sep}`))); + + const calls = []; + const result = runDashboardImport(['--yes', '--resource-group', 'rg-agentops-dev', '--grafana-name', 'graf-agentops-dev'], { + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, + spawnSync: (command, args, options) => { + calls.push({ command, args, options }); + if (command === 'az') return { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' }; + return { status: 0, stdout: 'imported\n', stderr: '' }; + } + }); + + assert.equal(result.ok, true); + assert.equal(result.dry_run, false); + assert.equal(calls.length, 2); + assert.equal(calls[0].command, 'az'); + assert.match(calls[1].command, /grafana-import-dashboard\.sh$/); + assert.equal(calls[1].options.env.AGENTOPS_V2_ONLY, 'true'); + assert.equal(calls[1].options.env.GRAFANA_NAME, 'graf-agentops-dev'); +}); + +test('dashboard import refuses execution when the active subscription differs', () => { + const calls = []; + const result = runDashboardImport(['--yes'], { + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, + spawnSync(command, args) { + calls.push({ command, args }); + return { status: 0, stdout: 'wrong-sub\n', stderr: '' }; + } + }); + + assert.equal(result.ok, false); + assert.equal(result.executed, false); + assert.match(result.errors[0], /refused the write/); + assert.equal(calls.length, 1); +}); diff --git a/agentops-cli/test/dashboard-kql-check.test.js b/agentops-cli/test/dashboard-kql-check.test.js new file mode 100644 index 0000000..f8ba4dc --- /dev/null +++ b/agentops-cli/test/dashboard-kql-check.test.js @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + dashboardKqlCheck, + substituteGrafanaMacros +} = require('../src/lib/dashboard-kql-check'); + +test('dashboard KQL helpers substitute Grafana macros and variables', () => { + const query = substituteGrafanaMacros( + 'AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where RunId == "$run_id" and Model == "${model}" | summarize count() by $__interval', + { last: '2h' } + ); + + assert.match(query, /ago\(2h\)/); + assert.match(query, /now\(\)/); + assert.match(query, /RunId == "__all"/); + assert.match(query, /Model == "__all"/); + assert.match(query, /summarize count\(\) by 1h/); + assert.match(query, /\| take 5$/); +}); + +test('dashboard KQL check supports injected dashboard bodies and query runner', () => { + const result = dashboardKqlCheck(['--require-rows', '--workspace-id', 'workspace-123'], { + dashboardBodies: () => [{ + body: { + uid: 'agentops-v2-home', + panels: [{ + title: 'Session Health', + targets: [{ query: 'AgentOpsRunSummary_CL | where RunId == "$run_id"' }] + }] + } + }], + smokePanels: [{ uid: 'agentops-v2-home', panel: 'Session Health', requireRows: true }], + runQuery: (query, options) => ({ + ok: query.includes('__all') && options.workspaceId === 'workspace-123', + rows: [{ ok: true }] + }) + }); + + assert.equal(result.ok, true, result.errors.join('\n')); + assert.equal(result.checks.length, 1); + assert.deepEqual(result.checks[0], { + uid: 'agentops-v2-home', + panel: 'Session Health', + ok: true, + rows: 1, + require_rows: true, + error: '' + }); +}); diff --git a/agentops-cli/test/dashboard-validation.test.js b/agentops-cli/test/dashboard-validation.test.js new file mode 100644 index 0000000..b5f9f84 --- /dev/null +++ b/agentops-cli/test/dashboard-validation.test.js @@ -0,0 +1,67 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + dashboardJsonFiles, + queryFromPanel, + validateDashboardFilters, + validateDashboardLinks, + validateDashboardUx, + validateDashboards, + v2DashboardBodies +} = require('../src/lib/dashboard-validation'); + +test('dashboard validation helpers inspect V2 dashboard contracts directly', () => { + const files = dashboardJsonFiles(); + const dashboards = v2DashboardBodies(); + const home = dashboards.find(item => item.body.uid === 'agentops-v2-home'); + const runs = dashboards.find(item => item.body.uid === 'agentops-v2-runs-explorer'); + const replay = dashboards.find(item => item.body.uid === 'agentops-v2-run-replay'); + const sessionHealth = (home.body.panels || []).find(panel => panel.title === 'Session Health'); + const runsTable = (runs.body.panels || []).find(panel => panel.title === 'Runs'); + const orderedTimeline = (replay.body.panels || []).find(panel => panel.title === 'Ordered timeline'); + const lineage = (replay.body.panels || []).find(panel => panel.title === 'Agent, skill, and MCP lineage'); + const primaryTitles = Object.fromEntries(dashboards + .filter(item => ['agentops-v2-home', 'agentops-v2-runs-explorer', 'agentops-v2-run-replay', 'agentops-v2-safety-privacy-policy'].includes(item.body.uid)) + .map(item => [item.body.uid, item.body.title])); + + assert.ok(files.some(file => file.endsWith('01-agentops-home.json'))); + assert.ok(home); + assert.deepEqual(primaryTitles, { + 'agentops-v2-home': 'Today', + 'agentops-v2-runs-explorer': 'Runs', + 'agentops-v2-run-replay': 'Run Story', + 'agentops-v2-safety-privacy-policy': 'Privacy' + }); + assert.match(queryFromPanel(sessionHealth), /AgentOpsRunSummary_CL/); + assert.match(queryFromPanel(sessionHealth), /Delivery='Visible in Azure'/); + assert.match(queryFromPanel(sessionHealth), /Coverage='AgentOps managed'/); + assert.match(queryFromPanel(sessionHealth), /Coverage='Native best effort'/); + assert.match(queryFromPanel(runsTable), /project TimeGenerated, Delivery, Coverage/); + const timelineQuery = queryFromPanel(orderedTimeline); + assert.match(timelineQuery, /Sequence, EventId, ParentEventId, Delivery, Coverage, AttributionConfidence, AttributionGap/); + assert.match(timelineQuery, /McpToolName, ToolName, CommandName, ScriptName/); + assert.match(timelineQuery, /InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd/); + assert.match(timelineQuery, /PermissionKind, PermissionDecision, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike/); + assert.match(timelineQuery, /order by TimeGenerated asc, SequenceSort asc, EventId asc/); + assert.match(queryFromPanel(lineage), /Status in \('failed', 'error', 'denied', 'blocked'\)/); + assert.match(queryFromPanel(lineage), /MissingAttribution/); + const homeText = (home.body.panels || []) + .filter(panel => panel.type === 'text') + .map(panel => panel.options?.content || '') + .join('\n'); + assert.match(homeText, /These runs are visible in Azure/); + assert.match(homeText, /agentops delivery status/); + assert.match((replay.body.panels || []).find(panel => panel.type === 'text').options.content, /with all three set to All, panels can mix matching runs/); + assert.equal((runs.body.panels || []).some(panel => panel.title === 'Token use'), true); + assert.equal((runs.body.panels || []).some(panel => panel.title === 'Cost and tokens'), false); + for (const dashboard of [home, runs, replay, dashboards.find(item => item.body.uid === 'agentops-v2-safety-privacy-policy')]) { + for (const panel of dashboard.body.panels.filter(panel => panel.type !== 'text')) { + assert.ok(String(panel.description || '').trim(), `${dashboard.body.title} panel ${panel.title} should explain its meaning`); + } + } + assert.equal(validateDashboards().ok, true); + assert.equal(validateDashboardLinks().ok, true); + assert.equal(validateDashboardFilters().ok, true); + assert.equal(validateDashboardUx().ok, true); +}); diff --git a/agentops-cli/test/dashboard-verify.test.js b/agentops-cli/test/dashboard-verify.test.js new file mode 100644 index 0000000..e2d792a --- /dev/null +++ b/agentops-cli/test/dashboard-verify.test.js @@ -0,0 +1,15 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { dashboardVerify } = require('../src/lib/dashboard-verify'); + +test('dashboardVerify combines static dashboard checks without live KQL by default', () => { + const result = dashboardVerify([]); + + assert.equal(result.ok, true); + assert.equal(result.live, false); + assert.ok(result.summary.dashboards > 0); + assert.ok(result.summary.checked_links > 0); + assert.equal(result.summary.kql_checks, 0); + assert.deepEqual(result.errors, []); +}); diff --git a/agentops-cli/test/delivery-command.test.js b/agentops-cli/test/delivery-command.test.js new file mode 100644 index 0000000..e4a0dcd --- /dev/null +++ b/agentops-cli/test/delivery-command.test.js @@ -0,0 +1,90 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { renderDelivery, runDeliveryCommand } = require('../src/lib/delivery-command'); + +function fakeDelivery() { + return { + status: () => ({ pending: 2, quarantined: 0 }), + async drain(ids, options) { + assert.deepEqual(ids, []); + assert.equal(options.cloud.subscriptionId, '11111111-1111-4111-8111-111111111111'); + return { + ok: true, + configured: true, + state: 'local_pending', + result: { acknowledged: 1, status: { pending: 1, quarantined: 0 } } + }; + } + }; +} + +test('delivery drain is preview-only without explicit yes', async () => { + let created = 0; + const result = await runDeliveryCommand(['drain'], { + createDelivery() { created += 1; return fakeDelivery(); }, + config: {} + }); + assert.equal(created, 1); + assert.equal(result.executed, false); + assert.match(renderDelivery(result), /No Azure request was made/); +}); + +test('delivery status explains local queue, Azure acceptance, and the next action in plain language', () => { + const empty = renderDelivery({ + action: 'status', + state: 'azure_acknowledged', + pending: 0, + quarantined: 0, + expired: 0, + acknowledged_cumulative: 4, + overflow_cumulative: 0, + directory: '/tmp/agentops-delivery' + }); + assert.doesNotMatch(empty, /azure_acknowledged|State:/); + assert.match(empty, /Local queue: nothing waiting/); + assert.match(empty, /Accepted by Azure ingestion: 4 total/); + assert.match(empty, /does not by itself prove the events are searchable yet/); + assert.match(empty, /Next: no delivery action is needed/); + + const waiting = renderDelivery({ + action: 'status', + state: 'local_pending', + pending: 2, + quarantined: 0, + expired: 0, + directory: '/tmp/agentops-delivery' + }); + assert.match(waiting, /2 receipt events waiting to send/); + assert.match(waiting, /agentops delivery drain to preview/); +}); + +test('empty drain preview says there is nothing to send', () => { + const output = renderDelivery({ action: 'drain', executed: false, before: { pending: 0 } }); + assert.match(output, /Nothing is waiting, so there is nothing to send/); + assert.doesNotMatch(output, /Run with --yes/); +}); + +test('delivery drain passes exact configured destination only after yes', async () => { + const result = await runDeliveryCommand(['drain', '--yes'], { + createDelivery: fakeDelivery, + env: {}, + config: { + subscriptionId: '11111111-1111-4111-8111-111111111111', + logsIngestionEndpoint: 'https://safe.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-safe' + } + }); + assert.equal(result.executed, true); + assert.equal(result.result.acknowledged, 1); +}); + +test('delivery drain with yes still makes no request when destination is incomplete', async () => { + const result = await runDeliveryCommand(['drain', '--yes'], { + createDelivery: fakeDelivery, + env: {}, + config: { subscriptionId: '11111111-1111-4111-8111-111111111111' } + }); + assert.equal(result.executed, false); + assert.match(result.error, /missing logs ingestion endpoint, DCR immutable ID/); +}); diff --git a/agentops-cli/test/delivery-state.test.js b/agentops-cli/test/delivery-state.test.js new file mode 100644 index 0000000..2cf3281 --- /dev/null +++ b/agentops-cli/test/delivery-state.test.js @@ -0,0 +1,43 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + deliveryStateFromEnqueue, + receiptDeliveryText, + summarizeDeliveryStatus +} = require('../src/lib/delivery-state'); + +test('delivery state maps only proven enqueue outcomes to saved locally', () => { + assert.equal(deliveryStateFromEnqueue({ status: 'pending' }), 'local_pending'); + assert.equal(deliveryStateFromEnqueue({ status: 'deduplicated' }), 'local_pending'); + assert.equal(deliveryStateFromEnqueue({ status: 'overflow' }), 'overflow'); + assert.equal(deliveryStateFromEnqueue({ status: 'unknown' }), 'native_best_effort'); + assert.match(receiptDeliveryText('azure_acknowledged'), /accepted by Azure/); + assert.doesNotMatch(receiptDeliveryText('azure_acknowledged'), /visible/i); +}); + +test('delivery status uses current-file severity and labels counters cumulative', () => { + const summary = summarizeDeliveryStatus({ + pending: 2, + uploading: 1, + quarantined: 1, + expired: 4, + acknowledged: 8, + overflow: 3 + }); + assert.equal(summary.state, 'quarantined'); + assert.equal(summary.pending, 3); + assert.equal(summary.acknowledged_cumulative, 8); + assert.equal(summary.overflow_cumulative, 3); + assert.match(summary.headline, /3 waiting · 1 held for review · 4 expired/); +}); + +test('missing spool status remains honest native best effort', () => { + assert.equal(summarizeDeliveryStatus().state, 'native_best_effort'); +}); + +test('empty queue with prior endpoint acceptance reports accepted, never visible', () => { + const summary = summarizeDeliveryStatus({ acknowledged: 4 }); + assert.equal(summary.state, 'azure_acknowledged'); + assert.doesNotMatch(summary.headline, /visible/i); +}); diff --git a/agentops-cli/test/demo-command.test.js b/agentops-cli/test/demo-command.test.js new file mode 100644 index 0000000..8844f94 --- /dev/null +++ b/agentops-cli/test/demo-command.test.js @@ -0,0 +1,45 @@ +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + demoOptionsFromArgs, + demoVerifyOutputPlan, + parseRuns +} = require('../src/lib/demo-command'); + +test('demo command library parses run counts and scenario flags', () => { + assert.equal(parseRuns('12'), 12); + assert.throws(() => parseRuns('0'), /--runs must be an integer between 1 and 1000/); + assert.deepEqual(demoOptionsFromArgs(['--without-failures', '--with-content']), { + withFailures: false, + withPrivacyDrops: true, + withGithubOutcomes: true, + withContent: true + }); + assert.throws( + () => demoOptionsFromArgs(['--with-github-outcomes', '--without-github-outcomes']), + /either --with-github-outcomes or --without-github-outcomes/ + ); +}); + +test('demo verification is workspace-read-only unless persistent output is explicit', () => { + const preview = demoVerifyOutputPlan([], { repoRoot: '/repo', tempRoot: os.tmpdir() }); + assert.equal(preview.writeArtifacts, false); + assert.equal(preview.artifactMode, 'temporary'); + assert.match(preview.outDir, /agentops-demo-verify-/); + assert.throws( + () => demoVerifyOutputPlan(['--out', '/repo/demo']), + /output paths require --write/ + ); + + const persistent = demoVerifyOutputPlan(['--write', '--out', '/repo/demo', '--insights-out', '/repo/insights'], { + repoRoot: '/repo', + tempRoot: os.tmpdir() + }); + assert.equal(persistent.writeArtifacts, true); + assert.equal(persistent.artifactMode, 'persistent'); + assert.equal(persistent.outDir, path.resolve('/repo/demo')); + assert.equal(persistent.insightsOutDir, path.resolve('/repo/insights')); +}); diff --git a/agentops-cli/test/demo-scenarios.test.js b/agentops-cli/test/demo-scenarios.test.js new file mode 100644 index 0000000..07712d9 --- /dev/null +++ b/agentops-cli/test/demo-scenarios.test.js @@ -0,0 +1,26 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { baseScenarios, chooseScenarios, contextProfile } = require('../src/lib/demo/agentops-demo-scenarios'); + +test('demo scenario helpers filter optional scenario families', () => { + assert.ok(baseScenarios.some(scenario => scenario.status === 'failed')); + assert.ok(baseScenarios.some(scenario => scenario.privacyDrops)); + assert.ok(baseScenarios.some(scenario => scenario.github.opened)); + + assert.equal(chooseScenarios({ withFailures: false }).some(scenario => scenario.status === 'failed'), false); + assert.equal(chooseScenarios({ withPrivacyDrops: false }).some(scenario => scenario.privacyDrops), false); + assert.equal(chooseScenarios({ withGithubOutcomes: false }).some(scenario => scenario.github.opened), false); +}); + +test('demo scenario helpers return deterministic context profiles', () => { + const expensive = contextProfile({ name: 'expensive-failed-run' }, 0); + assert.equal(expensive.ContextWindowPct, 94); + assert.equal(expensive.CacheReadTokens, 25000); + assert.equal(expensive.PermissionWaitMs, 18000); + + const fallback = contextProfile({ name: 'new-scenario' }, 2); + assert.equal(fallback.ContextWindowPct, 46); + assert.equal(fallback.CacheReadTokens, 3726); + assert.equal(fallback.CacheCreationTokens, 758); +}); diff --git a/agentops-cli/test/demo-verify.test.js b/agentops-cli/test/demo-verify.test.js new file mode 100644 index 0000000..d3af12a --- /dev/null +++ b/agentops-cli/test/demo-verify.test.js @@ -0,0 +1,28 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { buildDemoVerifyPayload } = require('../src/lib/demo-verify'); + +test('buildDemoVerifyPayload runs the local V2 control-room proof', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-demo-verify-')); + try { + const payload = buildDemoVerifyPayload({ + runs: 12, + outDir: path.join(tempDir, 'demo'), + insightsOutDir: path.join(tempDir, 'insights') + }); + + assert.equal(payload.ok, true); + assert.equal(payload.demo.runs, 12); + assert.equal(payload.demo.table_counts.AgentOpsRecommendations_CL, 1); + assert.ok(fs.existsSync(payload.insights.eval_file)); + assert.match(payload.open_links.links.replay, /agentops-v2-run-replay/); + assert.match(payload.recommendation.artifact.file, /AgentOpsRecommendations_CL\.jsonl/); + assert.ok(payload.next.some(command => command.includes('agentops azure-ingest plan'))); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/doctor-summary.test.js b/agentops-cli/test/doctor-summary.test.js new file mode 100644 index 0000000..d94fbeb --- /dev/null +++ b/agentops-cli/test/doctor-summary.test.js @@ -0,0 +1,52 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { setEnvForTest } = require('./support/env'); + +function patch(object, key, value) { + const original = object[key]; + object[key] = value; + return () => { + object[key] = original; + }; +} + +test('doctor summary library reports blocking stored connection strings', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-doctor-lib-')); + const configPath = path.join(dir, 'config.json'); + fs.writeFileSync(configPath, 'APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=fake'); + + const legacy = require('../src/legacy'); + const collector = require('../src/lib/collector-manager'); + const resolver = require('../src/lib/copilot-resolver'); + const restoreEnv = setEnvForTest({ AGENTOPS_CONFIG_PATH: configPath }); + const restore = [ + patch(legacy, 'doctor', () => [{ name: 'base-check', ok: true }]), + patch(collector, 'status', async () => ({ + running: true, + mode: 'auto', + effectiveMode: 'binary', + details: ['binary selected'], + safeLocalhostBinding: true, + health: { statusCode: 200 }, + binary: { ok: true, error: null } + })), + patch(resolver, 'resolveCopilotBinary', () => ({ ok: true, path: '/bin/copilot', error: null })) + ]; + + try { + const { doctorSummary, renderDoctor } = require('../src/lib/doctor-summary'); + const summary = await doctorSummary({ localOnly: true }); + + assert.equal(summary.ok, false); + assert.equal(summary.checks.find(item => item.name === 'connection-string-not-on-disk').ok, false); + assert.match(renderDoctor(summary), /connection-string-not-on-disk: failed/); + } finally { + restore.reverse().forEach(fn => fn()); + restoreEnv(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/durable-evidence-spool.test.js b/agentops-cli/test/durable-evidence-spool.test.js new file mode 100644 index 0000000..dada03f --- /dev/null +++ b/agentops-cli/test/durable-evidence-spool.test.js @@ -0,0 +1,308 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { canonicalJson, createDurableEvidenceSpool } = require('../src/lib/azure/durable-evidence-spool'); + +function tempSpool(t, options = {}) { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-durable-spool-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const directory = path.join(parent, 'queue'); + return { directory, spool: createDurableEvidenceSpool({ directory, ...options }) }; +} + +function row(sequence = 1) { + return { + RunId: 'run-safe-1', + SessionId: 'session-safe-1', + Sequence: sequence, + EventName: 'tool.completed', + ToolName: 'shell.test', + Status: 'passed', + PrivacyMode: 'strict', + ContentCaptureMode: 'off' + }; +} + +test('durable evidence spool writes atomic private metadata-only segments', { + skip: process.platform === 'win32' +}, t => { + const { directory, spool } = tempSpool(t); + const queued = spool.enqueue(row()); + + assert.equal(queued.status, 'pending'); + assert.match(queued.event_id, /^event_/); + assert.equal(queued.row_hash.length, 64); + assert.equal(fs.statSync(directory).mode & 0o777, 0o700); + assert.equal(fs.statSync(queued.file).mode & 0o777, 0o600); + assert.equal(fs.readdirSync(directory).some(name => name.endsWith('.tmp')), false); + const persisted = JSON.parse(fs.readFileSync(queued.file, 'utf8')); + assert.equal(persisted.row.EventId, queued.event_id); + assert.equal(persisted.row.Sequence, 1); + assert.equal(persisted.row.PrivacyMode, 'strict'); + assert.equal(persisted.row.ContentCaptureMode, 'off'); +}); + +test('durable evidence spool preserves precise cost without illegal Azure type coercion', t => { + const { spool } = tempSpool(t); + const fractional = spool.enqueue({ ...row(1), EstimatedCostUsd: 0.125 }); + const fractionalRow = JSON.parse(fs.readFileSync(fractional.file, 'utf8')).row; + assert.equal(fractionalRow.EstimatedCostUsdReal, 0.125); + assert.equal(Object.hasOwn(fractionalRow, 'EstimatedCostUsd'), false); + + const integer = spool.enqueue({ ...row(2), EstimatedCostUsd: 2 }); + const integerRow = JSON.parse(fs.readFileSync(integer.file, 'utf8')).row; + assert.equal(integerRow.EstimatedCostUsd, 2); + assert.equal(integerRow.EstimatedCostUsdReal, 2); +}); + +test('durable evidence spool rejects poison, unknown fields, and nested payloads', t => { + const { spool } = tempSpool(t); + + assert.throws(() => spool.enqueue({ ...row(), Prompt: 'SECRET_PROMPT' }), /non-allowlisted.*Prompt/); + assert.throws(() => spool.enqueue({ ...row(), ToolName: { arguments: 'SECRET_TOOL_ARGS' } }), /must be scalar metadata/); + assert.throws(() => spool.enqueue({ ...row(), RunId: '' }), /requires RunId/); + assert.throws(() => spool.enqueue({ ...row(), DurationMs: 'slow' }), /DurationMs must be a non-negative integer/); + assert.throws(() => spool.enqueue({ ...row(), SecretLike: 'false' }), /SecretLike must be boolean/); + assert.throws(() => spool.enqueue({ ...row(), TimeGenerated: 'yesterday-ish' }), /valid datetime/); + assert.throws(() => spool.enqueue(row(), { table: 'UnsafeContent_CL' }), /table is not allowlisted/); + assert.equal(spool.status().pending, 0); +}); + +test('durable evidence spool accepts only canonical tables and safe storage bounds', t => { + const { directory, spool } = tempSpool(t); + assert.throws( + () => spool.enqueue(row(), { table: 'AgentOpsLooksSafeButIsNot_CL' }), + /table is not allowlisted/ + ); + assert.throws(() => createDurableEvidenceSpool({ directory, maxBytes: -1 }), /maxBytes/); + assert.throws( + () => createDurableEvidenceSpool({ directory, ttlMs: 31 * 24 * 60 * 60 * 1000 }), + /ttlMs/ + ); + assert.throws(() => spool.enqueue(row(), { table: 'AgentOpsRunSummary_CL' }), /not allowlisted/); + assert.throws(() => spool.enqueue({ ...row(), SpanId: 'not-in-events-schema' }), /non-allowlisted.*SpanId/); +}); + +test('durable evidence spool rejects symlink roots and segment symlinks', { + skip: process.platform === 'win32' +}, t => { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-durable-symlink-')); + t.after(() => fs.rmSync(parent, { recursive: true, force: true })); + const target = path.join(parent, 'target'); + fs.mkdirSync(target); + const rootLink = path.join(parent, 'queue-link'); + fs.symlinkSync(target, rootLink, 'dir'); + assert.throws(() => createDurableEvidenceSpool({ directory: rootLink }), /not a symlink/); + + const { directory, spool } = tempSpool(t); + const outside = path.join(parent, 'outside.json'); + fs.writeFileSync(outside, '{}'); + fs.symlinkSync(outside, path.join(directory, '0001.pending.json')); + assert.throws(() => spool.status(), /segment must be a regular file/); +}); + +test('durable evidence spool reports bounded overflow and TTL expiry', async t => { + let clock = Date.parse('2026-08-03T12:00:00Z'); + const { spool } = tempSpool(t, { maxBytes: 1, ttlMs: 1000, now: () => clock }); + const overflow = spool.enqueue(row()); + assert.equal(overflow.status, 'overflow'); + assert.equal(spool.status().overflow, 1); + + const second = tempSpool(t, { maxBytes: 100000, ttlMs: 1000, now: () => clock }).spool; + second.enqueue(row()); + clock += 1001; + const drained = await second.drain(async () => ({ status: 200 })); + assert.equal(drained.expired, 1); + assert.equal(drained.status.expired, 1); + assert.equal(drained.status.pending, 0); +}); + +test('durable evidence spool retries network and retryable HTTP failures and honors Retry-After', async t => { + let calls = 0; + const sleeps = []; + const { spool } = tempSpool(t, { sleep: async ms => sleeps.push(ms), now: () => 1000 }); + const queued = spool.enqueue(row()); + + const drained = await spool.drain(async () => { + calls += 1; + if (calls === 1) throw new Error('network unavailable'); + if (calls === 2) return { status: 429, headers: { 'Retry-After': '3' } }; + return { status: 204 }; + }, { maxAttempts: 3 }); + + assert.equal(drained.acknowledged, 1); + assert.deepEqual(drained.acknowledged_event_ids, [queued.event_id]); + assert.equal(drained.status.acknowledged, 1); + assert.equal(drained.status.pending, 0); + assert.deepEqual(sleeps, [250, 3000]); +}); + +test('durable evidence spool caps retry controls and keeps auth failures pending', async t => { + const sleeps = []; + const { spool } = tempSpool(t, { sleep: async ms => sleeps.push(ms) }); + spool.enqueue(row()); + const drained = await spool.drain(async () => ({ status: 403, headers: { 'retry-after': '999999' } }), { maxAttempts: 2 }); + assert.equal(drained.pending, 1); + assert.equal(drained.quarantined, 0); + assert.deepEqual(sleeps, [60000]); + await assert.rejects(spool.drain(async () => ({ status: 503 }), { maxAttempts: 11 }), /maxAttempts/); + await assert.rejects(spool.drain(async () => ({ status: 503 }), { maxAttempts: Infinity }), /maxAttempts/); +}); + +test('durable evidence spool leaves exhausted transient failures pending and quarantines permanent 4xx', async t => { + const { spool } = tempSpool(t, { sleep: async () => {} }); + spool.enqueue(row(1)); + spool.enqueue(row(2)); + let item = 0; + + const drained = await spool.drain(async () => { + item += 1; + return item <= 2 ? { status: 503 } : { status: 400 }; + }, { maxAttempts: 2 }); + + assert.equal(drained.pending, 1); + assert.equal(drained.quarantined, 1); + assert.equal(drained.status.pending, 1); + assert.equal(drained.status.quarantined, 1); +}); + +test('durable evidence spool is restart-safe and deliberately permits duplicate resend before atomic ack', async t => { + const { directory, spool } = tempSpool(t, { claimLeaseMs: 20 }); + const queued = spool.enqueue(row()); + const sent = []; + + await assert.rejects(spool.drain(async (evidence, context) => { + sent.push([evidence.EventId, context.rowHash]); + return { status: 200 }; + }, { + afterUploadBeforeAck() { throw new Error('simulated process crash after remote acceptance'); } + }), /simulated process crash/); + assert.equal(spool.status().pending, 0); + assert.equal(spool.status().uploading, 1); + + await new Promise(resolve => setTimeout(resolve, 30)); + + const restarted = createDurableEvidenceSpool({ directory, claimLeaseMs: 20 }); + const drained = await restarted.drain(async (evidence, context) => { + sent.push([evidence.EventId, context.rowHash]); + return { status: 200 }; + }); + + assert.equal(drained.acknowledged, 1); + assert.equal(restarted.status().pending, 0); + assert.equal(sent.length, 2); + assert.deepEqual(sent[0], sent[1]); + assert.equal(sent[0][0], queued.event_id); +}); + +test('durable evidence spool deduplicates the same pending event across instances and restart', t => { + const { directory, spool } = tempSpool(t); + const first = spool.enqueue(row()); + const secondInstance = createDurableEvidenceSpool({ directory }); + const duplicate = secondInstance.enqueue(row()); + const restarted = createDurableEvidenceSpool({ directory }); + const restartDuplicate = restarted.enqueue(row()); + + assert.equal(first.status, 'pending'); + assert.equal(duplicate.status, 'deduplicated'); + assert.equal(duplicate.deduplicated, true); + assert.equal(duplicate.file, first.file); + assert.equal(restartDuplicate.status, 'deduplicated'); + assert.equal(restartDuplicate.row_hash, first.row_hash); + assert.equal(restarted.status().pending, 1); +}); + +test('two concurrent spool instances claim once and do not corrupt shared state', async t => { + const { directory, spool } = tempSpool(t, { claimLeaseMs: 30 }); + spool.enqueue(row()); + const secondInstance = createDurableEvidenceSpool({ directory, claimLeaseMs: 30 }); + let uploads = 0; + const uploader = async () => { + uploads += 1; + await new Promise(resolve => setTimeout(resolve, 90)); + return { status: 204 }; + }; + + const firstDrain = spool.drain(uploader); + await new Promise(resolve => setTimeout(resolve, 50)); + const secondDrain = secondInstance.drain(uploader); + const [first, second] = await Promise.all([firstDrain, secondDrain]); + + assert.equal(uploads, 1); + assert.equal(first.acknowledged + second.acknowledged, 1); + assert.equal(first.claimed + second.claimed, 1); + assert.equal(spool.status().pending, 0); + assert.equal(spool.status().uploading, 0); + assert.equal(spool.status().acknowledged, 1); + assert.equal(fs.readdirSync(directory).some(name => name.endsWith('.tmp')), false); +}); + +test('durable evidence spool quarantines corrupted segments before upload', async t => { + const { spool } = tempSpool(t); + const queued = spool.enqueue(row()); + const segment = JSON.parse(fs.readFileSync(queued.file, 'utf8')); + segment.row.ToolName = 'tampered'; + fs.writeFileSync(queued.file, JSON.stringify(segment), { mode: 0o600 }); + let uploaded = false; + + const drained = await spool.drain(async () => { + uploaded = true; + return { status: 200 }; + }); + + assert.equal(uploaded, false); + assert.equal(drained.quarantined, 1); + assert.equal(drained.status.quarantined, 1); +}); + +test('durable evidence spool revalidates allowlisted metadata after restart even with a matching hash', async t => { + const { spool } = tempSpool(t); + const queued = spool.enqueue(row()); + const segment = JSON.parse(fs.readFileSync(queued.file, 'utf8')); + segment.row.Prompt = 'SECRET_PROMPT_AFTER_RESTART'; + segment.row_hash = crypto.createHash('sha256').update(canonicalJson(segment.row)).digest('hex'); + fs.writeFileSync(queued.file, JSON.stringify(segment), { mode: 0o600 }); + let uploaded = false; + + const drained = await spool.drain(async () => { + uploaded = true; + return { status: 200 }; + }); + + assert.equal(uploaded, false); + assert.equal(drained.quarantined, 1); + assert.doesNotMatch(JSON.stringify(drained), /SECRET_PROMPT_AFTER_RESTART/); +}); + +test('durable evidence spool quarantines a non-canonical table after restart', async t => { + const { spool } = tempSpool(t); + const queued = spool.enqueue(row()); + const segment = JSON.parse(fs.readFileSync(queued.file, 'utf8')); + segment.table = 'AgentOpsLooksSafeButIsNot_CL'; + fs.writeFileSync(queued.file, JSON.stringify(segment), { mode: 0o600 }); + let uploaded = false; + + const drained = await spool.drain(async () => { + uploaded = true; + return { status: 200 }; + }); + + assert.equal(uploaded, false); + assert.equal(drained.quarantined, 1); +}); + +test('durable evidence spool authenticates immutable envelope metadata and fails closed on invalid TTL', async t => { + const { spool } = tempSpool(t); + const queued = spool.enqueue(row()); + const segment = JSON.parse(fs.readFileSync(queued.file, 'utf8')); + segment.expires_at = 'not-a-date'; + fs.writeFileSync(queued.file, JSON.stringify(segment), { mode: 0o600 }); + let uploaded = false; + const drained = await spool.drain(async () => { uploaded = true; return { status: 204 }; }); + assert.equal(uploaded, false); + assert.equal(drained.quarantined, 1); +}); diff --git a/agentops-cli/test/durable-receipt-schema-guard.test.js b/agentops-cli/test/durable-receipt-schema-guard.test.js new file mode 100644 index 0000000..b79a51e --- /dev/null +++ b/agentops-cli/test/durable-receipt-schema-guard.test.js @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + compareColumns, + durableReceiptSchema, + validateDurableReceiptAzureSchema +} = require('../src/lib/azure/durable-receipt-schema-guard'); + +const columns = Object.entries(durableReceiptSchema).map(([name, type]) => ({ name, type })); + +test('durable receipt schema comparator reports missing and mistyped contract columns', () => { + const result = compareColumns([ + { name: 'EventId', type: 'string' }, + { name: 'Sequence', type: 'string' } + ], { EventId: 'string', Sequence: 'long', EstimatedCostUsd: 'long', EstimatedCostUsdReal: 'real' }); + assert.equal(result.ok, false); + assert.deepEqual(result.drift, [ + { column: 'Sequence', expected: 'long', actual: 'string', issue: 'type_mismatch' }, + { column: 'EstimatedCostUsd', expected: 'long', actual: null, issue: 'missing' }, + { column: 'EstimatedCostUsdReal', expected: 'real', actual: null, issue: 'missing' } + ]); +}); + +test('live durable receipt guard uses read-only table show and DCR list operations', () => { + const calls = []; + const result = validateDurableReceiptAzureSchema({ + resourceGroup: 'rg-agentops-dev', + workspaceName: 'law-agentops-dev', + dcrImmutableId: 'dcr-immutable', + runAz(args) { + calls.push(args); + if (args.includes('table')) { + return { status: 0, stdout: JSON.stringify({ properties: { schema: { columns } } }), stderr: '' }; + } + return { status: 0, stdout: JSON.stringify([{ name: 'dcr-agentops', properties: { + immutableId: 'dcr-immutable', + streamDeclarations: { 'Custom-AgentOpsEvents_CL': { columns } } + } }]), stderr: '' }; + } + }); + assert.equal(result.ok, true); + assert.equal(result.dcr_name, 'dcr-agentops'); + assert.deepEqual(calls[0].slice(0, 5), ['monitor', 'log-analytics', 'workspace', 'table', 'show']); + assert.deepEqual(calls[1].slice(0, 4), ['monitor', 'data-collection', 'rule', 'list']); + assert.equal(calls.some(args => args.includes('create') || args.includes('update') || args.includes('delete')), false); +}); + +test('live durable receipt guard fails when table and DCR types drift independently', () => { + let call = 0; + const result = validateDurableReceiptAzureSchema({ + resourceGroup: 'rg-agentops-dev', + workspaceName: 'law-agentops-dev', + dcrImmutableId: 'dcr-immutable', + runAz() { + call += 1; + if (call === 1) return { status: 0, stdout: JSON.stringify({ schema: { columns: columns.map(column => column.name === 'Sequence' ? { ...column, type: 'string' } : column) } }) }; + return { status: 0, stdout: JSON.stringify([{ properties: { + immutableId: 'dcr-immutable', + streamDeclarations: { 'Custom-AgentOpsEvents_CL': { columns: columns.filter(column => column.name !== 'EventId') } } + } }]) }; + } + }); + assert.equal(result.ok, false); + assert.deepEqual(result.table.drift, [{ column: 'Sequence', expected: 'long', actual: 'string', issue: 'type_mismatch' }]); + assert.deepEqual(result.dcr.drift, [{ column: 'EventId', expected: 'string', actual: null, issue: 'missing' }]); +}); + +test('live durable receipt guard skips without complete non-secret destination config', () => { + let called = false; + const result = validateDurableReceiptAzureSchema({ runAz() { called = true; } }); + assert.equal(result.ok, true); + assert.equal(result.skipped, true); + assert.equal(called, false); +}); diff --git a/agentops-cli/test/e2e-browser-check.test.js b/agentops-cli/test/e2e-browser-check.test.js new file mode 100644 index 0000000..dfbe81b --- /dev/null +++ b/agentops-cli/test/e2e-browser-check.test.js @@ -0,0 +1,64 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { browserEvidenceStatus, e2eAuthProfile, e2eBrowserCheck } = require('../src/lib/e2e-browser-check'); +const { renderReportHtml } = require('../src/lib/e2e-report'); + +test('e2e browser check validates a report and writes browser notes without Playwright', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-e2e-browser-lib-')); + try { + const reportPath = path.join(tempDir, 'report.html'); + const notesPath = path.join(tempDir, 'notes.md'); + fs.writeFileSync(reportPath, renderReportHtml({ + ok: true, + privacyMode: 'strict', + e2eId: 'agentops-e2e-test', + latestSessionId: 'session-test', + collector: { effectiveMode: 'binary' }, + poison: { ok: true }, + grafanaLinks: [{ label: 'Overview', url: 'https://grafana.example.grafana.azure.com/d/overview' }], + evidenceFiles: ['/tmp/summary.json'] + })); + + const result = await e2eBrowserCheck(['--report', reportPath, '--out', notesPath]); + + assert.equal(result.ok, true); + assert.equal(result.static.ok, true); + assert.equal(result.playwright.status, 'skipped'); + assert.equal(result.notes, notesPath); + assert.match(fs.readFileSync(notesPath, 'utf8'), /Browser Validation Notes/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('e2e evidence keeps backend-live success separate from authenticated Grafana proof', () => { + const result = browserEvidenceStatus( + { ok: true }, + { ok: false, reportVerified: true, authenticatedGrafanaVerified: false }, + { wantsPlaywright: true, wantsGrafana: true } + ); + + assert.equal(result.backendEvidenceVerified, true); + assert.equal(result.reportBrowserVerified, true); + assert.equal(result.authenticatedGrafanaVerified, false); + assert.equal(result.ok, false); +}); + +test('e2e auth profile builds reusable Grafana profile guidance', () => { + const result = e2eAuthProfile([ + '--report', + '.agentops/e2e/latest/report.html', + '--browser-user-data-dir', + '/tmp/agentops-grafana-profile', + '--url', + 'https://grafana.example.grafana.azure.com/d/agentops-v2-home' + ]); + + assert.equal(result.ok, true); + assert.equal(result.browserProfile.browserUserDataDir, '/tmp/agentops-grafana-profile'); + assert.ok(result.remediation.verify_after_sign_in.some(command => command.includes('--require-grafana-visible'))); +}); diff --git a/agentops-cli/test/e2e-browser-notes.test.js b/agentops-cli/test/e2e-browser-notes.test.js new file mode 100644 index 0000000..4c93c31 --- /dev/null +++ b/agentops-cli/test/e2e-browser-notes.test.js @@ -0,0 +1,53 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { writeBrowserNotes } = require('../src/lib/e2e-browser-notes'); + +test('e2e browser notes summarize static checks Grafana status and auth remediation', () => { + const notesPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-browser-notes-')), 'browser-notes.md'); + + writeBrowserNotes(notesPath, { + reportPath: '/tmp/agentops-report.html', + static: { + ok: false, + passVisible: false, + secretLooking: true, + grafanaLinks: 2, + evidenceLinks: 1 + }, + playwright: { + status: 'checked', + reason: 'manual verification requested', + reportScreenshot: '/tmp/report.png', + browserProfile: { persistent: true, storageState: false }, + requireGrafanaVisible: true, + grafana: [ + { label: 'AgentOps V2 Home', dashboardVisible: true, authBlocked: false, url: 'https://grafana.example/d/home' }, + { label: 'V2 Runs Explorer', dashboardVisible: false, authBlocked: true, url: 'https://grafana.example/d/runs' }, + { label: 'V2 Run Story', dashboardVisible: false, authBlocked: false, url: 'https://grafana.example/d/replay' } + ], + authRemediation: { + reason: 'Azure Managed Grafana redirected to Microsoft sign-in.', + sign_in_once: ['sign-in-command'], + verify_after_sign_in: ['verify-command'] + } + } + }); + + const notes = fs.readFileSync(notesPath, 'utf8'); + assert.match(notes, /# Browser Validation Notes/); + assert.match(notes, /- Static report check: fail/); + assert.match(notes, /- PASS visible: no/); + assert.match(notes, /- Secret-looking values: yes/); + assert.match(notes, /- Browser profile: persistent profile/); + assert.match(notes, /AgentOps V2 Home: visible/); + assert.match(notes, /V2 Runs Explorer: auth-blocked/); + assert.match(notes, /V2 Run Story: not verified/); + assert.match(notes, /Required visible dashboards: failed/); + assert.match(notes, /## Auth Remediation/); + assert.match(notes, /sign-in-command/); + assert.match(notes, /verify-command/); +}); diff --git a/agentops-cli/test/e2e-grafana.test.js b/agentops-cli/test/e2e-grafana.test.js new file mode 100644 index 0000000..2c01167 --- /dev/null +++ b/agentops-cli/test/e2e-grafana.test.js @@ -0,0 +1,101 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + browserProfileOptionsFromArgs, + grafanaAuthRemediation, + grafanaScreenshotTargets, + grafanaVisualOk, + renderAuthProfile +} = require('../src/lib/e2e-grafana'); + +test('e2e grafana screenshot targets keep stable V2 file names', () => { + const targets = grafanaScreenshotTargets([ + { label: 'Today', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-home' }, + { label: 'Runs', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-runs-explorer' }, + { label: 'Run Story', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-run-replay' }, + { label: 'Overview & Detail', url: 'https://grafana.example.grafana.azure.com/d/overview' }, + { label: 'External', url: 'https://example.test/d/external' } + ], { v2Only: false }); + + assert.deepEqual(targets.map(target => target.fileName), [ + 'agentops-v2-home-live.png', + 'agentops-v2-runs-explorer-live.png', + 'agentops-v2-run-replay-live.png', + 'overview-and-detail.png' + ]); + assert.deepEqual(grafanaScreenshotTargets(targets, { v2Only: true }).map(target => target.label), [ + 'Today', + 'Runs', + 'Run Story', + 'Models, Cost & Tokens', + 'Tools & MCP Risk', + 'Privacy', + 'Code Outcomes', + 'Evals & Quality', + 'Insights & Regressions', + 'Collector Health' + ]); +}); + +test('e2e grafana visual gate never treats an authentication redirect as visual proof', () => { + const authBlocked = [ + { label: 'Today', authBlocked: true, dashboardVisible: false }, + { label: 'Runs', authBlocked: true, dashboardVisible: false } + ]; + const visible = [ + { label: 'Today', authBlocked: false, dashboardVisible: true }, + { label: 'Runs', authBlocked: false, dashboardVisible: true } + ]; + + assert.equal(grafanaVisualOk(authBlocked), false); + assert.equal(grafanaVisualOk(visible), true); + assert.equal(grafanaVisualOk([]), false); +}); + +test('e2e grafana targets can pin every dashboard to one observed run', () => { + const [target] = grafanaScreenshotTargets([ + { label: 'Today', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-home' } + ], { v2Only: true, runId: 'sdk live/02' }); + + const url = new URL(target.url); + assert.equal(url.searchParams.get('var-run_id'), 'sdk live/02'); +}); + +test('e2e browser profile options prefer args over env defaults', () => { + const options = browserProfileOptionsFromArgs([ + '--browser-executable', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '--browser-user-data-dir', + '/tmp/agentops-grafana-profile', + '--storage-state', + '/tmp/storage-state.json', + '--headed' + ], { + AGENTOPS_BROWSER_EXECUTABLE: '/env/chrome', + AGENTOPS_BROWSER_USER_DATA_DIR: '/env/profile', + AGENTOPS_BROWSER_STORAGE_STATE: '/env/storage.json', + AGENTOPS_BROWSER_HEADED: '0' + }); + + assert.equal(options.browserExecutable, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'); + assert.equal(options.browserUserDataDir, '/tmp/agentops-grafana-profile'); + assert.equal(options.storageState, '/tmp/storage-state.json'); + assert.equal(options.headed, true); +}); + +test('e2e grafana auth remediation renders sign-in and verification commands', () => { + const remediation = grafanaAuthRemediation({ + reportPath: '.agentops/e2e/latest/report.html', + browserExecutable: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + browserUserDataDir: '$HOME/.agentops/browser/grafana-profile', + grafanaUrl: 'https://grafana.example.grafana.azure.com/d/agentops-v2-home' + }); + const text = renderAuthProfile({ remediation }); + + assert.match(remediation.reason, /Microsoft sign-in/); + assert.ok(remediation.sign_in_once.some(command => command.includes('--user-data-dir="$HOME/.agentops/browser/grafana-profile"'))); + assert.ok(remediation.verify_after_sign_in.some(command => command.includes('--require-grafana-visible'))); + assert.match(text, /Sign in once/); + assert.match(text, /Verify after sign-in/); +}); diff --git a/agentops-cli/test/e2e-playwright.test.js b/agentops-cli/test/e2e-playwright.test.js new file mode 100644 index 0000000..79efce0 --- /dev/null +++ b/agentops-cli/test/e2e-playwright.test.js @@ -0,0 +1,60 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { playwrightBrowserCheck, summarizePageAudit } = require('../src/lib/e2e-playwright'); + +test('e2e Playwright helper reports skipped when Playwright is unavailable', async () => { + const result = await playwrightBrowserCheck({ + reportPath: '/tmp/agentops-e2e-report.html', + outDir: '/tmp/agentops-e2e-screenshots', + loadPlaywright: () => ({ playwright: null, error: new Error('not installed') }) + }); + + assert.equal(result.status, 'skipped'); + assert.match(result.reason, /Playwright is not available: not installed/); +}); + +test('page audit summarizes accessibility and performance evidence', () => { + const result = summarizePageAudit({ + navigation: { loadEventEnd: 1840, domContentLoadedEventEnd: 910 }, + firstContentfulPaint: 640, + resourceCount: 42, + transferBytes: 123456, + accessibility: { + hasTitle: true, + hasLanguage: true, + hasMain: true, + hasNavigation: true, + hasSkipLink: true, + headings: 8, + unlabeledInteractive: 0, + unlabeledInteractiveDetails: [], + duplicateIds: 0 + } + }); + + assert.equal(result.accessibility.ok, true); + assert.equal(result.performance.ok, true); + assert.equal(result.performance.navigationLoadMs, 1840); + assert.equal(result.performance.firstContentfulPaintMs, 640); + assert.equal(result.performance.transferBytes, 123456); +}); + +test('page audit fails closed for missing landmarks, labels, and slow navigation', () => { + const result = summarizePageAudit({ + navigation: { loadEventEnd: 12001 }, + accessibility: { + hasTitle: true, + hasLanguage: false, + hasMain: false, + hasNavigation: true, + hasSkipLink: false, + unlabeledInteractive: 2, + duplicateIds: 1 + } + }); + + assert.equal(result.accessibility.ok, false); + assert.equal(result.performance.ok, false); + assert.equal(result.accessibility.unlabeledInteractive, 2); +}); diff --git a/agentops-cli/test/e2e-report.test.js b/agentops-cli/test/e2e-report.test.js new file mode 100644 index 0000000..e53bb52 --- /dev/null +++ b/agentops-cli/test/e2e-report.test.js @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + checkReportHtml, + htmlLinks, + renderReportHtml +} = require('../src/lib/e2e-report'); + +test('e2e report html renders escaped summary links and evidence files', () => { + const html = renderReportHtml({ + ok: true, + live: true, + privacyMode: 'strict', + e2eId: 'agentops-e2e-<unsafe>', + latestSessionId: 'session-test', + latestE2eMatched: true, + collector: { effectiveMode: 'binary' }, + poison: { ok: true }, + grafanaLinks: [{ + label: 'Overview <script>', + url: 'https://grafana.example.grafana.azure.com/d/overview?x=1&y=2' + }], + evidenceFiles: ['/tmp/summary.json'] + }); + + assert.match(html, /AgentOps E2E Report/); + assert.match(html, /agentops-e2e-<unsafe>/); + assert.match(html, /Overview <script>/); + assert.match(html, /x=1&y=2/); + assert.match(html, /summary\.json/); + assert.match(html, /Backend live run: <code>verified<\/code>/); + assert.match(html, /Authenticated Grafana visual verification/); + assert.match(html, /href="#main-content">Skip to main content/); + assert.match(html, /<nav aria-label="Report sections">/); + assert.match(html, /<main id="main-content" tabindex="-1">/); +}); + +test('e2e report check requires pass status, Grafana link, JSON evidence, and no secrets', () => { + const html = renderReportHtml({ + ok: true, + privacyMode: 'strict', + e2eId: 'agentops-e2e-test', + latestSessionId: 'session-test', + collector: { effectiveMode: 'binary' }, + poison: { ok: true }, + grafanaLinks: [{ label: 'Overview', url: 'https://grafana.example.grafana.azure.com/d/overview' }], + evidenceFiles: ['/tmp/summary.json'] + }); + + const clean = checkReportHtml(html); + const leaked = checkReportHtml(html.replace('</main>', '<p>SECRET_SHOULD_NOT_LEAVE</p></main>')); + const checkStatus = checkReportHtml(html.replace(/\bPASS\b/, 'CHECK')); + + assert.equal(clean.ok, true); + assert.equal(clean.passVisible, true); + assert.equal(clean.grafanaLinks, 1); + assert.equal(clean.evidenceLinks, 1); + assert.equal(leaked.ok, false); + assert.equal(leaked.secretLooking, true); + assert.equal(checkStatus.ok, false); + assert.equal(checkReportHtml(html.replace(/\bPASS\b/, 'CHECK'), { allowCheckStatus: true }).ok, true); +}); + +test('e2e report link parser decodes hrefs and strips inner markup', () => { + const links = htmlLinks('<a href="https://example.test/a?x=1&y=2"><strong>Example</strong> Link</a>'); + + assert.deepEqual(links, [{ + href: 'https://example.test/a?x=1&y=2', + text: 'Example Link' + }]); +}); diff --git a/agentops-cli/test/e2e-run.test.js b/agentops-cli/test/e2e-run.test.js new file mode 100644 index 0000000..11d4546 --- /dev/null +++ b/agentops-cli/test/e2e-run.test.js @@ -0,0 +1,89 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { e2eRun, grafanaLinksFromOpenSummary } = require('../src/lib/e2e-run'); + +test('e2e run helper supports injected dry-run dependencies', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-e2e-run-')); + const runDir = path.join(tempRoot, 'run'); + const latestDir = path.join(tempRoot, 'latest'); + + const result = await e2eRun(['--browser-report'], { + evidenceDir: () => runDir, + latestEvidenceDir: () => latestDir, + runAgentops: args => ({ + command: ['agentops', ...args].join(' '), + status: 0, + stdout: '{}', + stderr: '', + error: null + }), + collector: { + start: async () => ({ ok: true, effectiveMode: 'binary' }), + status: async () => ({ ok: true, effectiveMode: 'binary' }), + smoke: async () => ({ ok: true }) + }, + openLinksSummary: () => ({ + v2_home_url: 'https://grafana.example/d/home', + v2_runs_url: '', + v2_replay_url: '', + main_dashboard_url: '', + sessions_dashboard_url: '', + latest_session_url: '' + }) + }); + + assert.equal(result.ok, true); + assert.equal(result.live, false); + assert.equal(result.evidenceDir, runDir); + assert.equal(result.liveCopilot.status, 'skipped'); + assert.deepEqual(result.grafanaLinks, [ + { label: 'Today', url: 'https://grafana.example/d/home' } + ]); + assert.ok(fs.existsSync(path.join(runDir, 'summary.json'))); + assert.ok(fs.existsSync(path.join(runDir, 'report.html'))); + assert.equal(fs.lstatSync(latestDir).isSymbolicLink(), true); +}); + +test('e2e run helper maps Grafana summary links in dashboard order', () => { + assert.deepEqual(grafanaLinksFromOpenSummary({ + v2_home_url: 'https://grafana.example/d/home', + v2_runs_url: 'https://grafana.example/d/runs', + v2_replay_url: '', + main_dashboard_url: 'https://grafana.example/d/main', + sessions_dashboard_url: '', + latest_session_url: 'https://grafana.example/d/latest' + }), [ + { label: 'Today', url: 'https://grafana.example/d/home' }, + { label: 'Runs', url: 'https://grafana.example/d/runs' }, + { label: 'Overview', url: 'https://grafana.example/d/main' }, + { label: 'Latest Session', url: 'https://grafana.example/d/latest' } + ]); +}); + +test('e2e run helper carries the Azure-native investigation link when configured', () => { + const links = grafanaLinksFromOpenSummary({ + azure_agents_view_url: 'https://portal.azure.com/#agents', + v2_home_url: 'https://grafana.example/d/home' + }); + + assert.deepEqual(links, [ + { label: 'Azure Monitor Agents view', url: 'https://portal.azure.com/#agents' }, + { label: 'Today', url: 'https://grafana.example/d/home' } + ]); +}); + +test('e2e run helper carries the Application Insights fallback link', () => { + const links = grafanaLinksFromOpenSummary({ + application_insights_url: 'https://portal.azure.com/#/resource/app-insights/overview', + v2_home_url: 'https://grafana.example/d/home' + }); + + assert.deepEqual(links, [ + { label: 'Application Insights (open Agents)', url: 'https://portal.azure.com/#/resource/app-insights/overview' }, + { label: 'Today', url: 'https://grafana.example/d/home' } + ]); +}); diff --git a/agentops-cli/test/e2e-runtime.test.js b/agentops-cli/test/e2e-runtime.test.js new file mode 100644 index 0000000..c06077b --- /dev/null +++ b/agentops-cli/test/e2e-runtime.test.js @@ -0,0 +1,44 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + evidenceDir, + latestEvidenceDir, + redactText, + safeE2eEnv, + writeJson +} = require('../src/lib/e2e-runtime'); +const { repoRoot } = require('../src/lib/paths'); + +test('e2e runtime helpers build stable paths and strict env defaults', () => { + assert.equal(evidenceDir('fixed-run'), path.join(repoRoot, '.agentops', 'e2e', 'fixed-run')); + assert.equal(latestEvidenceDir(), path.join(repoRoot, '.agentops', 'e2e', 'latest')); + assert.deepEqual(safeE2eEnv({ AGENTOPS_E2E_ID: 'agentops-e2e-test' }), { + AGENTOPS_PRIVACY_MODE: 'strict', + AGENTOPS_CAPTURE_CONTENT: 'false', + AGENTOPS_DISABLE_CONTENT_CAPTURE_OVERRIDE: '1', + COPILOT_OTEL_CAPTURE_CONTENT: 'false', + AGENTOPS_E2E_ID: 'agentops-e2e-test' + }); +}); + +test('e2e runtime helpers write json and redact sensitive command output', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-e2e-runtime-')); + const out = path.join(tempRoot, 'nested', 'payload.json'); + + writeJson(out, { ok: true }); + + assert.equal(fs.readFileSync(out, 'utf8'), '{\n "ok": true\n}\n'); + assert.equal( + redactText('TOKEN=abc InstrumentationKey=secret; Authorization=Bearer raw'), + 'TOKEN=[REDACTED] InstrumentationKey=[REDACTED] Authorization=Bearer [REDACTED]' + ); +}); + +test('e2e runtime uses shared JSON file writer', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', 'e2e-runtime.js'), 'utf8'); + assert.doesNotMatch(source, /function writeJson\(/); +}); diff --git a/agentops-cli/test/env-fixtures.test.js b/agentops-cli/test/env-fixtures.test.js new file mode 100644 index 0000000..00c2c0f --- /dev/null +++ b/agentops-cli/test/env-fixtures.test.js @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { setEnvForTest } = require('./support/env'); + +test('setEnvForTest restores existing and missing environment variables', () => { + const cleanup = setEnvForTest({ + AGENTOPS_TEST_EXISTING: 'before', + AGENTOPS_TEST_MISSING: undefined + }); + + try { + const restore = setEnvForTest({ + AGENTOPS_TEST_EXISTING: 'after', + AGENTOPS_TEST_MISSING: 'created' + }); + + assert.equal(process.env.AGENTOPS_TEST_EXISTING, 'after'); + assert.equal(process.env.AGENTOPS_TEST_MISSING, 'created'); + + restore(); + + assert.equal(process.env.AGENTOPS_TEST_EXISTING, 'before'); + assert.equal(process.env.AGENTOPS_TEST_MISSING, undefined); + } finally { + cleanup(); + } +}); + +test('collector tests use shared env restore helper', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const files = [ + 'collector-status.test.js', + 'collector-binary-install.test.js', + 'collector-binary-runtime.test.js', + 'commands.test.js', + 'doctor-summary.test.js', + 'azure-validation-runtime.test.js' + ]; + + for (const file of files) { + const source = fs.readFileSync(path.join(__dirname, file), 'utf8'); + assert.doesNotMatch(source, /const originalHome = process\.env\.AGENTOPS_COLLECTOR_HOME/); + assert.doesNotMatch(source, /const original(ConfigPath|Fallback|EventsPath|Path) = process\.env/); + assert.doesNotMatch(source, /if \(original(Home|Connection) === undefined\) delete process\.env/); + assert.doesNotMatch(source, /if \(original(ConfigPath|Fallback|EventsPath) === undefined\) delete process\.env/); + } +}); diff --git a/agentops-cli/test/grafana-browser-auth.test.js b/agentops-cli/test/grafana-browser-auth.test.js new file mode 100644 index 0000000..e90c799 --- /dev/null +++ b/agentops-cli/test/grafana-browser-auth.test.js @@ -0,0 +1,38 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { azureCliGrafanaBrowserAuth, MANAGED_GRAFANA_RESOURCE_APP_ID } = require('../src/lib/azure/grafana-browser-auth'); + +const approved = '11111111-1111-4111-8111-111111111111'; + +test('Azure CLI Grafana auth is subscription-guarded and returns non-secret evidence', () => { + const calls = []; + const result = azureCliGrafanaBrowserAuth({ + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: approved, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: approved }, + spawnSync(command, args) { + calls.push([command, args]); + if (args[0] === 'account' && args[1] === 'show') return { status: 0, stdout: `${approved}\n`, stderr: '' }; + return { status: 0, stdout: 'secret-token-value\n', stderr: '' }; + } + }); + + assert.equal(result.token, 'secret-token-value'); + assert.equal(result.evidence.tokenPersisted, false); + assert.equal(JSON.stringify(result.evidence).includes(result.token), false); + assert.deepEqual(calls[1][1], [ + 'account', 'get-access-token', '--subscription', approved, + '--resource', MANAGED_GRAFANA_RESOURCE_APP_ID, '--query', 'accessToken', '-o', 'tsv' + ]); +}); + +test('Azure CLI Grafana auth fails closed before token acquisition on subscription mismatch', () => { + let calls = 0; + assert.throws(() => azureCliGrafanaBrowserAuth({ + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: approved, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: approved }, + spawnSync() { + calls += 1; + return { status: 0, stdout: 'different-subscription\n', stderr: '' }; + } + }), /authentication refused/); + assert.equal(calls, 1); +}); diff --git a/agentops-cli/test/hash.test.js b/agentops-cli/test/hash.test.js new file mode 100644 index 0000000..9d2eb65 --- /dev/null +++ b/agentops-cli/test/hash.test.js @@ -0,0 +1,31 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { hashText, prefixedHash, prefixedHashOrEmpty } = require('../src/lib/hash'); + +test('hashText returns a stable sha256 hex digest', () => { + assert.equal(hashText('agentops'), '0a8b23877a34bfeb350af602abf8a5c5b662e6fafd8b26b34acd52ada8699b30'); +}); + +test('prefixed hash helpers preserve empty-value semantics', () => { + assert.equal(prefixedHash('agentops', 'repo'), 'repo_0a8b23877a34bfeb'); + assert.equal(prefixedHash(undefined, 'repo'), 'repo_eb045d78d2731073'); + assert.equal(prefixedHashOrEmpty(undefined, 'repo'), 'repo_e3b0c44298fc1c14'); +}); + +test('hash callers use shared helpers instead of local sha256 bodies', () => { + const root = path.join(__dirname, '..'); + const files = [ + 'src/saved-views.js', + 'src/lib/collector-binary-release.js', + 'src/lib/mcp/redactor.js', + 'src/lib/otel/genai-normalizer.js', + 'src/lib/otel/mcp-normalizer.js' + ]; + + for (const file of files) { + assert.doesNotMatch(fs.readFileSync(path.join(root, file), 'utf8'), /node:crypto|createHash\('sha256'\)/, file); + } +}); diff --git a/agentops-cli/test/health-summary.test.js b/agentops-cli/test/health-summary.test.js new file mode 100644 index 0000000..db5269f --- /dev/null +++ b/agentops-cli/test/health-summary.test.js @@ -0,0 +1,134 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function freshRequire(relativePath) { + const absolutePath = path.join(repoRoot, 'agentops-cli', relativePath); + delete require.cache[require.resolve(absolutePath)]; + return require(absolutePath); +} + +function patch(object, property, value) { + const original = object[property]; + object[property] = value; + return () => { + object[property] = original; + }; +} + +test('status summary library composes local collector and copilot readiness', async () => { + const legacy = require('../src/legacy'); + const collector = require('../src/lib/collector-manager'); + const resolver = require('../src/lib/copilot-resolver'); + const restore = [ + patch(legacy, 'doctor', () => [{ name: 'content-capture-disabled', ok: true }]), + patch(legacy, 'agentopsStatusSummary', () => ({ + ok: true, + required_files: { found: 3, total: 3 }, + shim: { agentops_cli: 'ok', agentops_command: 'ok', shadow: 'safe' } + })), + patch(collector, 'status', async () => ({ + running: true, + mode: 'auto', + effectiveMode: 'binary', + privacyMode: 'strict', + safeLocalhostBinding: true + })), + patch(resolver, 'resolveCopilotBinary', () => ({ + ok: true, + path: '/usr/local/bin/copilot', + source: 'PATH', + error: null, + candidates: [] + })) + ]; + + try { + const { renderStatus, statusSummary } = freshRequire('src/lib/status-summary.js'); + const summary = await statusSummary(); + assert.equal(summary.content_capture_off, true); + assert.equal(summary.collector.running, true); + assert.equal(summary.copilot.path, '/usr/local/bin/copilot'); + assert.match(renderStatus(summary), /Collector: running \(binary, strict\)\./); + } finally { + restore.reverse().forEach(fn => fn()); + } +}); + +test('status summary reports durable delivery counters without claiming Azure visibility', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-status-delivery-')); + try { + const { createDurableEvidenceSpool } = require('../src/lib/azure/durable-evidence-spool'); + const { durableDeliveryStatus } = freshRequire('src/lib/status-summary.js'); + const spool = createDurableEvidenceSpool({ directory: tempDir }); + spool.enqueue({ + TimeGenerated: '2026-08-03T12:00:00.000Z', + Sequence: 1, + EventId: 'event-safe', + RunId: 'run-safe', + SessionId: 'session-safe', + EventName: 'agentops.run.start', + PrivacyMode: 'strict', + ContentCaptureMode: 'off', + Surface: 'cli', + SchemaVersion: '2' + }); + const delivery = durableDeliveryStatus({ directory: tempDir }); + assert.equal(delivery.state, 'local_pending'); + assert.equal(delivery.pending, 1); + assert.doesNotMatch(delivery.headline, /visible|accepted by Azure/i); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('health summary library reports latest run attention state', () => { + const { renderHealth, runHealthFromRows, summarizeChecks } = freshRequire('src/lib/health-summary.js'); + const latestRun = runHealthFromRows([ + { + TimeGenerated: '2026-01-01T00:00:00.000Z', + RunId: 'run_old', + OutcomeStatus: 'success', + TestsRan: true, + PrivacyMode: 'strict' + }, + { + TimeGenerated: '2026-01-02T00:00:00.000Z', + RunId: 'run_new', + SessionId: 'session_new', + OutcomeStatus: 'success', + FilesEditedCount: 2, + TestsRan: false, + PrivacyMode: 'strict' + } + ]); + + assert.deepEqual(summarizeChecks([ + { name: 'pass', ok: true }, + { name: 'warn', ok: false, severity: 'warning' }, + { name: 'block', ok: false } + ]), { + total: 3, + passed: 1, + warnings: 1, + blocking: 1 + }); + assert.equal(latestRun.run_id, 'run_new'); + assert.equal(latestRun.status, 'needs-attention'); + assert.match(renderHealth({ + status: 'needs-attention', + checks: { passed: 1, total: 3, warnings: 1, blocking: 1 }, + local: { + collector_running: false, + collector_mode: 'auto', + collector_privacy_mode: 'strict', + content_capture_off: true + }, + latest_run: latestRun, + next_action: 'Review warnings.' + }), /Latest run: run_new \(needs-attention\)/); +}); diff --git a/agentops-cli/test/index.test.js b/agentops-cli/test/index.test.js index ff57a3c..a61d332 100644 --- a/agentops-cli/test/index.test.js +++ b/agentops-cli/test/index.test.js @@ -7,6 +7,7 @@ const path = require('node:path'); const test = require('node:test'); process.env.AGENTOPS_CONFIG_PATH = path.join(os.tmpdir(), `agentops-test-config-${process.pid}.json`); +const TEST_APPROVED_SUBSCRIPTION_ID = '11111111-1111-4111-8111-111111111111'; const collectorManager = require('../src/lib/collector-manager'); const copilotResolver = require('../src/lib/copilot-resolver'); const { createRunMetadata } = require('../src/lib/copilot/run-metadata'); @@ -63,10 +64,12 @@ const { securityAudit, securityPosture } = require('../src/lib/security-audit'); const { checkCliPublish } = require('../../scripts/check-cli-publish'); const { checkHomebrewFormula, releaseUrl, renderFormula } = require('../../scripts/check-homebrew-formula'); const { checkInstallSmoke } = require('../../scripts/check-install-smoke'); +const { checkPackagedLifecycle } = require('../../scripts/check-packaged-lifecycle'); const { checkReleaseDistribution } = require('../../scripts/check-release-distribution'); const { checkSdkPublish, isWildcardRange } = require('../../scripts/check-sdk-publish'); const { shouldCopy } = require('../../scripts/prepare-cli-package-assets'); const { askAgentOps, buildActionerReview, buildAskAgentOpsLaunch, buildAskAgentOpsResponse, buildGuardedRecommendationApply, buildRecommendationReview, buildSharedStoreEditor, buildSharedStoreWrite, sharedStoreEditor, sharedStoreWrite } = require('../../actioner'); +const { writeJsonlFixture } = require('./support/json-fixtures'); const { agentopsAttributionSmoke, @@ -220,6 +223,20 @@ test('CLI help exposes small core surface and hides experimental commands', () = assert.match(result.stdout, /agentops experimental <old-command>/); assert.doesNotMatch(result.stdout, /benchmark list/); assert.doesNotMatch(result.stdout, /saved-view add/); + assert.match(result.stdout, /Next:\n agentops init --full/); + assert.match(result.stdout, /agentops help <command>/); +}); + +test('CLI offers focused per-command help with a path back to the full reference', () => { + const result = spawnSync(process.execPath, [path.join(root, 'agentops-cli', 'src', 'index.js'), 'help', 'delivery'], { + cwd: root, + encoding: 'utf8' + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^agentops delivery status\|drain/); + assert.match(result.stdout, /agentops --help/); + assert.doesNotMatch(result.stdout, /dashboard validate/); }); test('init is a core command without experimental migration warning', () => { @@ -378,6 +395,7 @@ test('security audit combines static, privacy, OWASP, and CI gates', () => { assert.equal(byName['dashboard-content-guardrails'].ok, true); assert.equal(byName['content-capture-operational-guardrails'].ok, true); assert.equal(byName['dashboard-evidence-disclaimer'].ok, true); + assert.equal(byName['collector-persistent-queue-security'].ok, true); }); test('security posture maps OWASP LLM and ASVS controls to repo evidence', () => { @@ -404,6 +422,8 @@ test('CLI publish check validates package metadata', () => { assert.equal(result.package.bin, 'src/index.js'); assert.ok(result.checks.expected_files.includes('src/index.js')); assert.ok(result.checks.expected_files.includes('src/commands/collector.js')); + assert.ok(result.checks.expected_files.includes('docs/images/agentops-architecture-dataflow.png')); + assert.ok(result.checks.expected_files.includes('kql/00-discover-tables.kql')); assert.ok(result.checks.forbidden_files.includes('test/index.test.js')); }); @@ -428,14 +448,31 @@ test('SDK publish check validates package metadata', () => { test('release distribution check builds artifacts with checksums', () => { const result = checkReleaseDistribution({ skipDocs: false }); - assert.equal(result.ok, true, JSON.stringify(result, null, 2)); + if (result.source.git_available) { + assert.equal(result.ok, true, JSON.stringify(result, null, 2)); + } else { + assert.equal(result.ok, false, JSON.stringify(result, null, 2)); + assert.ok(result.failures.some(failure => failure.includes('Git identity is unavailable'))); + } assert.equal(result.artifacts.length, 2); + assert.equal(result.sboms.length, 2); + assert.equal(result.publish_authorized, false); + assert.equal(typeof result.source.worktree_dirty, 'boolean'); + assert.equal(result.manifest.publish_authorized, false); + assert.equal(result.manifest.artifacts.length, 2); + assert.equal(result.manifest.sboms.length, 2); assert.ok(result.artifacts.some(artifact => artifact.filename.startsWith('copilot-agentops-cli-'))); assert.ok(result.artifacts.some(artifact => artifact.filename.startsWith('agentops-copilot-sdk-'))); for (const artifact of result.artifacts) { assert.equal(artifact.sha256.length, 64); assert.ok(artifact.size > 0); } + for (const sbom of result.sboms) { + assert.equal(sbom.format, 'CycloneDX'); + assert.equal(sbom.spec_version, '1.5'); + assert.equal(sbom.sha256.length, 64); + assert.ok(sbom.size > 0); + } }); test('Homebrew formula check renders checked CLI artifact URL and SHA', () => { @@ -470,6 +507,8 @@ test('CLI package asset copier excludes heavyweight and local-only files', () => assert.equal(shouldCopy(path.join(root, 'docs', 'images', 'agentops-banner.png')), false); assert.equal(shouldCopy(path.join(root, 'scripts', 'install-copilot-agentops-shim.sh')), true); assert.equal(shouldCopy(path.join(root, 'scripts', 'check-install-smoke.js')), false); + assert.equal(shouldCopy(path.join(root, 'packages', 'agentops-copilot-sdk', 'node_modules', 'copilot', 'index.js')), false); + assert.equal(shouldCopy(path.join(root, 'packages', 'agentops-copilot-sdk', 'agentops-copilot-sdk-0.1.0.tgz')), false); }); test('packed CLI install smoke runs installed command from clean prefix', () => { @@ -479,10 +518,32 @@ test('packed CLI install smoke runs installed command from clean prefix', () => assert.ok(result.artifact.filename.startsWith('copilot-agentops-cli-')); assert.equal(result.artifact.sha256.length, 64); assert.ok(result.commands.some(command => command.name === 'agentops doctor --local-only --json' && command.ok)); + assert.ok(result.commands.some(command => command.name === 'agentops product audit --json' && command.ok)); assert.ok(result.commands.some(command => command.name === 'agentops dashboard verify' && command.ok)); assert.ok(result.commands.some(command => command.name === 'agentops collector validate --mode none --json' && command.ok)); }); +test('packed CLI POSIX lifecycle preserves privacy and restores Copilot byte-for-byte', { + skip: process.platform === 'win32' ? 'POSIX lifecycle is separate from the Windows PowerShell lane' : false +}, () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-packaged-lifecycle-test-')); + try { + const result = checkPackagedLifecycle({ tempDir }); + assert.equal(result.ok, true, JSON.stringify(result, null, 2)); + assert.equal(result.privacy.mode, 'strict'); + assert.equal(result.privacy.content_capture, false); + assert.equal(result.privacy.poison_persisted, false); + assert.equal(result.restoration.restored_sha256, result.restoration.original_sha256); + assert.equal(result.versions.downgrade, result.versions.baseline); + assert.notEqual(result.versions.upgrade, result.versions.baseline); + assert.ok(result.steps.some(step => step.name === 'normal copilot strict metadata-only receipt' && step.ok)); + assert.ok(result.steps.some(step => step.name === 'restored Copilot runs without AgentOps interception' && step.ok)); + assert.ok(result.unproven_lanes.includes('Windows PowerShell live lifecycle')); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('strict collector allowlist preserves V2 hierarchy metadata', () => { const config = fs.readFileSync(path.join(root, 'collector', 'otelcol.binary.strict.yaml'), 'utf8'); for (const key of [ @@ -602,12 +663,14 @@ test('demo verify runs the local V2 control-room proof', () => { output += String(chunk); return true; }; - demoVerifyCommand(['--runs', '12', '--out', path.join(tempDir, 'demo'), '--insights-out', path.join(tempDir, 'insights'), '--json']); + demoVerifyCommand(['--runs', '12', '--out', path.join(tempDir, 'demo'), '--insights-out', path.join(tempDir, 'insights'), '--write', '--json']); } finally { process.stdout.write = originalWrite; } const result = JSON.parse(output); assert.equal(result.ok, true); + assert.equal(result.artifact_mode, 'persistent'); + assert.equal(result.write_intent, true); assert.equal(result.demo.runs, 12); assert.equal(result.insights.table_counts.AgentOpsEval_CL, 12); assert.equal(result.dashboard.ok, true); @@ -681,6 +744,7 @@ test('azure-ingest logs-upload plans reviewed Logs Ingestion API calls', () => { test('azure-ingest logs-upload executes az rest only after a ready plan', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-logs-upload-run-')); const calls = []; + const uploadedBodies = []; try { const demo = generateDemoData({ runs: 1 }); writeDemoData(demo, tempDir); @@ -691,8 +755,14 @@ test('azure-ingest logs-upload executes az rest only after a ready plan', () => }); const result = runLogsIngestionUpload(plan, { + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], spawnSync: (command, args) => { calls.push({ command, args }); + if (args[0] === 'account') { + return { status: 0, stdout: '11111111-1111-4111-8111-111111111111\n', stderr: '' }; + } + uploadedBodies.push(JSON.parse(fs.readFileSync(args.at(-1).slice(1), 'utf8'))); return { status: 0, stdout: '', stderr: '' }; } }); @@ -700,11 +770,14 @@ test('azure-ingest logs-upload executes az rest only after a ready plan', () => assert.equal(result.ok, true); assert.equal(result.executed, true); assert.equal(calls[0].command, 'az'); - assert.deepEqual(calls[0].args.slice(0, 4), ['rest', '--method', 'post', '--uri']); - assert.equal(calls[0].args[calls[0].args.indexOf('--resource') + 1], 'https://monitor.azure.com/'); - assert.ok(calls[0].args.includes('Content-Type=application/json')); - assert.ok(calls[0].args.some(arg => /^@.*AgentOpsRunSummary_CL\.json$/.test(arg))); - assert.equal(JSON.parse(fs.readFileSync(calls[0].args.at(-1).slice(1), 'utf8')).length, 1); + assert.deepEqual(calls[0].args, ['account', 'show', '--query', 'id', '-o', 'tsv']); + assert.deepEqual(calls[1].args.slice(0, 4), ['rest', '--method', 'post', '--uri']); + assert.equal(calls[1].args[calls[1].args.indexOf('--resource') + 1], 'https://monitor.azure.com/'); + assert.ok(calls[1].args.includes('Content-Type=application/json')); + assert.ok(calls[1].args.some(arg => /^@.*AgentOpsRunSummary_CL\.json$/.test(arg))); + assert.equal(uploadedBodies[0].length, 1); + assert.equal(fs.existsSync(calls[1].args.at(-1).slice(1)), false); + assert.equal(result.temporary_payloads_cleaned, true); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -724,7 +797,7 @@ test('azure-ingest plan warns on missing or mismatched schema versions', () => { .map(line => JSON.parse(line)); delete rows[0].SchemaVersion; rows[1].SchemaVersion = '1'; - fs.writeFileSync(runFile, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`); + writeJsonlFixture(runFile, rows); const result = buildAzureIngestPlan({ dir: tempDir }); assert.equal(result.ok, true, result.errors.join('\n')); @@ -757,7 +830,7 @@ test('azure-ingest plan blocks unsupported newer schema versions', () => { .split('\n') .map(line => JSON.parse(line)); rows[0].SchemaVersion = '3'; - fs.writeFileSync(runFile, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`); + writeJsonlFixture(runFile, rows); const result = buildAzureIngestPlan({ dir: tempDir }); assert.equal(result.ok, false); @@ -857,7 +930,7 @@ test('shared storage upload plan covers metadata-only saved views recommendation Action: 'reduce_context', Severity: 'medium', ObservedPattern: 'context pressure', - NextAction: 'Open Run Replay' + NextAction: 'Open Run Story' })}\n`); fs.writeFileSync(path.join(tempDir, 'AgentOpsSavedViews_CL.jsonl'), `${JSON.stringify({ TimeGenerated: '2026-06-03T12:01:00.000Z', @@ -994,9 +1067,13 @@ test('span rollup converts raw OTel JSONL rows into V2 AgentOps tables', () => { assert.equal(result.tables.AgentOpsGithubOutcomes_CL[0].FilesChangedCount, 4); assert.equal(result.tables.AgentOpsPrivacy_CL[0].Action, 'dropped'); assert.equal(result.tables.AgentOpsEvents_CL[0].InputTokens, 100); + assert.deepEqual(result.tables.AgentOpsEvents_CL.map(event => event.Sequence), [1, 2, 3, 4]); + assert.ok(result.tables.AgentOpsEvents_CL.every(event => /^event_/.test(event.EventId))); + assert.ok(result.tables.AgentOpsEvents_CL.every(event => event.ParentEventId === '')); assert.equal(result.tables.AgentOpsRunSummary_CL[0].CacheReadTokens, 25); assert.equal(result.tables.AgentOpsRunSummary_CL[0].ContextWindowPct, 92); assert.equal(result.tables.AgentOpsRunSummary_CL[0].TokensRemoved, 11); + assert.ok(Object.values(result.tables).flat().every(row => row.SchemaVersion === '2')); assert.doesNotMatch(JSON.stringify(result.tables), /must be dropped|gen_ai\.input\.messages/); }); @@ -1544,7 +1621,7 @@ test('V2 recommend persists local recommendation store and exports table rows', observed_pattern: 'A tool failure repeated for this run.', next_action: 'Open Tools & MCP Risk and validate the tool policy.', evidence: { - dashboards: [{ title: 'Run Replay', url: 'https://graf.example/d/agentops-v2-run-replay?var-run_id=run-store' }], + dashboards: [{ title: 'Run Story', url: 'https://graf.example/d/agentops-v2-run-replay?var-run_id=run-store' }], eval: null, pattern: null, benchmark: null, @@ -2003,7 +2080,7 @@ test('V2 open builds run-scoped control-room links from run table rows', () => { assert.match(result.links.runs, /var-repo_hash=repo_hash/); assert.match(result.links.models, /var-model=gpt-5\.5/); assert.match(result.links.insights, /agentops-v2-insights-regressions/); - assert.match(renderOpenV2(result), /Run Replay:/); + assert.match(renderOpenV2(result), /Run Story:/); assert.match(renderOpenV2(result), /Prompt\/response viewer \(explicit opt-in\):/); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -2037,7 +2114,7 @@ test('V2 ask-context builds a metadata-only investigation bundle', () => { Severity: 'medium', ObservedPattern: 'validation missing after config change', NextAction: 'Run the benchmark gate before keeping the change.', - DashboardTitles: ['Run Replay'], + DashboardTitles: ['Run Story'], DashboardCount: 1, Validation: ['agentops benchmark run starter --variant candidate --repeat 1'], RollbackCondition: 'Revert the config change if validation regresses.', @@ -2433,7 +2510,7 @@ test('workflows map README goals to invocable skills and commands', () => { assert.ok(byName['science-mode'].commands.includes('node agentops-cli/src/index.js benchmark judge-provider')); assert.equal(byName['judge-provider'].skill, 'agentops-benchmark-gate'); assert.ok(byName['judge-provider'].commands.includes('node agentops-cli/src/index.js benchmark judge-provider --json')); - assert.ok(byName['offline-test'].commands.includes('node agentops-cli/src/index.js live --file tests/sample-otel/tool-failure.jsonl')); + assert.ok(byName['offline-test'].commands.includes('node agentops-cli/src/index.js live --file fixtures/sample-otel/tool-failure.ndjson.fixture')); assert.ok(byName['analyst-mode'].commands.includes('node agentops-cli/src/index.js alert recommend --last 14d')); assert.ok(byName.operations.commands.includes('node agentops-cli/src/index.js plugin uninstall')); assert.ok(byName.operations.commands.includes('node agentops-cli/src/index.js uninstall')); @@ -2493,7 +2570,8 @@ test('configure stores non-secret Azure and Grafana settings once', () => { resourceGroup: 'rg-agentops-dev', workspaceId: 'workspace-123', grafanaBaseUrl: 'https://grafana.example', - grafanaName: 'graf-agentops-dev' + grafanaName: 'graf-agentops-dev', + agentsViewUrl: 'https://portal.azure.com/#agents' } }); const output = renderConfigure(result); @@ -2501,6 +2579,7 @@ test('configure stores non-secret Azure and Grafana settings once', () => { assert.equal(result.values.resourceGroup, 'rg-agentops-dev'); assert.equal(stored.values.workspaceId, 'workspace-123'); + assert.equal(stored.values.agentsViewUrl, 'https://portal.azure.com/#agents'); assert.match(output, /agentops validate-azure/); assert.match(fs.readFileSync(configPath, 'utf8'), /graf-agentops-dev/); } finally { @@ -2523,7 +2602,10 @@ test('configure import-azd maps azd env values into AgentOps config', () => { 'AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID="workspace-123"', 'AGENTOPS_GRAFANA_BASE_URL="https://grafana.example"', 'GRAFANA_NAME="graf-agentops-dev"', - 'APPLICATIONINSIGHTS_NAME="appi-agentops-dev"' + 'APPLICATIONINSIGHTS_NAME="appi-agentops-dev"', + 'AGENTOPS_AZURE_AGENTS_URL="https://portal.azure.com/#agents"', + 'AGENTOPS_LOGS_INGESTION_ENDPOINT="https://ingest.example"', + 'AGENTOPS_DCR_IMMUTABLE_ID="dcr-safe"' ].join('\n'), stderr: '' }) @@ -2532,21 +2614,27 @@ test('configure import-azd maps azd env values into AgentOps config', () => { assert.equal(result.ok, true); assert.equal(result.values.subscriptionId, 'sub-123'); assert.equal(result.values.grafanaBaseUrl, 'https://grafana.example'); + assert.equal(result.values.agentsViewUrl, 'https://portal.azure.com/#agents'); assert.equal(result.values.appInsightsName, 'appi-agentops-dev'); + assert.equal(result.values.logsIngestionEndpoint, 'https://ingest.example'); + assert.equal(result.values.dcrImmutableId, 'dcr-safe'); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }); test('configure parsers normalize env assignment values and CLI flags', () => { - const envValues = parseEnvAssignments('AZURE_RESOURCE_GROUP="rg-a"\nGRAFANA_ENDPOINT=https://grafana.example\nAPPLICATIONINSIGHTS_NAME=appi-a\n'); + const envValues = parseEnvAssignments('AZURE_RESOURCE_GROUP="rg-a"\nGRAFANA_ENDPOINT=https://grafana.example\nAPPLICATIONINSIGHTS_NAME=appi-a\nAGENTOPS_AZURE_AGENTS_URL=https://portal.azure.com/#agents\nAGENTOPS_LOGS_INGESTION_ENDPOINT=https://ingest.example\nAGENTOPS_DCR_IMMUTABLE_ID=dcr-safe\n'); const configValues = configFromEnvValues(envValues); - const parsed = parseConfigureArgs(['set', '--resource-group', 'rg-b', '--workspace-id', 'workspace-b']); + const parsed = parseConfigureArgs(['set', '--resource-group', 'rg-b', '--workspace-id', 'workspace-b', '--agents-url', 'https://portal.azure.com/#agents']); assert.equal(configValues.resourceGroup, 'rg-a'); assert.equal(configValues.grafanaBaseUrl, 'https://grafana.example'); assert.equal(configValues.appInsightsName, 'appi-a'); - assert.deepEqual(parsed.values, { resourceGroup: 'rg-b', workspaceId: 'workspace-b' }); + assert.equal(configValues.agentsViewUrl, 'https://portal.azure.com/#agents'); + assert.equal(configValues.logsIngestionEndpoint, 'https://ingest.example'); + assert.equal(configValues.dcrImmutableId, 'dcr-safe'); + assert.deepEqual(parsed.values, { resourceGroup: 'rg-b', workspaceId: 'workspace-b', agentsViewUrl: 'https://portal.azure.com/#agents' }); assert.deepEqual(compactConfig({ resourceGroup: 'rg-c', workspaceId: '' }), { resourceGroup: 'rg-c' }); }); @@ -2556,7 +2644,8 @@ test('setup guide recommends the shortest non-mutating setup path', () => { const result = agentopsSetupGuide({ installDir: tempDir, config: {}, - env: {}, + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, + azureAccount: { id: TEST_APPROVED_SUBSCRIPTION_ID, name: 'Visual Studio Enterprise Subscription' }, commandAvailability: { az: true, azd: true, docker: true, copilot: true }, commandPaths: { az: '/usr/local/bin/az', @@ -2579,6 +2668,9 @@ test('setup guide recommends the shortest non-mutating setup path', () => { assert.equal(result.azd.ok, true); assert.equal(result.first_run.read_only, true); assert.equal(result.first_run.guided_command, 'agentops init --full'); + assert.equal(result.cloud.expected_subscription_id, TEST_APPROVED_SUBSCRIPTION_ID); + assert.equal(result.cloud.active_subscription_id, TEST_APPROVED_SUBSCRIPTION_ID); + assert.equal(result.cloud.subscription_match, true); assert.equal(result.first_run.bind_command, 'agentops configure import-azd'); assert.match(result.first_run.privacy_smoke_command, /collector smoke --privacy strict --poison/); assert.match(result.first_run.smoke_command, /smoke --real-copilot/); @@ -2589,13 +2681,18 @@ test('setup guide recommends the shortest non-mutating setup path', () => { assert.match(output, /This command is read-only/); assert.match(output, /One-minute first run/); assert.match(output, /Guided path: agentops init --full/); + assert.match(output, /Visual Studio Enterprise Subscription/); + assert.match(output, /match=yes/); assert.match(output, /Privacy smoke fallback: agentops collector smoke --privacy strict --poison --json/); assert.match(output, /Real smoke fallback: agentops smoke --real-copilot --wait 2m --poll 10s --open-browser/); - assert.match(output, /the smoke opens Run Replay/); + assert.match(output, /zero-write preview/); + assert.match(output, /agentops init --full --yes/); + assert.match(output, /the smoke opens Run Story/); assert.match(output, /agentops dashboard import --yes --resource-group rg-agentops-dev --grafana-name graf-agentops-dev/); assert.match(output, /Fastest path/); assert.ok(result.next.includes('agentops init --full')); assert.match(output, /agentops collector smoke --privacy strict --poison/); + assert.match(output, /more fallback commands are available in: agentops setup --json/); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -2610,6 +2707,9 @@ test('enterprise validation confirms local guardrails', () => { const output = renderValidateEnterprise(result); assert.equal(result.ok, true); + assert.equal(result.score_kind, 'local-blueprint'); + assert.equal(result.live_environment_checked, false); + assert.equal(result.enterprise_pilot_ready, false); assert.ok(result.score >= 88); assert.ok(result.checks.some(check => check.name === 'daily-ingestion-cap' && check.ok)); assert.ok(result.checks.some(check => check.name === 'least-privilege-rbac-module' && check.ok)); @@ -2623,7 +2723,10 @@ test('enterprise validation confirms local guardrails', () => { assert.ok(result.checks.some(check => check.name === 'grafana-managed-identity' && check.ok)); assert.ok(result.checks.some(check => check.name === 'grafana-network-posture-params' && check.ok)); assert.ok(result.checks.some(check => check.name === 'alert-action-groups-parameter' && check.ok)); - assert.match(output, /Enterprise guardrails passed/); + assert.match(output, /Local enterprise guardrails passed/); + assert.match(output, /Enterprise pilot ready: not proven/); + assert.match(output, /does not prove the deployed Azure environment/); + assert.ok(result.next.some(item => item.includes('validate-azure --profile internal'))); }); test('enterprise validation blocks content capture env overrides', () => { @@ -2710,22 +2813,23 @@ test('init workflow installs skills in dry-run mode and returns first-run next s assert.equal(result.dashboard_import.requested, false); assert.equal(result.real_smoke.requested, false); assert.equal(result.triage_latest.requested, false); - assert.equal(result.summary.status, 'needs_action'); - assert.equal(result.summary.next_action, './install-agentops.sh'); + assert.equal(result.summary.status, 'preview'); + assert.equal(result.summary.next_action, 'agentops install'); + assert.equal(result.summary.next_action, 'agentops install'); assert.ok(result.next.includes('agentops init --provision-cloud')); - assert.ok(result.next.includes('node agentops-cli/src/index.js validate-azure --import-dashboards --last 24h')); - assert.ok(result.next.includes('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s')); - assert.ok(result.next.includes('node agentops-cli/src/index.js open latest --last 2h')); - assert.ok(result.next.includes('node agentops-cli/src/index.js triage latest --out .agentops/triage/latest --json')); - assert.ok(result.next.includes('node agentops-cli/src/index.js plugin uninstall')); + assert.ok(result.next.includes('agentops validate-azure --import-dashboards --last 24h')); + assert.ok(result.next.includes('agentops smoke --real-copilot --wait 2m --poll 10s')); + assert.ok(result.next.includes('agentops open latest --last 2h')); + assert.ok(result.next.includes('agentops triage latest --out .agentops/triage/latest --json')); + assert.ok(result.next.includes('agentops plugin uninstall')); assert.ok(result.next.every(command => !command.includes('experimental'))); assert.match(output, /AgentOps init/); assert.match(output, /Agents:/); assert.match(output, /Skills:/); - assert.match(output, /Cloud config: workspace=missing, grafana=missing/); + assert.match(output, /Cloud config: workspace=missing, Azure Monitor Agents view=missing, Grafana advanced=missing/); assert.match(output, /azd environment:/); - assert.match(output, /Summary: needs_action\. Run next: \.\/install-agentops\.sh/); - assert.match(output, /First value: run the real smoke/); + assert.match(output, /Summary: preview\. No cloud or workflow writes were made/); + assert.match(output, /Everyday observed use: agentops copilot/); assert.match(output, /agentops plugin uninstall/); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -2758,17 +2862,18 @@ test('init --full dry run requests every first-run stage from the CLI parser', ( assert.equal(parsed.dashboard_import.requested, true); assert.equal(parsed.real_smoke.requested, true); assert.equal(parsed.triage_latest.requested, true); - assert.equal(parsed.summary.status, 'needs_action'); + assert.equal(parsed.summary.status, 'preview'); assert.equal(parsed.summary.stages.length, 4); - assert.ok(parsed.summary.stages.every(stage => stage.status === 'ready')); + assert.ok(parsed.summary.stages.every(stage => stage.status === 'planned')); + assert.equal(parsed.summary.next_action, 'agentops init --full --yes'); assert.equal(parsed.cloud_provision.dry_run, true); assert.equal(parsed.dashboard_import.dry_run, true); assert.equal(parsed.real_smoke.dry_run, true); assert.equal(parsed.triage_latest.dry_run, true); assert.equal(parsed.next.includes('agentops init --provision-cloud'), false); - assert.equal(parsed.next.includes('node agentops-cli/src/index.js validate-azure --import-dashboards --last 24h'), false); - assert.equal(parsed.next.includes('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s'), false); - assert.equal(parsed.next.includes('node agentops-cli/src/index.js triage latest --out .agentops/triage/latest --json'), false); + assert.equal(parsed.next.includes('agentops validate-azure --import-dashboards --last 24h'), false); + assert.equal(parsed.next.includes('agentops smoke --real-copilot --wait 2m --poll 10s'), false); + assert.equal(parsed.next.includes('agentops triage latest --out .agentops/triage/latest --json'), false); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -2792,7 +2897,7 @@ test('init triage-latest dry run plans explicit latest triage stage', () => { assert.equal(result.triage_latest.dry_run, true); assert.equal(result.triage_latest.ok, true); assert.deepEqual(result.summary.stages.map(stage => stage.name), ['triage_latest']); - assert.equal(result.next.includes('node agentops-cli/src/index.js triage latest --out .agentops/triage/latest --json'), false); + assert.equal(result.next.includes('agentops triage latest --out .agentops/triage/latest --json'), false); assert.match(output, /Latest triage: ready/); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -2823,7 +2928,7 @@ test('init triage-latest invokes the latest triage command when explicit', () => assert.ok(triageCall[1].includes('latest')); assert.ok(triageCall[1].includes('--out')); assert.ok(triageCall[1].includes('.agentops/triage/latest')); - assert.equal(result.next.includes('node agentops-cli/src/index.js triage latest --out .agentops/triage/latest --json'), false); + assert.equal(result.next.includes('agentops triage latest --out .agentops/triage/latest --json'), false); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -2845,7 +2950,7 @@ test('init triage-latest reports failed triage next steps', () => { assert.equal(result.triage_latest.ok, false); assert.ok(result.next.includes('agentops triage latest --out .agentops/triage/latest --json')); - assert.ok(result.next.includes('node agentops-cli/src/index.js latest --last 2h')); + assert.ok(result.next.includes('agentops latest --last 2h')); assert.match(output, /Latest triage: needs review/); assert.match(output, /Latest triage next:/); } finally { @@ -2870,7 +2975,7 @@ test('init run-smoke dry run plans explicit real smoke stage', () => { assert.equal(result.real_smoke.requested, true); assert.equal(result.real_smoke.dry_run, true); assert.equal(result.real_smoke.ok, true); - assert.equal(result.next.includes('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s'), false); + assert.equal(result.next.includes('agentops smoke --real-copilot --wait 2m --poll 10s'), false); assert.match(output, /Real smoke: ready/); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -2900,8 +3005,8 @@ test('init run-smoke invokes the real smoke command when explicit', () => { assert.ok(smokeCall); assert.ok(smokeCall[1].includes('--real-copilot')); assert.ok(smokeCall[1].includes('--open-browser')); - assert.equal(result.next.includes('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s'), false); - assert.ok(result.next.includes('node agentops-cli/src/index.js open latest --last 2h')); + assert.equal(result.next.includes('agentops smoke --real-copilot --wait 2m --poll 10s'), false); + assert.ok(result.next.includes('agentops open latest --last 2h')); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -2923,7 +3028,7 @@ test('init run-smoke reports failed smoke next steps', () => { assert.equal(result.real_smoke.ok, false); assert.ok(result.next.includes('agentops smoke --real-copilot --wait 2m --poll 10s --open-browser --json')); - assert.ok(result.next.includes('node agentops-cli/src/index.js latest --last 2h')); + assert.ok(result.next.includes('agentops latest --last 2h')); assert.match(output, /Real smoke: needs review/); assert.match(output, /Real smoke next:/); } finally { @@ -2948,7 +3053,7 @@ test('init import-dashboards dry run plans explicit dashboard remediation', () = assert.equal(result.dashboard_import.requested, true); assert.equal(result.dashboard_import.dry_run, true); assert.equal(result.dashboard_import.ok, true); - assert.equal(result.next.includes('node agentops-cli/src/index.js validate-azure --import-dashboards --last 24h'), false); + assert.equal(result.next.includes('agentops validate-azure --import-dashboards --last 24h'), false); assert.match(output, /Dashboard import: ready/); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -2977,8 +3082,8 @@ test('init import-dashboards runs validate-azure remediation when explicit', () assert.equal(calls.length, 1); assert.equal(calls[0].importDashboards, true); assert.equal(calls[0].last, '24h'); - assert.equal(result.next.includes('node agentops-cli/src/index.js validate-azure --import-dashboards --last 24h'), false); - assert.ok(result.next.includes('node agentops-cli/src/index.js collector smoke --privacy strict --poison --json')); + assert.equal(result.next.includes('agentops validate-azure --import-dashboards --last 24h'), false); + assert.ok(result.next.includes('agentops collector smoke --privacy strict --poison --json')); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -3047,7 +3152,7 @@ test('init provision-cloud can run azd provision then import outputs explicitly' const result = agentopsInit({ provisionCloud: true, noSkills: true, - env: {}, + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, workspaceId: '', grafanaBaseUrl: '', configPath, @@ -3056,6 +3161,7 @@ test('init provision-cloud can run azd provision then import outputs explicitly' commandPaths: { azd: '/usr/local/bin/azd' }, spawnSync: (command, args) => { calls.push([command, args]); + if (command === 'az') return { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' }; if (args[0] === 'provision') return { status: 0, stdout: 'provisioned\n', stderr: '' }; if (args[0] === 'env' && args[1] === 'get-values') { return { @@ -3084,19 +3190,120 @@ test('init provision-cloud can run azd provision then import outputs explicitly' } }); +test('init mutating workflows preview until the user explicitly confirms with yes', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-init-confirmation-')); + try { + const result = spawnSync(process.execPath, [ + path.join(__dirname, '..', 'src', 'index.js'), + 'init', + '--full', + '--no-skills', + '--json' + ], { + cwd: path.join(__dirname, '..', '..'), + env: { ...process.env, AGENTOPS_CONFIG_PATH: path.join(tempDir, 'config.json') }, + encoding: 'utf8' + }); + assert.equal(result.status, 0, result.stderr); + const preview = JSON.parse(result.stdout); + + assert.equal(preview.mode, 'preview-awaiting-confirmation'); + assert.equal(preview.confirmation_required, true); + assert.equal(preview.confirmation_command, 'agentops init --full --yes'); + assert.equal(preview.summary.status, 'preview'); + assert.match(preview.summary.detail, /No cloud or workflow writes were made/); + assert.ok(preview.summary.stages.every(stage => stage.status === 'planned')); + assert.equal(preview.cloud_provision.dry_run, true); + assert.equal(preview.dashboard_import.dry_run, true); + assert.equal(preview.real_smoke.dry_run, true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('init full reuses an existing cloud binding unless reprovision is explicit', () => { + const common = { + provisionCloud: true, + dryRun: true, + noSkills: true, + workspaceId: 'workspace-123', + grafanaBaseUrl: 'https://grafana.example', + commandAvailability: { azd: true }, + azdValues: '' + }; + const reused = agentopsInit({ ...common, forceProvisionCloud: false }); + const explicit = agentopsInit({ ...common, forceProvisionCloud: true }); + + assert.equal(reused.cloud_provision.requested, false); + assert.equal(reused.cloud_provision.skipped_reason, 'cloud_already_configured'); + assert.equal(explicit.cloud_provision.requested, true); + assert.equal(explicit.cloud_provision.command, 'azd provision'); +}); + +test('init blocks a configured but missing Azure target and offers explicit recovery', () => { + const result = agentopsInit({ + full: true, + confirmationRequired: true, + noSkills: true, + workspaceId: 'workspace-123', + grafanaBaseUrl: 'https://grafana.example', + resourceGroup: 'rg-missing', + subscriptionId: TEST_APPROVED_SUBSCRIPTION_ID, + resourceGroupExists: false, + azureAccount: { + id: TEST_APPROVED_SUBSCRIPTION_ID, + name: 'Approved subscription' + }, + commandAvailability: { az: true, azd: false }, + azdValues: '' + }); + + assert.equal(result.cloud.binding_status, 'configured-but-target-missing-or-unverified'); + assert.equal(result.summary.status, 'blocked'); + assert.equal(result.summary.next_action, 'agentops init --dry-run --provision-cloud'); + assert.ok(result.summary.detail.includes('AgentOps will not redirect')); + assert.ok(result.summary.stages.every(stage => stage.status === 'blocked')); + assert.ok(result.next.includes('agentops init --dry-run --provision-cloud')); + assert.ok(result.next.includes('agentops init --provision-cloud --yes')); +}); + +test('init provision-cloud refuses azd writes when the active subscription differs', () => { + const calls = []; + const result = agentopsInit({ + provisionCloud: true, + noSkills: true, + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, + workspaceId: '', + grafanaBaseUrl: '', + commandAvailability: { azd: true }, + spawnSync: (command, args) => { + calls.push([command, args]); + if (command === 'az') return { status: 0, stdout: 'payg-test-sub\n', stderr: '' }; + return { status: 0, stdout: 'must not run', stderr: '' }; + } + }); + + assert.equal(result.cloud_provision.ok, false); + assert.equal(result.cloud_provision.failing_stage, 'subscription guard'); + assert.match(result.cloud_provision.subscription_guard.error, /refused the write/); + assert.equal(calls.some(([command, args]) => command === 'azd' && args[0] === 'provision'), false); +}); + test('init provision-cloud reports azd provision failure with remediation', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-init-provision-fail-')); try { const result = agentopsInit({ provisionCloud: true, noSkills: true, - env: {}, + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, workspaceId: '', grafanaBaseUrl: '', installDir: path.join(tempDir, 'bin'), commandAvailability: { azd: true }, commandPaths: { azd: '/usr/local/bin/azd' }, - spawnSync: () => ({ status: 1, stdout: '', stderr: 'not logged in' }) + spawnSync: command => command === 'az' + ? { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' } + : { status: 1, stdout: '', stderr: 'not logged in' } }); const output = renderInit(result); @@ -3118,7 +3325,7 @@ test('init provision-cloud reports azd import failure with manual binding remedi const result = agentopsInit({ provisionCloud: true, noSkills: true, - env: {}, + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, workspaceId: '', grafanaBaseUrl: '', configPath: path.join(tempDir, 'config.json'), @@ -3126,6 +3333,7 @@ test('init provision-cloud reports azd import failure with manual binding remedi commandAvailability: { azd: true }, commandPaths: { azd: '/usr/local/bin/azd' }, spawnSync: (command, args) => { + if (command === 'az') return { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' }; if (args[0] === 'provision') return { status: 0, stdout: 'provisioned\n', stderr: '' }; if (args[0] === 'env' && args[1] === 'get-values') return { status: 1, stdout: '', stderr: 'no env selected' }; return { status: 1, stdout: '', stderr: 'unexpected command' }; @@ -3246,7 +3454,7 @@ test('smoke live mode verifies synthetic telemetry in Azure', async () => { assert.match(output, /Azure verification: found 1 row/); }); -test('smoke real-copilot mode runs safe prompt and prints Run Replay link', async () => { +test('smoke real-copilot mode runs safe prompt and prints Run Story link', async () => { let copilotCall = null; let openedUrl = null; let latestCalls = 0; @@ -3301,8 +3509,8 @@ test('smoke real-copilot mode runs safe prompt and prints Run Replay link', asyn assert.equal(openedUrl, result.links.v2_replay_url); assert.match(output, /Real Copilot smoke: completed/); assert.match(output, /Latest Copilot run: visible after 2 attempts/); - assert.match(output, /V2 Run Replay:/); - assert.match(output, /Browser open: opened Run Replay/); + assert.match(output, /Run Story:/); + assert.match(output, /Browser open: opened Run Story/); }); test('smoke live mode fails closed when Azure ingestion is not observed', async () => { @@ -3595,9 +3803,11 @@ test('validateAzure runs read-only Azure checks with mocked az output', () => { const calls = []; const expectedDashboards = [ { uid: 'copilot-agentops', title: 'Overview' }, - { uid: 'agentops-sessions', title: 'Sessions' } + { uid: 'agentops-sessions', title: 'Sessions' }, + { uid: 'agentops-v2-run-replay', title: 'Run Story' } ]; const result = validateAzure({ + verifyDashboardContent: true, workspaceId: 'workspace-123', resourceGroup: 'rg-agentops-dev', grafanaBaseUrl: 'https://grafana.example', @@ -3624,6 +3834,13 @@ test('validateAzure runs read-only Azure checks with mocked az output', () => { features: { enableLogAccessUsingOnlyResourcePermissions: true } }), stderr: '' }; } + if (args[0] === 'grafana' && args[1] === 'dashboard' && args[2] === 'show') { + return { status: 0, stdout: JSON.stringify({ dashboard: { + uid: 'agentops-v2-run-replay', + title: 'Run Story', + panels: [{ title: 'Run Story timeline' }] + } }), stderr: '' }; + } if (args[0] === 'grafana' && args[1] === 'show') { return { status: 0, stdout: JSON.stringify({ id: '/subscriptions/sub-123/resourceGroups/rg-agentops-dev/providers/Microsoft.Dashboard/grafana/graf-agentops-dev', @@ -3677,6 +3894,8 @@ test('validateAzure runs read-only Azure checks with mocked az output', () => { assert.equal(byName['azure-account'].ok, true); assert.equal(byName['resource-group'].ok, true); assert.equal(byName['log-analytics-query'].rows, 3); + assert.equal(byName['durable-receipt-live-schema'].ok, true); + assert.equal(byName['durable-receipt-live-schema'].skipped, true); assert.equal(byName['log-analytics-posture'].ok, true); assert.equal(byName['application-insights'].ok, true); assert.equal(byName['grafana-resource'].ok, true); @@ -3686,6 +3905,9 @@ test('validateAzure runs read-only Azure checks with mocked az output', () => { assert.equal(byName['access-rbac-posture'].ok, true); assert.equal(byName['grafana-datasource'].ok, true); assert.equal(byName['grafana-dashboards'].ok, true); + assert.equal(byName['grafana-dashboard-content'].ok, true); + assert.equal(byName['grafana-dashboard-content'].checked, 1); + assert.equal(byName['grafana-dashboard-content'].stale_run_replay_mentions, 0); assert.equal(byName['alert-routing-posture'].ok, true); assert.ok(calls.some(([, args]) => args.includes('log-analytics') && args.includes('query'))); assert.ok(result.next.includes('node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s --open-browser')); @@ -4193,6 +4415,115 @@ test('validateAzure production mode flags missing budget private access and acti assert.match(result.next.join('\n'), /action group destinations/); }); +test('validateAzure team profile fails uncapped ingestion and missing budget', () => { + const result = validateAzure({ + readinessProfile: 'team', + workspaceId: 'workspace-123', + workspaceName: 'law-agentops-dev', + resourceGroup: 'rg-agentops-dev', + expectedDashboards: [], + spawnSync: (command, args) => { + if (args.includes('account') && args.includes('show')) return { status: 0, stdout: JSON.stringify({ id: 'sub-123' }), stderr: '' }; + if (args.includes('group') && args.includes('exists')) return { status: 0, stdout: 'true\n', stderr: '' }; + if (args.includes('consumption') && args.includes('budget')) return { status: 0, stdout: '[]', stderr: '' }; + if (args.includes('log-analytics') && args.includes('query')) return { status: 0, stdout: JSON.stringify([{ Rows: 1 }]), stderr: '' }; + if (args.includes('workspace') && args.includes('show')) return { status: 0, stdout: JSON.stringify({ + retentionInDays: 30, + workspaceCapping: { dailyQuotaGb: -1 }, + features: { enableLogAccessUsingOnlyResourcePermissions: true } + }), stderr: '' }; + if (args.includes('scheduled-query')) return { status: 0, stdout: '[]', stderr: '' }; + return { status: 0, stdout: '{}', stderr: '' }; + } + }); + const byName = Object.fromEntries(result.checks.map(check => [check.name, check])); + + assert.equal(byName['azure-readiness-profile'].readiness_profile, 'team'); + assert.equal(byName['azure-budget-posture'].ok, false); + assert.equal(byName['azure-budget-posture'].required, true); + assert.equal(byName['log-analytics-posture'].ok, false); + assert.deepEqual(byName['log-analytics-posture'].issues, ['daily_cap']); + assert.equal(result.ok, false); +}); + +test('validateAzure internal profile requires group RBAC without claiming production', () => { + const result = validateAzure({ + readinessProfile: 'internal', + remediationPlan: true, + workspaceId: 'workspace-123', + workspaceName: 'law-agentops-dev', + resourceGroup: 'rg-agentops-dev', + grafanaBaseUrl: 'https://grafana.example', + grafanaName: 'graf-agentops-dev', + expectedDashboards: [], + spawnSync: (command, args) => { + if (args.includes('account') && args.includes('show')) return { status: 0, stdout: JSON.stringify({ id: 'sub-123' }), stderr: '' }; + if (args.includes('group') && args.includes('exists')) return { status: 0, stdout: 'true\n', stderr: '' }; + if (args.includes('consumption') && args.includes('budget')) return { status: 0, stdout: JSON.stringify([{ name: 'budget-agentops', amount: 100 }]), stderr: '' }; + if (args.includes('log-analytics') && args.includes('query')) return { status: 0, stdout: JSON.stringify([{ Rows: 1 }]), stderr: '' }; + if (args.includes('workspace') && args.includes('show')) return { status: 0, stdout: JSON.stringify({ + id: '/subscriptions/sub-123/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev', + retentionInDays: 30, + workspaceCapping: { dailyQuotaGb: 1 }, + features: { enableLogAccessUsingOnlyResourcePermissions: true } + }), stderr: '' }; + if (args[0] === 'grafana' && args[1] === 'show') return { status: 0, stdout: JSON.stringify({ + id: '/subscriptions/sub-123/resourceGroups/rg-agentops-dev/providers/Microsoft.Dashboard/grafana/graf-agentops-dev', + identity: { type: 'SystemAssigned' }, + properties: { apiKey: 'Disabled', publicNetworkAccess: 'Enabled', zoneRedundancy: 'Disabled' } + }), stderr: '' }; + if (args[0] === 'role' && args[1] === 'assignment') return { status: 0, stdout: JSON.stringify([{ principalType: 'User', roleDefinitionId: '/subscriptions/sub-123/providers/Microsoft.Authorization/roleDefinitions/60921a7e-fef1-4a43-9b16-a26c52ad4769', roleDefinitionName: 'Grafana Viewer' }]), stderr: '' }; + if (args.includes('data-source')) return { status: 0, stdout: JSON.stringify([{ uid: 'azure-monitor-oob' }]), stderr: '' }; + if (args.includes('dashboard')) return { status: 0, stdout: '[]', stderr: '' }; + if (args.includes('scheduled-query')) return { status: 0, stdout: '[]', stderr: '' }; + return { status: 0, stdout: '{}', stderr: '' }; + } + }); + const byName = Object.fromEntries(result.checks.map(check => [check.name, check])); + + assert.equal(result.config.production, false); + assert.equal(result.config.subscription_id, 'sub-123'); + assert.equal(result.config.active_subscription_id, 'sub-123'); + assert.equal(byName['log-analytics-rbac-posture'].group_rbac_required, true); + assert.equal(byName['grafana-rbac-posture'].group_rbac_required, true); + assert.equal(byName['access-rbac-posture'].ok, false); + assert.match(result.next.join('\n'), /internal rollout/); + assert.match(result.remediation_plan.actions.find(action => action.name === 'review-agentops-rbac-assignments').reason, /^internal posture/); + assert.match(result.remediation_plan.actions.find(action => action.name === 'review-agentops-rbac-assignments').commands.join('\n'), /--profile internal/); +}); + +test('validateAzure personal profile reports cost drift as non-blocking advisory', () => { + const result = validateAzure({ + readinessProfile: 'personal', + workspaceId: 'workspace-123', + workspaceName: 'law-agentops-dev', + resourceGroup: 'rg-agentops-dev', + expectedDashboards: [], + spawnSync: (command, args) => { + if (args.includes('account') && args.includes('show')) return { status: 0, stdout: JSON.stringify({ id: 'sub-123' }), stderr: '' }; + if (args.includes('group') && args.includes('exists')) return { status: 0, stdout: 'true\n', stderr: '' }; + if (args.includes('consumption') && args.includes('budget')) return { status: 0, stdout: '[]', stderr: '' }; + if (args.includes('log-analytics') && args.includes('query')) return { status: 0, stdout: JSON.stringify([{ Rows: 1 }]), stderr: '' }; + if (args.includes('workspace') && args.includes('show')) return { status: 0, stdout: JSON.stringify({ + retentionInDays: 30, + workspaceCapping: { dailyQuotaGb: -1 }, + features: { enableLogAccessUsingOnlyResourcePermissions: true } + }), stderr: '' }; + if (args.includes('scheduled-query')) return { status: 0, stdout: '[]', stderr: '' }; + return { status: 0, stdout: '{}', stderr: '' }; + } + }); + const byName = Object.fromEntries(result.checks.map(check => [check.name, check])); + + assert.equal(byName['azure-readiness-profile'].advisory, true); + assert.match(byName['azure-readiness-profile'].detail, /personal dev\/demo/); + assert.equal(byName['azure-budget-posture'].ok, true); + assert.equal(byName['azure-budget-posture'].advisory, true); + assert.equal(byName['log-analytics-posture'].ok, true); + assert.equal(byName['log-analytics-posture'].advisory, true); + assert.equal(result.config.data_boundary, 'personal-dev-demo-metadata-only'); +}); + test('validateAzure remediation plan proposes safe Azure commands without mutating', () => { const result = validateAzure({ production: true, @@ -4339,6 +4670,8 @@ test('V2 dashboard ux-check protects the Datadog-style operator flow', () => { assert.equal(result.contracts.semantic_review, true); assert.equal(result.contracts.promotion_approvals, true); assert.equal(result.contracts.ask_agentops_context, true); + assert.equal(result.contracts.privacy_scope_boundary, true); + assert.equal(result.contracts.privacy_capture_posture, true); }); test('V2 dashboard content guardrails isolate prompt response text to the opt-in viewer', () => { @@ -4376,6 +4709,7 @@ test('product audit proves the local AgentOps control-room contract', () => { const byName = Object.fromEntries(result.checks.map(check => [check.name, check])); assert.equal(result.ok, true, result.checks.filter(check => !check.ok).map(check => `${check.name}: ${check.missing.join(', ')}`).join('\n')); + assert.ok(result.checks.filter(check => check.ok).every(check => check.missing.length === 0)); assert.equal(result.scope, 'local-product-contract'); assert.equal(result.live_azure_verified, false); assert.equal(result.live_grafana_verified, false); @@ -4388,6 +4722,7 @@ test('product audit proves the local AgentOps control-room contract', () => { 'copilot-cli-surface', 'real-copilot-otel-fixture-contract', 'copilot-sdk-adapter', + 'copilot-sdk-ordered-events-contract', 'mcp-observability-proxy', 'github-outcomes', 'evals-insights-recommendations', @@ -4458,9 +4793,9 @@ test('product audit can require rendered Grafana visual proof', async () => { ok: true, playwright: { grafana: [ - { label: 'AgentOps V2 Home', dashboardVisible: true }, - { label: 'V2 Runs Explorer', dashboardVisible: true }, - { label: 'V2 Run Replay', dashboardVisible: true } + { label: 'Today', dashboardVisible: true }, + { label: 'Runs', dashboardVisible: true }, + { label: 'Run Story', dashboardVisible: true } ] } }) @@ -4549,7 +4884,7 @@ test('product visual audit returns auth remediation when Grafana is SSO-blocked' verify_after_sign_in: ['rerun-command'] }, grafana: [ - { label: 'AgentOps V2 Home', authBlocked: true, dashboardVisible: false } + { label: 'Today', authBlocked: true, dashboardVisible: false } ] } }) @@ -4612,6 +4947,11 @@ test('V2 dashboard links preserve drilldown contracts', () => { assert.match(JSON.stringify(homeDashboard), /RecommendedNextAction/); assert.match(JSON.stringify(homeDashboard), /RootAgent/); assert.match(JSON.stringify(homeDashboard), /HealthStatus/); + assert.match(JSON.stringify(homeDashboard), /These runs are visible in Azure/); + assert.match(JSON.stringify(homeDashboard), /agentops delivery status/); + assert.match(JSON.stringify(homeDashboard), /Delivery='Visible in Azure'/); + assert.match(JSON.stringify(homeDashboard), /Coverage='AgentOps managed'/); + assert.match(JSON.stringify(homeDashboard), /Coverage='Native best effort'/); assert.match(JSON.stringify(replayDashboard), /OpenTranscript/); assert.match(JSON.stringify(replayDashboard), /viewPanel=26/); assert.match(JSON.stringify(replayDashboard), /MessageText/); @@ -4693,21 +5033,23 @@ test('dashboard import plans V2 managed Grafana import safely by default', () => test('dashboard import --yes invokes the import script with explicit env', () => { const calls = []; const result = runDashboardImport(['--yes', '--resource-group', 'rg-agentops-dev', '--grafana-name', 'graf-agentops-dev'], { - env: {}, + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: TEST_APPROVED_SUBSCRIPTION_ID, AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: TEST_APPROVED_SUBSCRIPTION_ID }, spawnSync: (command, args, options) => { calls.push({ command, args, options }); + if (command === 'az') return { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' }; return { status: 0, stdout: 'imported\n', stderr: '' }; } }); assert.equal(result.ok, true); assert.equal(result.dry_run, false); - assert.equal(calls.length, 1); - assert.match(calls[0].command, /grafana-import-dashboard\.sh$/); - assert.equal(calls[0].options.env.AGENTOPS_V2_ONLY, 'true'); - assert.equal(calls[0].options.env.GRAFANA_FOLDER, 'AgentOps for Azure'); - assert.equal(calls[0].options.env.AZURE_RESOURCE_GROUP, 'rg-agentops-dev'); - assert.equal(calls[0].options.env.GRAFANA_NAME, 'graf-agentops-dev'); + assert.equal(calls.length, 2); + assert.equal(calls[0].command, 'az'); + assert.match(calls[1].command, /grafana-import-dashboard\.sh$/); + assert.equal(calls[1].options.env.AGENTOPS_V2_ONLY, 'true'); + assert.equal(calls[1].options.env.GRAFANA_FOLDER, 'AgentOps for Azure'); + assert.equal(calls[1].options.env.AZURE_RESOURCE_GROUP, 'rg-agentops-dev'); + assert.equal(calls[1].options.env.GRAFANA_NAME, 'graf-agentops-dev'); }); test('dashboard kql-check renders representative V2 panel queries', () => { @@ -5118,20 +5460,37 @@ test('pre-tool policy emits valid deny decisions for camelCase and snake_case in test('pre-tool policy allows explicit false broad content metadata', () => { const hook = path.join(root, 'plugin', 'scripts', 'pre-tool-policy.js'); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-script-observability-')); + const eventFile = path.join(tempDir, 'sidecar-events.jsonl'); const result = spawnSync(process.execPath, [hook], { input: JSON.stringify({ + sessionId: 'script-session-1', toolName: 'mcp__filesystem__read_file', - toolArgs: { path: 'notes.md' }, + toolArgs: { path: 'SECRET_SHOULD_NOT_LEAVE.md' }, + prompt: 'PROMPT_SHOULD_NOT_LEAVE', metadata: { allowAllTools: 'false', contentCaptureEnabled: 'false' } }), - encoding: 'utf8' + encoding: 'utf8', + env: { + ...process.env, + AGENTOPS_SIDECAR_EVENTS_PATH: eventFile + } }); assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, ''); + const event = JSON.parse(fs.readFileSync(eventFile, 'utf8').trim()); + assert.equal(event.type, 'agentops.script.executed'); + assert.equal(event.data.sessionId, 'script-session-1'); + assert.equal(event.data.scriptName, 'pre-tool-policy'); + assert.equal(event.data.hookType, 'preToolUse'); + assert.equal(event.data.outcome, 'allowed'); + assert.equal(event.data.contentCapture, false); + assert.doesNotMatch(JSON.stringify(event), /SECRET_SHOULD_NOT_LEAVE|PROMPT_SHOULD_NOT_LEAVE/); + fs.rmSync(tempDir, { recursive: true, force: true }); }); test('agent stop quality gate emits metadata-only warnings without blocking', () => { @@ -5220,7 +5579,8 @@ test('Copilot hook payload fixtures stay compatible with bundled hook scripts', for (const name of ['preToolUse.camel.json', 'preToolUse.vscode.json']) { const result = spawnSync(process.execPath, [preTool], { input: fixture(name), - encoding: 'utf8' + encoding: 'utf8', + env: { ...process.env, AGENTOPS_SIDECAR_EVENTS_PATH: eventFile } }); assert.equal(result.status, 0, result.stderr); const decision = JSON.parse(result.stdout); @@ -5229,14 +5589,16 @@ test('Copilot hook payload fixtures stay compatible with bundled hook scripts', const failure = spawnSync(process.execPath, [postToolFailure], { input: fixture('postToolUseFailure.camel.json'), - encoding: 'utf8' + encoding: 'utf8', + env: { ...process.env, AGENTOPS_SIDECAR_EVENTS_PATH: eventFile } }); assert.equal(failure.status, 2, failure.stderr); assert.match(failure.stdout, /Recovery hint/); const stop = spawnSync(process.execPath, [stopGate], { input: fixture('stop.vscode.json'), - encoding: 'utf8' + encoding: 'utf8', + env: { ...process.env, AGENTOPS_SIDECAR_EVENTS_PATH: eventFile } }); assert.equal(stop.status, 0, stop.stderr); @@ -5249,7 +5611,8 @@ test('Copilot hook payload fixtures stay compatible with bundled hook scripts', } }); assert.equal(notification.status, 0, notification.stderr); - const event = JSON.parse(fs.readFileSync(eventFile, 'utf8').trim()); + const event = fs.readFileSync(eventFile, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line)) + .find(row => row['agentops.hook.type'] === 'Notification'); assert.equal(event['agentops.hook.type'], 'Notification'); assert.equal(event['agentops.hook.reason_category'], 'permission_required'); assert.equal(event['gen_ai.conversation.id'], 'hook-session-notify'); @@ -5310,33 +5673,39 @@ test('E2E live commands force strict privacy and content capture off', () => { test('E2E Grafana screenshot targets use stable V2 tour names', () => { const targets = grafanaScreenshotTargets([ - { label: 'AgentOps V2 Home', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-home' }, - { label: 'V2 Runs Explorer', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-runs-explorer' }, - { label: 'V2 Run Replay', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-run-replay' }, + { label: 'Today', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-home' }, + { label: 'Runs', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-runs-explorer' }, + { label: 'Run Story', url: 'https://grafana.example.grafana.azure.com/d/agentops-v2-run-replay' }, { label: 'Overview', url: 'https://grafana.example.grafana.azure.com/d/overview' } ], { v2Only: true }); assert.deepEqual(targets.map(target => target.fileName), [ 'agentops-v2-home-live.png', 'agentops-v2-runs-explorer-live.png', - 'agentops-v2-run-replay-live.png' + 'agentops-v2-run-replay-live.png', + 'agentops-v2-models-cost-tokens-live.png', + 'agentops-v2-tools-mcp-risk-live.png', + 'agentops-v2-safety-privacy-policy-live.png', + 'agentops-v2-code-outcomes-live.png', + 'agentops-v2-evals-quality-live.png', + 'agentops-v2-insights-regressions-live.png', + 'agentops-v2-collector-health-live.png' ]); assert.ok(targets.every(target => target.v2Tour)); }); test('E2E Grafana visual gate distinguishes auth-blocked from visible dashboards', () => { const authBlocked = [ - { label: 'AgentOps V2 Home', authBlocked: true, dashboardVisible: false }, - { label: 'V2 Runs Explorer', authBlocked: true, dashboardVisible: false } + { label: 'Today', authBlocked: true, dashboardVisible: false }, + { label: 'Runs', authBlocked: true, dashboardVisible: false } ]; const visible = [ - { label: 'AgentOps V2 Home', authBlocked: false, dashboardVisible: true }, - { label: 'V2 Runs Explorer', authBlocked: false, dashboardVisible: true } + { label: 'Today', authBlocked: false, dashboardVisible: true }, + { label: 'Runs', authBlocked: false, dashboardVisible: true } ]; - assert.equal(grafanaVisualOk(authBlocked, false), true); - assert.equal(grafanaVisualOk(authBlocked, true), false); - assert.equal(grafanaVisualOk(visible, true), true); + assert.equal(grafanaVisualOk(authBlocked), false); + assert.equal(grafanaVisualOk(visible), true); }); test('E2E browser profile args support authenticated Grafana visual QA', () => { @@ -5392,7 +5761,7 @@ test('E2E auth-profile command renders reusable Grafana sign-in profile steps', }); test('import-jsonl summarizes operations', () => { - const result = importJsonl(path.join(root, 'tests', 'sample-otel', 'tool-failure.jsonl')); + const result = importJsonl(path.join(root, 'fixtures', 'sample-otel', 'tool-failure.ndjson.fixture')); assert.equal(result.rows, 2); assert.equal(result.operations.invoke_agent, 1); assert.equal(result.operations.execute_tool, 1); @@ -8194,15 +8563,40 @@ test('link trace builds OperationId query', () => { }); test('latest summarizes a fixture session in plain language', () => { - const summary = latestSessionSummary({ filePath: path.join(root, 'tests', 'sample-otel', 'tool-failure.jsonl') }); + const summary = latestSessionSummary({ filePath: path.join(root, 'fixtures', 'sample-otel', 'tool-failure.ndjson.fixture') }); const output = renderLatest(summary); assert.equal(summary.session.id, 'conv-tool-failure'); + assert.match(output, /AgentOps receipt/); assert.match(output, /Latest Copilot session/); assert.match(output, /1 tool call/); - assert.match(output, /Tools: shell/); - assert.match(output, /Grafana session:/); - assert.match(output, /Data missing: live Azure query/); + assert.match(output, /tools shell/); + assert.match(output, /Open team view:/); + assert.match(output, /Not available here: live Azure query/); +}); + +test('latest distinguishes dropped sensitive fields from retained content', () => { + const summary = latestSessionSummary({ + rows: [{ + TimeGenerated: '2026-08-03T14:55:24.000Z', + RunId: 'run-safe', + SessionId: 'session-safe', + OutcomeStatus: 'success', + DurationMs: 4900, + ContentCaptureMode: 'signal_only', + ContentCaptureSignal: true, + PrivacyMode: 'strict' + }], + source: 'local' + }); + const output = renderLatest(summary); + + assert.equal(summary.session.content_capture_warning, false); + assert.equal(summary.session.content_dropped_signal, true); + assert.equal(summary.session.duration_ms, 4900); + assert.match(output, /sensitive fields were detected and dropped/); + assert.match(output, /1 run summary/); + assert.doesNotMatch(output, /content capture may be on/); }); test('latest summarizes Azure query rows with Properties JSON strings', () => { @@ -8257,7 +8651,7 @@ test('latest summarizes Azure query rows with Properties JSON strings', () => { assert.equal(summary.session.output_tokens, 40); assert.equal(summary.session.credits, 2); assert.equal(summary.session.data_missing.includes('live Azure query'), false); - assert.match(output, /Tools: azure-mcp\/monitor/); + assert.match(output, /tools azure-mcp\/monitor/); assert.match(output, /Estimated cost: \$0\.02/); assert.doesNotMatch(output, /live Azure query/); }); @@ -8386,18 +8780,18 @@ test('latest Azure query rejects unsafe lookback values before spawning az', () }); test('explain latest classifies fixture sessions with simple labels', () => { - const failed = latestSessionSummary({ filePath: path.join(root, 'tests', 'sample-otel', 'tool-failure.jsonl') }); + const failed = latestSessionSummary({ filePath: path.join(root, 'fixtures', 'sample-otel', 'tool-failure.ndjson.fixture') }); const failedOutput = renderExplanation(explainLatest(failed)); assert.match(failedOutput, /Tools kept failing/); - const success = latestSessionSummary({ filePath: path.join(root, 'tests', 'sample-otel', 'simple-success.jsonl') }); + const success = latestSessionSummary({ filePath: path.join(root, 'fixtures', 'sample-otel', 'simple-success.ndjson.fixture') }); const successExplanation = explainLatest(success); assert.equal(successExplanation.classification, 'success'); assert.match(renderExplanation(successExplanation), /This session looks successful/); }); test('recommendation contract turns latest failure into evidence-backed next actions', () => { - const failed = latestSessionSummary({ filePath: path.join(root, 'tests', 'sample-otel', 'tool-failure.jsonl') }); + const failed = latestSessionSummary({ filePath: path.join(root, 'fixtures', 'sample-otel', 'tool-failure.ndjson.fixture') }); const recommendation = recommendationForExplanation(explainLatest(failed), { last: '7d' }); const output = renderRecommendation(recommendation); @@ -8411,13 +8805,13 @@ test('recommendation contract turns latest failure into evidence-backed next act }); test('open prints main Grafana and latest fixture session links', () => { - const summary = latestSessionSummary({ filePath: path.join(root, 'tests', 'sample-otel', 'simple-success.jsonl') }); + const summary = latestSessionSummary({ filePath: path.join(root, 'fixtures', 'sample-otel', 'simple-success.ndjson.fixture') }); const output = renderOpenLinks(openLinksSummary(summary)); assert.match(output, /Main dashboard:/); - assert.match(output, /AgentOps V2 Home:/); + assert.match(output, /Today:/); assert.match(output, /agentops-v2-home/); - assert.match(output, /V2 Run Replay:/); + assert.match(output, /Run Story:/); assert.match(output, /copilot-cli-agentops/); assert.match(output, /Latest session:/); assert.match(output, /var-conversation=conv-success/); @@ -8505,7 +8899,7 @@ test('replay summary avoids double counting parent agent usage when chat usage e }); test('live view can render from a local JSONL export', () => { - const view = liveViewFromArgs(['--file', path.join(root, 'tests', 'sample-otel', 'tool-failure.jsonl')]); + const view = liveViewFromArgs(['--file', path.join(root, 'fixtures', 'sample-otel', 'tool-failure.ndjson.fixture')]); const output = renderLive(view); assert.equal(view.ok, true); @@ -8515,7 +8909,7 @@ test('live view can render from a local JSONL export', () => { }); test('span source reads local JSONL without Azure access', () => { - const source = spanRowsFromSource(['--file', path.join(root, 'tests', 'sample-otel', 'simple-success.jsonl')]); + const source = spanRowsFromSource(['--file', path.join(root, 'fixtures', 'sample-otel', 'simple-success.ndjson.fixture')]); assert.equal(source.mode, 'local'); assert.equal(source.rows.length, 2); @@ -8638,8 +9032,11 @@ test('command plan exposes shadow and collector lifecycle commands', () => { assert.match(install.command, /install-agentops\.sh$/); assert.deepEqual(install.args, []); + const installShadow = commandPlan('install', ['--shadow-copilot'], 'darwin'); + assert.deepEqual(installShadow.args, ['--shadow-copilot']); + const installNoShadow = commandPlan('install', ['--no-shadow-copilot', '--no-collector'], 'darwin'); - assert.deepEqual(installNoShadow.args, ['--no-shadow-copilot', '--no-collector']); + assert.deepEqual(installNoShadow.args, ['--no-collector']); const enable = commandPlan('enable-shadow', [], 'darwin'); assert.match(enable.command, /install-copilot-agentops-shim\.sh$/); @@ -8707,6 +9104,21 @@ test('PowerShell shim does not auto-install plugin files and uninstall advice is assert.doesNotMatch(uninstallScript, /docker compose/); }); +test('top-level installers leave plain copilot unchanged unless explicitly opted in', () => { + const shellInstaller = fs.readFileSync(path.join(root, 'install-agentops.sh'), 'utf8'); + const powershellInstaller = fs.readFileSync(path.join(root, 'install-agentops.ps1'), 'utf8'); + const setupScript = fs.readFileSync(path.join(root, 'setup-agentops.sh'), 'utf8'); + + assert.match(shellInstaller, /shadow_copilot=false/); + assert.match(shellInstaller, /--shadow-copilot\s+Opt in/); + assert.match(shellInstaller, /collector start --mode local --privacy strict/); + assert.match(powershellInstaller, /\$installShadow = \$false/); + assert.match(powershellInstaller, /collector start --mode local --privacy strict/); + assert.match(setupScript, /agentops copilot --no-ask-user/); + assert.match(setupScript, /collector start --mode local --privacy strict/); + assert.match(setupScript, /Plain copilot is unchanged/); +}); + test('policy and mcp KQL queries expose documented Copilot dimensions', () => { const policy = kqlFileQuery('15-policy-governance.kql', '30d'); assert.match(policy, /let lookback = 30d;/); @@ -8984,8 +9396,8 @@ test('shared store write API accepts only metadata-only recommendation saved-vie Action: 'reduce_context', Severity: 'medium', ObservedPattern: 'context pressure', - NextAction: 'Open Run Replay', - DashboardTitles: ['Run Replay'], + NextAction: 'Open Run Story', + DashboardTitles: ['Run Story'], DashboardCount: 1, Validation: ['Run benchmark'], RollbackCondition: 'Revert if eval drops' @@ -9129,7 +9541,7 @@ test('ask agentops launcher builds metadata-only assistant context', async () => compare_command: 'agentops recommend run-123 --runs <after-AgentOpsRunSummary_CL.jsonl>' }, ChangeTargetRefs: ['skill:agentops-latest-run'], - DashboardTitles: ['Run Replay'], + DashboardTitles: ['Run Story'], DashboardCount: 1, Validation: ['agentops experimental benchmark report bench-123'], RollbackCondition: 'Revert the skill change if eval score drops.' @@ -9816,9 +10228,13 @@ test('alert azure devops route requires review gates before posting', () => { owners: ['agentops-oncall@example.com'], org: 'https://dev.azure.com/contoso', project: 'AgentOps', + expectedSubscriptionId: 'sub-approved', + approvedSubscriptionIds: ['sub-approved'], + env: { AGENTOPS_AZURE_SUBSCRIPTION_ID: 'sub-approved', AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: 'sub-approved' }, yes: true, spawnSync: (command, args, options) => { invoked = { command, args, options }; + if (args[0] === 'account') return { status: 0, stdout: 'sub-approved\n', stderr: '' }; return { status: 0, stdout: '{"id":123,"url":"https://dev.azure.com/contoso/_apis/wit/workItems/123"}', stderr: '' }; } }); @@ -9830,14 +10246,37 @@ test('alert azure devops route requires review gates before posting', () => { assert.equal(invoked.args[0], 'boards'); assert.equal(invoked.options.encoding, 'utf8'); + let writeCalled = false; + const refused = alertAzureDevOpsWorkItemRoute({ + rule: 'failed-spans', + session: 'session-456', + owners: ['agentops-oncall@example.com'], + org: 'https://dev.azure.com/contoso', + project: 'AgentOps', + expectedSubscriptionId: 'sub-unapproved', + approvedSubscriptionIds: ['sub-approved'], + yes: true, + spawnSync: () => { + writeCalled = true; + return { status: 0, stdout: '', stderr: '' }; + } + }); + assert.equal(refused.mode, 'refused-azure-devops-work-item-route'); + assert.match(refused.error, /not in AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS/); + assert.equal(writeCalled, false); + const failed = alertAzureDevOpsWorkItemRoute({ rule: 'failed-spans', session: 'session-456', owners: ['agentops-oncall@example.com'], org: 'https://dev.azure.com/contoso', project: 'AgentOps', + expectedSubscriptionId: 'sub-approved', + approvedSubscriptionIds: ['sub-approved'], yes: true, - spawnSync: () => ({ status: 1, stdout: '', stderr: 'not logged in' }) + spawnSync: (command, args) => args[0] === 'account' + ? ({ status: 0, stdout: 'sub-approved\n', stderr: '' }) + : ({ status: 1, stdout: '', stderr: 'not logged in' }) }); assert.equal(failed.mode, 'failed-azure-devops-work-item-route'); assert.equal(failed.error, 'not logged in'); @@ -9914,8 +10353,11 @@ test('alert action group route requires review gates before updating scheduled q actionGroups: [actionGroupId], enableAlert: true, yes: true, + expectedSubscriptionId: TEST_APPROVED_SUBSCRIPTION_ID, + approvedSubscriptionIds: [TEST_APPROVED_SUBSCRIPTION_ID], spawnSync: (command, args, options) => { invoked = { command, args, options }; + if (args[0] === 'account') return { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' }; return { status: 0, stdout: '{"name":"sqr-agentops-failed-spans"}', stderr: '' }; } }); @@ -9937,7 +10379,11 @@ test('alert action group route requires review gates before updating scheduled q scheduledQuery: 'sqr-agentops-failed-spans', actionGroups: [actionGroupId], yes: true, - spawnSync: () => ({ status: 1, stdout: '', stderr: 'not authorized' }) + expectedSubscriptionId: TEST_APPROVED_SUBSCRIPTION_ID, + approvedSubscriptionIds: [TEST_APPROVED_SUBSCRIPTION_ID], + spawnSync: (command, args) => args[0] === 'account' + ? { status: 0, stdout: `${TEST_APPROVED_SUBSCRIPTION_ID}\n`, stderr: '' } + : { status: 1, stdout: '', stderr: 'not authorized' } }); assert.equal(failed.mode, 'failed-action-group-route'); assert.equal(failed.error, 'not authorized'); diff --git a/agentops-cli/test/insights-command.test.js b/agentops-cli/test/insights-command.test.js new file mode 100644 index 0000000..9b401b1 --- /dev/null +++ b/agentops-cli/test/insights-command.test.js @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + normalizeInsightsArgs, + patternRows, + renderPatterns +} = require('../src/lib/insights-command'); + +test('insights command library normalizes args and renders recurring patterns', () => { + assert.deepEqual(normalizeInsightsArgs(['--runs', 'AgentOpsRunSummary_CL.jsonl']), [ + 'generate', + '--runs', + 'AgentOpsRunSummary_CL.jsonl' + ]); + assert.deepEqual(normalizeInsightsArgs([]), ['patterns']); + + const rows = patternRows([ + { InsightType: 'other', PatternRuns: 99 }, + { InsightType: 'recurring-cost', PatternRuns: 2, PatternKey: 'low' }, + { InsightType: 'recurring-tool', PatternRuns: 5, PatternKey: 'high', Summary: 'Tool drift' } + ]); + assert.deepEqual(rows.map(row => row.PatternKey), ['high', 'low']); + assert.match(renderPatterns(rows), /Tool drift/); +}); diff --git a/agentops-cli/test/json-fixtures.test.js b/agentops-cli/test/json-fixtures.test.js new file mode 100644 index 0000000..cc7cb3f --- /dev/null +++ b/agentops-cli/test/json-fixtures.test.js @@ -0,0 +1,28 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { writeJsonlFixture } = require('./support/json-fixtures'); + +test('test JSON fixture helper owns JSONL file writing', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-json-fixtures-')); + const file = writeJsonlFixture(path.join(dir, 'rows.jsonl'), [{ id: 1 }, { id: 2 }]); + + assert.equal(file, path.join(dir, 'rows.jsonl')); + assert.equal(fs.readFileSync(file, 'utf8'), '{"id":1}\n{"id":2}\n'); + + for (const testFile of [ + 'core-helpers.test.js', + 'index.test.js', + 'recommendation-files.test.js', + 'triage-packet.test.js', + 'v2-ask-context.test.js', + 'v2-open-links.test.js' + ]) { + const source = fs.readFileSync(path.join(__dirname, testFile), 'utf8'); + assert.doesNotMatch(source, /function writeJsonl\(/, testFile); + assert.doesNotMatch(source, /rows\.map\(row => JSON\.stringify\(row\)\)/, testFile); + } +}); diff --git a/agentops-cli/test/jsonl.test.js b/agentops-cli/test/jsonl.test.js new file mode 100644 index 0000000..47953f5 --- /dev/null +++ b/agentops-cli/test/jsonl.test.js @@ -0,0 +1,39 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { readJson, readJsonl, readJsonlIfExists, readJsonlRows } = require('../src/lib/json'); + +test('readJsonl reads newline-delimited JSON rows and tolerates missing paths', () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-jsonl-')), 'rows.jsonl'); + + fs.writeFileSync(file, '{"id":1}\n\n{"id":2}\r\n'); + + assert.deepEqual(readJsonl(file), [{ id: 1 }, { id: 2 }]); + assert.deepEqual(readJsonl(''), []); + assert.deepEqual(readJsonl(null), []); +}); + +test('readJsonlRows preserves the shared JSONL row-reader surface', () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-jsonl-rows-')), 'rows.jsonl'); + + fs.writeFileSync(file, '{"name":"first"}\n{"name":"second"}\n'); + + assert.deepEqual(readJsonlRows(file), [{ name: 'first' }, { name: 'second' }]); +}); + +test('readJsonlIfExists returns an empty list for missing files', () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-jsonl-missing-')), 'missing.jsonl'); + + assert.deepEqual(readJsonlIfExists(file), []); +}); + +test('readJson reads a UTF-8 JSON file', () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-json-')), 'payload.json'); + + fs.writeFileSync(file, '{"ok":true,"count":2}'); + + assert.deepEqual(readJson(file), { ok: true, count: 2 }); +}); diff --git a/agentops-cli/test/legacy-runtime.test.js b/agentops-cli/test/legacy-runtime.test.js new file mode 100644 index 0000000..42214cf --- /dev/null +++ b/agentops-cli/test/legacy-runtime.test.js @@ -0,0 +1,12 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('legacy runtime module exposes the legacy CLI surface', () => { + const runtime = require('../src/lib/legacy-runtime'); + + assert.equal(typeof runtime.main, 'function'); + assert.equal(typeof runtime.latestSummaryFromArgs, 'function'); + assert.equal(typeof runtime.validateAzure, 'function'); + assert.equal(typeof runtime.agentopsSetupGuide, 'function'); + assert.equal(typeof runtime.benchmarkReport, 'function'); +}); diff --git a/agentops-cli/test/local-status.test.js b/agentops-cli/test/local-status.test.js new file mode 100644 index 0000000..0fb359c --- /dev/null +++ b/agentops-cli/test/local-status.test.js @@ -0,0 +1,23 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createLocalStatus } = require('../src/lib/local-status'); + +test('plain Copilot shadow must actually invoke AgentOps', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-shadow-status-')); + const shadow = path.join(tempDir, 'copilot'); + const status = createLocalStatus({ root: path.resolve(__dirname, '..', '..'), defaultInstallDir: tempDir }); + + try { + fs.writeFileSync(shadow, '#!/usr/bin/env bash\nexport COPILOT_CLI_BIN="/opt/bin/copilot"\nexec "$COPILOT_CLI_BIN" "$@"\n'); + assert.equal(status.shadowObservesCopilot(shadow), false); + + fs.writeFileSync(shadow, '#!/usr/bin/env bash\nexec "/repo/scripts/copilot-agentops" "$@"\n'); + assert.equal(status.shadowObservesCopilot(shadow), true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/logs-ingestion-upload.test.js b/agentops-cli/test/logs-ingestion-upload.test.js new file mode 100644 index 0000000..584c172 --- /dev/null +++ b/agentops-cli/test/logs-ingestion-upload.test.js @@ -0,0 +1,226 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + createDurableLogsIngestionUploader, + runLogsIngestionUpload +} = require('../src/lib/azure/logs-ingestion-upload'); + +function approvedSubscriptionSpawn(command, args) { + assert.equal(command, 'az'); + assert.deepEqual(args, ['account', 'show', '--query', 'id', '-o', 'tsv']); + return { status: 0, stdout: '11111111-1111-4111-8111-111111111111\n', stderr: '' }; +} + +test('runLogsIngestionUpload converts JSONL rows, posts them, and removes the temporary payload', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-logs-ingestion-upload-')); + try { + const jsonlFile = path.join(tempDir, 'AgentOpsEvents_CL.jsonl'); + fs.writeFileSync(jsonlFile, `${JSON.stringify({ RunId: 'run-1', EventName: 'agent.start' })}\n`); + const calls = []; + let uploadedBody; + let temporaryBodyFile; + + const result = runLogsIngestionUpload({ + ok: true, + dir: tempDir, + errors: [], + uploads: [{ + table: 'AgentOpsEvents_CL', + file: jsonlFile, + uri: 'https://ingest.example/dataCollectionRules/dcr/streams/Custom-AgentOpsEvents_CL?api-version=2023-01-01', + rows: 1 + }] + }, { + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync(command, args) { + calls.push([command, args]); + if (args[0] === 'account') { + return { status: 0, stdout: '11111111-1111-4111-8111-111111111111\n', stderr: '' }; + } + temporaryBodyFile = args.find(arg => String(arg).startsWith('@')).slice(1); + uploadedBody = JSON.parse(fs.readFileSync(temporaryBodyFile, 'utf8')); + return { status: 0, stdout: '', stderr: '' }; + } + }); + + assert.equal(result.ok, true); + assert.equal(result.executed, true); + assert.equal(calls.length, 2); + assert.deepEqual(calls[0], ['az', ['account', 'show', '--query', 'id', '-o', 'tsv']]); + assert.equal(calls[1][0], 'az'); + assert.ok(calls[1][1].includes('--resource')); + assert.equal(uploadedBody[0].RunId, 'run-1'); + assert.equal(fs.existsSync(temporaryBodyFile), false); + assert.equal(result.temporary_payloads_cleaned, true); + assert.equal('body_file' in result.uploads[0], false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('runLogsIngestionUpload removes temporary payloads when az upload fails', () => { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-logs-ingestion-failure-')); + try { + const jsonlFile = path.join(sourceDir, 'AgentOpsContent_CL.jsonl'); + fs.writeFileSync(jsonlFile, `${JSON.stringify({ Content: 'PRIVATE_REPRO_SENTINEL' })}\n`); + let temporaryBodyFile; + const result = runLogsIngestionUpload({ + ok: true, + dir: sourceDir, + errors: [], + uploads: [{ + table: 'AgentOpsContent_CL', + file: jsonlFile, + uri: 'https://ingest.example/dataCollectionRules/dcr/streams/Custom-AgentOpsContent_CL?api-version=2023-01-01', + rows: 1 + }] + }, { + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync(_command, args) { + if (args[0] === 'account') return { status: 0, stdout: '11111111-1111-4111-8111-111111111111\n', stderr: '' }; + temporaryBodyFile = args.find(arg => String(arg).startsWith('@')).slice(1); + assert.match(fs.readFileSync(temporaryBodyFile, 'utf8'), /PRIVATE_REPRO_SENTINEL/); + return { status: 1, stdout: '', stderr: 'upload failed' }; + } + }); + + assert.equal(result.ok, false); + assert.equal(result.temporary_payloads_cleaned, true); + assert.equal(fs.existsSync(temporaryBodyFile), false); + assert.equal(fs.existsSync(path.dirname(temporaryBodyFile)), false); + } finally { + fs.rmSync(sourceDir, { recursive: true, force: true }); + } +}); + +test('runLogsIngestionUpload refuses Azure writes when the active subscription differs', () => { + const calls = []; + const result = runLogsIngestionUpload({ + ok: true, + dir: '/tmp/not-used', + errors: [], + uploads: [{ table: 'AgentOpsEvents_CL', file: '/tmp/not-read', uri: 'https://example', rows: 1 }] + }, { + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync(command, args) { + calls.push([command, args]); + return { status: 0, stdout: '11111111-1111-1111-1111-111111111111\n', stderr: '' }; + } + }); + + assert.equal(result.ok, false); + assert.equal(result.executed, false); + assert.equal(result.uploads.length, 0); + assert.match(result.errors[0], /refused the write/); + assert.equal(calls.length, 1); +}); + +test('durable uploader targets the canonical DCR stream and refreshes an expired token', async () => { + const requests = []; + const tokens = ['expired-token', 'fresh-token']; + const uploader = createDurableLogsIngestionUploader({ + endpoint: 'https://example.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-immutable-safe', + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync: approvedSubscriptionSpawn, + tokenProvider: async () => tokens.shift(), + fetchImpl: async (uri, request) => { + requests.push({ uri, request }); + return { status: requests.length === 1 ? 401 : 204, headers: {} }; + } + }); + + const row = { RunId: 'run-safe', Sequence: 1, EventId: 'event-safe' }; + const response = await uploader(row, { table: 'AgentOpsEvents_CL' }); + assert.equal(response.status, 204); + assert.equal(requests.length, 2); + assert.match(requests[0].uri, /streams\/Custom-AgentOpsEvents_CL\?api-version=2023-01-01$/); + assert.equal(requests[0].request.headers.Authorization, 'Bearer expired-token'); + assert.equal(requests[1].request.headers.Authorization, 'Bearer fresh-token'); + assert.deepEqual(JSON.parse(requests[1].request.body), [row]); +}); + +test('durable uploader fails closed on subscription mismatch and non-canonical tables', async () => { + assert.throws(() => createDurableLogsIngestionUploader({ + endpoint: 'https://example.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-safe', + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync() { + return { status: 0, stdout: '360d1dc4-b7ca-41c9-b3bf-399942056b69\n', stderr: '' }; + } + }), /refused the write/); + + const uploader = createDurableLogsIngestionUploader({ + endpoint: 'https://example.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-safe', + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync: approvedSubscriptionSpawn, + tokenProvider: async () => 'unused', + fetchImpl: async () => { throw new Error('must not send'); } + }); + assert.equal((await uploader({}, { table: 'AgentOpsUnknown_CL' })).status, 400); +}); + +test('durable uploader rejects token-exfiltration endpoints and malformed DCR IDs', () => { + const common = { + dcrImmutableId: 'dcr-safe', + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync: approvedSubscriptionSpawn + }; + for (const endpoint of [ + 'https://attacker.example', + 'https://example.ingest.monitor.azure.com.attacker.example', + 'https://user:password@example.ingest.monitor.azure.com', + 'https://example.ingest.monitor.azure.com/path', + 'https://example.ingest.monitor.azure.com?forward=true', + 'https://example.ingest.monitor.azure.com#fragment' + ]) { + assert.throws(() => createDurableLogsIngestionUploader({ ...common, endpoint }), /Azure public Monitor ingestion endpoint/); + } + assert.throws(() => createDurableLogsIngestionUploader({ + ...common, + endpoint: 'https://example.ingest.monitor.azure.com', + dcrImmutableId: '../unsafe' + }), /valid DCR immutable ID/); +}); + +test('durable uploader applies a bounded timeout and cancels response bodies', async () => { + let request; + let cancelled = 0; + const uploader = createDurableLogsIngestionUploader({ + endpoint: 'https://example.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-safe', + timeoutMs: 1234, + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync: approvedSubscriptionSpawn, + tokenProvider: async () => 'safe-token', + fetchImpl: async (_uri, options) => { + request = options; + return { status: 204, headers: {}, body: { async cancel() { cancelled += 1; } } }; + } + }); + const response = await uploader({}, { table: 'AgentOpsEvents_CL' }); + assert.equal(response.status, 204); + assert.ok(request.signal); + assert.equal(cancelled, 1); + assert.throws(() => createDurableLogsIngestionUploader({ + endpoint: 'https://example.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-safe', + timeoutMs: 120001, + expectedSubscriptionId: '11111111-1111-4111-8111-111111111111', + approvedSubscriptionIds: ['11111111-1111-4111-8111-111111111111'], + spawnSync: approvedSubscriptionSpawn + }), /timeoutMs/); +}); diff --git a/agentops-cli/test/native-onboarding.test.js b/agentops-cli/test/native-onboarding.test.js new file mode 100644 index 0000000..10dae97 --- /dev/null +++ b/agentops-cli/test/native-onboarding.test.js @@ -0,0 +1,196 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { optionValue } = require('../src/lib/cli-options'); +const { createSetupInit } = require('../src/lib/setup-init'); +const { usage: legacyUsage } = require('../src/lib/usage'); +const { usage: surfaceUsage } = require('../src/lib/cli-surface'); + +function createHarness() { + const calls = []; + const setup = createSetupInit({ + agentopsStatusSummary({ checks }) { + calls.push(['status', checks]); + return { + required_files: { found: 1, total: 1, missing: [] }, + content_capture_off: true, + collector_localhost: true + }; + }, + commandCandidates: () => [], + configFromEnvValues: () => ({}), + configuredCloudValues: () => ({}), + defaultInstallDir: path.join(os.tmpdir(), 'agentops-native-onboarding-bin'), + doctor(options) { + calls.push(['doctor', options]); + return [{ name: 'exists:local', ok: true }]; + }, + grafanaDashboardImportCommand: () => 'not-used', + installDefaultAgents(options) { + const target = path.join(options.copilotHome, 'agents', 'agentops.agent.md'); + if (!options.dryRun) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, 'agentops'); + } + return { + targetDir: path.dirname(target), + agents: [{ name: 'agentops', file: 'agentops.agent.md' }], + installedAgents: [{ target }], + updated: [], + skipped: [] + }; + }, + installDefaultSkills(options) { + const target = path.join(options.copilotHome, 'skills', 'agentops', 'SKILL.md'); + if (!options.dryRun) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, 'agentops'); + } + return { + targetDir: path.dirname(target), + skills: [{ name: 'agentops', directory: 'agentops' }], + installedSkills: [{ target: path.dirname(target) }], + updated: [], + skipped: [] + }; + }, + installedShimStatus: () => ({ agentops_cli_installed: false }), + isConfiguredValue: () => false, + optionValue, + parseEnvAssignments: () => ({}), + plural: (count, noun) => `${count} ${noun}`, + realCopilotSmokeCommand: () => 'copilot', + validateAzure: () => { + throw new Error('Azure must not be consulted by local-only init'); + } + }); + return { calls, setup }; +} + +test('local-only init parser separates native local setup from full compatibility init', () => { + const { setup } = createHarness(); + + assert.deepEqual(setup.parseInitArgs(['--local-only']), { + dryRun: true, + full: false, + localOnly: true, + yes: false, + shell: 'bash', + confirmationRequired: true, + confirmationCommand: 'agentops init --local-only --yes --shell bash', + forceSkills: false, + json: false, + importDashboards: false, + noSkills: false, + provisionCloud: false, + forceProvisionCloud: false, + runSmoke: false, + triageLatest: false, + copilotHome: null, + checkAzureAccount: true + }); + + const applied = setup.parseInitArgs(['--local-only', '--yes', '--shell', 'fish', '--no-skills']); + assert.equal(applied.dryRun, false); + assert.equal(applied.localOnly, true); + assert.equal(applied.shell, 'fish'); + assert.equal(applied.confirmationRequired, false); + assert.equal(applied.noSkills, true); + + const full = setup.parseInitArgs(['--full', '--dry-run', '--no-skills']); + assert.equal(full.full, true); + assert.equal(full.localOnly, false); + assert.equal(full.shell, null); + assert.throws(() => setup.parseInitArgs(['--local-only', '--provision-cloud']), /cannot be combined/); + assert.throws(() => setup.parseInitArgs(['--local-only', '--shell', 'cmd']), /must be/); + assert.throws(() => setup.parseInitArgs(['--shell', 'zsh']), /only supported/); +}); + +test('local-only preview is side-effect free, cloud-free, wrapper-free, and uses planned vocabulary', () => { + const { calls, setup } = createHarness(); + const copilotHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-native-preview-')); + try { + const result = setup.agentopsInit({ + localOnly: true, + dryRun: true, + confirmationRequired: true, + confirmationCommand: 'agentops init --local-only --yes --shell zsh', + copilotHome, + shell: 'zsh' + }); + const output = setup.renderInit(result); + + assert.equal(result.mutates, false); + assert.equal(result.cloud.checked, false); + assert.equal(result.wrapper_required, false); + assert.deepEqual(result.would_start, ['agentops collector start --mode local --privacy strict']); + assert.equal(result.applied, null); + assert.equal(fs.existsSync(path.join(copilotHome, 'skills')), false); + assert.equal(fs.existsSync(path.join(copilotHome, 'agents')), false); + assert.match(output, /Mode: preview/); + assert.match(output, /would_install:/); + assert.match(output, /would_write:/); + assert.match(output, /would_start:/); + assert.match(output, /would_emit/); + assert.doesNotMatch(output, /installed/i); + assert.doesNotMatch(output, /az account|azd provision|agentops copilot/); + const jsonPreview = setup.renderInit({ ...result, native_otel: { ...result.native_otel, shell: 'json' } }); + assert.doesNotMatch(jsonPreview, /installed/i); + assert.deepEqual(calls.map(([name]) => name), ['doctor', 'status']); + } finally { + fs.rmSync(copilotHome, { recursive: true, force: true }); + } +}); + +test('local-only yes applies only AgentOps-owned assets and emits native exports for every shell', () => { + const shells = ['bash', 'zsh', 'fish', 'powershell', 'json']; + for (const shell of shells) { + const { setup } = createHarness(); + const copilotHome = fs.mkdtempSync(path.join(os.tmpdir(), `agentops-native-${shell}-`)); + try { + const result = setup.agentopsInit({ + localOnly: true, + dryRun: false, + copilotHome, + shell + }); + const output = setup.renderInit(result); + + assert.equal(result.mutates, true); + assert.equal(result.cloud.checked, false); + assert.equal(result.wrapper_required, false); + assert.equal(result.native_otel.exports.COPILOT_OTEL_ENABLED, 'true'); + assert.equal(result.native_otel.exports.OTEL_EXPORTER_OTLP_ENDPOINT, 'http://127.0.0.1:4318'); + assert.equal(result.native_otel.exports.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, 'false'); + assert.equal(fs.existsSync(path.join(copilotHome, 'skills', 'agentops', 'SKILL.md')), true); + assert.equal(fs.existsSync(path.join(copilotHome, 'agents', 'agentops.agent.md')), true); + assert.equal(result.applied.collector, 'not_started_by_init'); + assert.ok(result.next.some(command => command.startsWith('copilot'))); + assert.ok(result.next.includes('copilot --no-remote-export')); + + if (shell === 'json') { + const parsed = JSON.parse(output); + assert.equal(parsed.local_only, true); + assert.equal(parsed.shell_exports.OTEL_EXPORTER_OTLP_PROTOCOL, 'http/protobuf'); + } else if (shell === 'fish') { + assert.match(output, /set -gx COPILOT_OTEL_ENABLED/); + } else if (shell === 'powershell') { + assert.match(output, /\$env:COPILOT_OTEL_ENABLED/); + } else { + assert.match(output, /export COPILOT_OTEL_ENABLED='true'/); + } + } finally { + fs.rmSync(copilotHome, { recursive: true, force: true }); + } + } +}); + +test('help surfaces expose local native onboarding and retain full-init compatibility', () => { + assert.match(surfaceUsage(), /eval.*init --local-only --yes --shell zsh/); + assert.match(surfaceUsage(), /init --local-only \[--yes\] \[--shell bash\|zsh\|fish\|powershell\|json\]/); + assert.match(surfaceUsage(), /init \[--dry-run\] --full/); + assert.match(legacyUsage(), /init --local-only \[--yes\] \[--shell bash\|zsh\|fish\|powershell\|json\]/); +}); diff --git a/agentops-cli/test/native-receipt.test.js b/agentops-cli/test/native-receipt.test.js new file mode 100644 index 0000000..9bcd301 --- /dev/null +++ b/agentops-cli/test/native-receipt.test.js @@ -0,0 +1,313 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const { + deriveReceipt, + nativeOpenResult, + readNativeReceiptFile, + readNativeReceiptFromArgs, + renderNativeReceipt +} = require('../src/lib/native-receipt'); + +function tempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-native-receipt-')); +} + +function attr(key, value) { + const encoded = typeof value === 'boolean' + ? { boolValue: value } + : typeof value === 'number' + ? { intValue: value } + : { stringValue: value }; + return { key, value: encoded }; +} + +function nano(iso) { + return String(BigInt(new Date(iso).getTime()) * 1000000n); +} + +function nativeRequest({ session = 'session-native', outcome, includePoison = false, ended = true, agentStop = false } = {}) { + const attributes = [ + attr('agentops.session.id', session), + attr('agentops.run.id', 'run-native'), + attr('gen_ai.operation.name', 'invoke_agent'), + attr('gen_ai.request.model', 'gpt-native'), + attr('gen_ai.tool.name', 'shell'), + attr('gen_ai.usage.input_tokens', 12), + attr('gen_ai.usage.output_tokens', 5), + attr('github.copilot.cost', 2), + ...(outcome ? [attr('agentops.outcome', outcome)] : []), + ...(includePoison ? [ + attr('gen_ai.input.messages', 'SECRET_PROMPT_DO_NOT_RETURN'), + attr('gen_ai.output.messages', 'SECRET_RESPONSE_DO_NOT_RETURN'), + attr('gen_ai.tool.call.arguments', 'SECRET_TOOL_ARGS_DO_NOT_RETURN'), + attr('gen_ai.tool.call.result', 'SECRET_TOOL_RESULT_DO_NOT_RETURN'), + attr('code.filepath', '/private/SECRET_SOURCE.js'), + attr('url.full', 'https://secret.example.invalid/token=SECRET'), + attr('authorization', 'Bearer SECRET_TOKEN_DO_NOT_RETURN') + ] : []) + ]; + const span = { + traceId: 'trace-native', + spanId: 'span-native', + name: 'copilot.invoke_agent', + startTimeUnixNano: nano('2026-08-14T10:00:00.000Z'), + attributes, + status: { code: 'STATUS_CODE_OK' } + }; + if (ended) span.endTimeUnixNano = nano('2026-08-14T10:00:02.000Z'); + if (agentStop) { + span.events = [{ + name: 'github.copilot.hook.end', + attributes: [attr('github.copilot.hook.type', 'agentStop')] + }]; + } + return { + resourceSpans: [{ + resource: { attributes: [attr('service.name', 'github-copilot-cli'), attr('agent.runtime', 'github-copilot')] }, + scopeSpans: [{ spans: [span] }] + }] + }; +} + +function writeJsonl(filePath, rows) { + fs.writeFileSync(filePath, `${rows.map(row => JSON.stringify(row)).join('\n')}\n`); + return filePath; +} + +test('native reader derives an explicit task-complete metadata-only receipt from sanitized OTLP file records', () => { + const result = readNativeReceiptFile(writeJsonl(path.join(tempDir(), 'receipt.jsonl'), [ + nativeRequest({ outcome: 'completed', includePoison: true }) + ])); + + assert.equal(result.recognized, true); + assert.equal(result.status, 'TASK_COMPLETED'); + assert.equal(result.reason, null); + assert.equal(result.receipt.session_id, 'session-native'); + assert.equal(result.receipt.run_id, 'run-native'); + assert.equal(result.receipt.duration_ms, 2000); + assert.deepEqual(result.receipt.tools, ['shell']); + assert.equal(result.receipt.tool_calls, 1); + assert.equal(result.receipt.input_tokens, 12); + assert.equal(result.receipt.output_tokens, 5); + assert.equal(result.receipt.credits, 2); + assert.equal(result.receipt.content_signal, true); + assert.doesNotMatch(JSON.stringify(result), /SECRET_PROMPT|SECRET_RESPONSE|SECRET_TOOL|SECRET_SOURCE|SECRET_TOKEN|authorization/i); +}); + +test('native reader distinguishes observed telemetry from missing completion semantics', () => { + const result = deriveReceipt([ + { + sessionId: 'session-partial', + traceId: 'trace-partial', + start: '2026-08-14T10:00:00.000Z', + end: '2026-08-14T10:00:01.000Z', + operation: 'invoke_agent', + tool: 'shell', + model: 'gpt-native', + inputTokens: 1, + outputTokens: 2, + credits: 0, + contentSignal: false, + contentDroppedBytes: 0, + failed: false, + terminal: null, + safePrivacyMode: 'strict', + safeContentMode: 'off' + } + ]); + + assert.equal(result.status, 'OBSERVED'); + assert.equal(result.reason, 'NO_SAFE_COMPLETION_SIGNAL'); + assert.equal(result.observed, true); + assert.ok(result.data_missing.includes('safe native OTLP processing/task completion signal')); + const rendered = renderNativeReceipt({ status: result.status, receipt: result }); + assert.doesNotMatch(rendered, /^(?:Result|Status): Completed$/m); + assert.match(rendered, /Observed: OBSERVED/); + assert.match(rendered, /No task-success claim was made/); +}); + +test('native reader classifies the Copilot agentStop hook as a processing-stop signal', () => { + const result = readNativeReceiptFile(writeJsonl(path.join(tempDir(), 'agent-stop.jsonl'), [ + nativeRequest({ agentStop: true }) + ])); + + assert.equal(result.status, 'PROCESSING_STOPPED'); + assert.equal(result.receipt.session_id, 'session-native'); +}); + +test('native reader selects the latest session without exposing raw OTLP payloads', () => { + const latest = deriveReceipt([ + { + sessionId: 'old-session', traceId: 'old-trace', start: '2026-08-14T09:00:00.000Z', end: '2026-08-14T09:00:01.000Z', + operation: 'invoke_agent', tool: 'old-tool', model: 'old-model', inputTokens: 1, outputTokens: 1, credits: 0, + contentSignal: false, contentDroppedBytes: 0, failed: false, terminal: 'TASK_COMPLETED', safePrivacyMode: 'strict', safeContentMode: 'off' + }, + { + sessionId: 'new-session', traceId: 'new-trace', start: '2026-08-14T10:00:00.000Z', end: '2026-08-14T10:00:03.000Z', + operation: 'invoke_agent', tool: 'new-tool', model: 'new-model', inputTokens: 3, outputTokens: 4, credits: 1, + contentSignal: false, contentDroppedBytes: 0, failed: false, terminal: 'TASK_COMPLETED', safePrivacyMode: 'strict', safeContentMode: 'off' + } + ]); + + assert.equal(latest.session_id, 'new-session'); + assert.deepEqual(latest.tools, ['new-tool']); + assert.equal(latest.status, 'TASK_COMPLETED'); +}); + +test('native reader handles metric and log file-export records without returning log bodies', () => { + const result = readNativeReceiptFile(writeJsonl(path.join(tempDir(), 'mixed.jsonl'), [{ + resourceMetrics: [{ + resource: { attributes: [attr('service.name', 'github-copilot-cli')] }, + scopeMetrics: [{ metrics: [{ + name: 'gen_ai.input_tokens', + sum: { dataPoints: [{ + attributes: [attr('agentops.session.id', 'mixed-session')], + asInt: '7', + timeUnixNano: nano('2026-08-14T10:00:00.000Z') + }] } + }] }] + }] + }, { + resourceLogs: [{ + scopeLogs: [{ logRecords: [{ + body: { stringValue: 'SECRET_LOG_BODY_DO_NOT_RETURN' }, + attributes: [ + attr('agentops.session.id', 'mixed-session'), + attr('agentops.outcome', 'completed'), + attr('gen_ai.prompt', 'SECRET_LOG_PROMPT_DO_NOT_RETURN') + ], + timeUnixNano: nano('2026-08-14T10:00:01.000Z') + }] }] + }] + }])); + + assert.equal(result.status, 'TASK_COMPLETED'); + assert.equal(result.receipt.session_id, 'mixed-session'); + assert.equal(result.receipt.input_tokens, 7); + assert.equal(result.receipt.content_signal, true); + assert.doesNotMatch(JSON.stringify(result), /SECRET_LOG_BODY|SECRET_LOG_PROMPT/); +}); + +test('explicit legacy wrapper/demo JSONL remains available as fallback', () => { + const file = writeJsonl(path.join(tempDir(), 'wrapper-events.jsonl'), [{ + type: 'span', + session: 'wrapper-session', + operation: 'chat', + attributes: { 'gen_ai.operation.name': 'chat' } + }]); + const result = readNativeReceiptFromArgs(['open', 'latest', '--file', file], {}); + + assert.equal(result.selected_by, 'cli'); + assert.equal(result.recognized, false); +}); + +test('env-selected native file fails closed when it is absent', () => { + const result = readNativeReceiptFromArgs([], { + AGENTOPS_OTEL_RECEIPT_PATH: path.join(tempDir(), 'missing.jsonl') + }); + + assert.equal(result.selected_by, 'env'); + assert.equal(result.recognized, true); + assert.equal(result.status, 'UNOBSERVED'); + assert.equal(result.reason, 'FILE_UNREADABLE'); + assert.equal(result.receipt.observed, false); +}); + +test('open latest selects the default local receipt when it exists', () => { + const collectorHome = path.join(tempDir(), 'collector'); + fs.mkdirSync(collectorHome, { recursive: true }); + writeJsonl(path.join(collectorHome, 'native-receipt.jsonl'), [nativeRequest({ outcome: 'completed' })]); + const cli = spawnSync(process.execPath, [ + path.join(__dirname, '..', 'src', 'index.js'), + 'open', 'latest', '--json' + ], { + encoding: 'utf8', + env: { + ...process.env, + AGENTOPS_COLLECTOR_HOME: collectorHome, + AGENTOPS_OTEL_RECEIPT_PATH: '' + } + }); + + assert.equal(cli.status, 0, cli.stderr); + const result = JSON.parse(cli.stdout); + assert.equal(result.source, 'native-local-file'); + assert.equal(result.status, 'TASK_COMPLETED'); +}); + +test('open command uses native receipt output for an OTLP file and keeps JSON metadata-only', () => { + const file = writeJsonl(path.join(tempDir(), 'native.jsonl'), [nativeRequest({ outcome: 'completed', includePoison: true })]); + const cli = spawnSync(process.execPath, [ + path.join(__dirname, '..', 'src', 'index.js'), + 'open', 'latest', '--file', file, '--json' + ], { encoding: 'utf8', env: { ...process.env, AGENTOPS_OTEL_RECEIPT_PATH: '' } }); + + assert.equal(cli.status, 0, cli.stderr); + const result = JSON.parse(cli.stdout); + assert.equal(result.source, 'native-local-file'); + assert.equal(result.status, 'TASK_COMPLETED'); + assert.equal(result.receipt.content_signal, true); + assert.doesNotMatch(cli.stdout, /SECRET_PROMPT|SECRET_RESPONSE|SECRET_TOOL|SECRET_SOURCE|SECRET_TOKEN/); +}); + +test('open command reads the env-selected native receipt when no file flag is supplied', () => { + const file = writeJsonl(path.join(tempDir(), 'env-native.jsonl'), [nativeRequest({ outcome: 'completed' })]); + const cli = spawnSync(process.execPath, [ + path.join(__dirname, '..', 'src', 'index.js'), + 'open', 'latest', '--json' + ], { encoding: 'utf8', env: { ...process.env, AGENTOPS_OTEL_RECEIPT_PATH: file } }); + + assert.equal(cli.status, 0, cli.stderr); + const result = JSON.parse(cli.stdout); + assert.equal(result.source, 'native-local-file'); + assert.equal(result.status, 'TASK_COMPLETED'); +}); + +test('open command preserves legacy file behavior when the file is not OTLP', () => { + const file = writeJsonl(path.join(tempDir(), 'legacy.jsonl'), [{ + TimeGenerated: '2026-08-14T10:00:00.000Z', + SessionId: 'legacy-session', + SpanName: 'legacy span' + }]); + const cli = spawnSync(process.execPath, [ + path.join(__dirname, '..', 'src', 'index.js'), + 'open', 'latest', '--file', file + ], { encoding: 'utf8', env: { ...process.env, AGENTOPS_OTEL_RECEIPT_PATH: '' } }); + + assert.equal(cli.status, 0, cli.stderr); + assert.match(cli.stdout, /AgentOps investigation links/); + assert.doesNotMatch(cli.stdout, /AgentOps native local receipt/); +}); + +test('native open result keeps links separate from the local receipt state', () => { + const native = readNativeReceiptFile(writeJsonl(path.join(tempDir(), 'native.jsonl'), [nativeRequest({ outcome: 'completed' })])); + const result = nativeOpenResult(native, { + primary_investigation_url: 'https://appinsights.test', + primary_investigation_label: 'Application Insights (open Agents)', + cloud_verified: true + }); + + assert.equal(result.status, 'TASK_COMPLETED'); + assert.equal(result.links.primary, 'https://appinsights.test'); + assert.equal(result.links.primary_label, 'Application Insights (open Agents)'); + assert.equal(result.links.cloud_verified, true); + assert.doesNotMatch(JSON.stringify(result), /prompt|completion|tool.call.arguments|secret/i); +}); + +test('native open keeps configured cloud links separate from query-verified evidence', () => { + const native = readNativeReceiptFile(writeJsonl(path.join(tempDir(), 'native-unverified.jsonl'), [nativeRequest({ outcome: 'completed' })])); + const result = nativeOpenResult(native, { + primary_investigation_url: 'https://appinsights.test', + primary_investigation_label: 'Application Insights (open Agents)' + }); + + assert.equal(result.links.primary, null); + assert.equal(result.links.configured_primary, 'https://appinsights.test'); + assert.equal(result.links.cloud_verified, false); + assert.equal(result.links.cloud_evidence, 'not-query-verified'); +}); diff --git a/agentops-cli/test/otel-setup.test.js b/agentops-cli/test/otel-setup.test.js new file mode 100644 index 0000000..09c99e3 --- /dev/null +++ b/agentops-cli/test/otel-setup.test.js @@ -0,0 +1,103 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + buildOtelSetup, + classifyOtelEndpoint, + parseOtelSetupArgs, + renderOtelSetup +} = require('../src/lib/otel-setup'); + +test('native setup defaults to loopback OTLP HTTP/protobuf with content capture off', () => { + const options = parseOtelSetupArgs([]); + const setup = buildOtelSetup(options); + + assert.equal(options.unsafeDirect, false); + assert.equal(setup.endpointPolicy.classification, 'loopback'); + assert.equal(setup.nativeEnv.COPILOT_OTEL_ENABLED, 'true'); + assert.equal(setup.nativeEnv.COPILOT_OTEL_EXPORTER_TYPE, 'otlp-http'); + assert.equal(setup.nativeEnv.COPILOT_OTEL_SOURCE_NAME, 'github.copilot'); + assert.equal(setup.nativeEnv.OTEL_EXPORTER_OTLP_ENDPOINT, 'http://127.0.0.1:4318'); + assert.equal(setup.nativeEnv.OTEL_EXPORTER_OTLP_PROTOCOL, 'http/protobuf'); + assert.equal(setup.nativeEnv.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, 'false'); + assert.deepEqual(setup.sessionExport.settings, { remoteExport: false }); + assert.equal(setup.sessionExport.cliFlag, '--no-remote-export'); + assert.equal(setup.fileExport.env.COPILOT_OTEL_FILE_EXPORTER_PATH, './copilot-otel.jsonl'); +}); + +test('endpoint classifier distinguishes loopback, link-local, public, file, and malformed values', () => { + const cases = [ + ['http://localhost:4318', 'loopback'], + ['https://127.0.0.2:4318/v1/traces', 'loopback'], + ['http://[::1]:4318', 'loopback'], + ['http://169.254.10.20:4318', 'link-local'], + ['https://collector.example.test:4318', 'public'], + ['file:///tmp/copilot-otel.jsonl', 'file'], + ['not-a-url', 'malformed'], + ['ftp://collector.example.test:4318', 'malformed'] + ]; + + for (const [endpoint, expected] of cases) { + assert.equal(classifyOtelEndpoint(endpoint).classification, expected, endpoint); + } +}); + +test('non-loopback endpoints require explicit unsafe-direct opt-in', () => { + for (const endpoint of [ + 'https://collector.example.test:4318', + 'http://169.254.10.20:4318', + 'file:///tmp/copilot-otel.jsonl' + ]) { + assert.throws( + () => parseOtelSetupArgs(['--endpoint', endpoint]), + /only loopback endpoints are allowed by default/, + endpoint + ); + + const options = parseOtelSetupArgs(['--endpoint', endpoint, '--unsafe-direct']); + const setup = buildOtelSetup(options); + assert.equal(setup.unsafeDirect, true); + assert.equal(setup.endpoint, endpoint); + } +}); + +test('unsafe-direct is the explicit bypass for a malformed endpoint classification', () => { + assert.throws( + () => parseOtelSetupArgs(['--endpoint', 'not-a-url']), + /classified as malformed/ + ); + + const options = parseOtelSetupArgs(['--endpoint', 'not-a-url', '--unsafe-direct']); + const setup = buildOtelSetup(options); + assert.equal(setup.endpointPolicy.classification, 'malformed'); + assert.equal(setup.endpoint, 'not-a-url'); +}); + +test('shell and JSON renderers emit native variables without session-export claims', () => { + const options = parseOtelSetupArgs(['--endpoint', 'http://localhost:4318', '--service-name', 'copilot-chat']); + const setup = buildOtelSetup(options); + const bash = renderOtelSetup(setup, options); + const powershell = renderOtelSetup(setup, { ...options, shell: 'powershell' }); + const json = JSON.parse(renderOtelSetup(setup, { ...options, shell: 'json' })); + + assert.match(bash, /export COPILOT_OTEL_ENABLED='true'/); + assert.match(bash, /export OTEL_EXPORTER_OTLP_ENDPOINT='http:\/\/localhost:4318'/); + assert.match(bash, /export OTEL_EXPORTER_OTLP_PROTOCOL='http\/protobuf'/); + assert.match(bash, /export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT='false'/); + assert.match(bash, /Session export controls are separate from OTel/); + assert.match(bash, /--no-remote-export/); + assert.match(bash, /remoteExport/); + assert.doesNotMatch(bash, /export COPILOT_OTEL_ENDPOINT=/); + assert.doesNotMatch(bash, /--no-remote proves/); + assert.match(powershell, /\$env:OTEL_EXPORTER_OTLP_PROTOCOL = "http\/protobuf"/); + assert.equal(json.nativeEnv.OTEL_EXPORTER_OTLP_PROTOCOL, 'http/protobuf'); + assert.equal(json.nativeEnv.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, 'false'); + assert.equal(json.fileExport.env.COPILOT_OTEL_FILE_EXPORTER_PATH, './copilot-otel.jsonl'); +}); + +test('capture-content remains an explicit opt-in', () => { + const setup = buildOtelSetup(parseOtelSetupArgs(['--capture-content'])); + + assert.equal(setup.nativeEnv.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, 'true'); + assert.equal(setup.fileExport.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, 'true'); +}); diff --git a/agentops-cli/test/paths.test.js b/agentops-cli/test/paths.test.js new file mode 100644 index 0000000..0978ff6 --- /dev/null +++ b/agentops-cli/test/paths.test.js @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { defaultUserAgentOpsPath } = require('../src/lib/paths'); + +test('paths helper owns default user AgentOps file paths', () => { + assert.equal(defaultUserAgentOpsPath('config.json', '/home/example'), path.join('/home/example', '.agentops', 'config.json')); + assert.equal(defaultUserAgentOpsPath('nested/file.json', '/home/example'), path.join('/home/example', '.agentops', 'nested/file.json')); + + for (const file of ['agentops-config.js', 'legacy-runtime.js', 'recommendation-store.js']) { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', file), 'utf8'); + assert.doesNotMatch(source, /path\.join\(os\.homedir\(\), '\.agentops'/, file); + } +}); diff --git a/agentops-cli/test/product-audit-checks.test.js b/agentops-cli/test/product-audit-checks.test.js new file mode 100644 index 0000000..c936176 --- /dev/null +++ b/agentops-cli/test/product-audit-checks.test.js @@ -0,0 +1,30 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + fileIncludes, + requiredFilesCheck +} = require('../src/lib/product-audit-checks'); + +test('product audit check helpers inspect files relative to a root', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-product-audit-checks-')); + fs.mkdirSync(path.join(tempRoot, 'docs'), { recursive: true }); + fs.writeFileSync(path.join(tempRoot, 'docs', 'audit.md'), 'Run-Centric UI\nTrace waterfall\n'); + + assert.equal(fileIncludes('docs/audit.md', ['Run-Centric UI', 'Trace waterfall'], tempRoot), true); + assert.equal(fileIncludes('docs/audit.md', ['missing term'], tempRoot), false); + assert.equal(fileIncludes('docs/missing.md', ['Run-Centric UI'], tempRoot), false); + + assert.deepEqual(requiredFilesCheck('audit-files', [ + 'docs/audit.md', + 'docs/missing.md' + ], tempRoot), { + name: 'audit-files', + ok: false, + evidence: ['docs/audit.md'], + missing: ['docs/missing.md'] + }); +}); diff --git a/agentops-cli/test/product-audit-render.test.js b/agentops-cli/test/product-audit-render.test.js new file mode 100644 index 0000000..743f85f --- /dev/null +++ b/agentops-cli/test/product-audit-render.test.js @@ -0,0 +1,34 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { renderProductAudit } = require('../src/lib/product-audit-render'); + +test('product audit renderer summarizes checks and next commands', () => { + const output = renderProductAudit({ + ok: false, + live_azure_verified: false, + live_grafana_verified: true, + visual_grafana_verified: false, + summary: { + passed: 1, + checks: 2, + v2_dashboards: 10, + checked_links: 20, + visual_dashboards: 1 + }, + checks: [ + { name: 'base-check', ok: true, missing: [] }, + { name: 'missing-check', ok: false, missing: ['first', 'second'] } + ], + next: ['agentops product audit --json'] + }); + + assert.match(output, /AgentOps product audit/); + assert.match(output, /Result: needs work\./); + assert.match(output, /Local checks: 1\/2 passed\./); + assert.match(output, /Live Grafana verified: yes\./); + assert.match(output, /Visual Grafana verified: no\./); + assert.match(output, /- FAIL missing-check/); + assert.match(output, /Missing: first, second/); + assert.match(output, /- agentops product audit --json/); +}); diff --git a/agentops-cli/test/product-audit-visual.test.js b/agentops-cli/test/product-audit-visual.test.js new file mode 100644 index 0000000..7a362d8 --- /dev/null +++ b/agentops-cli/test/product-audit-visual.test.js @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { productAuditWithVisual } = require('../src/lib/product-audit-visual'); +const { requiredVisualDashboards } = require('../src/lib/product-visual'); + +function baseAudit() { + return { + ok: true, + scope: 'local-product-contract', + live_azure_verified: false, + live_grafana_verified: false, + visual_grafana_verified: false, + summary: { + checks: 1, + passed: 1, + failed: 0, + v2_dashboards: 10, + checked_links: 20 + }, + checks: [{ name: 'base-product-check', ok: true, evidence: ['base'], missing: [] }], + next: ['agentops product audit --live --json'] + }; +} + +function writeEvidence() { + const evidenceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-product-audit-visual-')); + const screenshotsDir = path.join(evidenceDir, 'screenshots'); + fs.mkdirSync(screenshotsDir, { recursive: true }); + const dashboards = requiredVisualDashboards.map(uid => { + const screenshot = path.join('screenshots', `${uid}.png`); + fs.writeFileSync(path.join(evidenceDir, screenshot), Buffer.alloc(2048, 1)); + return { + uid, + title: uid, + url: `https://example.grafana.azure.com/d/${uid}`, + dashboardVisible: true, + authBlocked: false, + errors: [], + screenshot + }; + }); + const evidencePath = path.join(evidenceDir, 'visual-evidence.json'); + fs.writeFileSync(evidencePath, JSON.stringify({ dashboards }, null, 2)); + return evidencePath; +} + +test('product audit visual helper accepts authenticated evidence file', async () => { + const result = await productAuditWithVisual({ + requireVisual: true, + visualEvidencePath: writeEvidence() + }, { + productAudit: baseAudit + }); + + assert.equal(result.ok, true); + assert.equal(result.scope, 'local-and-visual-product-contract'); + assert.equal(result.visual_grafana_verified, true); + assert.equal(result.summary.checks, 2); + assert.equal(result.summary.passed, 2); + assert.equal(result.summary.failed, 0); + assert.equal(result.summary.visual_dashboards_visible, requiredVisualDashboards.length); + assert.deepEqual(result.next, ['agentops product audit --live --json']); + assert.equal(result.checks.at(-1).name, 'visual-grafana-rendered-dashboards'); +}); diff --git a/agentops-cli/test/product-audit.test.js b/agentops-cli/test/product-audit.test.js new file mode 100644 index 0000000..ebd5a86 --- /dev/null +++ b/agentops-cli/test/product-audit.test.js @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { productAudit } = require('../src/lib/product-audit'); + +test('product audit core module supports injected live gates', () => { + const result = productAudit({ + live: true, + last: '2h', + requireRows: true, + dashboardVerify: args => ({ + ok: args.includes('--live') && args.includes('--require-rows') && args.includes('2h'), + errors: [], + summary: { + kql_checks: 19, + checked_links: 709 + } + }), + validateAzure: options => ({ + ok: options.last === '2h', + checks: [ + { name: 'resource-group', ok: true }, + { name: 'grafana-dashboards', ok: true } + ] + }) + }); + + assert.equal(result.ok, true, result.checks.filter(check => check.name).join(', ')); + assert.equal(result.scope, 'local-and-live-product-contract'); + assert.equal(result.live_azure_verified, true); + assert.equal(result.live_grafana_verified, true); + assert.equal(result.summary.live_kql_checks, 19); + assert.equal(result.checks.some(check => check.name === 'live-grafana-dashboard-queries'), true); + assert.equal(result.checks.some(check => check.name === 'live-azure-resources'), true); +}); diff --git a/agentops-cli/test/product-visual.test.js b/agentops-cli/test/product-visual.test.js new file mode 100644 index 0000000..ec74f6c --- /dev/null +++ b/agentops-cli/test/product-visual.test.js @@ -0,0 +1,90 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + requiredVisualDashboards, + validateVisualEvidence, + visualAuditRecoveryCommands +} = require('../src/lib/product-visual'); + +function writeEvidence(overridesByUid = {}) { + const evidenceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-product-visual-')); + const screenshotsDir = path.join(evidenceDir, 'screenshots'); + fs.mkdirSync(screenshotsDir, { recursive: true }); + const dashboards = requiredVisualDashboards.map(uid => { + const screenshot = path.join('screenshots', `${uid}.png`); + fs.writeFileSync(path.join(evidenceDir, screenshot), Buffer.alloc(2048, 1)); + return { + uid, + title: uid, + url: `https://example.grafana.azure.com/d/${uid}`, + dashboardVisible: true, + authBlocked: false, + errors: [], + screenshot, + ...(overridesByUid[uid] || {}) + }; + }); + const evidencePath = path.join(evidenceDir, 'visual-evidence.json'); + fs.writeFileSync(evidencePath, JSON.stringify({ dashboards }, null, 2)); + return evidencePath; +} + +test('product visual evidence accepts rendered required dashboards', () => { + const evidence = validateVisualEvidence(writeEvidence()); + + assert.equal(evidence.ok, true, evidence.missing.join(', ')); + assert.equal(evidence.dashboards.length, requiredVisualDashboards.length); + assert.deepEqual(evidence.visible, requiredVisualDashboards); +}); + +test('product visual evidence reports dashboard and screenshot failures', () => { + const firstUid = requiredVisualDashboards[0]; + const secondUid = requiredVisualDashboards[1]; + const evidencePath = writeEvidence({ + [firstUid]: { + authBlocked: true, + dashboardVisible: false, + screenshot: '', + url: 'https://example.grafana.azure.com/d/wrong-dashboard' + }, + [secondUid]: { + errors: ['Panel failed to render'], + sha256: 'incorrect-hash' + } + }); + const evidence = validateVisualEvidence(evidencePath); + + assert.equal(evidence.ok, false); + assert.ok(evidence.missing.includes(`${firstUid}: auth-blocked`)); + assert.ok(evidence.missing.includes(`${firstUid}: not visible`)); + assert.ok(evidence.missing.includes(`${firstUid}: screenshot missing or too small`)); + assert.ok(evidence.missing.includes(`${firstUid}: URL does not match dashboard UID`)); + assert.ok(evidence.missing.includes(`${secondUid}: Panel failed to render`)); + assert.ok(evidence.missing.includes(`${secondUid}: screenshot hash mismatch`)); +}); + +test('product visual evidence reports missing and invalid files', () => { + const missing = validateVisualEvidence(path.join(os.tmpdir(), 'missing-product-visual-evidence.json')); + const invalidPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-product-visual-')), 'bad.json'); + fs.writeFileSync(invalidPath, '{'); + const invalid = validateVisualEvidence(invalidPath); + + assert.equal(missing.ok, false); + assert.match(missing.missing[0], /Visual evidence file not found/); + assert.equal(invalid.ok, false); + assert.match(invalid.missing[0], /Visual evidence file is not valid JSON/); +}); + +test('product visual recovery commands point to report regeneration and audit rerun', () => { + const commands = visualAuditRecoveryCommands('/tmp/agentops-report.html'); + + assert.deepEqual(commands, [ + 'agentops e2e run --live --browser-report --last 2h --json', + 'agentops e2e report --last 2h --out /tmp/agentops-report.html', + 'agentops product audit --live --last 2h --require-rows --require-visual --report /tmp/agentops-report.html --json' + ]); +}); diff --git a/agentops-cli/test/recommend-command.test.js b/agentops-cli/test/recommend-command.test.js new file mode 100644 index 0000000..416567a --- /dev/null +++ b/agentops-cli/test/recommend-command.test.js @@ -0,0 +1,21 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + firstPositional, + recommendationRow, + renderRecommendationV2 +} = require('../src/lib/recommend-command'); + +test('recommend command library preserves command helper exports', () => { + assert.equal(firstPositional(['--runs', 'runs.jsonl', 'run-1']), 'run-1'); + assert.equal(typeof recommendationRow, 'function'); + assert.match(renderRecommendationV2({ + run_id: 'run-1', + action: 'compare', + severity: 'medium', + next_action: 'Run a comparison.', + evidence: ['latency regression'], + linked_dashboards: [] + }), /Run a comparison/); +}); diff --git a/agentops-cli/test/recommendation-benchmark-evidence.test.js b/agentops-cli/test/recommendation-benchmark-evidence.test.js new file mode 100644 index 0000000..d20be5d --- /dev/null +++ b/agentops-cli/test/recommendation-benchmark-evidence.test.js @@ -0,0 +1,113 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { benchmarkEvidenceFromReport } = require('../src/lib/recommendation-benchmark-evidence'); + +test('recommendation benchmark evidence summarizes artifacts policy checks and approval', () => { + const evidence = benchmarkEvidenceFromReport({ + runId: 'bench-candidate', + passRatePct: 50, + averageScore: 61, + safetyViolationCount: 0, + toolFailures: 1, + totalTokens: 12000, + cost: 0.42, + artifactDiff: { + added: 1, + modified: 2, + deleted: 0, + totalChanged: 3 + }, + hiddenChecks: { + passed: 1, + failed: 0 + }, + policyBlocks: 1, + permissionProfiles: { 'allow-all-isolated': 1 }, + semanticChecks: { count: 1, averageScore: 100 }, + tasks: [{ + taskId: 'create-note', + permissionProfile: 'allow-all-isolated', + osSandbox: { mode: 'macos-network-blocked' }, + osSandboxRuntime: { active: true }, + toolPolicy: { blockedRisks: ['network', 'secret-access'] }, + policyBlocks: 1, + toolPolicyViolations: [{ tool: 'http_fetch_url', risk: 'network' }], + semanticChecks: [{ + id: 'hello-note-content', + adapter: 'file-contains', + file: 'notes/hello.txt', + ok: true, + score: 100, + detail: null + }], + hiddenCheckPacks: [{ + id: 'create-note-sealed', + title: 'Create note sealed checks', + commandCount: 1 + }], + artifactDiff: { + added: ['notes/hello.txt'], + modified: ['README.md', 'package.json'], + deleted: ['old.txt'] + }, + artifactContentDiffs: [{ + change: 'modified', + path: 'README.md', + diff: '--- a/README.md\n+++ b/README.md\n-Old\n+New' + }], + artifactReview: { + files: [{ + change: 'added', + path: 'notes/hello.txt', + diff: ['+hello'] + }] + } + }], + promotion: { + decision: 'reject', + gates: { requiredApprovals: 1 }, + approval: { + status: 'approved', + approvedBy: ['sre-team'], + approvedAt: '2026-06-03T04:00:00Z', + ticket: 'APPROVAL-123', + source: '/tmp/approval.json' + }, + validation: 'benchmark summary includes local checks', + rollback: 'do not promote until failures are explained' + } + }); + + assert.equal(evidence.run_id, 'bench-candidate'); + assert.equal(evidence.decision, 'reject'); + assert.deepEqual(evidence.artifact_diff, { added: 1, modified: 2, deleted: 0, total_changed: 3 }); + assert.deepEqual(evidence.artifact_files, [ + { task_id: 'create-note', change: 'added', path: 'notes/hello.txt' }, + { task_id: 'create-note', change: 'modified', path: 'README.md' }, + { task_id: 'create-note', change: 'modified', path: 'package.json' }, + { task_id: 'create-note', change: 'deleted', path: 'old.txt' } + ]); + assert.equal(evidence.artifact_content_diffs.length, 2); + assert.equal(evidence.hidden_checks.packs[0].id, 'create-note-sealed'); + assert.equal(evidence.policy.tasks[0].os_sandbox_active, true); + assert.deepEqual(evidence.policy.tasks[0].violation_risks, ['network']); + assert.equal(evidence.semantic_checks.checks[0].id, 'hello-note-content'); + assert.equal(evidence.approval.approved_count, 1); + assert.equal(evidence.approval.required_count, 1); + assert.equal(evidence.approval.source, 'approval.json'); +}); + +test('recommendation benchmark evidence reports missing benchmark summaries', () => { + const evidence = benchmarkEvidenceFromReport({ + runId: 'missing-bench', + ok: false, + message: 'no benchmark summaries were found for this run' + }); + + assert.equal(evidence.run_id, 'missing-bench'); + assert.equal(evidence.decision, 'missing'); + assert.match(evidence.validation, /no benchmark summaries/); + assert.match(evidence.rollback, /before promotion/); + assert.equal(benchmarkEvidenceFromReport(null), null); +}); diff --git a/agentops-cli/test/recommendation-builder.test.js b/agentops-cli/test/recommendation-builder.test.js new file mode 100644 index 0000000..ca093b8 --- /dev/null +++ b/agentops-cli/test/recommendation-builder.test.js @@ -0,0 +1,46 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { buildRecommendation } = require('../src/lib/recommendation-builder'); + +test('recommendation builder maps eval regression into compare action and metric movement', () => { + const recommendation = buildRecommendation({ + run: { + RunId: 'run-1', + SessionId: 'session-1', + TraceId: 'trace-1', + OutcomeStatus: 'success', + ModelActual: 'gpt-5.1' + }, + insight: { + InsightType: 'eval-regression', + Severity: 'high', + Summary: 'Eval dropped after an instruction change.', + SuggestedNextStep: 'Compare the instruction config against the baseline.', + BaselineValue: 0.91, + CurrentValue: 0.62 + }, + evaluation: { + RunId: 'run-1', + EvalOverall: 0.62, + EvalBucket: 'needs-work', + EvalReason: 'Regression in tool-use quality' + }, + links: { + v2_home_url: 'https://grafana.example/d/agentops-v2-home' + } + }); + + assert.equal(recommendation.action, 'compare_regression'); + assert.equal(recommendation.severity, 'high'); + assert.equal(recommendation.evidence.metric_movement.expected.status, 'ready'); + assert.deepEqual(recommendation.evidence.metric_movement.expected.metrics[0], { + metric: 'eval-regression', + baseline_value: 0.91, + current_value: 0.62, + expected_direction: 'increase', + source: 'insight' + }); + assert.ok(recommendation.evidence.file_refs.includes('agent_instruction_config')); + assert.ok(recommendation.evidence.file_refs.includes('skill_definition')); +}); diff --git a/agentops-cli/test/recommendation-files.test.js b/agentops-cli/test/recommendation-files.test.js new file mode 100644 index 0000000..4dd51f6 --- /dev/null +++ b/agentops-cli/test/recommendation-files.test.js @@ -0,0 +1,63 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { recommendFromFiles } = require('../src/lib/recommendation-files'); +const { writeJsonlFixture } = require('./support/json-fixtures'); + +test('recommendFromFiles selects the latest run and its strongest insight', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-recommendation-files-')); + try { + const runsFile = writeJsonlFixture(path.join(tempDir, 'runs.jsonl'), [ + { + TimeGenerated: '2026-06-03T09:00:00Z', + RunId: 'run-old', + OutcomeStatus: 'success' + }, + { + TimeGenerated: '2026-06-03T12:00:00Z', + RunId: 'run-latest', + SessionId: 'session-latest', + TraceId: 'trace-latest', + OutcomeStatus: 'failed', + ToolFailureCount: 1, + ModelActual: 'gpt-5.5' + } + ]); + const evalsFile = writeJsonlFixture(path.join(tempDir, 'evals.jsonl'), [{ + RunId: 'run-latest', + EvalOverall: 42, + EvalBucket: 'poor', + EvalReason: 'tool_failures' + }]); + const insightsFile = writeJsonlFixture(path.join(tempDir, 'insights.jsonl'), [{ + TimeGenerated: '2026-06-03T12:01:00Z', + RunId: 'run-latest', + Severity: 'high', + InsightType: 'tool-regression', + ToolName: 'shell', + Summary: 'The shell tool failure rate regressed.', + SuggestedNextStep: 'Open Tools & MCP Risk filtered to shell.' + }]); + + const recommendation = recommendFromFiles({ + runId: 'latest', + runsFile, + evalsFile, + insightsFile, + links: { + v2_home_url: 'https://graf.example/d/agentops-v2-home' + } + }); + + assert.equal(recommendation.ok, true); + assert.equal(recommendation.run_id, 'run-latest'); + assert.equal(recommendation.action, 'investigate_tool'); + assert.equal(recommendation.evidence.eval.overall, 42); + assert.ok(recommendation.evidence.dashboards.some(dashboard => dashboard.url.includes('var-tool_name=shell'))); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/recommendation-links.test.js b/agentops-cli/test/recommendation-links.test.js new file mode 100644 index 0000000..57066a7 --- /dev/null +++ b/agentops-cli/test/recommendation-links.test.js @@ -0,0 +1,95 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + dashboardBaseUrl, + dashboardUrl, + fileRefsForRecommendation, + linkedDashboardsForRecommendation, + matchingPatternInsight, + replayUrl, + topInsightForRun +} = require('../src/lib/recommendation-links'); + +const links = { v2_home_url: 'https://graf.example/d/agentops-v2-home?orgId=1' }; + +test('recommendation links reuse Grafana base URL and encode variables', () => { + assert.equal(dashboardBaseUrl(links), 'https://graf.example'); + assert.equal( + dashboardUrl('agentops-v2-run-replay', { run_id: 'run 1', empty: '', nil: null }, links), + 'https://graf.example/d/agentops-v2-run-replay?var-run_id=run%201' + ); + assert.equal( + replayUrl({ SessionId: 'session/1' }, links), + 'https://graf.example/d/agentops-v2-run-replay?var-session_id=session%2F1' + ); +}); + +test('recommendation links include dashboards relevant to the run and insight', () => { + const dashboards = linkedDashboardsForRecommendation({ + RunId: 'run-risk', + ToolFailureCount: 1, + ToolDeniedCount: 1, + EstimatedCostUsd: 0.42, + ModelActual: 'gpt-5.5', + PrOpened: true, + RepoHash: 'repo#1' + }, { + ToolName: 'shell tool', + PatternKey: 'cost|gpt-5.5|review' + }, links); + + assert.deepEqual(dashboards.map(dashboard => dashboard.title), [ + 'Run Story', + 'Tools & MCP Risk', + 'Models, Cost & Tokens', + 'Privacy', + 'Code Outcomes', + 'Insights Pattern' + ]); + assert.ok(dashboards.some(dashboard => dashboard.url.includes('var-tool_name=shell%20tool'))); + assert.ok(dashboards.some(dashboard => dashboard.url.includes('var-model=gpt-5.5'))); + assert.ok(dashboards.some(dashboard => dashboard.url.includes('var-repo_hash=repo%231'))); + assert.ok(dashboards.some(dashboard => dashboard.url.includes('var-pattern_key=cost%7Cgpt-5.5%7Creview'))); +}); + +test('recommendation insights rank by severity then recency for a run', () => { + const insight = topInsightForRun([ + { RunId: 'run-1', Severity: 'medium', TimeGenerated: '2026-06-03T10:00:00Z', Summary: 'medium' }, + { RunId: 'run-1', Severity: 'high', TimeGenerated: '2026-06-03T10:00:00Z', Summary: 'old high' }, + { RunId: 'run-1', Severity: 'high', TimeGenerated: '2026-06-03T11:00:00Z', Summary: 'new high' }, + { RunId: 'run-2', Severity: 'critical', TimeGenerated: '2026-06-03T12:00:00Z', Summary: 'other run' } + ], 'run-1'); + + assert.equal(insight.Summary, 'new high'); +}); + +test('recommendation pattern insights match run dimensions and rank by runs severity and recency', () => { + const run = { + TaskType: 'review', + ModelActual: 'gpt-5.5', + RepoHash: 'repo-A', + AgentName: 'copilot', + PrivacyMode: 'strict', + OutcomeReason: 'ci_failed' + }; + const insight = matchingPatternInsight([ + { PatternKey: 'noise|other|dimension', PatternRuns: 99, Severity: 'critical', TimeGenerated: '2026-06-03T12:00:00Z', Summary: 'noise' }, + { PatternKey: 'cost|gpt-5.5|review', PatternRuns: 4, Severity: 'high', TimeGenerated: '2026-06-03T09:00:00Z', Summary: 'model' }, + { PatternKey: 'outcome|repo-A|ci_failed', PatternRuns: 8, Severity: 'medium', TimeGenerated: '2026-06-03T10:00:00Z', Summary: 'outcome' }, + { PatternKey: 'privacy|copilot|strict', PatternRuns: 10, Severity: 'low', TimeGenerated: '2026-06-03T11:00:00Z', Summary: 'agent privacy' } + ], run); + + assert.equal(insight.Summary, 'agent privacy'); +}); + +test('recommendation file refs map actions and regression config hashes to change targets', () => { + assert.deepEqual(fileRefsForRecommendation('run_validation'), [ + 'tests_or_benchmark_suite', + 'agent_skill_validation_step' + ]); + assert.deepEqual(fileRefsForRecommendation('compare_regression', { ConfigHash: 'cfg-1' }), [ + 'agent_instruction_config', + 'skill_definition' + ]); +}); diff --git a/agentops-cli/test/recommendation-render.test.js b/agentops-cli/test/recommendation-render.test.js new file mode 100644 index 0000000..37fdf41 --- /dev/null +++ b/agentops-cli/test/recommendation-render.test.js @@ -0,0 +1,30 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { renderRecommendationV2 } = require('../src/lib/recommendation-render'); + +test('renderRecommendationV2 formats recommendation evidence for operators', () => { + const output = renderRecommendationV2({ + action: 'compare_after_run', + severity: 'review', + run_id: 'run-1', + observed_pattern: 'cost regression', + next_action: 'run benchmark before merge', + evidence: { + eval: { overall: 72, bucket: 'watch', reason: 'cost increased' }, + pattern: { key: 'cost|model|task', runs: 3, dimension: 'cost' }, + benchmark: { run_id: 'bench-1', decision: 'review', average_score: 82, pass_rate_pct: 75 }, + change_annotations: [{ component: 'model', target: 'deployment', change_type: 'updated', version: 'v2' }], + file_refs: ['infra/model.bicep'], + dashboards: [{ title: 'Cost', url: 'https://example.test/d/cost' }] + }, + validation: ['confirm cost trend'], + rollback_condition: 'cost keeps rising' + }); + + assert.match(output, /Action: compare_after_run/); + assert.match(output, /Eval: 72 \(watch\) - cost increased/); + assert.match(output, /Benchmark: bench-1/); + assert.match(output, /Dashboards:/); + assert.match(output, /Rollback condition: cost keeps rising/); +}); diff --git a/agentops-cli/test/recommendation-store.test.js b/agentops-cli/test/recommendation-store.test.js new file mode 100644 index 0000000..30f20bf --- /dev/null +++ b/agentops-cli/test/recommendation-store.test.js @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + exportRecommendationStore, + recommendationActionPlanForRow, + recommendationStoreCommand, + saveRecommendation +} = require('../src/lib/recommendation-store'); + +function sampleRecommendation() { + return { + ok: true, + action: 'investigate_tool', + severity: 'high', + run_id: 'run-store-module', + session_id: 'session-store-module', + trace_id: 'trace-store-module', + observed_pattern: 'A tool failure repeated for this run.', + next_action: 'Open Tools & MCP Risk and validate the tool policy.', + evidence: { + dashboards: [{ title: 'Run Story', url: 'https://graf.example/d/agentops-v2-run-replay?var-run_id=run-store-module' }], + metric_movement: { + expected: { status: 'ready', metrics: [] }, + before: {}, + after: {}, + observed: { status: 'awaiting-after-run' } + }, + file_refs: ['skill:agentops-latest-run'] + }, + validation: ['agentops dashboard kql-check --last 24h --json'], + rollback_condition: 'Rollback if failures rise.' + }; +} + +test('recommendation store module saves, lists, exports, and plans recommendation rows', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-recommendation-store-module-')); + try { + const storePath = path.join(tempDir, 'recommendations.json'); + const exportDir = path.join(tempDir, 'export'); + const saved = saveRecommendation(sampleRecommendation(), storePath, '2026-06-03T12:00:01Z'); + const listed = recommendationStoreCommand(['list', '--store', storePath]); + const exported = exportRecommendationStore({ storePath, outDir: exportDir }); + const approvedRow = { + ...exported.rows[0], + OperatorReview: { + status: 'approved', + decision: 'approve' + } + }; + const plan = recommendationActionPlanForRow(approvedRow, { + benchmarkSuite: 'starter', + hypothesis: 'rec-store-module' + }); + + assert.equal(saved.count, 1); + assert.equal(listed.recommendations[0].RecommendationId, saved.saved.RecommendationId); + assert.equal(exported.rows_written, 1); + assert.equal(plan.status, 'ready'); + assert.match(plan.commands.create_branch, /agentops\/rec-store-module/); + assert.deepEqual(plan.evidence.change_target_refs, ['skill:agentops-latest-run']); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/sdk-event-rollup-contract.test.js b/agentops-cli/test/sdk-event-rollup-contract.test.js new file mode 100644 index 0000000..8339b37 --- /dev/null +++ b/agentops-cli/test/sdk-event-rollup-contract.test.js @@ -0,0 +1,140 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { rollupSpanRows } = require('../src/lib/rollup/span-to-agentops-tables'); + +function sdkSpan(sequence, eventName, attributes = {}, options = {}) { + return { + TimeGenerated: `2026-08-03T10:00:0${sequence}.000Z`, + Name: eventName, + OperationId: 'trace-sdk-rollup', + Id: `span-${sequence}`, + DurationMs: options.durationMs || 0, + Status: options.status || { code: 'OK' }, + Properties: { + 'agentops.schema.version': '2', + 'agentops.run.id': 'run-sdk-rollup', + 'agentops.session.id': 'session-sdk-rollup', + 'agentops.surface': 'sdk', + 'agentops.privacy.mode': 'strict', + 'agentops.content_capture.mode': 'off', + 'agentops.event.sequence': sequence, + 'agentops.custom_event_id': `event_exact_${sequence}`, + 'agentops.parent_event_id': sequence > 1 ? `event_exact_${sequence - 1}` : '', + 'agentops.event.name': eventName, + 'gen_ai.operation.name': eventName, + ...attributes + } + }; +} + +test('canonical SDK spans retain ordered evidence through AgentOps table rollup', () => { + const rows = [ + sdkSpan(1, 'subagent.started', { + 'agentops.agent.name': 'orchestrator', + 'agentops.parent_agent.name': 'orchestrator', + 'agentops.sub_agent.name': 'reviewer' + }), + sdkSpan(2, 'skill.invoked', { + 'agentops.skill.name': 'qa', + 'agentops.content_capture.signal': true, + 'agentops.content.kind': 'prompt', + 'agentops.content.action': 'dropped', + 'agentops.content.dropped_bytes': 27, + 'agentops.content.secret_like': true, + content: 'SECRET_POISON_MUST_NOT_SURVIVE' + }), + sdkSpan(3, 'tool.execution_start', { + 'gen_ai.tool.name': 'shell', + 'agentops.mcp.server': 'azure', + 'agentops.mcp.tool': 'monitor_query', + 'agentops.command.name': 'node', + 'agentops.script.name': 'check-results.js', + 'agentops.tool.args_schema_hash': 'schema_safe_1' + }), + sdkSpan(4, 'tool.execution_complete', { + 'gen_ai.tool.name': 'shell', + 'agentops.mcp.server': 'azure', + 'agentops.mcp.tool': 'monitor_query', + 'agentops.command.name': 'node', + 'agentops.script.name': 'check-results.js', + 'agentops.tool.args_schema_hash': 'schema_safe_1', + 'agentops.tool.result_size_bytes': 123 + }, { durationMs: 250 }), + sdkSpan(5, 'assistant.usage', { + 'gen_ai.response.model': 'gpt-test', + 'gen_ai.usage.input_tokens': 100, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.reasoning.output_tokens': 5, + 'gen_ai.usage.cache_read.input_tokens': 7, + 'gen_ai.usage.cache_creation.input_tokens': 3, + 'gen_ai.usage.total_tokens': 135, + 'agentops.tools.count': 1, + 'github.copilot.cost': 0.25, + 'agentops.cost.estimated_usd': 0.5, + 'github.copilot.premium_requests': 2, + 'github.copilot.aiu.nano': 900, + 'agentops.api.duration_ms': 41, + 'agentops.lines.added': 8, + 'agentops.lines.removed': 2, + 'agentops.files.edited_count': 1, + 'agentops.repo.hash': 'repo_safe_hash', + 'agentops.branch.hash': 'branch_safe_hash', + 'agentops.workspace.hash': 'workspace_safe_hash' + }, { durationMs: 42 }), + sdkSpan(6, 'permission.completed', { + 'agentops.permission.kind': 'mcp', + 'agentops.permission.decision': 'denied-by-rules', + 'agentops.outcome': 'denied-by-rules' + }) + ]; + + const { tables } = rollupSpanRows(rows, { baseTime: '2026-08-03T10:00:00.000Z' }); + const events = tables.AgentOpsEvents_CL; + const run = tables.AgentOpsRunSummary_CL[0]; + + assert.deepEqual(events.map(row => row.Sequence), [1, 2, 3, 4, 5, 6]); + assert.deepEqual(events.map(row => row.EventId), [1, 2, 3, 4, 5, 6].map(value => `event_exact_${value}`)); + assert.deepEqual(events.map(row => row.ParentEventId), ['', 'event_exact_1', 'event_exact_2', 'event_exact_3', 'event_exact_4', 'event_exact_5']); + assert.equal(events[2].McpServerName, 'azure'); + assert.equal(events[2].McpToolName, 'monitor_query'); + assert.equal(events[2].CommandName, 'node'); + assert.equal(events[2].ScriptName, 'check-results.js'); + assert.equal(events[4].ReasoningTokens, 5); + assert.equal(events[4].CacheReadTokens, 7); + assert.equal(events[4].CacheWriteTokens, 3); + assert.equal(events[4].TotalTokens, 135); + assert.equal(events[4].EstimatedCostUsd, 0.5); + assert.equal(events[4].FilesModified, 1); + assert.equal(events[4].WorkingDirectoryHash, 'workspace_safe_hash'); + assert.equal(events[5].PermissionDecision, 'denied-by-rules'); + assert.equal(events[5].Status, 'failed'); + assert.equal(events[1].ContentDroppedBytes, 27); + assert.equal(events[1].ContentAction, 'dropped'); + assert.equal(events[1].SecretLike, true); + + assert.equal(run.InputTokens, 100); + assert.equal(run.OutputTokens, 20); + assert.equal(run.ReasoningTokens, 5); + assert.equal(run.CacheReadTokens, 7); + assert.equal(run.CacheCreationTokens, 3); + assert.equal(run.EstimatedCostUsd, 0.5); + assert.equal(run.ToolCount, 1); + assert.equal(run.ToolDeniedCount, 1); + assert.equal(run.FilesEditedCount, 1); + assert.equal(run.RepoHash, 'repo_safe_hash'); + assert.equal(run.BranchHash, 'branch_safe_hash'); + assert.equal(run.ContentCaptureSignal, true); + + assert.equal(tables.AgentOpsToolCalls_CL.length, 1); + assert.equal(tables.AgentOpsToolCalls_CL[0].ArgsSchemaHash, 'schema_safe_1'); + assert.equal(tables.AgentOpsToolCalls_CL[0].OutputSizeBytes, 123); + assert.equal(tables.AgentOpsMcpCalls_CL.length, 1); + assert.equal(tables.AgentOpsMcpCalls_CL[0].McpServerName, 'azure'); + assert.equal(tables.AgentOpsMcpCalls_CL[0].ToolName, 'monitor_query'); + assert.equal(tables.AgentOpsPrivacy_CL.length, 1); + assert.equal(tables.AgentOpsPrivacy_CL[0].ContentKind, 'prompt'); + assert.equal(tables.AgentOpsPrivacy_CL[0].Action, 'dropped'); + assert.equal(tables.AgentOpsPrivacy_CL[0].DroppedCount, 1); + assert.doesNotMatch(JSON.stringify(tables), /SECRET_POISON_MUST_NOT_SURVIVE|\"content\"/); +}); diff --git a/agentops-cli/test/security-durable-receipt.test.js b/agentops-cli/test/security-durable-receipt.test.js new file mode 100644 index 0000000..d7f061e --- /dev/null +++ b/agentops-cli/test/security-durable-receipt.test.js @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { durableReceiptSecurityCheck, securityAudit } = require('../src/lib/security-audit'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const files = [ + 'agentops-cli/src/lib/azure/durable-evidence-spool.js', + 'agentops-cli/src/lib/azure/logs-ingestion-upload.js', + 'agentops-cli/src/lib/azure/subscription-guard.js' +]; + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-durable-audit-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const file of files) { + const destination = path.join(root, file); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(path.join(repoRoot, file), destination); + } + return root; +} + +function rewrite(root, file, change) { + const absolute = path.join(root, file); + fs.writeFileSync(absolute, change(fs.readFileSync(absolute, 'utf8'))); +} + +test('durable receipt security audit aggregates all blocking delivery controls', t => { + const root = fixture(t); + const result = durableReceiptSecurityCheck({ root }); + + assert.equal(result.ok, true, result.detail); + assert.equal(result.severity, 'info'); + assert.deepEqual(result.evidence.map(item => item.file), files); + assert.ok(result.evidence.flatMap(item => item.controls).includes('no-raw-reason-error')); + + const audit = securityAudit({ + root, + runStaticCheck: () => ({ name: 'static', ok: true, severity: 'info' }), + runGitleaks: () => ({ name: 'gitleaks', ok: true, severity: 'info' }) + }); + assert.equal(audit.checks.some(check => check.name === 'durable-receipt-security'), true); +}); + +test('durable receipt security audit blocks aggregate schema privacy destination bounds and persistence regressions', t => { + const root = fixture(t); + const spool = files[0]; + const upload = files[1]; + rewrite(root, spool, body => body + .replace(" 'AgentOpsEvents_CL'", " 'AgentOpsEvents_CL', 'AgentOpsRunSummary_CL'") + .replace("row.PrivacyMode = 'strict'", "row.PrivacyMode = input.PrivacyMode") + .replace('const maximumDrainAttempts = 10;', 'const removedAttemptBound = 10;') + .replace(" 'SchemaVersion'", " 'SchemaVersion', 'Reason', 'Error'")); + rewrite(root, upload, body => body + .replace(".endsWith('.ingest.monitor.azure.com')", ".endsWith('.example.com')") + .replace('AbortSignal.timeout(timeoutMs)', 'undefined')); + + const result = durableReceiptSecurityCheck({ root }); + + assert.equal(result.ok, false); + assert.equal(result.severity, 'error'); + assert.match(result.detail, /AgentOpsEvents_CL only/); + assert.match(result.detail, /force strict privacy/); + assert.match(result.detail, /attempt bounds/); + assert.match(result.detail, /raw Reason\/Error/); + assert.match(result.detail, /Azure public Monitor hostname/); + assert.match(result.detail, /bounded timeout/); +}); diff --git a/agentops-cli/test/security-persistent-queue.test.js b/agentops-cli/test/security-persistent-queue.test.js new file mode 100644 index 0000000..4aa2a8e --- /dev/null +++ b/agentops-cli/test/security-persistent-queue.test.js @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { persistentCollectorQueueCheck } = require('../src/lib/security-audit'); + +const repoRoot = path.resolve(__dirname, '..', '..'); + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-queue-audit-')); + fs.cpSync(path.join(repoRoot, 'collector'), path.join(root, 'collector'), { recursive: true }); + const queue = path.join(root, 'runtime-queue'); + fs.mkdirSync(queue, { mode: 0o700 }); + return { root, queue }; +} + +function rewrite(file, change) { + fs.writeFileSync(file, change(fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'))); +} + +test('persistent collector queue audit accepts bounded privacy-first configs and private runtime storage', t => { + const { root, queue } = fixture(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const result = persistentCollectorQueueCheck({ root, collectorQueueDir: queue }); + + assert.equal(result.ok, true, result.detail); + assert.equal(result.evidence.filter(item => item.file).length, 4); + assert.ok(result.evidence.filter(item => item.file).every(item => item.queue_size === 1000)); + const runtime = result.evidence.at(-1); + assert.equal(runtime.directory, queue); + assert.equal(runtime.exists, true); + assert.equal(runtime.permissions_checked, process.platform !== 'win32'); + if (process.platform !== 'win32') assert.equal(runtime.mode, '700'); +}); + +test('persistent collector queue audit rejects unbounded, non-persistent, and privacy-late configs', t => { + const { root, queue } = fixture(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const compat = path.join(root, 'collector', 'otelcol.binary.compat.yaml'); + const strict = path.join(root, 'collector', 'otelcol.binary.strict.yaml'); + rewrite(compat, body => body + .replace(' storage: file_storage\n', '') + .replace(' queue_size: 1000', ' queue_size: 1000000')); + rewrite(strict, body => body.replace( + 'processors: [memory_limiter, transform/privacy_strict, batch]', + 'processors: [memory_limiter, batch, transform/privacy_strict]' + )); + + const result = persistentCollectorQueueCheck({ root, collectorQueueDir: queue }); + + assert.equal(result.ok, false); + assert.match(result.detail, /persist through file_storage/); + assert.match(result.detail, /bounded between 1 and 10000/); + assert.match(result.detail, /sanitize before batch\/queue\/export/); +}); + +test('persistent collector queue audit rejects group or world-readable runtime storage on POSIX', { + skip: process.platform === 'win32' +}, t => { + const { root, queue } = fixture(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.chmodSync(queue, 0o750); + + const result = persistentCollectorQueueCheck({ root, collectorQueueDir: queue }); + + assert.equal(result.ok, false); + assert.match(result.detail, /must not grant group\/other access/); + assert.equal(result.evidence.at(-1).mode, '750'); +}); diff --git a/agentops-cli/test/session-command.test.js b/agentops-cli/test/session-command.test.js new file mode 100644 index 0000000..10a2bbb --- /dev/null +++ b/agentops-cli/test/session-command.test.js @@ -0,0 +1,122 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { createSessionCommand } = require('../src/lib/session-command'); + +function createOutput() { + let text = ''; + return { + stdout: { + write(chunk) { + text += String(chunk); + return true; + } + }, + text() { + return text; + } + }; +} + +test('session command writes latest output through injected stdout', async () => { + const output = createOutput(); + const calls = []; + const { sessionCommand, sessionCommandNames } = createSessionCommand({ + stdout: output.stdout, + latestSummaryFromArgs(args) { + calls.push(args); + return { id: 'summary-1' }; + }, + renderLatest(summary) { + return `latest:${summary.id}`; + } + }); + + assert.ok(sessionCommandNames.includes('latest')); + + await sessionCommand('latest', ['--last', '1h']); + + assert.deepEqual(calls, [['--last', '1h']]); + assert.equal(output.text(), 'latest:summary-1'); +}); + +test('session command replays local latest spans without Azure lookup', async () => { + const output = createOutput(); + const calls = []; + const { sessionCommand } = createSessionCommand({ + stdout: output.stdout, + optionValue(args, names) { + calls.push(['optionValue', args, names]); + return args.includes('--file'); + }, + spanRowsFromSource(args, last) { + calls.push(['spanRowsFromSource', args, last]); + return { mode: 'file', rows: [{ span_id: 'span-1' }] }; + }, + replayTimeline(rows, options) { + calls.push(['replayTimeline', rows, options]); + return { rows, options }; + }, + renderReplay(timeline) { + return JSON.stringify(timeline); + } + }); + + await sessionCommand('replay', ['latest', '--file', 'run.jsonl']); + + assert.deepEqual(calls, [ + ['spanRowsFromSource', ['--file', 'run.jsonl'], '7d'], + ['replayTimeline', [{ span_id: 'span-1' }], { sessionId: 'latest', source: 'file' }] + ]); + assert.equal(output.text(), '{"rows":[{"span_id":"span-1"}],"options":{"sessionId":"latest","source":"file"}}'); +}); + +test('session command recommends only the latest session and keeps lookback context', async () => { + const output = createOutput(); + const calls = []; + const { sessionCommand } = createSessionCommand({ + stdout: output.stdout, + latestSummaryFromArgs(args) { + calls.push(['latestSummaryFromArgs', args]); + return { id: 'summary-2' }; + }, + parseLastArg(args, fallback) { + calls.push(['parseLastArg', args, fallback]); + return '2h'; + }, + explainLatest(summary) { + calls.push(['explainLatest', summary]); + return { classification: 'failed_tool' }; + }, + recommendationForExplanation(explanation, context) { + calls.push(['recommendationForExplanation', explanation, context]); + return { action: 'inspect-tools' }; + }, + renderRecommendation(recommendation) { + return `recommend:${recommendation.action}`; + } + }); + + await sessionCommand('recommend', ['latest', '--last', '2h']); + + assert.deepEqual(calls, [ + ['latestSummaryFromArgs', ['--last', '2h']], + ['parseLastArg', ['--last', '2h'], '7d'], + ['explainLatest', { id: 'summary-2' }], + ['recommendationForExplanation', { classification: 'failed_tool' }, { last: '2h' }] + ]); + assert.equal(output.text(), 'recommend:inspect-tools'); +}); + +test('session command rejects unsupported live interval and explain target', async () => { + const { sessionCommand } = createSessionCommand({}); + + await assert.rejects( + () => sessionCommand('live', ['--interval', '0']), + /--interval must be a positive number/ + ); + await assert.rejects( + () => sessionCommand('explain', ['session-1']), + /explain currently supports: explain latest/ + ); +}); diff --git a/agentops-cli/test/session-row-utils.test.js b/agentops-cli/test/session-row-utils.test.js new file mode 100644 index 0000000..24fb0e7 --- /dev/null +++ b/agentops-cli/test/session-row-utils.test.js @@ -0,0 +1,63 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + attributeValue, + booleanAttribute, + isFailedRow, + isSpanTelemetryRow, + numberAttribute, + operationFromRow, + rowAttributes, + sessionFromRow, + telemetryTime +} = require('../src/lib/session-row-utils'); + +test('session row helpers read structured and JSON-string attributes', () => { + assert.deepEqual(rowAttributes({ attributes: { model: 'gpt-4.1' } }), { model: 'gpt-4.1' }); + assert.deepEqual(rowAttributes({ Properties: '{"tool":"search"}' }), { tool: 'search' }); + assert.deepEqual(rowAttributes({ Properties: '{bad json' }), {}); +}); + +test('session attribute helpers normalize values by type', () => { + const attrs = { + primary: '', + fallback: '42', + enabled: 'true', + disabled: 'false', + explicit: false + }; + + assert.equal(attributeValue(attrs, ['primary', 'fallback']), '42'); + assert.equal(attributeValue(attrs, ['missing']), null); + assert.equal(numberAttribute(attrs, ['fallback']), 42); + assert.equal(numberAttribute(attrs, ['missing']), 0); + assert.equal(booleanAttribute(attrs, ['enabled']), true); + assert.equal(booleanAttribute(attrs, ['disabled']), false); + assert.equal(booleanAttribute(attrs, ['explicit']), false); +}); + +test('session row helpers infer operation session and failures', () => { + assert.equal(operationFromRow({ EventName: 'chat' }, {}), 'chat'); + assert.equal(operationFromRow({}, { 'gen_ai.operation.name': 'execute_tool' }), 'execute_tool'); + assert.equal(operationFromRow({}, {}), 'unknown'); + + assert.equal(sessionFromRow({ SessionId: 'session-1' }, {}), 'session-1'); + assert.equal(sessionFromRow({}, { 'gen_ai.conversation.id': 'conversation-1' }), 'conversation-1'); + assert.equal(sessionFromRow({}, {}), 'unknown-session'); + + assert.equal(isFailedRow({ Success: 'false' }, {}), true); + assert.equal(isFailedRow({ Status: 'blocked' }, {}), true); + assert.equal(isFailedRow({ ResultCode: 'ERROR' }, {}), true); + assert.equal(isFailedRow({}, { 'error.type': 'ToolError' }), true); + assert.equal(isFailedRow({ Success: true }, {}), false); +}); + +test('session row helpers normalize native Copilot file-export rows', () => { + const attrs = { 'gen_ai.operation.name': 'invoke_agent' }; + assert.equal(operationFromRow({ name: 'invoke_agent github.copilot.default' }, attrs), 'invoke_agent'); + assert.equal(telemetryTime([1785768920, 93000000]), '2026-08-03T14:55:20.093Z'); + assert.equal(isSpanTelemetryRow({ type: 'span' }), true); + assert.equal(isSpanTelemetryRow({ type: 'metric' }), false); + assert.equal(isSpanTelemetryRow({ Name: 'legacy Azure row' }), true); +}); diff --git a/agentops-cli/test/session-summary-receipt.test.js b/agentops-cli/test/session-summary-receipt.test.js new file mode 100644 index 0000000..f1babc6 --- /dev/null +++ b/agentops-cli/test/session-summary-receipt.test.js @@ -0,0 +1,100 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createSessionSummary } = require('../src/lib/session-summary'); + +function receipt() { + return createSessionSummary({ + appInsightsResourceUrl: 'https://portal.azure.com/#/resource/app-insights/overview', + buildLink: (_kind, id) => ({ grafana_url: `https://grafana.test/session/${id}` }), + mainGrafanaDashboardUrl: 'https://grafana.test', + optionValue: () => null, + parseLastArg: () => '7d' + }); +} + +test('open links prefer the native Application Insights resource before Grafana', () => { + const api = receipt(); + const links = api.openLinksSummary({ session: null }); + + assert.equal(links.primary_investigation_url, 'https://portal.azure.com/#/resource/app-insights/overview'); + assert.equal(links.primary_investigation_label, 'Application Insights (open Agents)'); + assert.equal(links.application_insights_url, 'https://portal.azure.com/#/resource/app-insights/overview'); + assert.equal(links.azure_agents_view_url, null); +}); + +test('receipt summarizes existing V2 delivery and safety metadata without content fields', () => { + const api = receipt(); + const result = api.latestSessionSummary({ + source: 'local', + rows: [ + { + TimeGenerated: '2026-08-03T12:00:00.000Z', + RunId: 'run-receipt', + SessionId: 'session-receipt', + AgentName: 'builder', + SkillName: 'agentops-setup', + SubAgentName: 'reviewer', + OutcomeStatus: 'success', + TestsRan: true, + TestsPassed: true, + PrOpened: true, + CiStatus: 'success', + ToolDeniedCount: 2, + Credits: 3, + Properties: { + 'gen_ai.operation.name': 'chat', + 'github.copilot.cost': 3, + 'agentops.cli.name': 'gh', + 'agentops.script.name': 'release-check' + } + }, + { + TimeGenerated: '2026-08-03T12:00:01.000Z', + SessionId: 'session-receipt', + McpServerName: 'github', + ToolName: 'search_issues', + Allowed: false, + DroppedCount: 4, + Properties: { 'gen_ai.operation.name': 'execute_tool' } + } + ] + }); + + assert.deepEqual(result.session.skills, ['agentops-setup']); + assert.deepEqual(result.session.subagents, ['reviewer']); + assert.deepEqual(result.session.mcp_servers, ['github']); + assert.deepEqual(result.session.cli_tools, ['gh']); + assert.deepEqual(result.session.scripts, ['release-check']); + assert.equal(result.session.denied_tool_calls, 2); + assert.equal(result.session.privacy_blocked_count, 4); + assert.equal(result.session.tests_passed, true); + assert.equal(result.session.pr_opened, true); + assert.equal(result.session.credits, 3); + + const output = api.renderLatest(result); + assert.match(output, /skills agentops-setup; subagents reviewer; MCP github/); + assert.match(output, /CLI gh; scripts release-check/); + assert.match(output, /tests passed; PR opened; CI success/); + assert.match(output, /2 denied tool calls; 4 privacy fields blocked/); + assert.match(output, /Copilot credits: 3/); + assert.match(output, /Delivery: Local evidence only · Azure not confirmed/); + assert.doesNotMatch(output, /Visible in Azure/); + assert.doesNotMatch(output, /SECRET_|raw transcript|file content/i); +}); + +test('Azure receipt distinguishes query visibility from native coverage', () => { + const api = receipt(); + const result = api.latestSessionSummary({ + source: 'azure', + rows: [{ + TimeGenerated: '2026-08-03T12:00:00.000Z', + SessionId: 'native-session', + Properties: { 'gen_ai.operation.name': 'chat' } + }] + }); + const output = api.renderLatest(result); + assert.match(output, /Delivery: Visible in Azure/); + assert.match(output, /Coverage: Native best effort/); + assert.doesNotMatch(output, /AgentOps managed/); +}); diff --git a/agentops-cli/test/setup-guide.test.js b/agentops-cli/test/setup-guide.test.js new file mode 100644 index 0000000..4f8ba83 --- /dev/null +++ b/agentops-cli/test/setup-guide.test.js @@ -0,0 +1,75 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { createSetupGuide } = require('../src/lib/setup-guide'); + +function createGuide() { + return createSetupGuide({ + commandCandidates: name => [`/usr/local/bin/${name}`], + configFromEnvValues: values => values, + configuredCloudValues: () => ({ + resourceGroup: '', + workspaceId: '', + workspaceName: '', + grafanaBaseUrl: '', + grafanaName: '', + appInsightsName: '' + }), + defaultInstallDir: '/tmp/agentops-bin', + grafanaDashboardImportCommand: () => 'agentops dashboard import', + installedShimStatus: () => ({ + agentops_cli_installed: true, + copilot_agentops_installed: false, + plain_copilot_observed: false + }), + isConfiguredValue: (value, placeholderPattern) => Boolean(value) && !placeholderPattern.test(value), + parseEnvAssignments: text => Object.fromEntries( + String(text) + .split(/\r?\n/) + .filter(Boolean) + .map(line => line.split('=')) + ), + realCopilotSmokeCommand: () => 'copilot -p "Reply with exactly: agentops smoke."' + }); +} + +test('setup guide helpers render read-only first-run guidance', () => { + const { agentopsSetupGuide, parseSetupArgs, renderSetupGuide } = createGuide(); + + const result = agentopsSetupGuide({ + azdValues: [ + 'workspaceId=11111111-1111-1111-1111-111111111111', + 'grafanaBaseUrl=https://agentops.grafana.azure.com' + ].join('\n') + }); + + assert.deepEqual(parseSetupArgs(['--json']), { json: true }); + assert.equal(result.mode, 'guide'); + assert.equal(result.mutates, false); + assert.equal(result.azd.ok, true); + assert.equal(result.phases[0].status, 'ready-to-import'); + assert.ok(result.next.includes('agentops configure import-azd')); + assert.match(renderSetupGuide(result), /This command is read-only/); +}); + +test('resource-group status is read-only and fails closed when the configured target is absent', () => { + const { azureResourceGroupStatus } = createGuide(); + const calls = []; + const result = azureResourceGroupStatus({ + resourceGroup: 'rg-agentops-dev', + subscriptionId: 'sub-approved', + spawnSync: (command, args) => { + calls.push([command, args]); + return { status: 0, stdout: 'false\n', stderr: '' }; + } + }, true); + + assert.equal(result.checked, true); + assert.equal(result.exists, false); + assert.equal(result.ok, false); + assert.equal(result.status, 'missing'); + assert.deepEqual(calls, [[ + 'az', + ['group', 'exists', '--name', 'rg-agentops-dev', '--subscription', 'sub-approved'] + ]]); +}); diff --git a/agentops-cli/test/shim-lifecycle.test.js b/agentops-cli/test/shim-lifecycle.test.js new file mode 100644 index 0000000..1528011 --- /dev/null +++ b/agentops-cli/test/shim-lifecycle.test.js @@ -0,0 +1,141 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); + +function run(script, args, env) { + return spawnSync('/bin/bash', [path.join(root, script), ...args], { + cwd: root, + env, + encoding: 'utf8' + }); +} + +function powershellCommand() { + for (const command of process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh', 'powershell']) { + const probe = spawnSync(command, ['-NoProfile', '-Command', 'exit 0'], { encoding: 'utf8' }); + if (!probe.error && probe.status === 0) return command; + } + return null; +} + +function runPowerShell(command, script, args, env) { + return spawnSync(command, [ + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', path.join(root, script), + ...args + ], { + cwd: root, + env, + encoding: 'utf8' + }); +} + +test('macOS/Linux shim lifecycle installs transparent routing and restores the original Copilot path', { + skip: process.platform === 'win32' ? 'POSIX shell fixture is not executable on Windows' : false +}, () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-shim-lifecycle-')); + const installDir = path.join(temp, 'installed-bin'); + const realBin = path.join(temp, 'real-bin'); + fs.mkdirSync(realBin, { recursive: true }); + const realCopilot = path.join(realBin, 'copilot'); + fs.writeFileSync(realCopilot, '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }); + const installedCopilot = path.join(installDir, 'copilot'); + fs.mkdirSync(installDir, { recursive: true }); + const original = '#!/usr/bin/env bash\necho ORIGINAL_COPILOT\n'; + fs.writeFileSync(installedCopilot, original, { mode: 0o755 }); + const env = { + ...process.env, + HOME: temp, + AGENTOPS_BIN_DIR: installDir, + PATH: `${installDir}:${realBin}:/usr/bin:/bin` + }; + + try { + const installed = run('scripts/install-copilot-agentops-shim.sh', ['--shadow-copilot'], env); + assert.equal(installed.status, 0, installed.stderr || installed.stdout); + assert.equal(fs.realpathSync(path.join(installDir, 'agentops')), path.join(root, 'agentops-cli', 'src', 'index.js')); + assert.equal(fs.realpathSync(path.join(installDir, 'copilot-agentops')), path.join(root, 'scripts', 'copilot-agentops')); + const shadow = fs.readFileSync(installedCopilot, 'utf8'); + assert.match(shadow, /# AgentOps managed shadow shim/); + assert.match(shadow, new RegExp(`COPILOT_CLI_BIN="${realCopilot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); + assert.equal(fs.readFileSync(`${installedCopilot}.agentops-original`, 'utf8'), original); + + const removed = run('scripts/uninstall-copilot-agentops-shim.sh', [], env); + assert.equal(removed.status, 0, removed.stderr || removed.stdout); + for (const name of ['agentops', 'copilot-agentops', 'agentops-codex']) { + assert.equal(fs.existsSync(path.join(installDir, name)), false, `${name} should be removed`); + } + assert.equal(fs.readFileSync(installedCopilot, 'utf8'), original); + assert.equal(fs.existsSync(`${installedCopilot}.agentops-original`), false); + assert.equal(fs.existsSync(realCopilot), true, 'the original Copilot CLI must be preserved'); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test('Windows PowerShell shadow lifecycle preserves and restores a pre-existing Copilot command byte-for-byte', { + skip: powershellCommand() ? false : 'PowerShell is unavailable on this host; exercised on Windows/PowerShell CI' +}, () => { + const powershell = powershellCommand(); + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-powershell-shim-lifecycle-')); + const installDir = path.join(temp, 'installed-bin'); + const realBin = path.join(temp, 'real-bin'); + fs.mkdirSync(installDir, { recursive: true }); + fs.mkdirSync(realBin, { recursive: true }); + + const original = '@echo off\r\necho ORIGINAL_COPILOT %*\r\n'; + const installedCopilot = path.join(installDir, 'copilot.cmd'); + fs.writeFileSync(installedCopilot, original, 'ascii'); + + if (process.platform === 'win32') { + fs.writeFileSync(path.join(realBin, 'copilot.cmd'), '@echo off\r\nexit /b 0\r\n', 'ascii'); + } else { + const realCopilot = path.join(realBin, 'copilot'); + fs.writeFileSync(realCopilot, '#!/usr/bin/env sh\nexit 0\n', { mode: 0o755 }); + } + + const env = { + ...process.env, + AGENTOPS_BIN_DIR: installDir, + PATH: [installDir, realBin, process.env.PATH].filter(Boolean).join(path.delimiter) + }; + + try { + const installed = runPowerShell(powershell, 'scripts/install-copilot-agentops-shim.ps1', [ + '-ShadowCopilot', '-InstallDir', installDir + ], env); + assert.equal(installed.status, 0, installed.stderr || installed.stdout); + assert.match(fs.readFileSync(installedCopilot, 'ascii'), /REM AgentOps managed shadow shim/); + assert.equal(fs.readFileSync(`${installedCopilot}.agentops-original`, 'ascii'), original); + + const removed = runPowerShell(powershell, 'scripts/uninstall-copilot-agentops-shim.ps1', [ + '-InstallDir', installDir + ], env); + assert.equal(removed.status, 0, removed.stderr || removed.stdout); + assert.equal(fs.readFileSync(installedCopilot, 'ascii'), original); + assert.equal(fs.existsSync(`${installedCopilot}.agentops-original`), false); + for (const name of ['agentops.cmd', 'copilot-agentops.cmd', 'agentops-codex.cmd']) { + assert.equal(fs.existsSync(path.join(installDir, name)), false, `${name} should be removed`); + } + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test('PowerShell shim scripts carry an ownership marker and refuse unsafe backup overwrite', () => { + const install = fs.readFileSync(path.join(root, 'scripts', 'install-copilot-agentops-shim.ps1'), 'utf8'); + const uninstall = fs.readFileSync(path.join(root, 'scripts', 'uninstall-copilot-agentops-shim.ps1'), 'utf8'); + + assert.match(install, /REM AgentOps managed shadow shim/); + assert.match(install, /copilot\.cmd\.agentops-original/); + assert.match(install, /Refusing to overwrite/); + assert.match(uninstall, /Preserved non-AgentOps copilot command/); + assert.match(uninstall, /Cannot restore the original Copilot command/); + assert.match(uninstall, /Restored original copilot command/); +}); diff --git a/agentops-cli/test/smoke-cli.test.js b/agentops-cli/test/smoke-cli.test.js new file mode 100644 index 0000000..4bd00c6 --- /dev/null +++ b/agentops-cli/test/smoke-cli.test.js @@ -0,0 +1,65 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + commandShellQuote, + durationToMs, + parseSmokeArgs, + realCopilotSmokeArgs, + realCopilotSmokeCommand +} = require('../src/lib/smoke-cli'); + +test('smoke CLI helpers parse durations flags and real Copilot commands', () => { + const args = parseSmokeArgs([ + '--dry-run', + '--endpoint', + 'http://collector.example:4318/', + '--id', + 'smoke-123', + '--last', + '30m', + '--real-copilot', + '--open-browser', + '--timeout', + '9s', + '--wait', + '5s', + '--poll', + '500ms', + '--no-verify', + '--json' + ]); + + assert.deepEqual(args, { + dryRun: true, + endpoint: 'http://collector.example:4318/', + id: 'smoke-123', + last: '30m', + realCopilot: true, + openBrowser: true, + copilotTimeoutMs: 9000, + verify: false, + waitMs: 5000, + pollMs: 500, + json: true + }); + + assert.equal(parseSmokeArgs([]).last, '2h'); + assert.equal(durationToMs('2m'), 120000); + assert.equal(durationToMs('', 42), 42); + assert.throws(() => durationToMs('7d'), /duration must look like/); + assert.throws(() => parseSmokeArgs(['--last']), /--last requires a duration/); + + assert.equal(commandShellQuote('plain/path'), 'plain/path'); + assert.equal(commandShellQuote("two words's"), "'two words'\\''s'"); + assert.deepEqual(realCopilotSmokeArgs().slice(0, 5), ['--no-ask-user', '--no-remote', '--no-remote-export', '--add-dir', '.']); + assert.match(realCopilotSmokeCommand(), /^copilot --no-ask-user --no-remote --no-remote-export --add-dir \./); + assert.match(realCopilotSmokeCommand(), /'Do not edit files\. Run pwd and ls docs \| head, then summarize\.'/); +}); + +test('smoke CLI uses shared option parsing helper', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'lib', 'smoke-cli.js'), 'utf8'); + assert.doesNotMatch(source, /function optionValue\(/); +}); diff --git a/agentops-cli/test/smoke-payloads.test.js b/agentops-cli/test/smoke-payloads.test.js new file mode 100644 index 0000000..cf46b63 --- /dev/null +++ b/agentops-cli/test/smoke-payloads.test.js @@ -0,0 +1,25 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + liveReplayGrafanaUrl, + otlpAttributionSmokeTracePayload, + smokeAzureQuery +} = require('../src/lib/smoke-payloads'); + +test('smoke payload helpers build metadata-only payloads and links', () => { + const payload = otlpAttributionSmokeTracePayload('agentops-attribution-smoke-test', Date.parse('2026-05-26T12:00:00Z')); + const spans = payload.resourceSpans[0].scopeSpans[0].spans; + const query = smokeAzureQuery('agentops-attribution-smoke-test', '30m'); + const url = liveReplayGrafanaUrl('agentops-live-replay-smoke-test', '30m', { + grafanaBaseUrl: 'https://grafana.example' + }); + + assert.equal(spans.length, 4); + assert.match(JSON.stringify(payload), /content\.capture\.enabled/); + assert.doesNotMatch(JSON.stringify(payload), /prompt|response|tool arguments/i); + assert.match(query, /ago\(30m\)/); + assert.match(query, /agentops-attribution-smoke-test/); + assert.match(url, /agentops-live-replay/); + assert.match(url, /var-conversation=agentops-live-replay-smoke-test/); +}); diff --git a/agentops-cli/test/smoke-runtime.test.js b/agentops-cli/test/smoke-runtime.test.js new file mode 100644 index 0000000..6703c6f --- /dev/null +++ b/agentops-cli/test/smoke-runtime.test.js @@ -0,0 +1,125 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const test = require('node:test'); + +const { + openUrlInBrowser, + postJson, + runRealCopilotSmoke, + verifySmokeInAzure, + waitForLatestRunSummary +} = require('../src/lib/smoke-runtime'); + +test('smoke runtime posts JSON and reports collector response', async () => { + let received = null; + const server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', chunk => body += chunk); + req.on('end', () => { + received = { + method: req.method, + url: req.url, + contentType: req.headers['content-type'], + body: JSON.parse(body) + }; + res.writeHead(202, { 'Content-Type': 'text/plain' }); + res.end('accepted'); + }); + }); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const port = server.address().port; + const response = await postJson(`http://127.0.0.1:${port}/v1/traces`, { hello: 'agentops' }); + + assert.deepEqual(response, { ok: true, statusCode: 202, body: 'accepted' }); + assert.deepEqual(received, { + method: 'POST', + url: '/v1/traces', + contentType: 'application/json', + body: { hello: 'agentops' } + }); + } finally { + await new Promise(resolve => server.close(resolve)); + } +}); + +test('smoke runtime runs real Copilot smoke with strict telemetry env', () => { + let invocation = null; + const result = runRealCopilotSmoke({ + cwd: '/tmp/agentops', + endpoint: 'http://127.0.0.1:4318', + timeout: '5s', + spawnSync: (command, args, options) => { + invocation = { command, args, options }; + return { status: 0, stdout: 'ok', stderr: '' }; + } + }); + + assert.equal(result.ok, true); + assert.equal(invocation.command, 'copilot'); + assert.equal(invocation.options.cwd, '/tmp/agentops'); + assert.equal(invocation.options.timeout, 5000); + assert.equal(invocation.options.env.AGENTOPS_PRIVACY_MODE, 'strict'); + assert.equal(invocation.options.env.AGENTOPS_CAPTURE_CONTENT, 'false'); + assert.equal(invocation.options.env.COPILOT_OTEL_ENABLED, 'true'); + assert.equal(invocation.options.env.COPILOT_OTEL_EXPORTER_TYPE, 'otlp-http'); + assert.equal(invocation.options.env.COPILOT_OTEL_SOURCE_NAME, 'github.copilot'); + assert.equal(invocation.options.env.COPILOT_OTEL_CAPTURE_CONTENT, 'false'); + assert.equal(invocation.options.env.OTEL_EXPORTER_OTLP_ENDPOINT, 'http://127.0.0.1:4318'); + assert.equal(invocation.options.env.OTEL_EXPORTER_OTLP_PROTOCOL, 'http/protobuf'); + assert.equal(invocation.options.env.OTEL_SERVICE_NAME, 'github-copilot'); + assert.match(invocation.options.env.OTEL_RESOURCE_ATTRIBUTES, /agentops\.profile=native-smoke/); +}); + +test('smoke runtime opens URLs through injected browser opener', () => { + const opened = openUrlInBrowser('https://grafana.example/d/run', { + openUrl: url => ({ ok: true, url, source: 'test' }) + }); + + assert.deepEqual(opened, { ok: true, url: 'https://grafana.example/d/run', source: 'test' }); + assert.deepEqual(openUrlInBrowser(''), { ok: false, reason: 'missing-url' }); +}); + +test('smoke runtime polls latest run summary until visible', async () => { + let calls = 0; + const result = await waitForLatestRunSummary({ + last: '30m', + waitMs: 1000, + pollMs: 1, + sleep: async () => {}, + latestSummary: ({ last }) => { + calls += 1; + return calls === 1 + ? { session: null } + : { session: { id: 'session-123', grafana_url: `https://grafana.example/${last}` } }; + } + }); + + assert.equal(result.ok, true); + assert.equal(result.status, 'found'); + assert.equal(result.summary.session.id, 'session-123'); + assert.equal(result.attempts.length, 2); +}); + +test('smoke runtime verifies smoke rows through injected Azure query', async () => { + const result = await verifySmokeInAzure('agentops-smoke-test', { + last: '15m', + waitMs: 0, + workspaceId: 'workspace-123', + runAzureLogAnalyticsQuery: (query, options) => ({ + ok: true, + rows: [{ Rows: 1 }], + query, + workspaceId: options.workspaceId + }) + }); + + assert.equal(result.ok, true); + assert.equal(result.status, 'found'); + assert.equal(result.workspace_id, 'workspace-123'); + assert.equal(result.rows, 1); + assert.match(result.query, /agentops-smoke-test/); + assert.match(result.query, /ago\(15m\)/); +}); diff --git a/agentops-cli/test/static-check.test.js b/agentops-cli/test/static-check.test.js new file mode 100644 index 0000000..e76995b --- /dev/null +++ b/agentops-cli/test/static-check.test.js @@ -0,0 +1,39 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { checkRequiredNonEmptyFiles, checkV2IngestionSchema, walk } = require('../../scripts/static-check'); + +test('static check rejects missing and empty required files', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-static-check-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'empty.js'), ' \n'); + fs.writeFileSync(path.join(root, 'working.js'), 'module.exports = {};\n'); + + assert.deepEqual(checkRequiredNonEmptyFiles(root, ['missing.js', 'empty.js', 'working.js']), [ + { file: 'missing.js', error: 'required file is missing' }, + { file: 'empty.js', error: 'required file is empty' } + ]); +}); + +test('static check includes the v2 AgentOpsEvents immutable schema contract', () => { + assert.deepEqual(checkV2IngestionSchema(path.resolve(__dirname, '../..')), []); +}); + +test('static check ignores transient package asset copies while packaging runs', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-static-package-assets-')); + try { + fs.mkdirSync(path.join(root, 'agentops-cli', 'actioner'), { recursive: true }); + fs.mkdirSync(path.join(root, 'agentops-cli', 'src', 'fixtures'), { recursive: true }); + fs.writeFileSync(path.join(root, 'agentops-cli', 'actioner', 'README.md'), '[missing](not-present.md)\n'); + fs.writeFileSync(path.join(root, 'agentops-cli', 'src', 'fixtures', 'generated.json'), '{"generated":true}\n'); + fs.writeFileSync(path.join(root, 'agentops-cli', 'src', 'index.js'), 'module.exports = {};\n'); + + const files = walk(root, [], root).map(file => path.relative(root, file).replaceAll('\\', '/')); + assert.deepEqual(files, ['agentops-cli/src/index.js']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/subscription-guard.test.js b/agentops-cli/test/subscription-guard.test.js new file mode 100644 index 0000000..f2239ef --- /dev/null +++ b/agentops-cli/test/subscription-guard.test.js @@ -0,0 +1,155 @@ +const assert = require('node:assert/strict'); +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { checkAzureSubscription } = require('../src/lib/azure/subscription-guard'); + +const APPROVED = '11111111-1111-4111-8111-111111111111'; +const UNAPPROVED = '22222222-2222-4222-8222-222222222222'; + +test('subscription guard fails closed without an explicitly configured subscription', () => { + let called = false; + const result = checkAzureSubscription({ + env: {}, + spawnSync() { + called = true; + } + }); + + assert.equal(result.ok, false); + assert.match(result.error, /Set AGENTOPS_AZURE_SUBSCRIPTION_ID/); + assert.equal(called, false); +}); + +test('subscription guard does not accept the generic Azure subscription variable as write approval', () => { + const result = checkAzureSubscription({ + env: { + AZURE_SUBSCRIPTION_ID: APPROVED, + AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: APPROVED + }, + spawnSync() { + throw new Error('az must not run without AGENTOPS_AZURE_SUBSCRIPTION_ID'); + } + }); + + assert.equal(result.ok, false); + assert.match(result.error, /Set AGENTOPS_AZURE_SUBSCRIPTION_ID/); +}); + +test('subscription guard fails closed when a public build has no approved allowlist', () => { + let called = false; + const result = checkAzureSubscription({ + expectedSubscriptionId: APPROVED, + env: {}, + spawnSync() { + called = true; + } + }); + + assert.equal(result.ok, false); + assert.match(result.error, /AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS/); + assert.equal(called, false); +}); + +test('subscription guard accepts the exact active subscription case-insensitively', () => { + const result = checkAzureSubscription({ + expectedSubscriptionId: APPROVED.toUpperCase(), + approvedSubscriptionIds: [APPROVED], + spawnSync() { + return { status: 0, stdout: `${APPROVED}\n`, stderr: '' }; + } + }); + + assert.deepEqual(result, { ok: true, expected: APPROVED, active: APPROVED, approved: [APPROVED], error: '' }); +}); + +test('subscription guard rejects a subscription missing from the explicit allowlist', () => { + let called = false; + const result = checkAzureSubscription({ + expectedSubscriptionId: UNAPPROVED, + approvedSubscriptionIds: [APPROVED], + spawnSync() { + called = true; + return { status: 0, stdout: `${UNAPPROVED}\n`, stderr: '' }; + } + }); + + assert.equal(result.ok, false); + assert.match(result.error, /not in AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS/); + assert.equal(called, false); +}); + +test('shell subscription guard also rejects a matching unapproved subscription', () => { + const guard = path.resolve(__dirname, '..', '..', 'scripts', 'lib', 'azure-subscription-guard.sh'); + const result = childProcess.spawnSync('bash', ['-c', `az() { printf '%s\\n' "$ACTIVE_TEST_SUB"; }; source "$GUARD_PATH"; agentops_require_azure_subscription`], { + encoding: 'utf8', + env: { + ...process.env, + ACTIVE_TEST_SUB: UNAPPROVED, + AGENTOPS_AZURE_SUBSCRIPTION_ID: UNAPPROVED, + AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: APPROVED, + GUARD_PATH: guard + } + }); + + assert.equal(result.status, 2); + assert.match(result.stderr, /not in AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS/); +}); + +test('shell subscription guard rejects AZURE_SUBSCRIPTION_ID as a write-approval substitute', () => { + const guard = path.resolve(__dirname, '..', '..', 'scripts', 'lib', 'azure-subscription-guard.sh'); + const result = childProcess.spawnSync('bash', ['-c', `az() { printf '%s\\n' "$ACTIVE_TEST_SUB"; }; source "$GUARD_PATH"; agentops_require_azure_subscription`], { + encoding: 'utf8', + env: { + ...process.env, + ACTIVE_TEST_SUB: APPROVED, + AZURE_SUBSCRIPTION_ID: APPROVED, + AGENTOPS_AZURE_SUBSCRIPTION_ID: '', + AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: APPROVED, + GUARD_PATH: guard + } + }); + + assert.equal(result.status, 2); + assert.match(result.stderr, /set AGENTOPS_AZURE_SUBSCRIPTION_ID/); +}); + +test('shell subscription guard accepts an explicitly approved active subscription', () => { + const guard = path.resolve(__dirname, '..', '..', 'scripts', 'lib', 'azure-subscription-guard.sh'); + const result = childProcess.spawnSync('bash', ['-c', `az() { printf '%s\\n' "$ACTIVE_TEST_SUB"; }; source "$GUARD_PATH"; agentops_require_azure_subscription`], { + encoding: 'utf8', + env: { + ...process.env, + ACTIVE_TEST_SUB: APPROVED, + AGENTOPS_AZURE_SUBSCRIPTION_ID: APPROVED, + AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: APPROVED, + GUARD_PATH: guard + } + }); + + assert.equal(result.status, 0); + assert.match(result.stderr, /verified/); +}); + +test('PowerShell collector guard uses the explicit public allowlist without an embedded subscription', () => { + const script = fs.readFileSync(path.resolve(__dirname, '..', '..', 'scripts', 'collector-azuremonitor-up.ps1'), 'utf8'); + assert.match(script, /AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS/); + assert.match(script, /not in AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS/); + assert.doesNotMatch(script, /elseif \(\$env:AZURE_SUBSCRIPTION_ID\)/); + assert.doesNotMatch(script, /approvedSubscriptionId\s*=\s*"[0-9a-f-]{36}"/i); +}); + +test('Application Insights smoke streams secret-bearing payloads without persistent temp files', () => { + const script = fs.readFileSync(path.resolve(__dirname, '..', '..', 'scripts', 'azure-smoke-appinsights.sh'), 'utf8'); + assert.doesNotMatch(script, /payload_file|appinsights\.response|--data-binary\s+"@\$payload_file"/); + assert.match(script, /--data-binary\s+@-/); +}); + +test('packaged infrastructure guide documents both explicit Azure write controls', () => { + const guide = fs.readFileSync(path.resolve(__dirname, '..', '..', 'infra', 'README.md'), 'utf8'); + assert.match(guide, /AGENTOPS_AZURE_SUBSCRIPTION_ID="<approved-subscription-id>"/); + assert.match(guide, /AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS="<approved-subscription-id>"/); + assert.doesNotMatch(guide, /\n\s*AZURE_SUBSCRIPTION_ID="<approved-subscription-id>"/); +}); diff --git a/agentops-cli/test/support/env.js b/agentops-cli/test/support/env.js new file mode 100644 index 0000000..ae2591d --- /dev/null +++ b/agentops-cli/test/support/env.js @@ -0,0 +1,18 @@ +function setEnvForTest(values) { + const original = {}; + for (const name of Object.keys(values)) original[name] = process.env[name]; + + for (const [name, value] of Object.entries(values)) { + if (value === undefined || value === null) delete process.env[name]; + else process.env[name] = String(value); + } + + return () => { + for (const [name, value] of Object.entries(original)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }; +} + +module.exports = { setEnvForTest }; diff --git a/agentops-cli/test/support/json-fixtures.js b/agentops-cli/test/support/json-fixtures.js new file mode 100644 index 0000000..4725a13 --- /dev/null +++ b/agentops-cli/test/support/json-fixtures.js @@ -0,0 +1,9 @@ +const { writeJsonlFile } = require('../../src/lib/command-output'); + +function writeJsonlFixture(file, rows) { + return writeJsonlFile(file, rows); +} + +module.exports = { + writeJsonlFixture +}; diff --git a/agentops-cli/test/timing.test.js b/agentops-cli/test/timing.test.js new file mode 100644 index 0000000..618b102 --- /dev/null +++ b/agentops-cli/test/timing.test.js @@ -0,0 +1,20 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { sleep } = require('../src/lib/timing'); + +test('shared timing helper owns sleep implementation', () => { + assert.equal(typeof sleep, 'function'); + + for (const file of [ + 'src/telemetry.js', + 'src/lib/collector-runtime.js', + 'src/lib/e2e-runtime.js', + 'src/lib/smoke-runtime.js' + ]) { + const source = fs.readFileSync(path.join(__dirname, '..', file), 'utf8'); + assert.doesNotMatch(source, /new Promise\(resolve => setTimeout\(resolve, ms\)\)/, file); + } +}); diff --git a/agentops-cli/test/triage-command.test.js b/agentops-cli/test/triage-command.test.js new file mode 100644 index 0000000..158a121 --- /dev/null +++ b/agentops-cli/test/triage-command.test.js @@ -0,0 +1,16 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + buildTriage, + renderTriage, + triageCommand, + writeTriage +} = require('../src/lib/triage-command'); + +test('triage command library preserves command and packet exports', () => { + assert.equal(typeof buildTriage, 'function'); + assert.equal(typeof renderTriage, 'function'); + assert.equal(typeof triageCommand, 'function'); + assert.equal(typeof writeTriage, 'function'); +}); diff --git a/agentops-cli/test/triage-packet.test.js b/agentops-cli/test/triage-packet.test.js new file mode 100644 index 0000000..db0a355 --- /dev/null +++ b/agentops-cli/test/triage-packet.test.js @@ -0,0 +1,73 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { buildTriage, renderTriage, writeTriage } = require('../src/lib/triage-packet'); +const { writeJsonlFixture } = require('./support/json-fixtures'); + +test('buildTriage creates a metadata-only packet with links prompt and recommendation', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-triage-packet-')); + try { + const runsFile = writeJsonlFixture(path.join(tempDir, 'runs.jsonl'), [{ + TimeGenerated: '2026-06-03T12:00:00Z', + RunId: 'run-triage', + SessionId: 'session-triage', + TraceId: 'trace-triage', + OutcomeStatus: 'failed', + ToolFailureCount: 1, + PrivacyMode: 'strict', + ContentCaptureMode: 'off' + }]); + const eventsFile = writeJsonlFixture(path.join(tempDir, 'events.jsonl'), [{ + TimeGenerated: '2026-06-03T12:01:00Z', + RunId: 'run-triage', + EventName: 'agent.tool', + Status: 'error', + ToolName: 'shell' + }]); + const toolsFile = writeJsonlFixture(path.join(tempDir, 'tools.jsonl'), [{ + TimeGenerated: '2026-06-03T12:02:00Z', + RunId: 'run-triage', + ToolName: 'shell', + Status: 'error' + }]); + const evalsFile = writeJsonlFixture(path.join(tempDir, 'evals.jsonl'), [{ + RunId: 'run-triage', + EvalOverall: 40, + EvalBucket: 'poor' + }]); + const insightsFile = writeJsonlFixture(path.join(tempDir, 'insights.jsonl'), [{ + TimeGenerated: '2026-06-03T12:03:00Z', + RunId: 'run-triage', + Severity: 'high', + InsightType: 'tool-regression', + ToolName: 'shell', + Summary: 'The shell tool failure rate regressed.', + SuggestedNextStep: 'Open Tools & MCP Risk filtered to shell.' + }]); + + const result = buildTriage({ + runId: 'latest', + runsFile, + eventsFile, + toolsFile, + evalsFile, + insightsFile + }); + + assert.equal(result.ok, true); + assert.equal(result.run_id, 'run-triage'); + assert.equal(result.evidence_counts.events, 1); + assert.equal(result.recommendation.action, 'investigate_tool'); + assert.match(result.ask_agentops.prompt, /Investigate AgentOps run run-triage/); + assert.match(renderTriage(result), /AgentOps triage/); + + const artifact = writeTriage(result, tempDir); + assert.equal(path.basename(artifact.file), 'agentops-triage.json'); + assert.equal(JSON.parse(fs.readFileSync(artifact.file, 'utf8')).run_id, 'run-triage'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/type-predicates.test.js b/agentops-cli/test/type-predicates.test.js new file mode 100644 index 0000000..b910561 --- /dev/null +++ b/agentops-cli/test/type-predicates.test.js @@ -0,0 +1,14 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { asArray, isPlainObject, isStringArray } = require('../src/lib/type-predicates'); + +test('type predicates identify plain objects and string arrays', () => { + assert.equal(isPlainObject({ ok: true }), true); + assert.equal(isPlainObject(null), false); + assert.equal(isPlainObject([]), false); + assert.equal(isStringArray(['one', 'two']), true); + assert.equal(isStringArray(['one', 2]), false); + assert.deepEqual(asArray(['one']), ['one']); + assert.deepEqual(asArray(null), []); +}); diff --git a/agentops-cli/test/usability-study-kit.test.js b/agentops-cli/test/usability-study-kit.test.js new file mode 100644 index 0000000..9c42487 --- /dev/null +++ b/agentops-cli/test/usability-study-kit.test.js @@ -0,0 +1,51 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const protocolPath = path.join(root, 'docs', 'usability-study-kit.md'); +const schemaPath = path.join(root, 'docs', 'usability-study-evidence.schema.json'); + +test('usability protocol covers the canonical first-run and primary-view tasks without claiming results', () => { + const protocol = fs.readFileSync(protocolPath, 'utf8'); + + for (const command of ['agentops init --full', 'agentops init --full --yes', 'agentops copilot']) { + assert.match(protocol, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } + for (const surface of ['Today', 'Runs', 'Run Story', 'Privacy']) { + assert.match(protocol, new RegExp(`### Task [0-9]+: .*${surface}|### Task [0-9]+: [^\n]*`, 'i')); + assert.match(protocol, new RegExp(surface, 'i')); + } + for (const evidence of ['start and end timestamps', 'elapsed milliseconds', 'help requests', 'error categories']) { + assert.match(protocol, new RegExp(evidence, 'i')); + } + assert.match(protocol, /No participant results are included/i); + assert.match(protocol, /does not prove human usability/i); + assert.match(protocol, /Do not record prompts, terminal output, code, tool arguments, tool results/i); + assert.match(protocol, /SUS-style/i); +}); + +test('usability evidence schema requires real consent and metadata-only structured outcomes', () => { + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); + + assert.equal(schema.properties.human_participant.const, true); + assert.equal(schema.properties.consent_confirmed.const, true); + assert.equal(schema.properties.environment.properties.privacy_mode.const, 'strict'); + assert.equal(schema.properties.environment.properties.content_capture.const, 'off'); + assert.equal(schema.properties.tasks.minItems, 7); + assert.equal(schema.properties.tasks.maxItems, 7); + assert.equal(schema.properties.tasks.prefixItems.length, 7); + assert.equal(schema.properties.tasks.items, false); + assert.equal(schema.properties.privacy_comprehension.minItems, 6); + assert.equal(schema.properties.privacy_comprehension.maxItems, 6); + assert.equal(schema.properties.privacy_comprehension.prefixItems.length, 6); + assert.equal(schema.properties.privacy_comprehension.items, false); + assert.equal(schema.$defs.task.additionalProperties, false); + assert.equal(schema.$defs.privacyAnswer.additionalProperties, false); + + const serialized = JSON.stringify(schema).toLowerCase(); + for (const forbidden of ['prompt_text', 'terminal_output', 'tool_arguments', 'tool_results', 'email', 'participant_name']) { + assert.equal(serialized.includes(forbidden), false, `${forbidden} must not be an evidence field`); + } +}); diff --git a/agentops-cli/test/v2-ask-context.test.js b/agentops-cli/test/v2-ask-context.test.js new file mode 100644 index 0000000..dba53f4 --- /dev/null +++ b/agentops-cli/test/v2-ask-context.test.js @@ -0,0 +1,82 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { buildV2AskContext, renderV2AskContext } = require('../src/lib/v2-ask-context'); +const { writeJsonlFixture } = require('./support/json-fixtures'); + +test('buildV2AskContext creates a metadata-only bundle for the latest run', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-v2-ask-context-')); + try { + const runsFile = writeJsonlFixture(path.join(tempDir, 'runs.jsonl'), [ + { + TimeGenerated: '2026-06-03T09:00:00Z', + RunId: 'run-old', + SessionId: 'session-old', + TraceId: 'trace-old' + }, + { + TimeGenerated: '2026-06-03T12:00:00Z', + RunId: 'run-latest', + SessionId: 'session-latest', + TraceId: 'trace-latest', + OutcomeStatus: 'failure', + OutcomeReason: 'tool_failed', + PrivacyMode: 'strict', + ContentCaptureMode: 'off' + } + ]); + const eventsFile = writeJsonlFixture(path.join(tempDir, 'events.jsonl'), [ + { + TimeGenerated: '2026-06-03T12:01:00Z', + RunId: 'run-latest', + EventName: 'agent.tool', + Status: 'error', + ToolName: 'shell' + } + ]); + const toolsFile = writeJsonlFixture(path.join(tempDir, 'tools.jsonl'), [ + { + TimeGenerated: '2026-06-03T12:02:00Z', + RunId: 'run-latest', + ToolName: 'shell', + Status: 'error', + Allowed: true + } + ]); + const recommendationsFile = writeJsonlFixture(path.join(tempDir, 'recommendations.jsonl'), [ + { + TimeGenerated: '2026-06-03T12:03:00Z', + SessionId: 'session-latest', + Action: 'run_validation', + Severity: 'medium', + NextAction: 'Run the benchmark gate.', + BenchmarkRunId: 'bench-latest' + } + ]); + + const result = buildV2AskContext({ + runId: 'latest', + runsFile, + eventsFile, + toolsFile, + recommendationsFile, + last: '2h' + }); + + assert.equal(result.ok, true); + assert.equal(result.run_id, 'run-latest'); + assert.equal(result.counts.events, 1); + assert.equal(result.counts.failed_tools, 1); + assert.equal(result.last_recommendation.benchmark_run_id, 'bench-latest'); + assert.match(result.replay_url, /var-run_id=run-latest/); + assert.match(result.kql_query, /union isfuzzy=true AppDependencies/); + assert.match(result.prompt, /metadata in this bundle/); + assert.doesNotMatch(JSON.stringify(result), /SECRET_FAKE_TEST_VALUE|gen_ai\.input\.messages/); + assert.match(renderV2AskContext(result), /AgentOps ask context/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/v2-ingestion-schema-safety.test.js b/agentops-cli/test/v2-ingestion-schema-safety.test.js new file mode 100644 index 0000000..d2a63ec --- /dev/null +++ b/agentops-cli/test/v2-ingestion-schema-safety.test.js @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { + agentOpsEventsColumnsFromBicep, + validateAdditiveSchemaMigration, + validateAgentOpsEventsBicepMigration +} = require('../src/lib/azure/v2-ingestion-schema-safety'); + +const bicepPath = path.resolve(__dirname, '../../infra/bicep/v2-ingestion.bicep'); +const bicep = fs.readFileSync(bicepPath, 'utf8'); + +test('AgentOpsEvents Bicep preserves legacy cost type and adds ordered receipt columns', () => { + const columns = new Map(agentOpsEventsColumnsFromBicep(bicep).map(column => [column.name, column.type])); + assert.equal(columns.get('EstimatedCostUsd'), 'long'); + assert.equal(columns.get('EstimatedCostUsdReal'), 'real'); + assert.equal(columns.get('EventId'), 'string'); + assert.equal(columns.get('Sequence'), 'long'); +}); + +test('migration guard accepts a purely additive desired schema', () => { + const live = [ + { name: 'TimeGenerated', type: 'datetime' }, + { name: 'EstimatedCostUsd', type: 'long' }, + { name: 'ExistingProductionColumn', type: 'string' } + ]; + const desired = [...live, { name: 'EventId', type: 'string' }]; + const result = validateAdditiveSchemaMigration(live, desired); + assert.equal(result.ok, true); + assert.deepEqual(result.additive_columns, [{ name: 'EventId', type: 'string' }]); + assert.deepEqual(result.violations, []); +}); + +test('migration guard rejects removal and immutable type changes independently', () => { + const result = validateAdditiveSchemaMigration([ + { name: 'KeepMe', type: 'string' }, + { name: 'EstimatedCostUsd', type: 'long' } + ], [ + { name: 'EstimatedCostUsd', type: 'real' }, + { name: 'EventId', type: 'string' } + ]); + assert.equal(result.ok, false); + assert.deepEqual(result.violations, [ + { column: 'KeepMe', live_type: 'string', desired_type: null, issue: 'would_remove_live_column' }, + { column: 'EstimatedCostUsd', live_type: 'long', desired_type: 'real', issue: 'would_change_existing_type' } + ]); +}); + +test('real v2 Bicep is additive against an injected compatible live schema', () => { + const live = agentOpsEventsColumnsFromBicep(bicep).filter(column => !['EventId', 'Sequence', 'EstimatedCostUsdReal'].includes(column.name)); + const result = validateAgentOpsEventsBicepMigration(bicep, live); + assert.equal(result.ok, true); + assert.deepEqual(result.contract_violations, []); + assert.deepEqual(result.additive_columns.map(column => column.name), ['Sequence', 'EventId', 'EstimatedCostUsdReal']); +}); + +test('Bicep migration preflight fails closed when an injected live column would be lost', () => { + const live = [ + ...agentOpsEventsColumnsFromBicep(bicep), + { name: 'LiveOnlyColumn', type: 'guid' } + ]; + const result = validateAgentOpsEventsBicepMigration(bicep, live); + assert.equal(result.ok, false); + assert.deepEqual(result.violations, [ + { column: 'LiveOnlyColumn', live_type: 'guid', desired_type: null, issue: 'would_remove_live_column' } + ]); +}); diff --git a/agentops-cli/test/v2-open-links.test.js b/agentops-cli/test/v2-open-links.test.js new file mode 100644 index 0000000..08d41f9 --- /dev/null +++ b/agentops-cli/test/v2-open-links.test.js @@ -0,0 +1,78 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { openV2FromFiles, renderOpenV2, v2OpenLinksForRun } = require('../src/lib/v2-open-links'); +const { writeJsonlFixture } = require('./support/json-fixtures'); + +const legacyLinks = { + application_insights_url: 'https://portal.azure.com/#/resource/app-insights/overview', + primary_investigation_url: 'https://portal.azure.com/#agents', + primary_investigation_label: 'Azure Monitor Agents view', + azure_agents_view_url: 'https://portal.azure.com/#agents', + v2_home_url: 'https://graf.example/d/agentops-v2-home', + v2_runs_url: 'https://graf.example/d/agentops-v2-runs-explorer', + v2_replay_url: 'https://graf.example/d/agentops-v2-run-replay' +}; + +test('v2OpenLinksForRun builds run scoped dashboard links', () => { + const result = v2OpenLinksForRun({ + RunId: 'run-open', + SessionId: 'session-open', + TraceId: 'trace-open', + RepoHash: 'repo_hash', + AgentName: 'agent-main', + ModelActual: 'gpt-5.5', + OutcomeStatus: 'success' + }, legacyLinks); + + assert.equal(result.ok, true); + assert.match(result.links.replay, /var-run_id=run-open/); + assert.match(result.links.runs, /var-repo_hash=repo_hash/); + assert.match(result.links.runs, /var-agent_name=agent-main/); + assert.match(result.links.content_viewer, /viewPanel=26/); + assert.match(result.links.models, /var-model=gpt-5\.5/); + assert.match(renderOpenV2(result), /Prompt\/response viewer \(explicit opt-in\):/); +}); + +test('v2OpenLinksForRun prefers the configured Azure-native investigation view', () => { + const result = v2OpenLinksForRun({ + RunId: 'run-native', + OutcomeStatus: 'success' + }, legacyLinks); + + assert.equal(result.links.primary, legacyLinks.azure_agents_view_url); + assert.equal(result.links.primary_label, 'Azure Monitor Agents view'); + assert.equal(result.links.application_insights, legacyLinks.application_insights_url); + assert.match(renderOpenV2(result), /Azure Monitor Agents view: https:\/\/portal\.azure\.com\/#agents/); +}); + +test('openV2FromFiles selects the latest run row', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-v2-open-links-')); + try { + const runsFile = writeJsonlFixture(path.join(tempDir, 'runs.jsonl'), [ + { + TimeGenerated: '2026-06-03T09:00:00Z', + RunId: 'run-old', + SessionId: 'session-old', + TraceId: 'trace-old' + }, + { + TimeGenerated: '2026-06-03T12:00:00Z', + RunId: 'run-latest', + SessionId: 'session-latest', + TraceId: 'trace-latest' + } + ]); + + const result = openV2FromFiles({ runId: 'latest', runsFile, legacyLinks }); + + assert.equal(result.ok, true); + assert.equal(result.run_id, 'run-latest'); + assert.match(result.links.replay, /var-session_id=session-latest/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/agentops-cli/test/wrapper-delivery.test.js b/agentops-cli/test/wrapper-delivery.test.js new file mode 100644 index 0000000..6290117 --- /dev/null +++ b/agentops-cli/test/wrapper-delivery.test.js @@ -0,0 +1,71 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { createWrapperDelivery } = require('../src/lib/copilot/wrapper-delivery'); + +function tempDirectory(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-wrapper-delivery-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +test('wrapper delivery fsyncs canonical lifecycle evidence and deduplicates restart', t => { + const directory = tempDirectory(t); + const event = { RunId: 'run-safe', SessionId: 'session-safe', EventName: 'agentops.run.start', Reason: 'SECRET' }; + const first = createWrapperDelivery({ directory }).record(event, { sequence: 1, timeGenerated: '2026-08-03T12:00:00.000Z' }); + const second = createWrapperDelivery({ directory }).record(event, { sequence: 1, timeGenerated: '2026-08-03T12:00:00.000Z' }); + assert.equal(first.state, 'local_pending'); + assert.equal(second.queued.status, 'deduplicated'); + assert.doesNotMatch(fs.readFileSync(first.queued.file, 'utf8'), /SECRET|Reason/); +}); + +test('wrapper delivery remains pending without cloud config', async t => { + const delivery = createWrapperDelivery({ directory: tempDirectory(t), env: {} }); + const recorded = delivery.record({ RunId: 'run-safe', SessionId: 'session-safe', EventName: 'agentops.run.end', ExitCode: 0 }, { sequence: 2 }); + const drained = await delivery.drain([recorded.evidence.EventId], { cloud: {} }); + assert.equal(drained.configured, false); + assert.equal(drained.state, 'local_pending'); +}); + +test('wrapper delivery reports Azure acceptance only for the exact acknowledged event', async t => { + const directory = tempDirectory(t); + const delivery = createWrapperDelivery({ directory, env: { AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: '11111111-1111-4111-8111-111111111111' } }); + const recorded = delivery.record({ RunId: 'run-safe', SessionId: 'session-safe', EventName: 'agentops.run.end', ExitCode: 0 }, { sequence: 2 }); + let tokenCalls = 0; + const drained = await delivery.drain([recorded.evidence.EventId], { + cloud: { + subscriptionId: '11111111-1111-4111-8111-111111111111', + logsIngestionEndpoint: 'https://safe.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-safe' + }, + spawnSync() { return { status: 0, stdout: '11111111-1111-4111-8111-111111111111\n', stderr: '' }; }, + tokenProvider: async () => `token-${++tokenCalls}`, + fetchImpl: async () => ({ status: 204, headers: {}, body: { cancel: async () => {} } }) + }); + assert.equal(drained.state, 'azure_acknowledged'); + assert.equal(drained.result.acknowledged, 1); +}); + +test('operator drain reports Azure acceptance when every claimed row was accepted', async t => { + const directory = tempDirectory(t); + const delivery = createWrapperDelivery({ directory, env: { AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS: '11111111-1111-4111-8111-111111111111' } }); + delivery.record( + { RunId: 'run-operator', SessionId: 'session-operator', EventName: 'agentops.run.start' }, + { sequence: 1 } + ); + const drained = await delivery.drain([], { + cloud: { + subscriptionId: '11111111-1111-4111-8111-111111111111', + logsIngestionEndpoint: 'https://safe.ingest.monitor.azure.com', + dcrImmutableId: 'dcr-safe' + }, + spawnSync() { return { status: 0, stdout: '11111111-1111-4111-8111-111111111111\n', stderr: '' }; }, + tokenProvider: async () => 'token', + fetchImpl: async () => ({ status: 204, headers: {}, body: { cancel: async () => {} } }) + }); + assert.equal(drained.state, 'azure_acknowledged'); + assert.equal(drained.result.status.pending, 0); +}); diff --git a/agentops-cli/test/wrapper-evidence.test.js b/agentops-cli/test/wrapper-evidence.test.js new file mode 100644 index 0000000..8ac5486 --- /dev/null +++ b/agentops-cli/test/wrapper-evidence.test.js @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { canonicalWrapperEvidence } = require('../src/lib/copilot/wrapper-evidence'); +const { safeWrapperEvent } = require('../src/lib/copilot/wrapper-envelope'); + +test('wrapper lifecycle evidence is deterministic ordered and strict metadata only', () => { + const raw = { + RunId: 'wrapper_run_safe', + SessionId: 'wrapper_session_safe', + EventName: 'agentops.run.end', + ExitCode: 0, + Reason: 'SECRET reason must not persist', + Error: 'SECRET error must not persist', + Prompt: 'SECRET prompt must not persist' + }; + const first = canonicalWrapperEvidence(raw, { sequence: 2, timeGenerated: '2026-08-03T12:00:00.000Z' }); + const again = canonicalWrapperEvidence(raw, { sequence: 2, timeGenerated: '2026-08-03T12:00:00.000Z' }); + + assert.deepEqual(first, again); + assert.equal(first.Sequence, 2); + assert.equal(first.Status, 'success'); + assert.equal(first.PrivacyMode, 'strict'); + assert.equal(first.ContentCaptureMode, 'off'); + assert.doesNotMatch(JSON.stringify(first), /SECRET|Reason|Error|Prompt/); +}); + +test('wrapper lifecycle evidence gives distinct stable IDs and safe statuses', () => { + const base = { RunId: 'run-safe', SessionId: 'session-safe' }; + const start = canonicalWrapperEvidence({ ...base, EventName: 'agentops.run.start' }, { sequence: 1 }); + const end = canonicalWrapperEvidence({ ...base, EventName: 'agentops.run.end', ExitCode: 2 }, { sequence: 2 }); + const fallback = canonicalWrapperEvidence({ ...base, EventName: 'agentops.wrapper.fallback_unobserved' }, { sequence: 3 }); + assert.notEqual(start.EventId, end.EventId); + assert.equal(start.Status, 'started'); + assert.equal(end.Status, 'failed'); + assert.equal(fallback.Status, 'unobserved'); +}); + +test('wrapper lifecycle evidence rejects unsafe identity event and sequence', () => { + assert.throws(() => canonicalWrapperEvidence({ RunId: 'unsafe\nrun', SessionId: 'safe', EventName: 'agentops.run.start' }, { sequence: 1 }), /safe RunId/); + assert.throws(() => canonicalWrapperEvidence({ RunId: 'safe', SessionId: 'safe', EventName: 'unknown' }, { sequence: 1 }), /Unsupported/); + assert.throws(() => canonicalWrapperEvidence({ RunId: 'safe', SessionId: 'safe', EventName: 'agentops.run.start' }, { sequence: 0 }), /positive sequence/); +}); + +test('local wrapper JSONL projection never stores raw error text', () => { + const row = safeWrapperEvent({ + EventName: 'agentops.collector.start_failed', + RunId: 'run-safe', + SessionId: 'session-safe', + Reason: 'SECRET path /private/source/file.js' + }); + assert.equal(row.ReasonCategory, 'collector_start_failed'); + assert.doesNotMatch(JSON.stringify(row), /SECRET|private|source|Reason":/); +}); diff --git a/collector/docker-compose.azuremonitor.yaml b/collector/docker-compose.azuremonitor.yaml index b932194..e92c3c7 100644 --- a/collector/docker-compose.azuremonitor.yaml +++ b/collector/docker-compose.azuremonitor.yaml @@ -10,5 +10,10 @@ services: environment: APPLICATIONINSIGHTS_CONNECTION_STRING: ${APPLICATIONINSIGHTS_CONNECTION_STRING:-} AGENTOPS_PRIVACY_MODE: ${AGENTOPS_PRIVACY_MODE:-strict} + AGENTOPS_OTEL_STORAGE_DIR: /var/lib/agentops/queue volumes: - ./otelcol.azuremonitor.${AGENTOPS_PRIVACY_MODE:-strict}.yaml:/etc/otelcol/config.yaml:ro + - agentops-otel-queue:/var/lib/agentops/queue + +volumes: + agentops-otel-queue: diff --git a/collector/otelcol.azuremonitor.compat.yaml b/collector/otelcol.azuremonitor.compat.yaml index 1d6d4fe..35c991c 100644 --- a/collector/otelcol.azuremonitor.compat.yaml +++ b/collector/otelcol.azuremonitor.compat.yaml @@ -9,6 +9,12 @@ receivers: extensions: health_check: endpoint: 0.0.0.0:13133 + file_storage: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + create_directory: true + compaction: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + on_start: true processors: memory_limiter: @@ -48,6 +54,16 @@ processors: # action: delete - key: code.filepath action: hash + - key: enduser.pseudo.id + action: hash + - key: github.copilot.git.repository + action: hash + - key: github.copilot.github.org + action: hash + - key: github.copilot.git.branch + action: hash + - key: github.copilot.git.commit_sha + action: hash - key: url.full action: delete - key: http.request.body.content @@ -60,9 +76,13 @@ processors: exporters: azuremonitor: connection_string: ${env:APPLICATIONINSIGHTS_CONNECTION_STRING} + sending_queue: + enabled: true + storage: file_storage + queue_size: 1000 service: - extensions: [health_check] + extensions: [health_check, file_storage] pipelines: traces: receivers: [otlp] diff --git a/collector/otelcol.azuremonitor.native.strict.yaml b/collector/otelcol.azuremonitor.native.strict.yaml new file mode 100644 index 0000000..e28f150 --- /dev/null +++ b/collector/otelcol.azuremonitor.native.strict.yaml @@ -0,0 +1,48 @@ +# Native Azure Monitor OTLP preview overlay. +# +# This file is intentionally a partial Collector config. Run it together with +# otelcol.local.strict.yaml so the single source of truth for the privacy +# processor remains the local strict config: +# +# otelcol-contrib \ +# --config collector/otelcol.local.strict.yaml \ +# --config collector/otelcol.azuremonitor.native.strict.yaml +# +# Endpoints must be copied from the selected Application Insights OTLP +# Connection Info page. Do not hand-build or substitute arbitrary endpoints. + +extensions: + azure_auth: + use_default: true + scopes: + - https://monitor.azure.com/.default + +processors: + cumulativetodelta: {} + +exporters: + otlp_http/azuremonitor: + encoding: proto + traces_endpoint: ${env:AZURE_MONITOR_OTLP_TRACES_ENDPOINT} + logs_endpoint: ${env:AZURE_MONITOR_OTLP_LOGS_ENDPOINT} + metrics_endpoint: ${env:AZURE_MONITOR_OTLP_METRICS_ENDPOINT} + auth: + authenticator: azure_auth + retry_on_failure: + enabled: true + sending_queue: + enabled: true + storage: file_storage + queue_size: 1000 + num_consumers: 1 + +service: + extensions: [health_check, file_storage, azure_auth] + pipelines: + traces: + exporters: [otlp_http/azuremonitor, file/receipt] + metrics: + processors: [memory_limiter, transform/privacy_strict, cumulativetodelta, batch] + exporters: [otlp_http/azuremonitor, file/receipt] + logs: + exporters: [otlp_http/azuremonitor, file/receipt] diff --git a/collector/otelcol.azuremonitor.strict.yaml b/collector/otelcol.azuremonitor.strict.yaml index 0f2b80d..0b5ff7c 100644 --- a/collector/otelcol.azuremonitor.strict.yaml +++ b/collector/otelcol.azuremonitor.strict.yaml @@ -9,6 +9,12 @@ receivers: extensions: health_check: endpoint: 0.0.0.0:13133 + file_storage: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + create_directory: true + compaction: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + on_start: true processors: memory_limiter: @@ -23,7 +29,7 @@ processors: - context: span statements: - set(attributes["agentops.content_capture.signal"], true) where attributes["content.capture.enabled"] == true or attributes["gen_ai.input.messages"] != nil or attributes["gen_ai.output.messages"] != nil or attributes["gen_ai.prompt"] != nil or attributes["gen_ai.completion"] != nil or attributes["gen_ai.system_instructions"] != nil or attributes["gen_ai.tool.definitions"] != nil or attributes["gen_ai.tool.call.arguments"] != nil or attributes["gen_ai.tool.call.result"] != nil or attributes["github.copilot.message"] != nil or attributes["http.request.body.content"] != nil or attributes["http.response.body.content"] != nil or attributes["url.full"] != nil or attributes["code.filepath"] != nil - - keep_keys(attributes, ["agentops.schema.version", "agentops.run.id", "agentops.session.id", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.custom_event_id", "agentops.event.name", "agentops.agent.name", "agentops.agent.hash", "agentops.parent_agent.name", "agentops.sub_agent.name", "agentops.delegation.id", "agentops.skill.name", "agentops.skill.hash", "agentops.workflow.name", "agentops.step.name", "agentops.outcome", "agentops.mcp.server", "agentops.mcp.tool", "agentops.mcp.server.hash", "agentops.mcp.allowed", "agentops.mcp.tool.risk", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.conversation.id", "gen_ai.tool.name", "gen_ai.tool.type", "gen_ai.tool.call.id", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_creation.input_tokens", "github.copilot.cost", "github.copilot.aiu", "github.copilot.turn_count", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "error.type", "exception.type", "http.status_code"]) + - keep_keys(attributes, ["agentops.schema.version","agentops.run.id","agentops.session.id","agentops.surface","agentops.privacy.mode","agentops.content_capture.mode","agentops.content_capture.signal","agentops.custom_event_id","agentops.parent_event_id","agentops.event.sequence","agentops.command.name","agentops.script.name","agentops.event.name","agentops.agent.name","agentops.agent.hash","agentops.parent_agent.name","agentops.sub_agent.name","agentops.delegation.id","agentops.subagent.duration_ms","agentops.subagent.total_tokens","agentops.subagent.tool_count","agentops.skill.name","agentops.skill.hash","agentops.workflow.name","agentops.step.name","agentops.outcome","agentops.mcp.server","agentops.mcp.tool","agentops.mcp.server.hash","agentops.mcp.allowed","agentops.mcp.tool.risk","agentops.poison_id","gen_ai.operation.name","gen_ai.provider.name","gen_ai.request.model","gen_ai.response.model","gen_ai.conversation.id","gen_ai.tool.name","gen_ai.tool.type","gen_ai.tool.call.id","gen_ai.usage.input_tokens","gen_ai.usage.output_tokens","gen_ai.usage.cache_read.input_tokens","gen_ai.usage.cache_creation.input_tokens","github.copilot.cost","github.copilot.aiu","github.copilot.turn_count","github.copilot.hook.type","github.copilot.hook.invocation_id","github.copilot.skill.name","github.copilot.skill.plugin_name","github.copilot.skill.plugin_version","error.type","exception.type","http.status_code","agentops.api.duration_ms","agentops.branch.hash","agentops.content.action","agentops.content.dropped_bytes","agentops.content.kind","agentops.content.secret_like","agentops.cost.estimated_usd","agentops.duration.ms","agentops.error.size_bytes","agentops.files.edited_count","agentops.lines.added","agentops.lines.removed","agentops.permission.decision","agentops.permission.kind","agentops.prompt.hash","agentops.prompt.size_bytes","agentops.repo.hash","agentops.tool.args_schema_hash","agentops.tool.args_size_bytes","agentops.tool.result_size_bytes","agentops.tools.count","agentops.workspace.hash","gen_ai.usage.reasoning.output_tokens","gen_ai.usage.total_tokens","github.copilot.aiu.nano","github.copilot.premium_requests"]) - context: spanevent statements: - keep_keys(attributes, ["agentops.content_capture.signal", "agentops.event.name", "agentops.agent.name", "agentops.skill.name", "agentops.mcp.server", "agentops.mcp.tool", "gen_ai.tool.name", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "github.copilot.success", "error.type", "exception.type"]) @@ -42,16 +48,20 @@ processors: statements: - set(attributes["agentops.content_capture.signal"], true) where attributes["content.capture.enabled"] == true or attributes["gen_ai.input.messages"] != nil or attributes["gen_ai.output.messages"] != nil or attributes["gen_ai.prompt"] != nil or attributes["gen_ai.completion"] != nil or attributes["gen_ai.system_instructions"] != nil or attributes["gen_ai.tool.definitions"] != nil or attributes["gen_ai.tool.call.arguments"] != nil or attributes["gen_ai.tool.call.result"] != nil or attributes["github.copilot.message"] != nil or attributes["http.request.body.content"] != nil or attributes["http.response.body.content"] != nil or attributes["url.full"] != nil or attributes["code.filepath"] != nil - set(body, "redacted by AgentOps strict privacy mode") where body != nil - - keep_keys(attributes, ["agentops.schema.version", "agentops.run.id", "agentops.session.id", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.custom_event_id", "agentops.event.name", "agentops.agent.name", "agentops.agent.hash", "agentops.parent_agent.name", "agentops.sub_agent.name", "agentops.delegation.id", "agentops.skill.name", "agentops.skill.hash", "agentops.workflow.name", "agentops.step.name", "agentops.outcome", "agentops.mcp.server", "agentops.mcp.tool", "agentops.mcp.server.hash", "agentops.mcp.allowed", "agentops.mcp.tool.risk", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.conversation.id", "gen_ai.tool.name", "gen_ai.tool.type", "gen_ai.tool.call.id", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_creation.input_tokens", "github.copilot.cost", "github.copilot.aiu", "github.copilot.turn_count", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "error.type", "exception.type", "http.status_code"]) + - keep_keys(attributes, ["agentops.schema.version","agentops.run.id","agentops.session.id","agentops.surface","agentops.privacy.mode","agentops.content_capture.mode","agentops.content_capture.signal","agentops.custom_event_id","agentops.parent_event_id","agentops.event.sequence","agentops.command.name","agentops.script.name","agentops.event.name","agentops.agent.name","agentops.agent.hash","agentops.parent_agent.name","agentops.sub_agent.name","agentops.delegation.id","agentops.subagent.duration_ms","agentops.subagent.total_tokens","agentops.subagent.tool_count","agentops.skill.name","agentops.skill.hash","agentops.workflow.name","agentops.step.name","agentops.outcome","agentops.mcp.server","agentops.mcp.tool","agentops.mcp.server.hash","agentops.mcp.allowed","agentops.mcp.tool.risk","agentops.poison_id","gen_ai.operation.name","gen_ai.provider.name","gen_ai.request.model","gen_ai.response.model","gen_ai.conversation.id","gen_ai.tool.name","gen_ai.tool.type","gen_ai.tool.call.id","gen_ai.usage.input_tokens","gen_ai.usage.output_tokens","gen_ai.usage.cache_read.input_tokens","gen_ai.usage.cache_creation.input_tokens","github.copilot.cost","github.copilot.aiu","github.copilot.turn_count","github.copilot.hook.type","github.copilot.hook.invocation_id","github.copilot.skill.name","github.copilot.skill.plugin_name","github.copilot.skill.plugin_version","error.type","exception.type","http.status_code","agentops.api.duration_ms","agentops.branch.hash","agentops.content.action","agentops.content.dropped_bytes","agentops.content.kind","agentops.content.secret_like","agentops.cost.estimated_usd","agentops.duration.ms","agentops.error.size_bytes","agentops.files.edited_count","agentops.lines.added","agentops.lines.removed","agentops.permission.decision","agentops.permission.kind","agentops.prompt.hash","agentops.prompt.size_bytes","agentops.repo.hash","agentops.tool.args_schema_hash","agentops.tool.args_size_bytes","agentops.tool.result_size_bytes","agentops.tools.count","agentops.workspace.hash","gen_ai.usage.reasoning.output_tokens","gen_ai.usage.total_tokens","github.copilot.aiu.nano","github.copilot.premium_requests"]) cumulativetodelta: {} batch: {} exporters: azuremonitor: connection_string: ${env:APPLICATIONINSIGHTS_CONNECTION_STRING} + sending_queue: + enabled: true + storage: file_storage + queue_size: 1000 service: - extensions: [health_check] + extensions: [health_check, file_storage] pipelines: traces: receivers: [otlp] diff --git a/collector/otelcol.azuremonitor.yaml b/collector/otelcol.azuremonitor.yaml index dcc4fdf..7c14545 100644 --- a/collector/otelcol.azuremonitor.yaml +++ b/collector/otelcol.azuremonitor.yaml @@ -9,6 +9,12 @@ receivers: extensions: health_check: endpoint: 0.0.0.0:13133 + file_storage: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + create_directory: true + compaction: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + on_start: true processors: memory_limiter: @@ -48,6 +54,16 @@ processors: action: delete - key: code.filepath action: hash + - key: enduser.pseudo.id + action: hash + - key: github.copilot.git.repository + action: hash + - key: github.copilot.github.org + action: hash + - key: github.copilot.git.branch + action: hash + - key: github.copilot.git.commit_sha + action: hash - key: url.full action: delete - key: http.request.body.content @@ -60,9 +76,13 @@ processors: exporters: azuremonitor: connection_string: ${env:APPLICATIONINSIGHTS_CONNECTION_STRING} + sending_queue: + enabled: true + storage: file_storage + queue_size: 1000 service: - extensions: [health_check] + extensions: [health_check, file_storage] pipelines: traces: receivers: [otlp] diff --git a/collector/otelcol.binary.compat.yaml b/collector/otelcol.binary.compat.yaml index f4f438b..b5253c7 100644 --- a/collector/otelcol.binary.compat.yaml +++ b/collector/otelcol.binary.compat.yaml @@ -9,6 +9,12 @@ receivers: extensions: health_check: endpoint: 127.0.0.1:13133 + file_storage: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + create_directory: true + compaction: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + on_start: true processors: memory_limiter: @@ -48,6 +54,16 @@ processors: # action: delete - key: code.filepath action: hash + - key: enduser.pseudo.id + action: hash + - key: github.copilot.git.repository + action: hash + - key: github.copilot.github.org + action: hash + - key: github.copilot.git.branch + action: hash + - key: github.copilot.git.commit_sha + action: hash - key: url.full action: delete - key: http.request.body.content @@ -60,9 +76,13 @@ processors: exporters: azuremonitor: connection_string: ${env:APPLICATIONINSIGHTS_CONNECTION_STRING} + sending_queue: + enabled: true + storage: file_storage + queue_size: 1000 service: - extensions: [health_check] + extensions: [health_check, file_storage] pipelines: traces: receivers: [otlp] diff --git a/collector/otelcol.binary.strict.yaml b/collector/otelcol.binary.strict.yaml index b027c19..9257e82 100644 --- a/collector/otelcol.binary.strict.yaml +++ b/collector/otelcol.binary.strict.yaml @@ -9,6 +9,12 @@ receivers: extensions: health_check: endpoint: 127.0.0.1:13133 + file_storage: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + create_directory: true + compaction: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + on_start: true processors: memory_limiter: @@ -23,7 +29,7 @@ processors: - context: span statements: - set(attributes["agentops.content_capture.signal"], true) where attributes["content.capture.enabled"] == true or attributes["gen_ai.input.messages"] != nil or attributes["gen_ai.output.messages"] != nil or attributes["gen_ai.prompt"] != nil or attributes["gen_ai.completion"] != nil or attributes["gen_ai.system_instructions"] != nil or attributes["gen_ai.tool.definitions"] != nil or attributes["gen_ai.tool.call.arguments"] != nil or attributes["gen_ai.tool.call.result"] != nil or attributes["github.copilot.message"] != nil or attributes["http.request.body.content"] != nil or attributes["http.response.body.content"] != nil or attributes["url.full"] != nil or attributes["code.filepath"] != nil - - keep_keys(attributes, ["agentops.schema.version", "agentops.run.id", "agentops.session.id", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.custom_event_id", "agentops.event.name", "agentops.agent.name", "agentops.agent.hash", "agentops.parent_agent.name", "agentops.sub_agent.name", "agentops.delegation.id", "agentops.skill.name", "agentops.skill.hash", "agentops.workflow.name", "agentops.step.name", "agentops.outcome", "agentops.mcp.server", "agentops.mcp.tool", "agentops.mcp.server.hash", "agentops.mcp.allowed", "agentops.mcp.tool.risk", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.conversation.id", "gen_ai.tool.name", "gen_ai.tool.type", "gen_ai.tool.call.id", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_creation.input_tokens", "github.copilot.cost", "github.copilot.aiu", "github.copilot.turn_count", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "error.type", "exception.type", "http.status_code"]) + - keep_keys(attributes, ["agentops.schema.version","agentops.run.id","agentops.session.id","agentops.surface","agentops.privacy.mode","agentops.content_capture.mode","agentops.content_capture.signal","agentops.custom_event_id","agentops.parent_event_id","agentops.event.sequence","agentops.event.name","agentops.agent.name","agentops.agent.hash","agentops.parent_agent.name","agentops.sub_agent.name","agentops.delegation.id","agentops.subagent.duration_ms","agentops.subagent.total_tokens","agentops.subagent.tool_count","agentops.skill.name","agentops.skill.hash","agentops.command.name","agentops.script.name","agentops.workflow.name","agentops.step.name","agentops.outcome","agentops.mcp.server","agentops.mcp.tool","agentops.mcp.server.hash","agentops.mcp.allowed","agentops.mcp.tool.risk","agentops.poison_id","gen_ai.operation.name","gen_ai.provider.name","gen_ai.request.model","gen_ai.response.model","gen_ai.conversation.id","gen_ai.tool.name","gen_ai.tool.type","gen_ai.tool.call.id","gen_ai.usage.input_tokens","gen_ai.usage.output_tokens","gen_ai.usage.cache_read.input_tokens","gen_ai.usage.cache_creation.input_tokens","github.copilot.cost","github.copilot.aiu","github.copilot.turn_count","github.copilot.hook.type","github.copilot.hook.invocation_id","github.copilot.skill.name","github.copilot.skill.plugin_name","github.copilot.skill.plugin_version","error.type","exception.type","http.status_code","agentops.api.duration_ms","agentops.branch.hash","agentops.content.action","agentops.content.dropped_bytes","agentops.content.kind","agentops.content.secret_like","agentops.cost.estimated_usd","agentops.duration.ms","agentops.error.size_bytes","agentops.files.edited_count","agentops.lines.added","agentops.lines.removed","agentops.permission.decision","agentops.permission.kind","agentops.prompt.hash","agentops.prompt.size_bytes","agentops.repo.hash","agentops.tool.args_schema_hash","agentops.tool.args_size_bytes","agentops.tool.result_size_bytes","agentops.tools.count","agentops.workspace.hash","gen_ai.usage.reasoning.output_tokens","gen_ai.usage.total_tokens","github.copilot.aiu.nano","github.copilot.premium_requests"]) - context: spanevent statements: - keep_keys(attributes, ["agentops.content_capture.signal", "agentops.event.name", "agentops.agent.name", "agentops.skill.name", "agentops.mcp.server", "agentops.mcp.tool", "gen_ai.tool.name", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "github.copilot.success", "error.type", "exception.type"]) @@ -42,16 +48,20 @@ processors: statements: - set(attributes["agentops.content_capture.signal"], true) where attributes["content.capture.enabled"] == true or attributes["gen_ai.input.messages"] != nil or attributes["gen_ai.output.messages"] != nil or attributes["gen_ai.prompt"] != nil or attributes["gen_ai.completion"] != nil or attributes["gen_ai.system_instructions"] != nil or attributes["gen_ai.tool.definitions"] != nil or attributes["gen_ai.tool.call.arguments"] != nil or attributes["gen_ai.tool.call.result"] != nil or attributes["github.copilot.message"] != nil or attributes["http.request.body.content"] != nil or attributes["http.response.body.content"] != nil or attributes["url.full"] != nil or attributes["code.filepath"] != nil - set(body, "redacted by AgentOps strict privacy mode") where body != nil - - keep_keys(attributes, ["agentops.schema.version", "agentops.run.id", "agentops.session.id", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.custom_event_id", "agentops.event.name", "agentops.agent.name", "agentops.agent.hash", "agentops.parent_agent.name", "agentops.sub_agent.name", "agentops.delegation.id", "agentops.skill.name", "agentops.skill.hash", "agentops.workflow.name", "agentops.step.name", "agentops.outcome", "agentops.mcp.server", "agentops.mcp.tool", "agentops.mcp.server.hash", "agentops.mcp.allowed", "agentops.mcp.tool.risk", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.conversation.id", "gen_ai.tool.name", "gen_ai.tool.type", "gen_ai.tool.call.id", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_creation.input_tokens", "github.copilot.cost", "github.copilot.aiu", "github.copilot.turn_count", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "error.type", "exception.type", "http.status_code"]) + - keep_keys(attributes, ["agentops.schema.version","agentops.run.id","agentops.session.id","agentops.surface","agentops.privacy.mode","agentops.content_capture.mode","agentops.content_capture.signal","agentops.custom_event_id","agentops.parent_event_id","agentops.event.sequence","agentops.command.name","agentops.script.name","agentops.event.name","agentops.agent.name","agentops.agent.hash","agentops.parent_agent.name","agentops.sub_agent.name","agentops.delegation.id","agentops.subagent.duration_ms","agentops.subagent.total_tokens","agentops.subagent.tool_count","agentops.skill.name","agentops.skill.hash","agentops.workflow.name","agentops.step.name","agentops.outcome","agentops.mcp.server","agentops.mcp.tool","agentops.mcp.server.hash","agentops.mcp.allowed","agentops.mcp.tool.risk","agentops.poison_id","gen_ai.operation.name","gen_ai.provider.name","gen_ai.request.model","gen_ai.response.model","gen_ai.conversation.id","gen_ai.tool.name","gen_ai.tool.type","gen_ai.tool.call.id","gen_ai.usage.input_tokens","gen_ai.usage.output_tokens","gen_ai.usage.cache_read.input_tokens","gen_ai.usage.cache_creation.input_tokens","github.copilot.cost","github.copilot.aiu","github.copilot.turn_count","github.copilot.hook.type","github.copilot.hook.invocation_id","github.copilot.skill.name","github.copilot.skill.plugin_name","github.copilot.skill.plugin_version","error.type","exception.type","http.status_code","agentops.api.duration_ms","agentops.branch.hash","agentops.content.action","agentops.content.dropped_bytes","agentops.content.kind","agentops.content.secret_like","agentops.cost.estimated_usd","agentops.duration.ms","agentops.error.size_bytes","agentops.files.edited_count","agentops.lines.added","agentops.lines.removed","agentops.permission.decision","agentops.permission.kind","agentops.prompt.hash","agentops.prompt.size_bytes","agentops.repo.hash","agentops.tool.args_schema_hash","agentops.tool.args_size_bytes","agentops.tool.result_size_bytes","agentops.tools.count","agentops.workspace.hash","gen_ai.usage.reasoning.output_tokens","gen_ai.usage.total_tokens","github.copilot.aiu.nano","github.copilot.premium_requests"]) cumulativetodelta: {} batch: {} exporters: azuremonitor: connection_string: ${env:APPLICATIONINSIGHTS_CONNECTION_STRING} + sending_queue: + enabled: true + storage: file_storage + queue_size: 1000 service: - extensions: [health_check] + extensions: [health_check, file_storage] pipelines: traces: receivers: [otlp] diff --git a/collector/otelcol.local.compat.yaml b/collector/otelcol.local.compat.yaml index 136c7d9..58f7c1d 100644 --- a/collector/otelcol.local.compat.yaml +++ b/collector/otelcol.local.compat.yaml @@ -48,6 +48,16 @@ processors: # action: delete - key: code.filepath action: hash + - key: enduser.pseudo.id + action: hash + - key: github.copilot.git.repository + action: hash + - key: github.copilot.github.org + action: hash + - key: github.copilot.git.branch + action: hash + - key: github.copilot.git.commit_sha + action: hash - key: url.full action: delete - key: http.request.body.content diff --git a/collector/otelcol.local.strict.yaml b/collector/otelcol.local.strict.yaml index dbee225..5a1f268 100644 --- a/collector/otelcol.local.strict.yaml +++ b/collector/otelcol.local.strict.yaml @@ -5,35 +5,55 @@ receivers: endpoint: 127.0.0.1:4318 grpc: endpoint: 127.0.0.1:4317 + otlp/receipt: + protocols: + http: + endpoint: 127.0.0.1:4319 extensions: health_check: endpoint: 127.0.0.1:13133 + file_storage: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + create_directory: true + directory_permissions: "0700" + fsync: true + compaction: + directory: ${env:AGENTOPS_OTEL_STORAGE_DIR} + on_start: true processors: memory_limiter: check_interval: 5s limit_mib: 256 transform/privacy_strict: - error_mode: ignore + error_mode: propagate trace_statements: - context: resource statements: - keep_keys(attributes, ["service.name", "service.namespace", "service.version", "telemetry.sdk.name", "telemetry.sdk.language", "telemetry.sdk.version", "agent.framework", "agent.runtime", "agentops.profile", "agentops.experiment", "agentops.e2e.id", "agentops.pack.version", "agentops.repo.hash", "agentops.content_capture.enabled", "agentops.content_capture.scope", "agentops.content_capture.signal", "agentops.poison_id"]) - context: span statements: + # Signal fields are outside the attribute allowlist. Replace them + # before debug, relay, or file export so content cannot bypass it. + - set(name, "agentops.span") where name != nil + - set(status.message, "redacted by AgentOps strict privacy mode") where status.message != nil - set(attributes["agentops.content_capture.signal"], true) where attributes["content.capture.enabled"] == true or attributes["gen_ai.input.messages"] != nil or attributes["gen_ai.output.messages"] != nil or attributes["gen_ai.prompt"] != nil or attributes["gen_ai.completion"] != nil or attributes["gen_ai.system_instructions"] != nil or attributes["gen_ai.tool.definitions"] != nil or attributes["gen_ai.tool.call.arguments"] != nil or attributes["gen_ai.tool.call.result"] != nil or attributes["github.copilot.message"] != nil or attributes["http.request.body.content"] != nil or attributes["http.response.body.content"] != nil or attributes["url.full"] != nil or attributes["code.filepath"] != nil - - keep_keys(attributes, ["agentops.schema.version", "agentops.run.id", "agentops.session.id", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.custom_event_id", "agentops.event.name", "agentops.agent.name", "agentops.agent.hash", "agentops.parent_agent.name", "agentops.sub_agent.name", "agentops.delegation.id", "agentops.skill.name", "agentops.skill.hash", "agentops.workflow.name", "agentops.step.name", "agentops.outcome", "agentops.mcp.server", "agentops.mcp.tool", "agentops.mcp.server.hash", "agentops.mcp.allowed", "agentops.mcp.tool.risk", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.conversation.id", "gen_ai.tool.name", "gen_ai.tool.type", "gen_ai.tool.call.id", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_creation.input_tokens", "github.copilot.cost", "github.copilot.aiu", "github.copilot.turn_count", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "error.type", "exception.type", "http.status_code"]) + - keep_keys(attributes, ["agentops.schema.version","agentops.run.id","agentops.session.id","agentops.surface","agentops.privacy.mode","agentops.content_capture.mode","agentops.content_capture.signal","agentops.custom_event_id","agentops.parent_event_id","agentops.event.sequence","agentops.command.name","agentops.script.name","agentops.event.name","agentops.agent.name","agentops.agent.hash","agentops.parent_agent.name","agentops.sub_agent.name","agentops.delegation.id","agentops.subagent.duration_ms","agentops.subagent.total_tokens","agentops.subagent.tool_count","agentops.skill.name","agentops.skill.hash","agentops.workflow.name","agentops.step.name","agentops.outcome","agentops.mcp.server","agentops.mcp.tool","agentops.mcp.server.hash","agentops.mcp.allowed","agentops.mcp.tool.risk","agentops.poison_id","gen_ai.operation.name","gen_ai.provider.name","gen_ai.request.model","gen_ai.response.model","gen_ai.conversation.id","gen_ai.tool.name","gen_ai.tool.type","gen_ai.tool.call.id","gen_ai.usage.input_tokens","gen_ai.usage.output_tokens","gen_ai.usage.cache_read.input_tokens","gen_ai.usage.cache_creation.input_tokens","github.copilot.cost","github.copilot.aiu","github.copilot.turn_count","github.copilot.hook.type","github.copilot.hook.invocation_id","github.copilot.skill.name","github.copilot.skill.plugin_name","github.copilot.skill.plugin_version","error.type","exception.type","http.status_code","agentops.api.duration_ms","agentops.branch.hash","agentops.content.action","agentops.content.dropped_bytes","agentops.content.kind","agentops.content.secret_like","agentops.cost.estimated_usd","agentops.duration.ms","agentops.error.size_bytes","agentops.files.edited_count","agentops.lines.added","agentops.lines.removed","agentops.permission.decision","agentops.permission.kind","agentops.prompt.hash","agentops.prompt.size_bytes","agentops.repo.hash","agentops.tool.args_schema_hash","agentops.tool.args_size_bytes","agentops.tool.result_size_bytes","agentops.tools.count","agentops.workspace.hash","gen_ai.usage.reasoning.output_tokens","gen_ai.usage.total_tokens","github.copilot.aiu.nano","github.copilot.premium_requests"]) - context: spanevent statements: + - set(name, "agentops.event") where name != nil - keep_keys(attributes, ["agentops.content_capture.signal", "agentops.event.name", "agentops.agent.name", "agentops.skill.name", "agentops.mcp.server", "agentops.mcp.tool", "gen_ai.tool.name", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "github.copilot.success", "error.type", "exception.type"]) metric_statements: - context: resource statements: - keep_keys(attributes, ["service.name", "service.namespace", "service.version", "telemetry.sdk.name", "telemetry.sdk.language", "telemetry.sdk.version", "agent.framework", "agent.runtime", "agentops.profile", "agentops.experiment", "agentops.e2e.id", "agentops.pack.version"]) + - context: metric + statements: + - set(name, "agentops.metric") where name != nil - context: datapoint statements: - - keep_keys(attributes, ["agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.tool.name", "gen_ai.tool.type", "github.copilot.cost", "github.copilot.aiu", "error.type", "http.status_code"]) + - keep_keys(attributes, ["agentops.custom_event_id", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.tool.name", "gen_ai.tool.type", "github.copilot.cost", "github.copilot.aiu", "error.type", "http.status_code"]) log_statements: - context: resource statements: @@ -42,25 +62,51 @@ processors: statements: - set(attributes["agentops.content_capture.signal"], true) where attributes["content.capture.enabled"] == true or attributes["gen_ai.input.messages"] != nil or attributes["gen_ai.output.messages"] != nil or attributes["gen_ai.prompt"] != nil or attributes["gen_ai.completion"] != nil or attributes["gen_ai.system_instructions"] != nil or attributes["gen_ai.tool.definitions"] != nil or attributes["gen_ai.tool.call.arguments"] != nil or attributes["gen_ai.tool.call.result"] != nil or attributes["github.copilot.message"] != nil or attributes["http.request.body.content"] != nil or attributes["http.response.body.content"] != nil or attributes["url.full"] != nil or attributes["code.filepath"] != nil - set(body, "redacted by AgentOps strict privacy mode") where body != nil - - keep_keys(attributes, ["agentops.schema.version", "agentops.run.id", "agentops.session.id", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.custom_event_id", "agentops.event.name", "agentops.agent.name", "agentops.agent.hash", "agentops.parent_agent.name", "agentops.sub_agent.name", "agentops.delegation.id", "agentops.skill.name", "agentops.skill.hash", "agentops.workflow.name", "agentops.step.name", "agentops.outcome", "agentops.mcp.server", "agentops.mcp.tool", "agentops.mcp.server.hash", "agentops.mcp.allowed", "agentops.mcp.tool.risk", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.conversation.id", "gen_ai.tool.name", "gen_ai.tool.type", "gen_ai.tool.call.id", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.cache_read.input_tokens", "gen_ai.usage.cache_creation.input_tokens", "github.copilot.cost", "github.copilot.aiu", "github.copilot.turn_count", "github.copilot.hook.type", "github.copilot.hook.invocation_id", "github.copilot.skill.name", "github.copilot.skill.plugin_name", "github.copilot.skill.plugin_version", "error.type", "exception.type", "http.status_code"]) + - keep_keys(attributes, ["agentops.schema.version","agentops.run.id","agentops.session.id","agentops.surface","agentops.privacy.mode","agentops.content_capture.mode","agentops.content_capture.signal","agentops.custom_event_id","agentops.parent_event_id","agentops.event.sequence","agentops.command.name","agentops.script.name","agentops.event.name","agentops.agent.name","agentops.agent.hash","agentops.parent_agent.name","agentops.sub_agent.name","agentops.delegation.id","agentops.subagent.duration_ms","agentops.subagent.total_tokens","agentops.subagent.tool_count","agentops.skill.name","agentops.skill.hash","agentops.workflow.name","agentops.step.name","agentops.outcome","agentops.mcp.server","agentops.mcp.tool","agentops.mcp.server.hash","agentops.mcp.allowed","agentops.mcp.tool.risk","agentops.poison_id","gen_ai.operation.name","gen_ai.provider.name","gen_ai.request.model","gen_ai.response.model","gen_ai.conversation.id","gen_ai.tool.name","gen_ai.tool.type","gen_ai.tool.call.id","gen_ai.usage.input_tokens","gen_ai.usage.output_tokens","gen_ai.usage.cache_read.input_tokens","gen_ai.usage.cache_creation.input_tokens","github.copilot.cost","github.copilot.aiu","github.copilot.turn_count","github.copilot.hook.type","github.copilot.hook.invocation_id","github.copilot.skill.name","github.copilot.skill.plugin_name","github.copilot.skill.plugin_version","error.type","exception.type","http.status_code","agentops.api.duration_ms","agentops.branch.hash","agentops.content.action","agentops.content.dropped_bytes","agentops.content.kind","agentops.content.secret_like","agentops.cost.estimated_usd","agentops.duration.ms","agentops.error.size_bytes","agentops.files.edited_count","agentops.lines.added","agentops.lines.removed","agentops.permission.decision","agentops.permission.kind","agentops.prompt.hash","agentops.prompt.size_bytes","agentops.repo.hash","agentops.tool.args_schema_hash","agentops.tool.args_size_bytes","agentops.tool.result_size_bytes","agentops.tools.count","agentops.workspace.hash","gen_ai.usage.reasoning.output_tokens","gen_ai.usage.total_tokens","github.copilot.aiu.nano","github.copilot.premium_requests"]) batch: {} exporters: + otlp_http/local_receipt: + endpoint: http://127.0.0.1:4319 + sending_queue: + enabled: true + storage: file_storage + queue_size: 1000 + num_consumers: 1 + file/receipt: + path: ${env:AGENTOPS_OTEL_RECEIPT_PATH} + format: json + append: true + flush_interval: 100ms + create_directory: true + directory_permissions: "0700" debug: verbosity: detailed service: - extensions: [health_check] + extensions: [health_check, file_storage] pipelines: traces: receivers: [otlp] processors: [memory_limiter, transform/privacy_strict, batch] - exporters: [debug] + exporters: [otlp_http/local_receipt, debug] metrics: receivers: [otlp] processors: [memory_limiter, transform/privacy_strict, batch] - exporters: [debug] + exporters: [otlp_http/local_receipt, debug] logs: receivers: [otlp] processors: [memory_limiter, transform/privacy_strict, batch] - exporters: [debug] + exporters: [otlp_http/local_receipt, debug] + traces/receipt: + receivers: [otlp/receipt] + processors: [transform/privacy_strict, batch] + exporters: [file/receipt] + metrics/receipt: + receivers: [otlp/receipt] + processors: [transform/privacy_strict, batch] + exporters: [file/receipt] + logs/receipt: + receivers: [otlp/receipt] + processors: [transform/privacy_strict, batch] + exporters: [file/receipt] diff --git a/collector/otelcol.local.yaml b/collector/otelcol.local.yaml index cd6d141..6642122 100644 --- a/collector/otelcol.local.yaml +++ b/collector/otelcol.local.yaml @@ -48,6 +48,16 @@ processors: action: delete - key: code.filepath action: hash + - key: enduser.pseudo.id + action: hash + - key: github.copilot.git.repository + action: hash + - key: github.copilot.github.org + action: hash + - key: github.copilot.git.branch + action: hash + - key: github.copilot.git.commit_sha + action: hash - key: url.full action: delete - key: http.request.body.content diff --git a/collector/processors/strict-allowlist.yaml b/collector/processors/strict-allowlist.yaml index eab1429..f5da605 100644 --- a/collector/processors/strict-allowlist.yaml +++ b/collector/processors/strict-allowlist.yaml @@ -10,7 +10,7 @@ processors: - keep_keys(attributes, ["service.name", "service.namespace", "service.version", "telemetry.sdk.name", "telemetry.sdk.language", "telemetry.sdk.version", "agent.framework", "agent.runtime", "agentops.profile", "agentops.experiment", "agentops.e2e.id", "agentops.pack.version", "agentops.repo.hash", "agentops.branch.hash", "agentops.workspace.hash", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.poison_id"]) - context: span statements: - - keep_keys(attributes, ["agentops.schema.version", "agentops.run.id", "agentops.session.id", "agentops.surface", "agentops.privacy.mode", "agentops.content_capture.mode", "agentops.content_capture.signal", "agentops.custom_event_id", "agentops.event.name", "agentops.repo.hash", "agentops.branch.hash", "agentops.workspace.hash", "agentops.command.hash", "agentops.task.type", "agentops.agent.name", "agentops.agent.hash", "agentops.parent_agent.name", "agentops.sub_agent.name", "agentops.delegation.id", "agentops.skill.name", "agentops.skill.hash", "agentops.workflow.name", "agentops.step.name", "agentops.outcome", "agentops.model.requested", "agentops.model.actual", "agentops.outcome.status", "agentops.outcome.reason", "agentops.duration.ms", "agentops.error.type", "agentops.retry.count", "agentops.tools.count", "agentops.tools.failed_count", "agentops.tools.denied_count", "agentops.tests.ran", "agentops.tests.passed", "agentops.tests.command_hash", "agentops.files.read_count", "agentops.files.edited_count", "agentops.files.sensitive_touched", "agentops.pr.opened", "agentops.pr.number_hash", "agentops.ci.status", "agentops.cost.estimated_usd", "agentops.risk.score", "agentops.eval.overall", "agentops.mcp.server", "agentops.mcp.tool", "agentops.mcp.server.hash", "agentops.mcp.allowed", "agentops.mcp.tool.risk", "gen_ai.operation.name", "gen_ai.provider.name", "gen_ai.conversation.id", "gen_ai.request.model", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "gen_ai.usage.reasoning.output_tokens", "gen_ai.tool.name", "gen_ai.tool.type", "mcp.method.name", "mcp.session.id", "mcp.transport", "mcp.server.name", "mcp.client.name", "error.type", "server.address", "http.status_code", "agentops.poison_id"]) + - keep_keys(attributes, ["agentops.schema.version","agentops.run.id","agentops.session.id","agentops.surface","agentops.privacy.mode","agentops.content_capture.mode","agentops.content_capture.signal","agentops.custom_event_id","agentops.parent_event_id","agentops.event.sequence","agentops.command.name","agentops.script.name","agentops.event.name","agentops.repo.hash","agentops.branch.hash","agentops.workspace.hash","agentops.command.hash","agentops.task.type","agentops.agent.name","agentops.agent.hash","agentops.parent_agent.name","agentops.sub_agent.name","agentops.delegation.id","agentops.subagent.duration_ms","agentops.subagent.total_tokens","agentops.subagent.tool_count","agentops.skill.name","agentops.skill.hash","agentops.workflow.name","agentops.step.name","agentops.outcome","agentops.model.requested","agentops.model.actual","agentops.outcome.status","agentops.outcome.reason","agentops.duration.ms","agentops.error.type","agentops.retry.count","agentops.tools.count","agentops.tools.failed_count","agentops.tools.denied_count","agentops.tests.ran","agentops.tests.passed","agentops.tests.command_hash","agentops.files.read_count","agentops.files.edited_count","agentops.files.sensitive_touched","agentops.pr.opened","agentops.pr.number_hash","agentops.ci.status","agentops.cost.estimated_usd","agentops.risk.score","agentops.eval.overall","agentops.mcp.server","agentops.mcp.tool","agentops.mcp.server.hash","agentops.mcp.allowed","agentops.mcp.tool.risk","gen_ai.operation.name","gen_ai.provider.name","gen_ai.conversation.id","gen_ai.request.model","gen_ai.response.model","gen_ai.usage.input_tokens","gen_ai.usage.output_tokens","gen_ai.usage.reasoning.output_tokens","gen_ai.tool.name","gen_ai.tool.type","mcp.method.name","mcp.session.id","mcp.transport","mcp.server.name","mcp.client.name","error.type","server.address","http.status_code","agentops.poison_id","agentops.api.duration_ms","agentops.content.action","agentops.content.dropped_bytes","agentops.content.kind","agentops.content.secret_like","agentops.error.size_bytes","agentops.lines.added","agentops.lines.removed","agentops.permission.decision","agentops.permission.kind","agentops.prompt.hash","agentops.prompt.size_bytes","agentops.tool.args_schema_hash","agentops.tool.args_size_bytes","agentops.tool.result_size_bytes","gen_ai.usage.cache_creation.input_tokens","gen_ai.usage.cache_read.input_tokens","gen_ai.usage.total_tokens","github.copilot.aiu.nano","github.copilot.cost","github.copilot.premium_requests"]) - context: spanevent statements: - keep_keys(attributes, ["agentops.content_capture.signal", "agentops.run.id", "agentops.session.id", "agentops.event.name", "agentops.agent.name", "agentops.skill.name", "agentops.mcp.server", "agentops.mcp.tool", "agentops.poison_id", "gen_ai.operation.name", "gen_ai.tool.name", "mcp.method.name", "error.type", "exception.type"]) diff --git a/collector/tests/owasp-abuse-fixtures/broad-tool-permissions.json b/collector/security-fixtures/owasp-abuse-fixtures/broad-tool-permissions.json similarity index 100% rename from collector/tests/owasp-abuse-fixtures/broad-tool-permissions.json rename to collector/security-fixtures/owasp-abuse-fixtures/broad-tool-permissions.json diff --git a/collector/tests/owasp-abuse-fixtures/injected-tool-instructions.json b/collector/security-fixtures/owasp-abuse-fixtures/injected-tool-instructions.json similarity index 100% rename from collector/tests/owasp-abuse-fixtures/injected-tool-instructions.json rename to collector/security-fixtures/owasp-abuse-fixtures/injected-tool-instructions.json diff --git a/collector/tests/owasp-abuse-fixtures/mcp-dangerous-tool-classes.json b/collector/security-fixtures/owasp-abuse-fixtures/mcp-dangerous-tool-classes.json similarity index 100% rename from collector/tests/owasp-abuse-fixtures/mcp-dangerous-tool-classes.json rename to collector/security-fixtures/owasp-abuse-fixtures/mcp-dangerous-tool-classes.json diff --git a/collector/tests/owasp-abuse-fixtures/mcp-prompt-injection.json b/collector/security-fixtures/owasp-abuse-fixtures/mcp-prompt-injection.json similarity index 100% rename from collector/tests/owasp-abuse-fixtures/mcp-prompt-injection.json rename to collector/security-fixtures/owasp-abuse-fixtures/mcp-prompt-injection.json diff --git a/collector/tests/owasp-abuse-fixtures/prompt-injection.json b/collector/security-fixtures/owasp-abuse-fixtures/prompt-injection.json similarity index 100% rename from collector/tests/owasp-abuse-fixtures/prompt-injection.json rename to collector/security-fixtures/owasp-abuse-fixtures/prompt-injection.json diff --git a/collector/tests/owasp-abuse-fixtures/runaway-tool-loop.json b/collector/security-fixtures/owasp-abuse-fixtures/runaway-tool-loop.json similarity index 100% rename from collector/tests/owasp-abuse-fixtures/runaway-tool-loop.json rename to collector/security-fixtures/owasp-abuse-fixtures/runaway-tool-loop.json diff --git a/collector/tests/owasp-abuse-fixtures/secret-tool-result.json b/collector/security-fixtures/owasp-abuse-fixtures/secret-tool-result.json similarity index 100% rename from collector/tests/owasp-abuse-fixtures/secret-tool-result.json rename to collector/security-fixtures/owasp-abuse-fixtures/secret-tool-result.json diff --git a/collector/tests/privacy-poison-fixtures/content-poison.json b/collector/security-fixtures/privacy-poison-fixtures/content-poison.json similarity index 100% rename from collector/tests/privacy-poison-fixtures/content-poison.json rename to collector/security-fixtures/privacy-poison-fixtures/content-poison.json diff --git a/collector/tests/privacy-poison-fixtures/mcp-poison.json b/collector/security-fixtures/privacy-poison-fixtures/mcp-poison.json similarity index 100% rename from collector/tests/privacy-poison-fixtures/mcp-poison.json rename to collector/security-fixtures/privacy-poison-fixtures/mcp-poison.json diff --git a/docs/README.md b/docs/README.md index 497a0ee..4f5056f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,8 +7,10 @@ Start here when you want the shortest path through the repo: ```text README.md -> docs/README.md # docs index + -> docs/simplified-azure-design.md # next-version product shape -> docs/architecture.md # system shape -> docs/grafana-dashboard-tour-v2.md + -> docs/public-release.md # package and public-release checklist -> docs/release-checklist-v2.md ``` @@ -16,11 +18,10 @@ README.md ```text Copilot CLI / SDK / VS Code + MCP - -> local AgentOps wrapper or proxy - -> localhost OTLP collector - -> strict privacy processors - -> Azure Monitor + Log Analytics - -> Grafana AgentOps for Azure dashboards + -> local AgentOps privacy boundary + -> Azure Monitor / Application Insights + -> native Agents view first + -> optional Workbooks or Managed Grafana for advanced operators ``` AgentOps answers: @@ -46,10 +47,10 @@ For rendered docs and presentations: ### New User -1. [Secure by default](secure-by-default.md) -2. [Collector modes](collector-modes.md) -3. [Privacy modes](privacy-modes.md) -4. [Grafana dashboard tour V2](grafana-dashboard-tour-v2.md) +1. [Simplified Azure-native design](simplified-azure-design.md) +2. [Secure by default](secure-by-default.md) +3. [Collector modes](collector-modes.md) +4. [Privacy modes](privacy-modes.md) 5. [E2E validation](e2e-validation.md) ### Operator diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 9f386b7..fd8725d 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -140,7 +140,7 @@ Docs used for this support matrix: ## Shadow Install And Plugin Files -The setup script is the shortest local wrapper. It installs the tested Collector binary, installs shims, and makes plain `copilot` observed when `~/.local/bin` is first on `PATH`: +The setup script is the shortest local wrapper. It installs the tested Collector binary plus the `agentops` and `copilot-agentops` commands. Plain `copilot` remains unchanged by default: ```bash ./setup-agentops.sh @@ -152,10 +152,10 @@ The product-style CLI installer is: agentops install ``` -The installer adds `agentops`, `copilot-agentops`, the tested local Collector binary, and the plain-`copilot` shim by default. Skip the plain shim with: +The installer adds `agentops`, `copilot-agentops`, and the tested local Collector binary. To explicitly route plain `copilot` through AgentOps too, use: ```bash -agentops install --no-shadow-copilot +agentops install --shadow-copilot ``` If you deployed with `azd` and the environment contains the expected outputs, use: diff --git a/docs/agent-run-data-model.md b/docs/agent-run-data-model.md index c80e86d..963c64c 100644 --- a/docs/agent-run-data-model.md +++ b/docs/agent-run-data-model.md @@ -112,7 +112,7 @@ Use a separate restricted workspace/dashboard when this table contains real prom Convert a raw span JSONL export into the V2 custom-table shape with: ```bash -agentops run-summary generate --file tests/sample-otel/tool-failure.jsonl --json +agentops run-summary generate --file fixtures/sample-otel/tool-failure.ndjson.fixture --json ``` The command writes metadata-only `AgentOps*_CL.jsonl` files under `.agentops/run-summary/latest` by default. It drops content-like attributes from exported rows and records privacy-signal counts in `AgentOpsPrivacy_CL`. diff --git a/docs/agentops-architecture-product-audit.md b/docs/agentops-architecture-product-audit.md index 99e64b0..68e3b6e 100644 --- a/docs/agentops-architecture-product-audit.md +++ b/docs/agentops-architecture-product-audit.md @@ -406,7 +406,7 @@ Current gaps: Product recommendation: -- Keep real Copilot OTel fixture snapshot contract tests in CI as Copilot fields evolve. The local `real-copilot-otel-fixture-contract` product-audit check now validates `tests/sample-otel/copilot-cli-wrapper-snapshot.jsonl` through `agentops-cli/src/lib/copilot/fixture-contract.js`. +- Keep real Copilot OTel fixture snapshot contract tests in CI as Copilot fields evolve. The local `real-copilot-otel-fixture-contract` product-audit check now validates `fixtures/sample-otel/copilot-cli-wrapper-snapshot.ndjson.fixture` through `agentops-cli/src/lib/copilot/fixture-contract.js`. - Keep Copilot help snapshots current and classify new flags as tracked or intentionally ignored. - Generate Bash/PowerShell flag metadata from one source of truth if this grows further. @@ -457,7 +457,6 @@ Product recommendation: Primary files: - `azure.yaml` -- `.azure/deployment-plan.md` - `infra/bicep/main.bicep` - `infra/bicep/log-analytics.bicep` - `infra/bicep/app-insights.bicep` @@ -579,7 +578,7 @@ Current gaps: - Some KQL files are investigation scripts with more than one final tabular expression. That is fine for manual use, but dashboard panels usually need one predictable result shape. - `agentops health --json` now exposes a stable machine-readable setup and latest-run health contract for setup wizards and UI adapters. - Recommendations are rule-based heuristics, not evidence ranking over recurring patterns. -- `open` prints links. Real-Copilot smoke can open the Run Replay link directly with `--open-browser` after latest-run visibility is verified. +- `open` prints links. Real-Copilot smoke can open the Run Story link directly with `--open-browser` after latest-run visibility is verified. Product recommendation: @@ -739,7 +738,7 @@ What works well: - warn on unresolved tool failures, content-capture signals, and missing validation metadata at agent stop - write metadata-only notification sidecar rows for hook type, decision, reason category, duration, and session ID - validate bundled hook scripts against documented camelCase and VS Code-compatible snake_case hook stdin fixtures -- `agentops-evidence-prompts` now starts from a concrete `agentops ask-context` investigation bundle with session ID, time range, Run Replay URL, KQL query, latest recommendation, and benchmark run ID when present. +- `agentops-evidence-prompts` now starts from a concrete `agentops ask-context` investigation bundle with session ID, time range, Run Story URL, KQL query, latest recommendation, and benchmark run ID when present. - MCP config is read-only for Azure Monitor and tokenized for Grafana. Current gaps: @@ -874,7 +873,7 @@ What works well: - Failure and high-AIU alerts exist as infrastructure. - No action groups are attached by default. - `agentops alert history` and `agentops alert detail` provide metadata-only fired-alert candidate review with KQL and session links. -- `agentops alert open` turns an alert rule/session pair into Run Replay, Runs Explorer, session detail, content-viewer, and Azure Logs links. +- `agentops alert open` turns an alert rule/session pair into Run Story, Runs Explorer, session detail, content-viewer, and Azure Logs links. - `agentops alert review` bundles alert detail, open links, action-plan metadata, and export evidence into one metadata-only packet. - The Alert Tuning dashboard includes metadata-only threshold recommendations, suggested threshold impact, and fired-alert candidates with session detail, replay, Azure Logs links, and `agentops alert review` commands. - `agentops alert action-plan` generates deterministic GitHub issue or Azure DevOps work-item payload metadata with KQL, session links, and guardrails. @@ -1031,7 +1030,7 @@ agentops latest Then: ```text -Use agentops-latest-run to find my latest AgentOps run, open the Run Replay link, explain it, and recommend one next action. +Use agentops-latest-run to find my latest AgentOps run, open the Run Story link, explain it, and recommend one next action. ``` ### Daily Copilot Use @@ -1041,9 +1040,9 @@ Current experience: - If shadow shim is first on PATH, plain `copilot` is observed. - If collector is missing, wrapper tries to start it. - If collector fails to start, Copilot still runs without observation. -- Successful wrapped runs print an optional `AgentOps Run Replay` link scoped to the wrapper run/session IDs. +- Successful wrapped runs print an optional `AgentOps Run Story` link scoped to the wrapper run/session IDs. - The user can query latest run with CLI or ask `agentops-latest-run`. -- The first-run real-Copilot smoke command includes `--open-browser`, so Run Replay opens directly after latest-run visibility is verified. +- The first-run real-Copilot smoke command includes `--open-browser`, so Run Story opens directly after latest-run visibility is verified. This is close to native. @@ -1060,8 +1059,8 @@ This is strong for technical users. Missing world-class behavior: -- The dashboard now gives the agent explicit session context, Run Replay URL, starter KQL, copyable `agentops ask-context` commands, a linked `AskAgentOpsLaunch` action for the hosted `/api/ask-agentops` page/packet, one-click shared recommendation/saved-view actions, and alert handoff review rows for the hosted `/api/ask-agentops/shared/*` routes. The hosted page now includes a first-party metadata-only response draft, optional metadata-only live assistant response flow, and optional inline or shared-storage hydrated recommendation, saved-view, and alert-handoff context. It is still not a fully embedded Grafana-native assistant. -- Recommendations are now present in Run Replay as first-class artifacts with copyable follow-up commands, a local metadata-only recommendation store, an opt-in shared Blob upload plan, a hosted metadata-only write API, and a hosted browser editor for team review artifacts. The CLI can compare a completed follow-up run with `agentops recommend compare` and populate `AfterTelemetry` plus pass/fail `ObservedMetricMovement`, then turn an approved `OperatorReview` row into a guarded `agentops recommend action-plan` patch/benchmark workflow. The hosted Ask AgentOps flow can now render recommendation target refs, benchmark links, artifact file paths, `ExpectedMetricMovement`, `BeforeTelemetry`, `AfterTelemetry`, validation, rollback, a guided approve/reject `OperatorReview`, saved-view annotations, and alert handoff config-change context, but the dashboard still does not apply the patch for the user. +- The dashboard now gives the agent explicit session context, Run Story URL, starter KQL, copyable `agentops ask-context` commands, a linked `AskAgentOpsLaunch` action for the hosted `/api/ask-agentops` page/packet, one-click shared recommendation/saved-view actions, and alert handoff review rows for the hosted `/api/ask-agentops/shared/*` routes. The hosted page now includes a first-party metadata-only response draft, optional metadata-only live assistant response flow, and optional inline or shared-storage hydrated recommendation, saved-view, and alert-handoff context. It is still not a fully embedded Grafana-native assistant. +- Recommendations are now present in Run Story as first-class artifacts with copyable follow-up commands, a local metadata-only recommendation store, an opt-in shared Blob upload plan, a hosted metadata-only write API, and a hosted browser editor for team review artifacts. The CLI can compare a completed follow-up run with `agentops recommend compare` and populate `AfterTelemetry` plus pass/fail `ObservedMetricMovement`, then turn an approved `OperatorReview` row into a guarded `agentops recommend action-plan` patch/benchmark workflow. The hosted Ask AgentOps flow can now render recommendation target refs, benchmark links, artifact file paths, `ExpectedMetricMovement`, `BeforeTelemetry`, `AfterTelemetry`, validation, rollback, a guided approve/reject `OperatorReview`, saved-view annotations, and alert handoff config-change context, but the dashboard still does not apply the patch for the user. - Saved investigations now surface on the Home dashboard from metadata-only `AgentOpsSavedViews_CL` exports, with an opt-in shared Blob store, hosted metadata-only write API, and browser-native saved-view editor. - Saved-view exports can now include session-matched config-change annotation counts and change-target refs from `--events`, so saved investigations keep the nearby skill/hook/MCP/model change context. @@ -1140,13 +1139,13 @@ Implemented first slice: - `agentops init --dry-run` for setup readiness, bundled skill install planning, and first-run next steps. - `agentops setup` now prints a read-only one-minute first-run loop that recommends `agentops init --full` first, then keeps bind, strict poison smoke, `agentops smoke --real-copilot`, latest/open, dashboard import, and live dashboard verification as fallbacks. -- `agentops smoke --real-copilot` sends the synthetic OTLP smoke, runs a safe no-edit Copilot prompt with content capture off, waits for the latest Copilot run to appear, then prints the V2 Run Replay link. +- `agentops smoke --real-copilot` sends the synthetic OTLP smoke, runs a safe no-edit Copilot prompt with content capture off, waits for the latest Copilot run to appear, then prints the V2 Run Story link. - `agentops validate-azure` for read-only Azure CLI, subscription, resource group, workspace, App Insights, query, Grafana resource, datasource, and dashboard UID checks. - `agentops validate-azure --import-dashboards` for explicit remediation when validation finds missing Grafana dashboards. - `agentops init --dry-run` now points to the same core first-run loop instead of older experimental smoke/context commands. - `agentops init --full` now runs the explicit cloud provision, dashboard import, smoke/open-link, and latest triage stages together. - `agentops init --full` now returns a compact summary with the single next action and requested stage statuses. -- The bundled `agentops-setup` Copilot skill now uses `agentops init --full` and asks Copilot to report the Run Replay link plus one evidence-backed next action. +- The bundled `agentops-setup` Copilot skill now uses `agentops init --full` and asks Copilot to report the Run Story link plus one evidence-backed next action. - The latest-run workflow and bundled skill now prefer `agentops ask-context latest` as the metadata-only Copilot investigation bundle before explain/recommend commands. - `agentops annotation config-change` now emits metadata-only `agentops.config.changed` events for skill, hook, MCP, model, deployment, and benchmark changes, with an Insights dashboard panel for change/regression correlation. - `agentops recommend --events` now attaches matching config-change annotations to regression recommendations and persists them as metadata-only `ChangeAnnotations` plus concrete `ChangeTargetRefs`. @@ -1155,12 +1154,12 @@ Implemented first slice: - `actioner/SharedStoreWrite` now accepts hosted metadata-only recommendation, saved-view, and alert-handoff row writes into the shared Blob store through managed identity. - `actioner/SharedStoreEditor` now renders a hosted browser form for metadata-only recommendation, saved-investigation, and alert-handoff rows that submits to the same validated write API. - `actioner/AskAgentOps` now renders a hosted metadata-only assistant launch packet/page for a selected run, session, or trace, with optional `AGENTOPS_ASSISTANT_URL` deep links. -- The Run Replay `Ask AgentOps context` panel now includes an `AskAgentOpsLaunch` action URL driven by the configurable `actioner_url` dashboard variable. +- The Run Story `Ask AgentOps context` panel now includes an `AskAgentOpsLaunch` action URL driven by the configurable `actioner_url` dashboard variable. - `actioner/AskAgentOps` now includes a first-party metadata-only response draft with evidence, root-cause candidates, proposed action, validation, and rollback condition so the hosted workflow has an immediate investigation answer even before an external assistant is configured. - `actioner/AskAgentOps` can now accept a schema-valid metadata-only recommendation row and render linked `ChangeTargetRefs`, benchmark run id/decision, artifact file paths, expected/before/after metric movement, validation steps, rollback, and a guided `OperatorReview` approve/reject control without rendering raw diff content. - `actioner/AskAgentOps` can now accept metadata-only saved-view rows and alert handoff packets, then render annotation-linked saved-view context, alert owner/query context, config-change counts, operator steps, and guardrails in the hosted workflow. - `actioner/AskAgentOpsShared` now exposes `/api/ask-agentops/shared`, which hydrates recommendation, saved-view, and alert-handoff context from shared Blob ids instead of requiring the full metadata packets to be posted inline. -- Run Replay, Home, Insights, and Safety dashboards now include shared Ask AgentOps action cells for recommendation, saved-view, and alert handoff review rows, backed by GET routes that hydrate shared Blob metadata by id. +- Run Story, Home, Insights, and Safety dashboards now include shared Ask AgentOps action cells for recommendation, saved-view, and alert handoff review rows, backed by GET routes that hydrate shared Blob metadata by id. - Recommendation rows now carry metadata-only `ExpectedMetricMovement`, `BeforeTelemetry`, `AfterTelemetry`, `ObservedMetricMovement`, and `OperatorReview` fields. `agentops recommend compare` populates the after-run snapshot and marks metric movement as improved, mixed, regressed, or not comparable. - `agentops recommend action-plan` now requires an approved `OperatorReview` and emits a metadata-only guarded patch prompt plus branch, benchmark run, benchmark report, and after-run compare commands. It refuses unapproved, regressed, rejected, or validation-missing recommendation rows. - `agentops alert handoff --events` now attaches matching config-change annotations plus a session-scoped annotation KQL query to alert operator handoffs and route previews. @@ -1173,15 +1172,9 @@ Implemented first slice: - `agentops init --provision-cloud` is the explicit guided cloud deploy/bind path: it runs `azd provision` and imports azd outputs into AgentOps config. - `agentops init --provision-cloud` now reports the failing setup stage and targeted remediation when `azd provision` or `agentops configure import-azd` fails. - `agentops product audit` now verifies the local control-room contract: schema, strict privacy, Copilot CLI/SDK, MCP, GitHub outcomes, evals/insights, V2 dashboards, drilldowns, transcript opt-in, KQL library, and first-run wiring. -- `agentops product audit --live --last 2h --require-rows --json` now verifies that same contract plus live Azure resources and row-backed Grafana KQL checks. Latest observed result on 2026-05-31: 18/18 checks passed, live Azure verified, live Grafana verified, 19 live KQL checks, and 709 dashboard links checked. -- `agentops product audit --live --last 2h --require-rows --require-visual --json` is the final completion gate. It adds rendered Grafana dashboard proof through the same strict browser visual check used by E2E. -- `npm --prefix agentops-cli test` latest observed result on 2026-05-31: 171/171 tests passed. -- Read-only live validation on 2026-05-31 found the Azure resource group, Log Analytics workspace, Application Insights component, Managed Grafana resource, Azure Monitor datasource, and all 24 expected dashboards. -- `agentops dashboard verify --live --last 24h --json` passed 19 live KQL syntax checks, 10 V2 dashboard UX checks, and 709 dashboard links. Current live tables were mostly empty for run-specific panels, so row-presence proof still needs a fresh real Copilot smoke or demo ingest. -- Fresh strict real-Copilot smoke on 2026-05-31 produced `agentops-smoke-20260531122930-30d7a7`, verified one Log Analytics smoke row, and printed a V2 Run Replay link for session `github-copilot-cli_6729a76812fbd25604f1fd345c2fc29c_20260531_1200`. -- `agentops dashboard verify --live --last 2h --require-rows --json` then passed row-required live checks: 19 KQL checks, 10 V2 dashboards, 709 links, 16 row-required panels populated, and the explicit opt-in transcript/pattern panels allowed to remain empty. -- Fresh live E2E on 2026-05-31 produced `agentops-e2e-20260531T123920Z`, forced `AGENTOPS_PRIVACY_MODE=strict`, `AGENTOPS_CAPTURE_CONTENT=false`, and `COPILOT_OTEL_CAPTURE_CONTENT=false`, matched session `61a403b4-c5ea-4fff-bd88-a3a4b75ae1e5`, and generated a PASS browser report screenshot. -- Browser screenshot attempts against Azure Managed Grafana were blocked by Microsoft sign-in in this unauthenticated browser profile. The E2E checker records `auth-blocked` and no longer copies sign-in pages into `docs/screenshots/v2/`. +- Live product-audit and dashboard commands are operator-run gates only. Their output may contain tenant-specific resource IDs, row data, URLs, session identifiers, and screenshots; keep that evidence outside the public repository. +- Local product-audit, dashboard-link, schema, and visual-report checks are useful offline evidence, but do not prove live Azure resources, query-back, authentication, or production readiness. +- A successful OTLP HTTP response or local smoke is not query proof. For an approved pilot, independently verify traces, logs, metrics, and any rendered Agents/Grafana view, then record the result in a private evidence store. - `agentops e2e browser-check --require-grafana-visible` is now the strict visual gate: auth-blocked pages are acceptable for local report QA, but they fail authenticated dashboard visual verification. - The strict visual gate now accepts `--browser-user-data-dir`, `--storage-state`, `--browser-executable`, and `--headed` so authenticated Grafana screenshot QA can reuse a deliberate signed-in browser profile instead of relying on the default automation profile. - When the strict visual gate hits Microsoft SSO, `.agentops/e2e/latest/browser-notes.md` now includes an Auth Remediation section with the exact one-time sign-in command and the exact rerun command for the same report/profile. @@ -1206,13 +1199,13 @@ The default UI answers: what happened, why did it fail or cost money, what chang Implemented: - Session explorer as first screen through the Home **Session Health** panel and the **Runs Explorer** dashboard. -- Trace waterfall through Run Replay's **Replay timeline** and `OpenTrace` drilldowns. -- Policy/safety strip through Run Replay's **Policy, privacy, tests, and GitHub outcome** panel plus the Safety, Privacy & Policy dashboard. -- Tool/MCP waterfall through Run Replay's **Agent, skill, and MCP lineage** panel plus the Tools & MCP Risk dashboard. -- Context/tokens panel through Run Replay's **Context and cache posture** panel and the Models, Cost & Tokens dashboard. -- Recommendation panel through Home **Recommended next actions**, Run Replay **Latest recommendation**, and Insights **Recommendation artifacts**. +- Trace waterfall through Run Story's **Replay timeline** and `OpenTrace` drilldowns. +- Policy/safety strip through Run Story's **Policy, privacy, tests, and GitHub outcome** panel plus the Safety, Privacy & Policy dashboard. +- Tool/MCP waterfall through Run Story's **Agent, skill, and MCP lineage** panel plus the Tools & MCP Risk dashboard. +- Context/tokens panel through Run Story's **Context and cache posture** panel and the Models, Cost & Tokens dashboard. +- Recommendation panel through Home **Recommended next actions**, Run Story **Latest recommendation**, and Insights **Recommendation artifacts**. - Eval/benchmark linkage through Evals & Quality scorecards, before/after comparison, artifact review, hidden checks, policy review, semantic checks, and promotion approvals. -- Ask AgentOps panel through Run Replay **Ask AgentOps context** and hosted Ask AgentOps action links. +- Ask AgentOps panel through Run Story **Ask AgentOps context** and hosted Ask AgentOps action links. - `agentops product audit` now includes `run-centric-ui-contract` so this UI shape remains a local release gate. ### 3. Agent Improvement Loop @@ -1270,7 +1263,7 @@ Implemented: - Schema versioning in demo rows, ingest-plan warnings, and Collector Health coverage. - Exporter failure visibility in Collector Health rows and dashboard review actions. - Schema migration policy in ingest planning for current, legacy, missing, and unsupported newer table versions. -- Real Copilot fixture regression tests through `real-copilot-otel-fixture-contract` and `tests/sample-otel/copilot-cli-wrapper-snapshot.jsonl`. +- Real Copilot fixture regression tests through `real-copilot-otel-fixture-contract` and `fixtures/sample-otel/copilot-cli-wrapper-snapshot.ndjson.fixture`. ## Recommended Roadmap diff --git a/docs/architecture.md b/docs/architecture.md index 4dea6fe..f250343 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,9 @@ Copilot AgentOps for Azure is a local-first observability loop for Copilot CLI, Copilot SDK apps, VS Code/MCP tools, and GitHub code outcomes. -The default product path is V2: Agent Run tables plus the `AgentOps for Azure` Grafana dashboards. +The default product path is the Azure Monitor Application Insights Agents +view, with Agent Run tables and the `AgentOps for Azure` Grafana dashboards as +the optional advanced evidence pack. ## One Screen @@ -25,17 +27,18 @@ OpenTelemetry Collector drop/redact content-like fields normalize GenAI + MCP spans roll up Agent Run tables + bounded persistent sending queue | v Azure - Application Insights + Application Insights / Agents view Log Analytics custom tables - Azure Managed Grafana + optional Azure Managed Grafana / Workbooks | v Operator workflows - Home -> Runs -> Replay -> Tools -> Models - Privacy -> Outcomes -> Evals -> Insights -> Collector + Native Agents view -> Run Story -> Privacy receipt + optional Grafana: Today -> Runs -> Tools -> Models -> Outcomes ``` Rendered architecture assets: @@ -86,9 +89,23 @@ raw local event -> strict allowlist -> content-signal detector -> secret-like redaction + -> bounded persistent queue -> safe metadata export ``` +The persistent queue is deliberately downstream of the privacy processors, so +queued records contain the same scrubbed metadata intended for Azure rather +than raw prompts, responses, tool arguments, or tool results. Binary mode keeps +the queue in the permission-restricted AgentOps collector home; Docker mode +uses a dedicated named volume. The queue is bounded to 1,000 batches. + +Current durability evidence is narrower than full outage tolerance: an +integration test proves byte-identical replay of an in-flight OTLP batch after +a forced Collector process crash. The Azure Monitor exporter shipped in the +validated Collector version does not accept the standard `retry_on_failure` +configuration, so arbitrary Azure connection failures, long outages, queue +expiry, corruption recovery, and disk-pressure behavior remain release gates. + Strict mode does not export by default: - prompts or responses; @@ -139,13 +156,13 @@ See [Agent run data model](agent-run-data-model.md) and [OTel GenAI and MCP sche ## Dashboard Product ```text -AgentOps Home +Today -> executive health and next actions -Runs Explorer +Runs -> trace-list style run search -Agent Run Replay +Run Story -> metadata-only timeline for one run Models, Cost & Tokens @@ -154,7 +171,7 @@ Models, Cost & Tokens Tools & MCP Risk -> tool failure, denial, MCP, and risk analysis -Safety, Privacy & Policy +Privacy -> trust posture and strict-mode proof Code Outcomes diff --git a/docs/azure-native-otlp-preview.md b/docs/azure-native-otlp-preview.md new file mode 100644 index 0000000..c7e25b3 --- /dev/null +++ b/docs/azure-native-otlp-preview.md @@ -0,0 +1,153 @@ +# Azure native OTLP preview + +This is the optional cloud lane for the native-first AgentOps setup. Copilot +CLI, VS Code Copilot Chat, and Copilot SDK applications emit their own native +OpenTelemetry. AgentOps only provides a local strict Collector boundary and a +small CLI; it does not wrap or re-instrument Copilot. + +```text +native Copilot OTel + | + v +127.0.0.1:4318 -> local strict Collector -> Azure Monitor OTLP/DCR + | + v + Agents view + KQL +``` + +This path is a preview pilot. It is not a production support claim, and it is +separate from the existing connection-string and Logs Ingestion API +compatibility paths. + +## Gate before running + +- [ ] Confirm the exact Visual Studio Enterprise subscription. +- [ ] Confirm the exact resource group and region; do not silently substitute + another resource group. +- [ ] Use an Application Insights resource created with OTLP support enabled, + or use a separately reviewed manual DCE/DCR design. +- [ ] Copy the DCR resource ID and the traces, logs, and metrics endpoint URLs + verbatim from the Application Insights **OTLP Connection Info** section. +- [ ] Confirm the Collector identity has **Monitoring Metrics Publisher** on + that DCR. +- [ ] Keep Copilot content capture disabled. +- [ ] Review the endpoints and set `AGENTOPS_APPROVE_NATIVE_OTLP=yes` before + starting the native Collector. + +## First-party Azure onboarding (write-gated) + +Microsoft's recommended preview path is not a hand-authored Bicep property. +After the exact subscription, resource group, and region are approved: + +1. Register the `Microsoft.Insights/OtlpApplicationInsights` preview feature and + ensure the `Microsoft.Insights` provider is registered. +2. In the Azure portal, create or select an Application Insights resource, + turn **Enable OTLP support (Preview)** on, and use managed workspaces. +3. Let the first-party onboarding create and connect the required DCR, DCE, + Log Analytics workspace, and Azure Monitor workspace resources. +4. Copy the DCR resource ID plus the trace, log, and metric endpoints from + **OTLP Connection Info**. Do not construct or normalize them locally. +5. Grant the Collector identity the least-privilege + **Monitoring Metrics Publisher** role scoped to that DCR, then rerun the + read-only readiness check. + +The feature registration, resource creation, and role assignment are Azure +writes. The repository does not perform them implicitly, and its checked-in +Bicep does not invent an undocumented OTLP-enable property. + +The read-only repository gate is: + +```bash +AZURE_SUBSCRIPTION_ID="<approved-subscription-id>" \ +AGENTOPS_AZURE_SUBSCRIPTION_ID="<approved-subscription-id>" \ +AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS="<approved-subscription-id>" \ +./scripts/azure-native-otlp-readiness.sh +``` + +That command never registers a feature, creates a resource group, assigns a +role, runs a what-if, or deploys infrastructure. + +## Configure the local native Collector + +The easiest safe path is to let the read-only helper discover the values from +the selected Application Insights resource. It checks the approved +subscription, resource group, feature registration, DCR role, and all three +signal endpoints before printing exports: + +```bash +eval "$(./scripts/azure-native-otlp-env.sh)" +``` + +Do not put credentials in the repository. If you need to inspect the values +manually, they are the endpoint values from OTLP Connection Info; do not +construct or normalize the URLs yourself. + +```bash +export AGENTOPS_AZURE_OTLP_DCR_RESOURCE_ID="/subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.Insights/dataCollectionRules/<dcr-name>" +export AZURE_MONITOR_OTLP_TRACES_ENDPOINT="https://<logs-dce-domain>/datacollectionRules/<dcr-immutable-id>/streams/Microsoft-OTLP-Traces/otlp/v1/traces" +export AZURE_MONITOR_OTLP_LOGS_ENDPOINT="https://<logs-dce-domain>/datacollectionRules/<dcr-immutable-id>/streams/Microsoft-OTLP-Logs/otlp/v1/logs" +export AZURE_MONITOR_OTLP_METRICS_ENDPOINT="https://<metrics-dce-domain>/datacollectionRules/<dcr-immutable-id>/streams/microsoft-otelmetrics/otlp/v1/metrics" +``` + +Validate the merged Collector configuration without starting it: + +```bash +agentops collector validate --mode azure-native --privacy strict --json +``` + +Start it only after the target and endpoint review: + +```bash +export AGENTOPS_APPROVE_NATIVE_OTLP=yes +agentops collector start --mode azure-native --privacy strict --json +``` + +The `azure-native` mode merges +`collector/otelcol.local.strict.yaml` with +`collector/otelcol.azuremonitor.native.strict.yaml`. This keeps the strict +allowlist, fail-closed transform, private file-backed queue, and loopback +receiver in one source-controlled base config. The overlay adds only +`azure_auth` and the current `otlp_http` Azure exporter. + +## Test-drive sequence + +- [ ] `agentops collector validate --mode azure-native --privacy strict` +- [ ] Send a synthetic metadata-only smoke event to `127.0.0.1:4318`. +- [ ] Confirm the Collector log has no authentication or export errors. +- [ ] Confirm the synthetic event is query-visible in `OTelSpans` in the + selected Log Analytics workspace. +- [ ] Confirm a real run is visible in Azure Monitor **Agents (Preview)**. +- [ ] Run one real plain `copilot` task with content capture still disabled. +- [ ] Compare the local receipt, native OTLP rows, and Agents view without + claiming success where only processing-stopped telemetry exists. + +The success boundary is query visibility plus Agents-view evidence. An HTTP +`2xx` from the OTLP endpoint alone is not enough. + +## Operator-run proof + +This public repository contains the procedure, not a tenant-specific pilot +record. Run the checklist only against an explicitly approved subscription and +retain any query results, screenshots, resource IDs, and raw telemetry outside +the public tree. Local validation, an HTTP `2xx`, or a generated demo row does +not prove Azure query-back, Agents-view rendering, authentication, or +production readiness. + +For logs, query `OTelLogs` and use `contains` against the safe +`agentops.custom_event_id` or resource correlation field. For metrics, use the +Azure Monitor Workspace PromQL endpoint discovered from the Application +Insights resource; `az monitor metrics list` exposes workspace platform +metrics, not custom OTLP series. The helper command is: + +```bash +AGENTOPS_SMOKE_ID=<metric-smoke-id> ./scripts/azure-native-metric-query.sh +``` + +## Official documentation + +- [GitHub Copilot CLI OTel configuration](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference) +- [GitHub Copilot SDK OpenTelemetry](https://docs.github.com/en/copilot/how-tos/copilot-sdk/observability/opentelemetry) +- [Azure Monitor OTLP ingestion with the OpenTelemetry Collector](https://learn.microsoft.com/en-us/azure/azure-monitor/containers/opentelemetry-protocol-ingestion) +- [Azure Monitor OpenTelemetry ingestion options](https://learn.microsoft.com/en-us/azure/azure-monitor/containers/opentelemetry-options) +- [Azure Monitor Agents view](https://learn.microsoft.com/en-us/azure/azure-monitor/app/agents-view) +- [OTLP HTTP exporter configuration](https://github.com/open-telemetry/opentelemetry-collector/blob/main/exporter/otlphttpexporter/README.md) diff --git a/docs/azure-v2-ingestion.md b/docs/azure-v2-ingestion.md index c559c9f..0687ce2 100644 --- a/docs/azure-v2-ingestion.md +++ b/docs/azure-v2-ingestion.md @@ -47,6 +47,20 @@ export AGENTOPS_LOGS_INGESTION_ENDPOINT="<AGENTOPS_LOGS_INGESTION_ENDPOINT outpu export AGENTOPS_DCR_IMMUTABLE_ID="<AGENTOPS_DCR_IMMUTABLE_ID output>" ``` +Or import/store the non-secret outputs once, then inspect the durable lifecycle +queue before any write: + +```bash +agentops configure import-azd +agentops delivery status +agentops delivery drain +agentops delivery drain --yes +``` + +The drain command uploads only strict metadata-only `AgentOpsEvents_CL` +lifecycle receipts. It does not make the complete native Copilot OTLP stream +durable; that stream remains on the Collector/Application Insights path. + 3. Confirm one custom Log Analytics table per `AgentOps*_CL` table and one DCR stream per table exist. 4. Send each JSONL row to the matching DCR stream. 5. Import the V2 dashboard pack from `grafana/dashboards/v2/`. diff --git a/docs/collector-local-benchmark.md b/docs/collector-local-benchmark.md new file mode 100644 index 0000000..545302d --- /dev/null +++ b/docs/collector-local-benchmark.md @@ -0,0 +1,26 @@ +# Local strict collector performance benchmark + +Run: + +```bash +node scripts/benchmark-collector-local.js +``` + +The benchmark starts the installed `otelcol-contrib` with the processor block copied at runtime from `collector/otelcol.local.strict.yaml`. It sends synthetic metadata over loopback OTLP/HTTP and uses a temporary loopback OTLP/HTTP sink. No Azure exporter, connection string, content, prompt, completion, tool payload, or external network destination is configured. + +It reports p50 and p95 for two related observations: + +- receiver acknowledgement: elapsed time until the collector accepts the OTLP request after the receiver and processor pipeline; +- local sink acknowledgement: elapsed time until the temporary local exporter sink receives and acknowledges that event. + +The default is 10 warm-up events and 100 sequential measured events. Inputs are hard-capped at 500 so the test stays bounded: + +```bash +AGENTOPS_COLLECTOR_BENCH_WARMUP=25 \ +AGENTOPS_COLLECTOR_BENCH_EVENTS=250 \ +node scripts/benchmark-collector-local.js +``` + +The benchmark also verifies that an allowlisted event ID reaches the sink and a synthetic disallowed metadata field does not. It does not inject or capture user content. + +Results are descriptive and have no approved threshold. They include loopback HTTP, collector processing, batching, and a local sink. They do not measure SDK capture, WAN latency, Azure Monitor ingestion, KQL availability, Grafana rendering, or end-user latency. Compare results only with the same collector version, hardware, workload settings, and machine power state. diff --git a/docs/copilot-sdk-adapter.md b/docs/copilot-sdk-adapter.md index bd59e59..6c69edc 100644 --- a/docs/copilot-sdk-adapter.md +++ b/docs/copilot-sdk-adapter.md @@ -6,9 +6,16 @@ - `captureContent=false`; - source name: `agentops-copilot-sdk`; - W3C trace context callback; -- safe hook telemetry for prompt, tool, session, and error events. +- safe hook telemetry for prompt, tool, session, and error events; +- an ordered session-event observer for model, token, cost, custom-agent, + subagent, skill, MCP, tool, command, permission, compaction, and outcome data; +- automatic privacy-safe OTLP/JSON export of those ordered events through the + same localhost collector, with bounded transient retries and explicit flush + after the underlying `client.stop()` completes. It does not store prompts, model responses, tool arguments, or tool results in strict mode. +`captureContent=true` is rejected in every mode because upstream SDK content +export would occur before this adapter could safely redact it. ## Usage @@ -22,34 +29,92 @@ const client = createAgentOpsCopilotClient(CopilotClient, { privacyMode: 'strict', captureContent: false, emit: event => { - // Write to a local JSONL file, custom exporter, or test harness. + // Optional: receive the same safe event locally too. console.log(JSON.stringify(event)); } }); -const session = await client.createSession( - client.createAgentOpsSessionConfig({ - hooks: { - // Your app hooks can still be added here. - } - }) -); +const session = await client.createAgentOpsSession({ + hooks: { + // Your app hooks can still be added here. + } +}); + +try { + // Use the session normally. +} finally { + await session.destroy(); + await client.stop(); // stops Copilot, then verifies AgentOps OTLP delivery +} ``` +`createAgentOpsSession()` enables SDK streaming by default, composes your hooks, +and registers the observer through the SDK's early `onEvent` configuration so +session-start events cannot race session creation. Use +`client.resumeAgentOpsSession(id, config)` for the same behavior on resume. If +your application receives a session through another path, call +`client.observeAgentOpsSession(session)` and retain the returned detach function. +The ordered exporter is on by default. Set `exportOrderedEvents: false` only +when the host supplies and flushes its own event sink. Non-loopback HTTP +endpoints are rejected; remote collectors must use HTTPS. +The exporter retries network failures plus HTTP 408, 429, and 5xx responses up +to three times by default. Final delivery failures reject `flush()`/`stop()` and +may also be observed with `onExportError`; they are never reported as success. +Retries are in memory and target the local collector. Restart-safe downstream +delivery remains the collector's responsibility. +The queue is bounded to 1,000 pending metadata events by default; override it +with `maxPendingEvents` up to 10,000. Overflow is counted, reported through +`onExportError`, and makes the next flush fail. Inspect +`client.agentopsDeliveryStatus()` for accepted, sent, retried, failed, pending, +overflowed, and last-success evidence. +Test runners can set `AGENTOPS_DISABLE_ORDERED_EXPORT=1` so unit fixtures never +pollute a developer's running collector or Azure workspace. + +Each exported row has a deterministic `Sequence`, hashed `EventId` and +`ParentEventId`, and safe attribution fields. Prompts, assistant messages, +reasoning, tool arguments/results, command arguments, repository paths, session +titles, summaries, and error text are measured and dropped. Repository, branch, +and working-directory values are represented only by stable hashes. +When the SDK supplies `ParentEventId`, the exporter also emits a deterministic +OTLP `parentSpanId`; the original safe parent event ID remains available for +ordered evidence. +Instant lifecycle facts use zero-duration spans; only events with measured +duration contribute latency. The W3C trace ID supplied to Copilot and the +ordered-event exporter are derived from the same trace seed. + +`assistant.usage.data.cost` is emitted as `CopilotCost`, because the SDK defines +it as a billing/model cost unit rather than USD. `EstimatedCostUsd` remains zero +unless the caller explicitly supplies a reviewed `usdPerCostUnit` conversion. + ## Hook Mapping - `onUserPromptSubmitted` -> `agentops.prompt.submitted`, prompt hash and size only. - `onPreToolUse` -> `agentops.policy.decision`, tool name, args schema hash, args size. - `onPostToolUse` -> `agentops.tool.result`, result size only. +- `onPostToolUseFailure` -> failed `agentops.tool.result`, error type and size only. - `onSessionStart` -> `agentops.session.start`. - `onSessionEnd` -> `agentops.session.end`. -- `onError` -> `agentops.error`. +- `onErrorOccurred` -> `agentops.error`. + +The session observer subscribes once to the complete event stream, so it also +records new SDK event types without retaining their payloads. Known event fields +provide safe attribution for subagents, skills, MCP tools, model usage, token +usage, permissions, and lifecycle timings. The adapter passes through user-provided hooks after emitting safe metadata. +It does not add an allow or deny decision when the application has no policy +handler. Instrumentation callback failures are reported separately and do not +suppress the application's event or hook handler. Concurrent sessions receive +independent conversation IDs, trace IDs, sequences, and lifecycle state. ## Docs Alignment -GitHub's Copilot SDK docs describe `TelemetryConfig` options including `otlpEndpoint`, `sourceName`, `captureContent`, and Node.js `onGetTraceContext` for W3C trace propagation. The hook overview documents pre/post tool, prompt, session lifecycle, and error hooks. +GitHub's Copilot SDK docs describe `TelemetryConfig` options including +`otlpEndpoint`, `sourceName`, `captureContent`, and Node.js +`onGetTraceContext` for W3C trace propagation. The hook overview documents +pre/post tool, prompt, session lifecycle, and `onErrorOccurred` hooks. Streaming +session events are currently public preview; this adapter isolates that evolving +surface behind `createAgentOpsSessionObserver()`. ## Verify @@ -58,6 +123,10 @@ npm --prefix packages/agentops-copilot-sdk test npm --prefix packages/agentops-copilot-sdk run publish:check -- --json ``` +For live proof, start the strict collector, run the example with the official +SDK installed, call `agentops latest --last 15m --json`, and confirm the SDK +session is sourced from Azure with no content-capture warning. + ## Publish Readiness Before publishing the adapter package, run the publish check. It validates package metadata, rejects wildcard Copilot SDK peer dependencies, and inspects `npm pack --dry-run --json` so the package contains only the intended source, type definitions, examples, and package metadata. diff --git a/docs/dashboard-tour.md b/docs/dashboard-tour.md index 7a9237d..d4a9b54 100644 --- a/docs/dashboard-tour.md +++ b/docs/dashboard-tour.md @@ -2,6 +2,11 @@ Use the dashboards in this order: start broad, then drill down only when you need more detail. +This guide describes the dashboard contracts and expected empty states. No live +Azure or Grafana screenshots are checked into the public repository. Local +demo data and offline dashboard verification are not proof of live Azure +ingestion, query-back, authentication, or production readiness. + ```text Overview | @@ -27,7 +32,6 @@ Use this as the front door. It answers: **is Copilot/agent activity flowing, how Good for daily health checks and quick demos. If this page is empty after setup, run the real quick-start Copilot check first. -![AgentOps overview dashboard](screenshots/agentops-overview-live.png) ## Sessions @@ -35,7 +39,6 @@ Use this when someone asks: **which run should I look at?** Each row is a session. Sort by failures, cost, token use, duration, or risk. This is usually the best place to start an incident or cost investigation. -![AgentOps sessions dashboard](screenshots/agentops-sessions-live.png) ## Session Detail @@ -45,7 +48,6 @@ You get span count, failures, token/cost summary, tool waterfall, runtime events Think of this as the first version of live session replay. For a simple agent, it shows one run timeline with LLM calls, tools, MCP calls, scripts/hooks, timings, cost, and errors. For an orchestrator agent, the same view can become a delegation tree when spans include parent/child IDs or optional `agentops.parent_agent.*` and `agentops.delegation.*` fields. No sub-agents are required. -![AgentOps session detail dashboard](screenshots/agentops-session-detail-live.png) ## Live Replay @@ -53,7 +55,6 @@ Use this when you want to watch a full run unfold. It answers: **which agent lan Single-agent runs show one lane. Orchestrator runs become a tree when spans include parent/child IDs or optional `agentops.parent_agent.name` and `agentops.delegation.id` fields. This keeps the dashboard generic: it works for Copilot CLI, Codex, VS Code, SDK agents, CI agents, and agents that never delegate. -![AgentOps live replay dashboard](screenshots/agentops-live-replay-live.jpg) ## Traces / Spans @@ -61,7 +62,6 @@ Use this when you need raw evidence. It answers: **what exact spans did Copilot This page is intentionally lower-level: operation IDs, parent/child spans, durations, tool names, models, result codes, and errors. -![AgentOps traces dashboard](screenshots/agentops-traces-live.png) ## Tools & MCP @@ -69,7 +69,6 @@ Use this for tool reliability. It answers: **which tools or MCP servers are bein This is where Azure MCP, shell tools, custom tools, and likely MCP-provided tools show up. Tools are auto-detected from `gen_ai.tool.name`; MCP server/tool attribution is exact for names such as `mcp__server__tool` or `server/tool`, and inferred for known prefixes such as Azure MCP. -![AgentOps tools and MCP dashboard](screenshots/agentops-tools-live.png) ## Attribution @@ -77,7 +76,6 @@ Use this to understand ownership. It answers: **which custom agents, skills, MCP This is useful when teams share one Azure workspace but want to know what agent/plugin/workflow generated the traffic. -![AgentOps attribution dashboard](screenshots/agentops-attribution-live.png) ## Runtime Events @@ -94,7 +92,6 @@ agentops copilot --agent agentops-orchestrator \ -p "Do not edit files. Use read-only shell commands: pwd and ls docs | head. Summarize what you saw." ``` -![AgentOps runtime events dashboard](screenshots/agentops-runtime-live.png) To generate lifecycle-style data for this page: @@ -118,7 +115,6 @@ agentops copilot --agent agentops-orchestrator \ -p "Use bash once to run: az keyvault secret show --vault-name agentops-nonexistent-vault --name agentops-nonexistent-secret. If AgentOps blocks it, do not retry." ``` -![AgentOps safety and policy dashboard](screenshots/agentops-safety-policy-live.png) ## Permission Friction @@ -132,7 +128,6 @@ To create real policy-friction data for this page, run the safe policy-block che Retry-hint panels stay quiet unless a real post-tool-failure hook emits a recovery hint. -![AgentOps permission friction dashboard](screenshots/agentops-permission-friction-live.png) ## Alert Tuning @@ -146,7 +141,6 @@ The **Fired alert candidates** table includes dashboard links and an `agentops a This page needs enough history before it becomes visually interesting. On a fresh install, it may have little to recommend. Run real traffic, real custom lifecycle events, and the safe policy-block check over time, then use the recommendations here before turning on scheduled-query alerts. -![AgentOps alert tuning dashboard](screenshots/agentops-alert-tuning-live.png) ## Quality @@ -154,7 +148,6 @@ Use this for improvement work. It answers: **which sessions are slow, expensive, This page is where you find candidates for better prompts, safer tools, smaller context, cheaper models, or workflow changes. -![AgentOps quality dashboard](screenshots/agentops-quality-live.png) ## Experiments @@ -162,7 +155,6 @@ Use this for benchmark and variant comparisons. It answers: **did a change help Run `agentops benchmark run ...` or label real runs with experiment metadata, then compare pass rate, score, token use, cost, safety issues, and failures. -![AgentOps experiments dashboard](screenshots/agentops-experiments-live.png) ## Data Quality @@ -170,7 +162,6 @@ Use this when something looks wrong. It answers: **are the fields, token rollups This is the troubleshooting dashboard for schema drift and ingestion issues. -![AgentOps data quality dashboard](screenshots/agentops-data-quality-live.png) ## Expected Quiet Panels diff --git a/docs/datadog-lapdog-ux-target.md b/docs/datadog-lapdog-ux-target.md index ebe3609..418d7ad 100644 --- a/docs/datadog-lapdog-ux-target.md +++ b/docs/datadog-lapdog-ux-target.md @@ -34,7 +34,7 @@ The V2 Grafana control room should preserve this path without requiring KQL: ```text Home -> Runs Explorer - -> Run Replay + -> Run Story -> span/tool/model/privacy/GitHub/eval detail -> related dashboard filtered to the same run, model, tool, repo, skill, or sub-agent ``` @@ -42,7 +42,7 @@ Home Content follows the same contract, but only after opt-in: ```text -Run Replay +Run Story -> Content posture panel -> Prompt/response viewer -> related privacy signals and trace spans diff --git a/docs/demo-data.md b/docs/demo-data.md index 047c0f1..946348b 100644 --- a/docs/demo-data.md +++ b/docs/demo-data.md @@ -66,7 +66,7 @@ Synthetic scenarios: Demo payloads are metadata-only by default. They include hashes, counts, model names, statuses, risk labels, durations, token totals, costs, eval scores, and GitHub outcome states. They do not include fake prompts, responses, tool arguments, tool results, source code, file contents, full URLs, or secrets. -To preview the optional Run Replay prompt/response viewer with safe synthetic text: +To preview the optional Run Story prompt/response viewer with safe synthetic text: ```bash agentops demo generate --runs 10 --with-content --json diff --git a/docs/durable-evidence-spool.md b/docs/durable-evidence-spool.md new file mode 100644 index 0000000..c05f7ca --- /dev/null +++ b/docs/durable-evidence-spool.md @@ -0,0 +1,50 @@ +# Durable Evidence Spool + +Status: durable wrapper-lifecycle receipts are wired into normal `agentops copilot` runs and the existing dev DCE/DCR destination is configured locally. Four strict/off receipts were accepted and became query-visible on 2026-08-03. The live V2 table currently drops `EventId`, `Sequence`, and other newer receipt columns, so exact ordered Azure correlation remains blocked on an additive schema update. Detailed native Copilot telemetry remains on the best-effort Collector path. + +`agentops-cli/src/lib/azure/durable-evidence-spool.js` is the first production-oriented delivery boundary for critical AgentOps evidence. It is deliberately separate from the OpenTelemetry Collector's Azure Monitor exporter because that exporter acknowledges its persistent queue before the inner Application Insights sender receives an Azure response. + +The spool accepts only allowlisted scalar metadata. It rejects unknown fields and nested values, forces strict privacy/content-off markers, and requires a run ID and positive sequence. Every segment carries an event ID and SHA-256 hash of the canonical row. + +Local guarantees: + +- spool directories are mode `0700` and files are `0600` on POSIX; +- each pending segment is written through an exclusive temporary file, flushed, and atomically renamed; +- enqueue capacity checks, overflow counters, and pending-event deduplication use a short cross-process admission lock that is never held during upload work; +- drainers atomically rename `pending` segments to `uploading`, so concurrent processes cannot normally send the same claimed segment; +- active upload claims refresh a bounded lease; a later drainer recovers a stale claim after process failure; +- pending storage has a byte limit and TTL; +- network errors and HTTP `408`, `429`, and `5xx` responses retry, including `Retry-After`; +- a segment is acknowledged and removed only after an injected uploader returns `2xx`; +- permanent `4xx`, malformed, and hash-mismatched segments are quarantined; +- only the DCR-compatible `AgentOpsEvents_CL` lifecycle schema is accepted, including after restart; +- the uploader fails closed unless the exact approved subscription is active, uses HTTPS and canonical DCR stream names, and refreshes its Entra token once after `401` or `403`; +- process restart preserves pending work; +- status reports pending, acknowledged, overflow, expired, and quarantined counts. +- concurrent writers use a short atomic admission lock; concurrent drainers atomically claim segments with a heartbeat and bounded stale-claim recovery; +- identical pending lifecycle evidence deduplicates by table, event ID, and canonical row hash. + +The interactive receipt deliberately separates the two scopes: + +- `Delivery` describes only the durable wrapper lifecycle receipt; +- `Coverage` says native Copilot detail still uses best-effort Collector delivery. + +A local receipt may therefore say `Run receipt saved locally · waiting for Azure` +while the detailed native session is already visible through Application Insights. +Neither state is promoted to `Visible in Azure` without a query-back proof. + +Delivery is **at least once**. If Azure accepts a request and the process stops before the local atomic acknowledgement, the same event ID and hash are sent again. Consumers must deduplicate by `EventId` (and may verify `RunId`, `Sequence`, and the row hash). Exactly-once delivery is not claimed. + +Enqueue deduplicates an already pending or uploading segment only when table, event ID, and canonical row hash all match. This prevents repeated local admission from multiplying identical work, including after a process restart. It does not change the at-least-once crash window after remote acceptance. + +The spool is bounded, so it cannot promise recovery from an unlimited outage. Queue capacity and TTL must be sized from measured event volume. Overflow, expiry, and quarantine are explicit failure states, not successful observation. + +Before treating this as full durable delivery: + +1. expose explicit operator drain/review/requeue lifecycle commands for pending and held records; +2. apply a reviewed additive table/DCR schema update that preserves the legacy `EstimatedCostUsd:long`, adds `EstimatedCostUsdReal:real`, `EventId`, and `Sequence`, then live-correlate exact wrapper event IDs; +3. add a post-`transform/privacy_strict` Collector tee before extending durability to detailed native Copilot events; +4. prove live `2xx`, `408`, `429`, `5xx`, token expiry, schema `4xx`, disk-full, corruption, restart, and duplicate delivery behavior; +5. measure disk growth, delivery latency, recovery time, and Azure ingestion cost. + +Until those gates pass, the wired lifecycle receipt is reliable local groundwork rather than proof that the complete native Copilot event stream has durable Azure delivery. diff --git a/docs/evals-and-insights.md b/docs/evals-and-insights.md index 41fdaf6..51f3d23 100644 --- a/docs/evals-and-insights.md +++ b/docs/evals-and-insights.md @@ -61,7 +61,7 @@ agentops recommend latest \ --save ``` -The recommendation is metadata-only. It links to Run Replay, Tools & MCP Risk, Models/Cost, Safety/Privacy, Code Outcomes, or Insights based on the run and top insight. If `--events` includes `agentops.config.changed` annotations for the same run, session, or trace, the recommendation also carries the changed component and target. If the selected run has no direct insight but matches a recurring metadata pattern, the recommendation falls back to that pattern and links Insights & Regressions with `$pattern_key`. +The recommendation is metadata-only. It links to Run Story, Tools & MCP Risk, Models/Cost, Safety/Privacy, Code Outcomes, or Insights based on the run and top insight. If `--events` includes `agentops.config.changed` annotations for the same run, session, or trace, the recommendation also carries the changed component and target. If the selected run has no direct insight but matches a recurring metadata pattern, the recommendation falls back to that pattern and links Insights & Regressions with `$pattern_key`. When `--out` is provided, AgentOps appends an `AgentOpsRecommendations_CL.jsonl` row containing the action, severity, observed pattern, next action, pattern id/key, eval bucket, benchmark-gate summary, config-change annotations, validation steps, dashboard count, metadata-only change-target refs, expected metric movement, and before-run telemetry. `--save` also writes the same metadata-only row to a local durable recommendation store; use `agentops recommend list` or `agentops recommend export --out <dir>` to review or export it later. After a follow-up run completes, use `agentops recommend compare --recommendation-id <id> --after-runs <AgentOpsRunSummary_CL.jsonl> --after-evals <AgentOpsEval_CL.jsonl>` to populate `AfterTelemetry` and mark `ObservedMetricMovement` as improved, mixed, regressed, or not comparable. After an operator approves the row with `OperatorReview`, use `agentops recommend action-plan --recommendation-id <id>` to generate the guarded patch prompt plus benchmark/report/compare commands. To share exported recommendations with a team, use `agentops azure-ingest upload-plan --dir <export-dir> --account <storage-account>` and run the reviewed Azure Blob upload commands only after owner approval. It does not store prompt text, model responses, tool arguments, tool results, source code, or file contents. @@ -80,4 +80,4 @@ agentops ask-context latest \ --last 24h ``` -The bundle includes run metadata, timeline events, failed/denied tools, privacy signals, GitHub outcomes, evals, insights, the latest recommendation, benchmark run ID when present, Run Replay links, and a copyable KQL query. It explicitly tells the investigator not to request or enable prompt/response/tool-argument/file-content capture. +The bundle includes run metadata, timeline events, failed/denied tools, privacy signals, GitHub outcomes, evals, insights, the latest recommendation, benchmark run ID when present, Run Story links, and a copyable KQL query. It explicitly tells the investigator not to request or enable prompt/response/tool-argument/file-content capture. diff --git a/docs/grafana-dashboard-tour-v2.md b/docs/grafana-dashboard-tour-v2.md index d5f2787..4b7a558 100644 --- a/docs/grafana-dashboard-tour-v2.md +++ b/docs/grafana-dashboard-tour-v2.md @@ -8,7 +8,7 @@ Start here when you want the answer in one screen. It shows: -- a first-row action strip for opening the latest Run Replay, generating one recommendation, and building an Ask AgentOps context bundle with prompt-template guidance; +- a first-row action strip for opening the latest Run Story, generating one recommendation, and building an Ask AgentOps context bundle with prompt-template guidance; - runs, success rate, failed runs, policy blocks, privacy drops, estimated cost, input/output tokens, p95 duration, tests ran percent, PRs opened, and collector health; - Session Health table with status, risk, root agent, model, tool failures, policy denials, privacy signal, context pressure, eval score, benchmark linkage, and recommended next action; - recommended next actions from insight rows; @@ -16,7 +16,7 @@ It shows: - GitHub outcome summary; - shared saved investigations exported from `agentops saved-view export` or created in the hosted `/api/shared-store/editor` page. -Click a `RunId` to open Run Replay. Click a model, repo hash, tool, skill, or sub-agent to keep drilling with the same time range. +Click a `RunId` to open Run Story. Click a model, repo hash, tool, skill, or sub-agent to keep drilling with the same time range. ## 2. Runs Explorer @@ -35,7 +35,7 @@ The table keeps the fields operators need most: run/session/trace IDs, surface, Use the explicit `OpenReplay`, `OpenTrace`, and `OpenGithub` action cells when you want the shortest Datadog-style drilldown path from the trace list. -## 3. Agent Run Replay +## 3. Agent Run Story This is the main debugging screen. @@ -55,7 +55,7 @@ It tells the story of one run using metadata that remains useful in strict priva Prompt and response text appears only in `AgentOpsContent_CL`, which is explicit opt-in. In strict mode, the transcript panel stays empty and does not error. When content rows exist, the viewer renders them as a transcript with role, turn, content kind, message text, capture mode, redaction status, content hash, and content length. -From the CLI, `agentops open latest --runs <AgentOpsRunSummary_CL.jsonl>` prints both the normal Run Replay URL and a dedicated prompt/response viewer URL for panel 26. That link is a drilldown target, not permission to collect content. +From the CLI, `agentops open latest --runs <AgentOpsRunSummary_CL.jsonl>` prints both the normal Run Story URL and a dedicated prompt/response viewer URL for panel 26. That link is a drilldown target, not permission to collect content. Inside Grafana, the **Transcript availability** panel has an `OpenTranscript` cell that jumps to the same prompt/response viewer while preserving the dashboard time range. @@ -130,11 +130,11 @@ Scores cover: - reliability; - code outcome. -Low-score runs link back into Run Replay. +Low-score runs link back into Run Story. The **Eval scorecard by repo, model, and task** panel groups eval rows into scorecards with overall, test discipline, tool efficiency, security, reliability, and code outcome averages. It also counts poor and review-bucket runs so weak slices are visible without opening every run. -The **Eval regression follow-up** panel shows poor/review eval recommendations and regression actions with Run Replay and pattern drilldowns. +The **Eval regression follow-up** panel shows poor/review eval recommendations and regression actions with Run Story and pattern drilldowns. The **Before/after run comparison** panel compares each run with the previous run in the same repo, model, and task slice. It highlights eval, cost, token, tool-failure, and risk deltas so before/after changes can be reviewed without opening every run. @@ -199,7 +199,7 @@ It shows: ```text Home -> click failed RunId - -> Run Replay + -> Run Story -> click failed ToolName -> Tools & MCP Risk -> click ModelActual diff --git a/docs/grafana-ux-spec.md b/docs/grafana-ux-spec.md index 6f87bff..0a00a7b 100644 --- a/docs/grafana-ux-spec.md +++ b/docs/grafana-ux-spec.md @@ -4,12 +4,12 @@ The V2 dashboards live in the `AgentOps for Azure` folder and are designed as a ## Dashboard Set -1. AgentOps Home -2. Runs Explorer -3. Agent Run Replay +1. Today +2. Runs +3. Run Story 4. Models, Cost & Tokens 5. Tools & MCP Risk -6. Safety, Privacy & Policy +6. Privacy 7. Code Outcomes 8. Evals & Quality 9. Insights & Regressions @@ -46,7 +46,7 @@ Every V2 dashboard uses: - Top strip answers “What happened?” - Tables answer “Why?” - Data links answer “What should I check next?” -- Runs Explorer exposes `OpenReplay`, `OpenTrace`, and `OpenGithub` action cells. +- Runs exposes `OpenReplay`, `OpenTrace`, and `OpenGithub` action cells. - Agent, skill, MCP server, and sub-agent cells drill into filtered dashboards. - Recurring pattern rows expose `OpenPattern` and preserve `$pattern_key` for Datadog/Lapdog-style triage. - Evals & Quality exposes an **Eval scorecard by repo, model, and task** table with overall and dimension-level averages plus poor/review counts. @@ -58,10 +58,10 @@ Every V2 dashboard uses: - Evals & Quality exposes a **Benchmark semantic checks** table with task ID, check ID, adapter, file, pass/fail state, score, and failure detail. It must avoid expected content strings, regex patterns, and judge commands. - Evals & Quality exposes a **Benchmark promotion approvals** table with approval status, required/observed approval counts, ticket, and review action. - Insights & Regressions exposes an **Eval regression queue** table that combines eval-related insight and recommendation rows. -- Run Replay exposes an **Ask AgentOps context** panel with a metadata-only prompt and `agentops triage` command. +- Run Story exposes an **Ask AgentOps context** panel with a metadata-only prompt and `agentops triage` command. - Empty states point to the smallest command that generates data. - Raw content never appears in dashboards by default. -- Prompt/response text appears only in the opt-in `AgentOpsContent_CL` Run Replay panel. The viewer must present a transcript-style `MessageText` column with role, turn, content kind, capture mode, redaction status, content hash, and content length so operators can read a conversation without losing privacy context. +- Prompt/response text appears only in the opt-in `AgentOpsContent_CL` Run Story panel. The viewer must present a transcript-style `MessageText` column with role, turn, content kind, capture mode, redaction status, content hash, and content length so operators can read a conversation without losing privacy context. - Dashboards and evals are evidence aids, not correctness, compliance, or security guarantees. They should guide investigation and review, not replace code review, threat modeling, approval workflows, or production change controls. ## Validation @@ -74,8 +74,8 @@ agentops dashboard verify agentops dashboard import ``` -`links-check` verifies V2 nav targets, Run Replay links, tool/model/repo drilldowns, and time-range preservation. -`ux-check` verifies the operator flow: Home top strip, Runs action cells, Run Replay story panels, Ask AgentOps context, transcript safety column order, tool risk correlation, Code Outcomes delivery timing, benchmark artifact diff/file review, hidden check review, policy review, semantic review, and promotion approval review. +`links-check` verifies V2 nav targets, Run Story links, tool/model/repo drilldowns, and time-range preservation. +`ux-check` verifies the operator flow: Today top strip, Runs action cells, Run Story panels, Ask AgentOps context, transcript safety column order, tool risk correlation, Code Outcomes delivery timing, benchmark artifact diff/file review, hidden check review, policy review, semantic review, and promotion approval review. `verify` runs the static dashboard gates together. Add `--live --last 24h` to include Azure KQL checks. `import` is a dry-run by default. Use `agentops dashboard import --yes --resource-group <rg> --grafana-name <name>` to import the V2 pack into the `AgentOps for Azure` folder. diff --git a/docs/junior-quickstart.md b/docs/junior-quickstart.md new file mode 100644 index 0000000..550fc9b --- /dev/null +++ b/docs/junior-quickstart.md @@ -0,0 +1,125 @@ +# AgentOps in plain English + +AgentOps gives GitHub Copilot a safe observability path. Copilot emits its own +native OpenTelemetry; a local OpenTelemetry Collector removes sensitive fields, +keeps a small private receipt, and can forward the safe metadata to Azure +Monitor. You run the normal `copilot` command. There is no required wrapper. + +## What you get + +- Run, model, tool, token, cost, timing, and error metadata. +- Azure Monitor **Agents (Preview)** for the useful “what happened?” view. +- A local receipt that proves what crossed the privacy boundary. +- No prompts, answers, code, file contents, tool arguments, or tool results by + default. + +## First value locally + +You need Node.js and an authenticated GitHub Copilot CLI. Azure is not needed +for this first check. + +```bash +cd /path/to/copilot-cli-agentops-azure +./setup-agentops.sh +export PATH="$HOME/.local/bin:$PATH" +eval "$(agentops init --local-only --yes --shell zsh)" +agentops smoke --local --real-copilot --no-verify --json +agentops open latest --json +``` + +What success looks like: + +- `ok: true` from the smoke command. +- A local receipt with a Copilot session ID, model, token counts, tool names, + and `content_capture_mode: "off"`. +- No prompt or response text in the receipt. + +If the Collector is missing, run: + +```bash +agentops collector install-binary +agentops smoke --local --real-copilot --no-verify --json +``` + +## First value in Azure + +The checked-in dev pilot is already onboarded as an Azure Application Insights +resource with native OTLP support. To use another environment, repeat the +first-party onboarding described in [the Azure preview runbook](azure-native-otlp-preview.md). + +1. Sign in and select the approved subscription: + + ```bash + az login + az account set --subscription <approved-subscription-id> + export AGENTOPS_AZURE_SUBSCRIPTION_ID=<approved-subscription-id> + export AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS=<approved-subscription-id> + ``` + +2. Run the read-only readiness check. It discovers the exact Application + Insights resource, DCR, OTLP endpoints, feature state, and ingestion role: + + ```bash + ./scripts/azure-native-otlp-readiness.sh + ``` + +3. Load the verified endpoint values into your current shell. This avoids + copying long URLs by hand: + + ```bash + eval "$(./scripts/azure-native-otlp-env.sh)" + export AGENTOPS_APPROVE_NATIVE_OTLP=yes + ``` + +4. Validate, start, and test the native Collector: + + ```bash + agentops collector validate --mode azure-native --privacy strict --json + agentops collector start --mode azure-native --privacy strict --json + agentops smoke --json + ``` + + The cloud smoke may take a couple of minutes to become query-visible. The + default cloud wait is five minutes and the command succeeds only when Azure + returns at least one matching OTel span row. + +5. Run a real plain Copilot task: + + ```bash + agentops smoke --real-copilot --no-verify --json + copilot -p "Do not edit files. Run pwd and ls docs | head, then summarize." + agentops open latest --json + ``` + + Open the printed Application Insights Agents link. The pilot should show an + agent run, model, tool call, and token totals. `--no-verify` on the real task + avoids confusing a successful Copilot run with a separate ingestion wait; + use `agentops smoke --json` when you want the query-backed gate. + +## Privacy rule of thumb + +Keep content capture off. `--no-remote-export` controls Copilot session export; +it is not the same thing as OpenTelemetry content capture. AgentOps therefore +keeps both controls explicit and labels the session-export setting as verified +only when the invocation actually includes the flag. + +## If something fails + +- Collector will not start: run `agentops collector status --json` and then + `agentops collector validate --mode azure-native --privacy strict --json`. +- Azure readiness fails: check the subscription, resource group, feature state, + and DCR role shown by the command. Do not hand-edit endpoint URLs. +- Smoke says “sent but not found”: wait for Azure ingestion and rerun + `agentops smoke --json`; a local `2xx` only means the Collector accepted the + event. +- Agents is empty: confirm the time range is **Last 24 hours**, refresh the + workbook, and check that the OTel span query already returns a row. + +## Stop the local Collector + +```bash +agentops collector stop --mode auto --json +``` + +The local receipt and queue live under `~/.agentops`. They are local-only +runtime artifacts and are not part of the repository. diff --git a/docs/llm-map.md b/docs/llm-map.md index 41a5415..9ff818d 100644 --- a/docs/llm-map.md +++ b/docs/llm-map.md @@ -162,7 +162,7 @@ Next good targets: ```text Home -> Runs Explorer - -> Run Replay + -> Run Story -> Tools & MCP Risk -> Models, Cost & Tokens -> Safety, Privacy & Policy diff --git a/docs/observability-product-patterns-roadmap.md b/docs/observability-product-patterns-roadmap.md index d031e47..26c669f 100644 --- a/docs/observability-product-patterns-roadmap.md +++ b/docs/observability-product-patterns-roadmap.md @@ -58,7 +58,7 @@ Implementation surface: - `agentops-cli/src/index.js` - `agentops-cli/test/index.test.js` - `README.md` -- `docs/testing-and-next-steps.md` +- `docs/public-release.md` ### 3. Shareable Deep Links @@ -200,7 +200,7 @@ Implementation surface: - `agentops-cli/src/index.js` - `infra/bicep/alerts.bicep` -- `docs/testing-and-next-steps.md` +- `docs/public-release.md` ### 9. AgentOps Assistant With Explicit Page Context @@ -222,7 +222,7 @@ Implementation surface: - `plugin/agents/telemetry-investigator.agent.md` - `plugin/agents/agent-optimizer.agent.md` -- `docs/testing-and-next-steps.md` +- `docs/public-release.md` - `scripts/build-grafana-dashboard-pack.js` ### 10. Funnel Analysis For Agent Workflows diff --git a/docs/privacy-modes.md b/docs/privacy-modes.md index 89c3348..4960b9e 100644 --- a/docs/privacy-modes.md +++ b/docs/privacy-modes.md @@ -22,7 +22,7 @@ The poison smoke test injects synthetic `SECRET_*` fields and checks that strict ## Prompt And Response Viewer -Run Replay includes a prompt/response viewer, but strict mode leaves it empty by design. It only renders rows from `AgentOpsContent_CL`, which must be explicitly produced and explicitly allowed for ingestion. +Run Story includes a prompt/response viewer, but strict mode leaves it empty by design. It only renders rows from `AgentOpsContent_CL`, which must be explicitly produced and explicitly allowed for ingestion. Check the current state with: @@ -54,4 +54,4 @@ The strict collector contract is represented as source-controlled fragments unde - `mcp-normalizer.yaml` - `span-to-run-summary.yaml` -Poison fixtures live under `collector/tests/privacy-poison-fixtures/`. `agentops collector validate` includes an artifact check so missing processor fragments or leaking fixtures fail validation before export. +Poison fixtures live under `collector/security-fixtures/privacy-poison-fixtures/`. `agentops collector validate` includes an artifact check so missing processor fragments or leaking fixtures fail validation before export. diff --git a/docs/privacy-threat-model-v2.md b/docs/privacy-threat-model-v2.md index 56e9cac..444f9c4 100644 --- a/docs/privacy-threat-model-v2.md +++ b/docs/privacy-threat-model-v2.md @@ -29,7 +29,7 @@ The local OpenTelemetry Collector is the scrub-before-export boundary. If the co ## Explicit Content Opt-In -The Run Replay dashboard can read `AgentOpsContent_CL` when an operator intentionally enables content capture. This table is outside the strict default path. +The Run Story dashboard can read `AgentOpsContent_CL` when an operator intentionally enables content capture. This table is outside the strict default path. Rules: diff --git a/docs/public-release.md b/docs/public-release.md index b37dd92..ced547c 100644 --- a/docs/public-release.md +++ b/docs/public-release.md @@ -78,11 +78,18 @@ Users should set their own environment values: ```bash export AZURE_SUBSCRIPTION_ID="<subscription-id>" +export AGENTOPS_AZURE_SUBSCRIPTION_ID="<subscription-id>" +export AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS="<subscription-id>" export AZURE_RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" export AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID="<workspace-id>" +export AGENTOPS_AZURE_AGENTS_URL="<azure-monitor-agents-view-url>" export AGENTOPS_GRAFANA_BASE_URL="https://<your-grafana>.grafana.azure.com" ``` +The Azure Monitor Agents view is the primary investigation surface when +`AGENTOPS_AZURE_AGENTS_URL` is configured. Managed Grafana remains an optional +advanced pack for fleet, privacy, outcome, and cross-run dashboards. + Do not commit `.env` files, connection strings, Grafana tokens, or raw telemetry exports. ## Release Position diff --git a/docs/release-checklist-v2.md b/docs/release-checklist-v2.md index 6a217cf..e9d8a01 100644 --- a/docs/release-checklist-v2.md +++ b/docs/release-checklist-v2.md @@ -10,6 +10,7 @@ npm --prefix agentops-cli run publish:check -- --json npm --prefix packages/agentops-copilot-sdk run publish:check -- --json node scripts/check-release-distribution.js --json node scripts/check-install-smoke.js --json +node scripts/check-packaged-lifecycle.js --json node scripts/check-homebrew-formula.js --json node agentops-cli/src/index.js security audit --json node agentops-cli/src/index.js security posture --json @@ -32,7 +33,7 @@ node agentops-cli/src/index.js demo generate --runs 10 --with-content --out .age node agentops-cli/src/index.js content status --dir .agentops/demo/content-preview --allow-content --json node agentops-cli/src/index.js content opt-in --json node agentops-cli/src/index.js azure-ingest plan --dir .agentops/demo/content-preview --allow-content --json -node agentops-cli/src/index.js run-summary generate --file tests/sample-otel/tool-failure.jsonl --json +node agentops-cli/src/index.js run-summary generate --file fixtures/sample-otel/tool-failure.ndjson.fixture --json node agentops-cli/src/index.js insights generate --runs .agentops/demo/latest/AgentOpsRunSummary_CL.jsonl --tools .agentops/demo/latest/AgentOpsToolCalls_CL.jsonl --privacy .agentops/demo/latest/AgentOpsPrivacy_CL.jsonl --github .agentops/demo/latest/AgentOpsGithubOutcomes_CL.jsonl --json node agentops-cli/src/index.js insights patterns --insights .agentops/insights/latest/AgentOpsInsights_CL.jsonl --json node agentops-cli/src/index.js explain latest --runs .agentops/demo/latest/AgentOpsRunSummary_CL.jsonl --evals .agentops/insights/latest/AgentOpsEval_CL.jsonl --insights .agentops/insights/latest/AgentOpsInsights_CL.jsonl @@ -46,6 +47,7 @@ npm --prefix packages/agentops-copilot-sdk test npm --prefix packages/agentops-copilot-sdk run publish:check -- --json node scripts/check-release-distribution.js --json node scripts/check-install-smoke.js --json +node scripts/check-packaged-lifecycle.js --json node scripts/check-homebrew-formula.js --json node --test --test-name-pattern github agentops-cli/test/index.test.js node --test --test-name-pattern mcp-proxy agentops-cli/test/index.test.js diff --git a/docs/release-distribution.md b/docs/release-distribution.md index 431876b..fb3d9ba 100644 --- a/docs/release-distribution.md +++ b/docs/release-distribution.md @@ -14,6 +14,7 @@ Run this before creating a GitHub release: ```bash node scripts/check-release-distribution.js --json node scripts/check-install-smoke.js --json +node scripts/check-packaged-lifecycle.js --json node scripts/check-homebrew-formula.js --json ``` @@ -23,14 +24,27 @@ The check: - runs the Copilot SDK publish-readiness check; - builds npm `.tgz` artifacts for both packages; - computes a SHA256 checksum for each artifact; +- creates a CycloneDX 1.5 SBOM for each package; +- writes a release manifest containing package/SBOM hashes, the source revision, dirty-worktree state, and `publish_authorized: false`; - verifies this release documentation is present. +A bundle built from a dirty worktree is review-only. Rebuild from a clean, reviewed commit before publishing; generating this evidence never authorizes publication. + Then the install smoke: - installs the packed CLI into a clean temporary npm prefix; - runs the installed `agentops` command, not the repo checkout; - verifies `doctor`, dashboard verification, security audit, collector artifact validation, and plugin dry-run install. +Then the POSIX packaged-lifecycle gate: + +- uses disposable AgentOps, Copilot, npm-prefix, and command paths without changing the user's home or configuration; +- preserves a pre-existing `copilot` command byte-for-byte while installing the transparent shadow; +- runs normal `copilot` through the packed CLI with strict metadata-only lifecycle receipts and a prompt-poison persistence check; +- exercises a same-code metadata-version upgrade and downgrade before uninstalling; +- proves uninstall restores the original command and leaves no AgentOps interception; +- reports Windows PowerShell, Linux distribution, WSL, and container clean-machine lanes as unproven until those environments run their native gates. + Then the Homebrew formula check: - renders `homebrew/Formula/copilot-agentops-cli.rb.template`; @@ -58,6 +72,7 @@ Verification - npm --prefix packages/agentops-copilot-sdk run publish:check -- --json - node scripts/check-release-distribution.js --json - node scripts/check-install-smoke.js --json +- node scripts/check-packaged-lifecycle.js --json - node scripts/check-homebrew-formula.js --json - node agentops-cli/src/index.js collector smoke --privacy strict --poison --json ``` @@ -79,6 +94,7 @@ Before publishing or updating a formula: - run `node scripts/check-release-distribution.js --json`; - run `node scripts/check-install-smoke.js --json`; +- run `node scripts/check-packaged-lifecycle.js --json`; - run `node scripts/check-homebrew-formula.js --json`; - verify the formula SHA256 matches the generated CLI artifact SHA256; - install into a clean temp prefix; diff --git a/docs/screenshots/agentops-alert-tuning-live.png b/docs/screenshots/agentops-alert-tuning-live.png deleted file mode 100644 index 53a2c9a..0000000 Binary files a/docs/screenshots/agentops-alert-tuning-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-attribution-live.png b/docs/screenshots/agentops-attribution-live.png deleted file mode 100644 index 615de45..0000000 Binary files a/docs/screenshots/agentops-attribution-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-custom-eval-widget-live.png b/docs/screenshots/agentops-custom-eval-widget-live.png deleted file mode 100644 index e6354a8..0000000 Binary files a/docs/screenshots/agentops-custom-eval-widget-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-data-quality-live.png b/docs/screenshots/agentops-data-quality-live.png deleted file mode 100644 index 8414549..0000000 Binary files a/docs/screenshots/agentops-data-quality-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-experiments-live.png b/docs/screenshots/agentops-experiments-live.png deleted file mode 100644 index 24c339c..0000000 Binary files a/docs/screenshots/agentops-experiments-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-live-replay-live.jpg b/docs/screenshots/agentops-live-replay-live.jpg deleted file mode 100644 index d52cb2d..0000000 Binary files a/docs/screenshots/agentops-live-replay-live.jpg and /dev/null differ diff --git a/docs/screenshots/agentops-overview-live.png b/docs/screenshots/agentops-overview-live.png deleted file mode 100644 index 00a0972..0000000 Binary files a/docs/screenshots/agentops-overview-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-permission-friction-live.png b/docs/screenshots/agentops-permission-friction-live.png deleted file mode 100644 index 874658a..0000000 Binary files a/docs/screenshots/agentops-permission-friction-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-quality-live.png b/docs/screenshots/agentops-quality-live.png deleted file mode 100644 index 57e74e6..0000000 Binary files a/docs/screenshots/agentops-quality-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-runtime-live.png b/docs/screenshots/agentops-runtime-live.png deleted file mode 100644 index 5c62b70..0000000 Binary files a/docs/screenshots/agentops-runtime-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-safety-policy-live.png b/docs/screenshots/agentops-safety-policy-live.png deleted file mode 100644 index b2c4685..0000000 Binary files a/docs/screenshots/agentops-safety-policy-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-session-detail-live.png b/docs/screenshots/agentops-session-detail-live.png deleted file mode 100644 index ccc415f..0000000 Binary files a/docs/screenshots/agentops-session-detail-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-sessions-live.png b/docs/screenshots/agentops-sessions-live.png deleted file mode 100644 index 760caf4..0000000 Binary files a/docs/screenshots/agentops-sessions-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-tools-live.png b/docs/screenshots/agentops-tools-live.png deleted file mode 100644 index 3e2ba98..0000000 Binary files a/docs/screenshots/agentops-tools-live.png and /dev/null differ diff --git a/docs/screenshots/agentops-traces-live.png b/docs/screenshots/agentops-traces-live.png deleted file mode 100644 index ec7e5b2..0000000 Binary files a/docs/screenshots/agentops-traces-live.png and /dev/null differ diff --git a/docs/simplified-azure-design.md b/docs/simplified-azure-design.md new file mode 100644 index 0000000..30f15b9 --- /dev/null +++ b/docs/simplified-azure-design.md @@ -0,0 +1,118 @@ +# Simplified Azure-native design + +Status: target architecture for the next AgentOps developer-preview version. +The local implementation is built and locally validated; the configured Azure +target still needs to be re-established before this is a live-pilot claim. + +## The short version + +AgentOps should be a small privacy and evidence layer around services Azure +already provides: + +```text +Copilot CLI / SDK / VS Code + MCP + -> AgentOps wrapper, SDK adapter, or MCP proxy + -> localhost OpenTelemetry Collector + strict metadata allowlist + content and secret scrubbing + bounded private queue + -> Azure Monitor / Application Insights + standard OTLP agent telemetry + Log Analytics custom tables for AgentOps receipts and outcomes + -> primary: Application Insights Agents view + -> optional advanced: Workbooks or Azure Managed Grafana +``` + +The primary user should not need to know KQL, Grafana, DCRs, or collector +configuration. They run Copilot normally, see a safe receipt, and follow the +native Azure investigation link. Grafana remains useful for fleet trends, +privacy posture, custom outcomes, and operator workflows, but it is not a +first-run dependency. + +Microsoft documents the Application Insights Agents view as the unified place +to inspect agent executions, token usage, costs, errors, tools, and traces: +[Monitor AI agents with Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/agents-view). + +## Minimum Azure footprint + +The default path needs only the Azure resources that back the native experience +and the metadata-only receipt contract: + +| Service | Role | Required for first value | +| --- | --- | --- | +| Application Insights | Native agent telemetry and Agents view | yes | +| Log Analytics workspace | Application Insights backing store and KQL evidence | yes | +| Data Collection Endpoint + Data Collection Rule | Authenticated Logs Ingestion for durable AgentOps custom-table receipts | only when receipt ingestion is enabled | +| Azure Managed Grafana | Advanced dashboards and fleet views | no | +| Compute, database, hosted agent service, Key Vault | Not part of the default design | no | + +The local collector remains the privacy boundary. Azure receives only the +allowlisted metadata contract by default: identifiers, timing, model and token +metadata, tool names and statuses, outcome counters, hashes, and privacy +signals. Prompts, responses, source code, file contents, tool arguments, +results, and secrets stay local unless a user explicitly opts into the +separate content path. + +The Bicep entrypoint follows this split: the default deployment creates only +Log Analytics and Application Insights. `deployAdvancedServices=true` is an +explicit opt-in for Azure Monitor Workspace, Managed Grafana, and Key Vault; +the post-provision Grafana hook exits cleanly when that option is disabled. + +The guarded recovery entrypoint is `scripts/azure-minimal-deploy.sh`. It +requires both `AGENTOPS_APPROVE_AZURE_CHANGES=yes` and +`AGENTOPS_CONFIRM_MINIMAL_DEPLOY=yes`, performs a what-if before deployment, +and never enables the advanced services by default. + +## First-value flow + +```text +1. agentops setup + read-only prerequisite and target check +2. agentops init --full + preview only +3. agentops init --full --yes + execute the reviewed local/cloud stages +4. agentops smoke --real-copilot --open-browser + safe observed run and metadata-only receipt +5. agentops open latest + native Azure Agents view first; Run Story/Grafana fallback and advanced links +``` + +Every step reports its evidence boundary. A configured URL or workspace ID is +not treated as proof that the resource exists. `agentops setup` and +`agentops validate-azure` check the configured resource group before calling a +binding ready, and they never silently switch to another group. + +## Product states + +```text +local-only + local privacy and demo evidence work; no Azure claim + +configured + identifiers are saved, but the Azure target is not yet verified + +cloud-verified + subscription, resource group, resources, schema, and query access pass + +native-ready + cloud-verified plus a user-approved Application Insights Agents URL + +advanced-ready + native-ready plus optional Managed Grafana import and rendered browser proof +``` + +Only `cloud-verified`, `native-ready`, and `advanced-ready` support live Azure +claims. Local tests, generated demo rows, static dashboard checks, and a URL in +config do not promote the product between these states. + +## Current boundary + +The approved Visual Studio Enterprise subscription is available, but the +configured `rg-copilot-agentops-dev` resource group is absent from the current +subscription listing. The existing workspace, DCR, DCE, Application Insights, +and Grafana identifiers are therefore historical configuration, not current +deployment proof. No Azure write or silent redirect is part of this change. + +The next external gate is explicit: confirm the intended resource group, then +re-establish or provision the minimal Azure footprint and rerun live validation. diff --git a/docs/telemetry-schema.md b/docs/telemetry-schema.md index a326bf9..09a6fbf 100644 --- a/docs/telemetry-schema.md +++ b/docs/telemetry-schema.md @@ -189,7 +189,7 @@ The notification sidecar hook appends metadata-only hook rows to `.agentops/side `tests/fixtures/copilot-hooks/*.json` covers the documented Copilot hook stdin variants used by the bundled hook scripts, including camelCase fields and VS Code-compatible snake_case fields. These fixtures are compatibility guards; a live Copilot hook smoke should still be used before making hook warnings blocking. -`tests/sample-otel/copilot-cli-wrapper-snapshot.jsonl` is the contract fixture for native Copilot CLI OTel rows enriched by the wrapper. `agentops run-summary generate` must preserve the wrapper run/session IDs, model, token counts, tool call, MCP attribution, and strict privacy posture from that fixture. `agentops-cli/src/lib/copilot/fixture-contract.js` keeps those expectations explicit for CI and `agentops product audit`. +`fixtures/sample-otel/copilot-cli-wrapper-snapshot.ndjson.fixture` is the contract fixture for native Copilot CLI OTel rows enriched by the wrapper. `agentops run-summary generate` must preserve the wrapper run/session IDs, model, token counts, tool call, MCP attribution, and strict privacy posture from that fixture. `agentops-cli/src/lib/copilot/fixture-contract.js` keeps those expectations explicit for CI and `agentops product audit`. When the wrapper sees `--agent <name>`, it emits `agentops.agent.name`. If a matching local agent file is present under `COPILOT_HOME/agents`, `.copilot/agents`, or `agents`, it also emits the basename and content hash. Skills, hooks, scripts, and exact MCP server/tool attribution are supported when spans or sidecar events provide `agentops.skill.*`, `agentops.script.*`, `agentops.hook.*`, or `agentops.mcp.*`. Native Copilot CLI runs may instead expose loaded skills in `github.copilot.context.skills`; dashboards surface that as context rather than charging every loaded skill for session cost. MCP server attribution is inferred from tool names such as `mcp__<server>__<tool>`, `<server>/<tool>`, and observed Azure MCP tool names such as `azure-mcp-monitor`. diff --git a/docs/testing-and-next-steps.md b/docs/testing-and-next-steps.md deleted file mode 100644 index cf91d1a..0000000 --- a/docs/testing-and-next-steps.md +++ /dev/null @@ -1,490 +0,0 @@ -# Testing and Next Steps - -## Current Validation Status - -Completed locally: - -```bash -npm --prefix agentops-cli test -node agentops-cli/src/index.js doctor --local-only -node agentops-cli/src/index.js doctor --json -az bicep build --file infra/bicep/main.bicep --stdout >/tmp/agentops-main-arm.json -node agentops-cli/src/index.js collector validate --mode auto --privacy strict --json -``` - -All of the above pass. - -For a compact machine-readable setup/UI contract, run: - -```bash -node agentops-cli/src/index.js health --json -``` - -Azure deployment checklist: - -- Core telemetry stack can be deployed with the Bicep/AZD files in this repo. -- Managed Grafana user access and data-source RBAC must be configured for your subscription. -- Application Insights synthetic ingestion should pass before real Copilot telemetry testing. -- Collector-backed real Copilot CLI telemetry should pass through `copilot-observe`. -- Grafana dashboard imports should show run, token, AIU, latency, model, failure, content-capture, compaction/truncation, policy-block, and session-lifecycle panels. -- Proposal-only Azure Monitor scheduled query rules should stay disabled until thresholds are tuned. - -## Local Collector Smoke Test - -The collector smoke test uses the local Collector binary by default. Docker/OrbStack is optional. - -### Option A: Local Collector Binary - -Install and validate the tested OpenTelemetry Collector Contrib binary: - -```bash -node agentops-cli/src/index.js collector install-binary -node agentops-cli/src/index.js collector validate --mode binary --privacy strict -node agentops-cli/src/index.js collector smoke --privacy strict --poison -``` - -### Option B: Docker/OrbStack - -Start Docker Desktop or OrbStack, then run: - -```bash -node agentops-cli/src/index.js collector start --mode docker --privacy strict -node agentops-cli/src/index.js smoke --dry-run -node agentops-cli/src/index.js experimental collector-health --last 24h -``` - -Stop the collector with: - -```bash -node agentops-cli/src/index.js collector stop --mode docker -``` - -## Azure Monitor Collector Smoke Test - -Binary mode is the default tested path for collector-backed Azure export. Start the Azure Monitor collector with the deployed Application Insights connection string retrieved at runtime: - -```bash -node agentops-cli/src/index.js collector start --mode auto --privacy strict -``` - -Send a privacy-safe OTLP trace through the local collector: - -```bash -node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s --open-browser -./scripts/otlp-smoke-trace.sh -``` - -The CLI smoke command sends a synthetic client span and polls Log Analytics for the same `smokeId`. The shell script is still available for low-level collector testing. The Azure Monitor exporter maps the synthetic span into `AppDependencies`: - -```bash -az monitor log-analytics query \ - --workspace "$AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID" \ - --analytics-query "AppDependencies | where TimeGenerated > ago(2h) | where Properties has '<smokeId>' or Name has '<smokeId>' | project TimeGenerated, Name, Properties | order by TimeGenerated desc | take 20" -``` - -Stop the Azure Monitor collector after testing: - -```bash -node agentops-cli/src/index.js collector stop --mode auto -``` - -The collector helper retrieves the Application Insights connection string at runtime and does not write it to the repository. - -## Copilot CLI Wrapper Smoke Test - -After the collector is running: - -```bash -source copilot/env.sample.sh -copilot-observe --help -``` - -For a real telemetry run, execute a small Copilot CLI task through `copilot-observe`. Keep content capture disabled. - -```bash -node agentops-cli/src/index.js collector start --mode auto --privacy strict -node agentops-cli/src/index.js copilot -p "Reply with exactly: agentops real telemetry smoke." -node agentops-cli/src/index.js collector stop --mode auto -``` - -## Always-On Copilot Collection - -For daily use, install the AgentOps shim and use `copilot-agentops` instead of starting the collector manually: - -```bash -node agentops-cli/src/index.js install --shadow-copilot -export PATH="$HOME/.local/bin:$PATH" -copilot --help -``` - -PowerShell: - -```powershell -./scripts/install-copilot-agentops-shim.ps1 -$env:PATH = "$HOME/.local/bin;$env:PATH" -copilot-agentops --help -``` - -The shim checks whether the Azure Monitor collector is already running. If it is not, it retrieves the Application Insights connection string at runtime, starts the collector, and then launches Copilot CLI through `copilot-observe` with content capture disabled. - -If the Azure Monitor collector cannot start because the configured Azure resources are missing or unavailable, the shim fails closed unless `AGENTOPS_ALLOW_UNOBSERVED_FALLBACK=1` is set. The CLI wrapper writes metadata-only lifecycle rows to `.agentops/wrapper-events.jsonl`, including `agentops.run.start`, `agentops.run.end`, `agentops.collector.start_failed`, and `agentops.wrapper.fallback_unobserved`. - -After a successful wrapped run, `agentops copilot` prints an `AgentOps Run Replay` link scoped to the wrapper run and session IDs. Set `AGENTOPS_PRINT_RUN_LINK=false` to suppress the link. - -The wrapper preserves user-supplied OpenTelemetry settings where possible. It sets safe defaults for `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`, `COPILOT_OTEL_SOURCE_NAME`, and `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`, then prepends AgentOps metadata to existing `OTEL_RESOURCE_ATTRIBUTES` instead of replacing them. - -## Native Copilot OTel Without The Wrapper - -The installed `agentops` command and shim are optional for telemetry ingestion. VS Code Copilot Chat, Copilot CLI, and Copilot SDK apps can send OTLP directly to the AgentOps collector. - -Generate copyable setup snippets: - -```bash -node agentops-cli/src/index.js otel-setup -node agentops-cli/src/index.js otel-setup --shell powershell -``` - -Minimum VS Code settings: - -```json -{ - "github.copilot.chat.otel.enabled": true, - "github.copilot.chat.otel.exporterType": "otlp-http", - "github.copilot.chat.otel.otlpEndpoint": "http://127.0.0.1:4318", - "github.copilot.chat.otel.captureContent": false -} -``` - -Minimum Copilot CLI environment: - -```bash -export OTEL_EXPORTER_OTLP_ENDPOINT='http://127.0.0.1:4318' -export COPILOT_OTEL_ENABLED='true' -export COPILOT_OTEL_EXPORTER_TYPE='otlp-http' -export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT='false' -``` - -After running a Copilot interaction, check whether the incoming data has the fields used by dashboards and evals: - -```bash -node agentops-cli/src/index.js compat-check --last 2h -``` - -Use `./scripts/collector-azuremonitor-up.sh` to start the collector without installing the CLI. Use `kql/22-otel-compatibility.kql` for the compatibility check directly in Log Analytics when you do not want to run any CLI helper. A `ready` result means the stack found operation, session, model, and token fields. The query also reports matching Copilot/GenAI metrics and events from `AppMetrics`, `AppTraces`, and `AppEvents`. A `partial` result means ingestion works, but some dashboards or anti-cheat/eval rollups may be limited. - -To bind this to the normal `copilot` command, install the shadow shim: - -```bash -./scripts/install-copilot-agentops-shim.sh --shadow-copilot -export PATH="$HOME/.local/bin:$PATH" -copilot --help -``` - -PowerShell: - -```powershell -./scripts/install-copilot-agentops-shim.ps1 -ShadowCopilot -$env:PATH = "$HOME/.local/bin;$env:PATH" -copilot --help -``` - -The shadow shim stores the real Copilot CLI path in `COPILOT_CLI_BIN` before routing through AgentOps, which avoids recursive calls. Stop the collector when you want collection off: - -```bash -node agentops-cli/src/index.js collector stop --mode auto -``` - -Query recent real Copilot CLI spans: - -```bash -az monitor log-analytics query \ - --workspace "$AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID" \ - --analytics-query "AppDependencies | where TimeGenerated > ago(2h) | where (Properties has 'github.copilot' or Properties has 'gen_ai.operation.name' or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli')) | project TimeGenerated, Name, AppRoleName, Properties | order by TimeGenerated desc | take 20" -``` - -Summarize recent operational posture: - -```bash -az monitor log-analytics query \ - --workspace "$AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID" \ - --analytics-query "AppDependencies | where TimeGenerated > ago(2h) | where (Properties has 'github.copilot' or Properties has 'gen_ai.operation.name' or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli')) | summarize Spans=count(), Runs=countif(tostring(Properties['gen_ai.operation.name']) == 'invoke_agent'), InputTokens=sum(todouble(Properties['gen_ai.usage.input_tokens'])), OutputTokens=sum(todouble(Properties['gen_ai.usage.output_tokens'])), AIU=sum(todouble(Properties['github.copilot.aiu'])), Cost=sum(todouble(Properties['github.copilot.cost'])), P95DurationMs=percentile(DurationMs, 95)" -``` - -## Local Live And Replay Checks - -Use the live and replay commands when you want immediate local session visibility without prompt, response, tool argument, or file-content capture: - -```bash -node agentops-cli/src/index.js configure show -node agentops-cli/src/index.js init --dry-run -node agentops-cli/src/index.js init --full -node agentops-cli/src/index.js init --import-dashboards -node agentops-cli/src/index.js init --run-smoke -node agentops-cli/src/index.js init --triage-latest -node agentops-cli/src/index.js validate-azure -node agentops-cli/src/index.js smoke --wait 2m --poll 10s -node agentops-cli/src/index.js live --last 2h -node agentops-cli/src/index.js replay latest --last 2h -node agentops-cli/src/index.js lineage --last 24h -node agentops-cli/src/index.js primitives --last 7d -node agentops-cli/src/index.js attribution --last 7d -node agentops-cli/src/index.js recommend latest --last 2h -node agentops-cli/src/index.js ask-context latest --last 2h -``` - -Use the fixture path when Azure telemetry is unavailable: - -```bash -node agentops-cli/src/index.js live --file tests/sample-otel/tool-failure.jsonl -node agentops-cli/src/index.js replay latest --file tests/sample-otel/tool-failure.jsonl -node agentops-cli/src/index.js recommend latest --file tests/sample-otel/tool-failure.jsonl -node agentops-cli/src/index.js run-summary generate --file tests/sample-otel/copilot-cli-wrapper-snapshot.jsonl --json -``` - -Save repeat investigations locally: - -```bash -node agentops-cli/src/index.js saved-view add latest-risk --session <conversation-id> --tag risk -node agentops-cli/src/index.js saved-view list -node agentops-cli/src/index.js saved-view open latest-risk -node agentops-cli/src/index.js saved-view export --events .agentops/demo/latest/AgentOpsEvents_CL.jsonl --out .agentops/saved-views/latest -node agentops-cli/src/index.js azure-ingest upload-plan --dir .agentops/saved-views/latest --account <storage-account> --container agentops-shared -``` - -Saved views are stored outside the repo in `~/.agentops/views.json` unless `AGENTOPS_VIEWS_PATH` is set. The upload plan is preview-only: it validates metadata-only saved-view/recommendation exports and prints `az storage blob upload --auth-mode login` commands for a shared Blob container. - -## Permission Friction Checks - -Permission friction covers broad allow modes, policy blocks, denied or excluded tools, disabled MCP servers, tool failures, and recovery hints. - -```bash -node agentops-cli/src/index.js permission-friction --last 7d -node agentops-cli/src/index.js mcp --last 7d -node agentops-cli/src/index.js lineage --last 24h -node agentops-cli/src/index.js primitives --last 7d -``` - -The same signal is available in the Permission Friction Grafana dashboard after rebuilding and importing the dashboard pack: - -```bash -node scripts/build-grafana-dashboard-pack.js -``` - -## Alert Recommendation Check - -Keep deployed scheduled query rules disabled until thresholds are tuned. Use the recommendation command to inspect historical p95/p99 AIU, failure, tool-failure, and content-capture evidence before changing alert thresholds: - -```bash -node agentops-cli/src/index.js alert recommend --last 14d -node agentops-cli/src/index.js alert tune-plan --last 14d --owner agentops-oncall -node agentops-cli/src/index.js alert threshold-simulate --rule failed-spans --threshold 1 --owner agentops-oncall --last 14d -node agentops-cli/src/index.js alert threshold-patch --rule failed-spans --threshold 1 --owner agentops-oncall --last 14d -node agentops-cli/src/index.js alert resources --resource-group "${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" -node agentops-cli/src/index.js alert handoff --rule failed-spans --session <conversation-id> --owner agentops-oncall --events .agentops/demo/latest/AgentOpsEvents_CL.jsonl --last 24h -``` - -`alert tune-plan` is proposal-only and summarizes reviewable threshold changes with Bicep patch targets. `alert threshold-simulate` is preview-only and prints metadata-only KQL that compares current and proposed alert windows. `alert threshold-patch` is also preview-only and prints a concrete `infra/bicep/alerts.bicep` diff for owner-approved direct threshold changes. `alert resources` is read-only and summarizes current scheduled-query rule enabled/disabled state plus attached action groups. `alert handoff --events` adds session-matched config-change annotations and a session-scoped annotation query to the metadata-only operator packet. - -## Read-Only MCP Investigation Smoke Test - -Validate the MCP sample JSON before using it: - -```bash -node -e "JSON.parse(require('fs').readFileSync('plugin/.mcp.json', 'utf8')); JSON.parse(require('fs').readFileSync('copilot/mcp.azure-monitor.sample.json', 'utf8')); JSON.parse(require('fs').readFileSync('copilot/mcp.grafana.sample.json', 'utf8')); JSON.parse(require('fs').readFileSync('copilot/mcp.microsoft-learn.sample.json', 'utf8'))" -``` - -Use Azure MCP in read-only Azure Monitor scope: - -```bash -az login -copilot --additional-mcp-config @copilot/mcp.azure-monitor.sample.json --allow-tool='azure-mcp' -``` - -Use Microsoft Learn MCP for official Microsoft documentation lookup: - -```bash -copilot --additional-mcp-config @copilot/mcp.microsoft-learn.sample.json --allow-tool='microsoft-learn' -``` - -Use Codex with the same read-only Azure Monitor MCP server: - -```bash -az login -codex mcp add azure-mcp -- npx -y @azure/mcp@latest server start --read-only --namespace monitor -codex mcp list -``` - -Use Azure Managed Grafana MCP only after setting a token outside the repo: - -```bash -sed -n '1,80p' copilot/mcp.grafana.sample.json -export AZURE_GRAFANA_MCP_TOKEN="<set-outside-repo>" -copilot --additional-mcp-config @copilot/mcp.grafana.sample.json --allow-tool='agent-grafana' -``` - -Before running that command, replace `<grafana-endpoint>` in the sample with your Azure Managed Grafana host. - -Prompt templates for session investigation, tool failures, benchmark variant comparison, agent improvement, hook policy tuning, and MCP/tool regression checks are in `docs/copilot-mcp-agentops-prompts.md`. - -## Real Agent, Skill, MCP, And Script Attribution Check - -After the collector and Azure checks pass, prefer a real observed Copilot run plus real custom lifecycle metadata: - -```bash -copilot plugin install c-mongan/copilot-cli-agentops-azure:plugin -agentops copilot --agent agentops-orchestrator --allow-tool=bash --add-dir . --no-ask-user --no-remote \ - -p "Do not edit files. Use read-only shell commands: pwd and ls docs | head. Summarize what you saw." -agentops custom emit --event agent.delegation.started --agent investigator --parent-agent agentops-orchestrator --delegation-id e2e-delegation --workflow investigation --step delegate --outcome started -agentops attribution --last 2h -agentops mcp --last 2h -agentops lineage --last 2h -``` - -The older `agentops attribution-smoke` command remains useful as a low-level collector/filter diagnostic, but do not use it as screenshot or product-demo data when real Copilot/custom telemetry is available. - -## Real Copilot CLI E2E Dashboard Check - -The May 27, 2026 E2E pass used GitHub Copilot CLI `1.0.55-3` with the AgentOps shadow shim installed. Official Copilot CLI docs confirm that `-p`, custom agents, MCP config, permissions, and native OTel export are supported surfaces, and VS Code docs confirm the related custom agent, MCP, and `AGENTS.md` customization paths. - -Commands used: - -```bash -copilot --agent agentops-kitchen-sink-smoke \ - --allow-all-tools --allow-all-paths --allow-all-urls --no-ask-user \ - --name agentops-e2e-kitchen-sink \ - -p "AgentOps E2E smoke. Do not edit files. Inspect this repo using read-only commands only..." - -copilot --allow-all-tools --allow-all-paths --allow-all-urls --no-ask-user \ - --name agentops-e2e-tool-failure \ - -p "Do not edit files. Run a harmless command that succeeds, then a harmless command that fails..." - -copilot --agent telemetry-investigator \ - --allow-all-tools --allow-all-paths --allow-all-urls --no-ask-user \ - --name agentops-e2e-telemetry-investigator \ - -p "Use read-only local commands and Azure MCP if available..." - -node agentops-cli/src/index.js attribution-smoke -node agentops-cli/src/index.js smoke -node agentops-cli/src/index.js benchmark run starter --variant e2e-docs --repeat 1 --hypothesis copilot-docs-e2e -``` - -Observed in Log Analytics for the last 24 hours: - -- 220 matching AgentOps/Copilot spans across 31 sessions. -- Operations: `invoke_agent`, `skill.invoke`, `execute_tool`, `hook.execute`, `plan`, `chat`, and `smoke_test`. -- Custom agents: `agentops-kitchen-sink-smoke` and `telemetry-investigator`. -- Tools: shell/read-only local tools plus Azure MCP monitor/subscription/resource-group tools. -- MCP server: `azure-mcp`. -- Hook/script: `pre-tool-policy`. -- Benchmark run: `bench-20260527071414-40ba63ac`. -- Hypothesis: `copilot-docs-e2e`. -- Intentional tool failures: 7, including KQL syntax failures generated during MCP query testing. -- Content capture signals: 0. - -Grafana checks: - -- Overview, Sessions, Session Detail, Traces / Spans, Tools & MCP, Attribution, Runtime Events, Data Quality, Safety & Policy, Permission Friction, Alert Tuning, and Quality all rendered from live Azure telemetry without query errors. -- Session Detail showed data when opened with a concrete conversation id. -- Experiments opened populated without query errors or `No data`, showing the benchmark row for Suite=`starter`, Task=`create-note`, Variant=`e2e-docs`, Run=`bench-20260527071414-40ba63ac`, Hypothesis=`copilot-docs-e2e`. -- Dropdown variables populated and concrete selections worked. Operational dashboards keep **All** for normal triage; benchmark variables avoid **All** so sparse experiment pages do not open blank. - -## Alert Rule Validation - -The v0.2 alert rules are deployed but disabled. Verify them with: - -```bash -for name in \ - sqr-copilot-agentops-dev-high-aiu \ - sqr-copilot-agentops-dev-content-capture \ - sqr-copilot-agentops-dev-failures; do - az resource show \ - --resource-group "${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" \ - --resource-type Microsoft.Insights/scheduledQueryRules \ - --name "$name" \ - --query "{name:name,enabled:properties.enabled,severity:properties.severity,description:properties.description}" \ - -o json -done -``` - -Keep `enableAlerts=false` until thresholds are tuned against more real sessions. - -## Azure Validation Path - -Set these values for your subscription before running Azure scripts: - -```bash -export AZURE_SUBSCRIPTION_ID="<subscription-id>" -export AZURE_RESOURCE_GROUP="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" -export AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID="<workspace-id>" -export AGENTOPS_GRAFANA_BASE_URL="https://<your-grafana>.grafana.azure.com" -``` - -The default Bicep resource names are generated from `environmentName` and `baseName`. - -Read-only readiness check: - -```bash -./scripts/azure-readiness.sh -``` - -Provisioning prerequisites are already complete. The guarded prerequisite script remains available for rebuilding from a fresh subscription/resource group: - -```bash -AGENTOPS_APPROVE_AZURE_CHANGES=yes ./scripts/azure-prereqs.sh -``` - -Run a what-if before future infrastructure changes: - -```bash -./scripts/azure-what-if.sh -``` - -Only after reviewing what-if should future provisioning run. - -## Azure Ingestion Smoke Test - -Send a privacy-safe synthetic event to the deployed Application Insights resource: - -```bash -./scripts/azure-smoke-appinsights.sh -``` - -The script prints a `smokeId`. Query it in Application Insights: - -```bash -az monitor app-insights query \ - --resource-group "${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" \ - --app "${APPLICATIONINSIGHTS_NAME:-appi-agentops-dev}" \ - --analytics-query "customEvents | where name == 'AgentOpsSmokeTest' | where customDimensions.smokeId == '<smokeId>' | project timestamp, name, customDimensions" -``` - -Or query the linked Log Analytics workspace: - -```bash -az monitor log-analytics query \ - --workspace "$AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID" \ - --analytics-query "AppEvents | where Name == 'AgentOpsSmokeTest' | where Properties has '<smokeId>' | project TimeGenerated, Name, Properties" -``` - -The script retrieves the Application Insights connection string at runtime and does not write it to the repository. - -## What Azure Details Are Safe to Provide - -Safe to provide here: - -- Subscription name or ID -- Tenant ID if needed -- Target region -- Resource group name -- Environment name such as `dev`, `test`, or `prod` -- Whether provider registration and resource-group creation are approved - -Do not provide secrets here: - -- Passwords -- Client secrets -- Tokens -- Grafana service account tokens -- Key Vault secret values - -If a command prompts for a secret, type it directly into the terminal yourself. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b93eb4f..a20709d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,7 +8,7 @@ 4. Run `node agentops-cli/src/index.js collector validate --mode auto --privacy strict`. 5. Confirm `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`. 6. Confirm `COPILOT_OTEL_ENABLED=true`. -7. Run `node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s --open-browser` to verify Azure ingestion and open the latest Run Replay link. +7. Run `node agentops-cli/src/index.js smoke --real-copilot --wait 2m --poll 10s --open-browser` to verify Azure ingestion and open the latest Run Story link. ## Content Capture Detected diff --git a/docs/usability-study-evidence.schema.json b/docs/usability-study-evidence.schema.json new file mode 100644 index 0000000..3181da6 --- /dev/null +++ b/docs/usability-study-evidence.schema.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentops.example/schemas/usability-study-evidence.schema.json", + "title": "AgentOps usability study evidence", + "description": "One metadata-only record from one real, consenting participant session. This schema is not participant evidence by itself.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "evidence_kind", + "human_participant", + "consent_confirmed", + "participant_ref", + "study_session_id", + "started_at", + "ended_at", + "environment", + "tasks", + "privacy_comprehension", + "critical_failures" + ], + "properties": { + "schema_version": { "const": "1.0" }, + "evidence_kind": { "const": "observed_human_usability_session" }, + "human_participant": { "const": true }, + "consent_confirmed": { "const": true }, + "participant_ref": { "type": "string", "pattern": "^participant-[a-f0-9]{8,64}$" }, + "study_session_id": { "type": "string", "pattern": "^study-[A-Za-z0-9_-]{8,80}$" }, + "started_at": { "type": "string", "format": "date-time" }, + "ended_at": { "type": "string", "format": "date-time" }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": ["os_family", "install_route", "copilot_surface", "privacy_mode", "content_capture", "assistive_technology"], + "properties": { + "os_family": { "enum": ["macos", "windows", "linux", "wsl"] }, + "install_route": { "enum": ["package", "source_checkout", "managed_internal"] }, + "copilot_surface": { "enum": ["interactive_cli", "headless_cli"] }, + "privacy_mode": { "const": "strict" }, + "content_capture": { "const": "off" }, + "assistive_technology": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["none", "keyboard_only", "screen_reader", "screen_magnifier", "voice_control", "other_declared"] } + } + } + }, + "tasks": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "prefixItems": [ + { "$ref": "#/$defs/previewSetupTask" }, + { "$ref": "#/$defs/applySetupTask" }, + { "$ref": "#/$defs/observedRunTask" }, + { "$ref": "#/$defs/todayTask" }, + { "$ref": "#/$defs/runsTask" }, + { "$ref": "#/$defs/runStoryTask" }, + { "$ref": "#/$defs/privacyTask" } + ], + "items": false + }, + "privacy_comprehension": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "prefixItems": [ + { "$ref": "#/$defs/promptsAnswer" }, + { "$ref": "#/$defs/codeFilesAnswer" }, + { "$ref": "#/$defs/toolPayloadsAnswer" }, + { "$ref": "#/$defs/strictMetadataAnswer" }, + { "$ref": "#/$defs/agentopsScopeAnswer" }, + { "$ref": "#/$defs/privacyWarningActionAnswer" } + ], + "items": false + }, + "sus_style": { + "type": "object", + "additionalProperties": false, + "required": ["completed", "answers"], + "properties": { + "completed": { "type": "boolean" }, + "answers": { + "type": "array", + "maxItems": 10, + "items": { "type": "integer", "minimum": 1, "maximum": 5 } + }, + "score": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "missing_items": { + "type": "array", + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1, "maximum": 10 } + } + }, + "allOf": [ + { + "if": { "properties": { "completed": { "const": true } } }, + "then": { + "required": ["score", "missing_items"], + "properties": { + "answers": { "minItems": 10, "maxItems": 10 }, + "missing_items": { "maxItems": 0 } + } + }, + "else": { + "properties": { + "answers": { "maxItems": 0 }, + "score": { "type": "null" } + } + } + } + ] + }, + "critical_failures": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "unexpected_subscription_apply", + "unintended_transparent_routing", + "secret_or_content_exposure", + "false_setup_success", + "privacy_scope_misunderstood", + "wrong_run_not_detected" + ] + } + }, + "issue_refs": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$" } + } + }, + "$defs": { + "task": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "surface", "started_at", "ended_at", "elapsed_ms", "outcome", "help_requests", "recovered_without_help", "error_categories"], + "properties": { + "task_id": { "enum": ["preview_setup", "apply_setup", "observed_run", "today", "runs", "run_story", "privacy"] }, + "surface": { "enum": ["terminal", "today", "runs", "run_story", "privacy"] }, + "started_at": { "type": "string", "format": "date-time" }, + "ended_at": { "type": "string", "format": "date-time" }, + "elapsed_ms": { "type": "integer", "minimum": 0 }, + "outcome": { "enum": ["success", "partial", "failed", "not_attempted"] }, + "help_requests": { "type": "integer", "minimum": 0 }, + "recovered_without_help": { "type": "boolean" }, + "error_categories": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "could_not_find_start", + "command_unclear", + "subscription_unclear", + "routing_unclear", + "setup_error", + "receipt_not_found", + "navigation_error", + "filter_error", + "wrong_run", + "event_order_misread", + "unknown_treated_as_zero", + "inference_treated_as_fact", + "privacy_scope_unclear", + "accessibility_barrier", + "timeout", + "other_coded" + ] + } + } + } + }, + "previewSetupTask": { "allOf": [{ "$ref": "#/$defs/task" }, { "properties": { "task_id": { "const": "preview_setup" }, "surface": { "const": "terminal" } } }] }, + "applySetupTask": { "allOf": [{ "$ref": "#/$defs/task" }, { "properties": { "task_id": { "const": "apply_setup" }, "surface": { "const": "terminal" } } }] }, + "observedRunTask": { "allOf": [{ "$ref": "#/$defs/task" }, { "properties": { "task_id": { "const": "observed_run" }, "surface": { "const": "terminal" } } }] }, + "todayTask": { "allOf": [{ "$ref": "#/$defs/task" }, { "properties": { "task_id": { "const": "today" }, "surface": { "const": "today" } } }] }, + "runsTask": { "allOf": [{ "$ref": "#/$defs/task" }, { "properties": { "task_id": { "const": "runs" }, "surface": { "const": "runs" } } }] }, + "runStoryTask": { "allOf": [{ "$ref": "#/$defs/task" }, { "properties": { "task_id": { "const": "run_story" }, "surface": { "const": "run_story" } } }] }, + "privacyTask": { "allOf": [{ "$ref": "#/$defs/task" }, { "properties": { "task_id": { "const": "privacy" }, "surface": { "const": "privacy" } } }] }, + "privacyAnswer": { + "type": "object", + "additionalProperties": false, + "required": ["question_id", "result"], + "properties": { + "question_id": { "enum": ["prompts", "code_files", "tool_payloads", "strict_metadata", "agentops_scope", "privacy_warning_action"] }, + "result": { "enum": ["correct", "incorrect", "not_answered"] } + } + }, + "promptsAnswer": { "allOf": [{ "$ref": "#/$defs/privacyAnswer" }, { "properties": { "question_id": { "const": "prompts" } } }] }, + "codeFilesAnswer": { "allOf": [{ "$ref": "#/$defs/privacyAnswer" }, { "properties": { "question_id": { "const": "code_files" } } }] }, + "toolPayloadsAnswer": { "allOf": [{ "$ref": "#/$defs/privacyAnswer" }, { "properties": { "question_id": { "const": "tool_payloads" } } }] }, + "strictMetadataAnswer": { "allOf": [{ "$ref": "#/$defs/privacyAnswer" }, { "properties": { "question_id": { "const": "strict_metadata" } } }] }, + "agentopsScopeAnswer": { "allOf": [{ "$ref": "#/$defs/privacyAnswer" }, { "properties": { "question_id": { "const": "agentops_scope" } } }] }, + "privacyWarningActionAnswer": { "allOf": [{ "$ref": "#/$defs/privacyAnswer" }, { "properties": { "question_id": { "const": "privacy_warning_action" } } }] } + } +} diff --git a/docs/usability-study-kit.md b/docs/usability-study-kit.md new file mode 100644 index 0000000..f1b5847 --- /dev/null +++ b/docs/usability-study-kit.md @@ -0,0 +1,243 @@ +# AgentOps usability study kit + +Status: study protocol only. No participant results are included. Passing automated tests does not prove human usability. + +## What this study answers + +Can a new user: + +1. set up AgentOps without changing plain `copilot` by accident; +2. run one observed Copilot task and find its receipt; +3. use Today, Runs, Run Story, and Privacy without knowing Grafana or KQL; +4. explain what AgentOps records and what it does not record? + +Use 5–10 participants for a pilot. Include developers, platform operators, and at least one keyboard-only user. A screen-reader study is a separate required accessibility activity; do not infer it from this study. + +## Safety and preparation + +- Use a non-production test project and the approved development subscription. +- Use synthetic files and a harmless prompt. Do not use customer data, secrets, source code, or personal data. +- Keep content capture off and privacy mode strict. +- Tell the participant what will be recorded before starting. +- Record task timings, task outcomes, help requests, error categories, and questionnaire answers only. Do not record prompts, terminal output, code, tool arguments, tool results, screen video, voice, names, or email addresses in the evidence JSON. +- Use a random participant reference such as `participant-a1b2c3d4`. Keep any consent record outside this repository. +- Check that `agentops init --full` targets the intended development subscription before allowing apply. + +Required setup: + +- AgentOps package ready to install through the supported route. +- GitHub Copilot CLI installed and signed in. +- Azure CLI signed in to the approved development subscription when Azure setup is part of the session. +- A synthetic repository containing one harmless task, for example creating `agentops-study-note.txt` with the text `study complete`. +- Today, Runs, Run Story, and Privacy links available from the test deployment. +- A stopwatch or timestamp capture tool. + +## Facilitator opening script + +Read this without adding product instructions: + +> We are testing AgentOps, not you. Please work as you normally would and say what you expect before each action. I will record timings, whether each task worked, help requests, and broad error categories. I will not record your prompt, code, terminal output, tool inputs or results. You may stop at any time. Please do not use real secrets or customer data. + +Ask for consent to continue. Record only `consent_confirmed: true`; do not put consent documents or identity data in the study evidence. + +## Tasks + +Start the timer when the facilitator finishes reading a task. Stop it when the success condition is met or the time limit is reached. Do not help unless the participant asks or safety requires it. Record each help request. + +### Task 1: Preview setup + +Say: + +> Set up AgentOps, but preview all changes before applying them. Tell me whether plain `copilot` will change. + +Expected starting point: the README or installed command help. + +Canonical command: + +```text +agentops init --full +``` + +Success criteria, within 5 minutes: + +- reaches the preview without applying changes; +- identifies the expected and active Azure subscription; +- correctly says that plain `copilot` remains unchanged unless transparent routing is explicitly enabled; +- can name the next command that would apply the setup. + +Critical failure: applies setup to an unexpected subscription or enables transparent routing unintentionally. + +### Task 2: Apply setup + +Say: + +> Apply the reviewed setup and tell me whether it completed safely. + +Canonical command: + +```text +agentops init --full --yes +``` + +Success criteria, within 7 minutes: + +- applies only after reviewing the target; +- recognizes success or gives the exact recovery action shown by the product; +- does not expose a token, connection string, prompt, or source content. + +Critical failure: continues after a subscription mismatch or reports success when setup failed. + +### Task 3: Run an observed Copilot task + +Say: + +> Ask Copilot to complete the harmless study task with AgentOps observing it. Then show me the receipt. + +Canonical command shape: + +```text +agentops copilot -p "<synthetic study prompt>" +``` + +Success criteria, within 5 minutes: + +- chooses `agentops copilot`, not plain `copilot`, for an observed run; +- finds the receipt; +- can identify status, elapsed time, token information when available, privacy mode, and the Run Story action; +- does not mistake an unavailable field for zero. + +### Task 4: Answer “what happened today?” + +Say: + +> Open Today and tell me whether the observed run succeeded, how long it took, and the most useful next action. + +Success criteria, within 3 minutes: + +- opens Today without needing Grafana or KQL knowledge; +- finds the correct run status and duration; +- describes one evidence-backed next action; +- does not present estimated cost or inferred outcomes as exact facts. + +### Task 5: Find a run + +Say: + +> Use Runs to find the study run and open its story. + +Success criteria, within 2 minutes: + +- finds the correct run using time, status, agent, repository, or run identifier; +- opens the matching Run Story; +- does not open a different run without noticing. + +### Task 6: Explain the Run Story + +Say: + +> Tell me, in order, what the agent did. Point out any custom agent, subagent, skill, model, MCP server, tool, script, or CLI activity that is present. Then tell me what is unknown. + +Success criteria, within 5 minutes: + +- follows the event order correctly; +- distinguishes observed items from absent or unavailable items; +- finds timing, token, status, and parent/child information when present; +- does not claim that an activity occurred merely because the UI supports that activity type. + +### Task 7: Check privacy + +Say: + +> Use Privacy to decide whether AgentOps recorded the prompt, code, file contents, tool arguments, or tool results for this run. + +Success criteria, within 3 minutes: + +- opens Privacy for the correct run; +- correctly reports strict mode and content capture off; +- understands that AgentOps metadata-only proof does not make claims about data stored independently by GitHub Copilot or another tool; +- can identify a privacy drop or schema warning if the study fixture includes one. + +Critical failure: believes strict mode proves that every connected product stores no content. + +## Timing and error capture + +For every task record: + +- start and end timestamps in UTC; +- elapsed milliseconds; +- outcome: `success`, `partial`, `failed`, or `not_attempted`; +- number of help requests; +- whether the participant recovered without help; +- zero or more error categories from the evidence schema; +- the product surface used. + +Do not write a transcript. If context is needed, choose a fixed observation code. Product defects belong in the normal issue tracker, linked by an opaque issue reference. + +Study-level pilot targets: + +- at least 80% unassisted success for Tasks 1, 3, 5, and 7; +- median observed-run-to-receipt time under 5 minutes; +- median Runs-to-correct-Run-Story time under 2 minutes; +- zero critical safety or privacy-comprehension failures; +- at least 80% correct answers to every privacy comprehension question. + +These are proposed pilot thresholds, not current results. + +## Privacy comprehension questions + +Ask after Task 7. Do not coach until all answers are recorded. + +1. Does AgentOps record prompts by default? Expected: no. +2. Does AgentOps record code or file contents by default? Expected: no. +3. Does AgentOps record tool arguments or tool results by default? Expected: no. +4. What can AgentOps record in strict mode? Expected: metadata such as event type, safe names, order, timing, status, tokens, privacy decisions, and derived outcomes. +5. Does this screen prove that GitHub Copilot itself stored no content? Expected: no; the statement is scoped to AgentOps. +6. What should you do if Privacy shows a dropped or unknown field? Expected: inspect the warning and follow the safe review or support action before broader rollout. + +Record `correct`, `incorrect`, or `not_answered`; do not store the participant's verbatim answer. + +## Optional SUS-style questionnaire + +This is a ten-item usability pulse using the standard 1–5 agreement pattern. It is optional and must be labelled “SUS-style” unless the study uses the licensed/approved standard wording and scoring process required by the organization. + +Scale: 1 strongly disagree, 2 disagree, 3 neutral, 4 agree, 5 strongly agree. + +1. I would like to use AgentOps frequently. +2. AgentOps felt unnecessarily complex. +3. AgentOps was easy to use. +4. I would need help from a technical expert to use AgentOps. +5. The main parts of AgentOps worked well together. +6. AgentOps felt inconsistent. +7. Most intended users would learn AgentOps quickly. +8. AgentOps felt awkward to use. +9. I felt confident using AgentOps. +10. I needed to learn a lot before I could use AgentOps. + +If a SUS-style score is calculated, use the usual alternating contribution: odd items contribute `answer - 1`; even items contribute `5 - answer`; multiply the sum by 2.5. Report response count and missing items with the score. Do not compare a very small pilot as if it were a representative benchmark. + +## Closing questions + +Ask, but store only fixed categories and optional issue references: + +- Where did you first feel unsure? +- Which screen gave you the most useful answer? +- What did you expect to find but could not? +- Was any privacy statement unclear or too broad? +- What is the one change that would save you the most time? + +## Evidence and reporting + +Store one JSON file per completed participant session using [the study evidence schema](usability-study-evidence.schema.json). Validate it before aggregation. + +Always report these separately: + +- protocol prepared; +- automated schema/contract validation; +- number of real consenting participants; +- participant environment and assistive technology coverage; +- per-task outcomes and timing; +- privacy comprehension; +- optional SUS-style responses; +- unresolved critical failures. + +Never turn a schema test, facilitator dry run, synthetic browser test, or researcher walkthrough into a claim of human usability. diff --git a/tests/sample-otel/copilot-cli-wrapper-snapshot.jsonl b/fixtures/sample-otel/copilot-cli-wrapper-snapshot.ndjson.fixture similarity index 100% rename from tests/sample-otel/copilot-cli-wrapper-snapshot.jsonl rename to fixtures/sample-otel/copilot-cli-wrapper-snapshot.ndjson.fixture diff --git a/tests/sample-otel/simple-success.jsonl b/fixtures/sample-otel/simple-success.ndjson.fixture similarity index 100% rename from tests/sample-otel/simple-success.jsonl rename to fixtures/sample-otel/simple-success.ndjson.fixture diff --git a/tests/sample-otel/tool-failure.jsonl b/fixtures/sample-otel/tool-failure.ndjson.fixture similarity index 100% rename from tests/sample-otel/tool-failure.jsonl rename to fixtures/sample-otel/tool-failure.ndjson.fixture diff --git a/grafana/dashboards/v2/01-agentops-home.json b/grafana/dashboards/v2/01-agentops-home.json index 86d1c74..853feae 100644 --- a/grafana/dashboards/v2/01-agentops-home.json +++ b/grafana/dashboards/v2/01-agentops-home.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -121,7 +121,7 @@ }, "options": { "mode": "markdown", - "content": "## AgentOps Home\nCopilot AgentOps control room for Azure. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." + "content": "## Today\nThese runs are visible in Azure. If a recent run is missing, check `agentops delivery status`. Coverage is marked as AgentOps managed or Native best effort. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." } }, { @@ -136,7 +136,7 @@ }, "options": { "mode": "markdown", - "content": "### Open latest run\nStart with the newest session, then drill into Run Replay.\n\n`agentops open latest --last 2h --json`\n\n[Run Replay](/d/agentops-v2-run-replay?${__url_time_range})" + "content": "### Open latest run\nStart with the newest session, then drill into Run Story.\n\n`agentops open latest --last 2h --json`\n\n[Run Story](/d/agentops-v2-run-replay?${__url_time_range})" } }, { @@ -166,7 +166,7 @@ }, "options": { "mode": "markdown", - "content": "### Ask AgentOps\nBuild a metadata-only context bundle for investigation.\n\n`agentops ask-context latest --last 2h --json`\n\nExported evidence bundle:\n\n`agentops ask-context latest --last 2h --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --privacy <AgentOpsPrivacy_CL.jsonl> --github <AgentOpsGitHubOutcome_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl> --recommendations <AgentOpsRecommendations_CL.jsonl> --json`\n\nUse `docs/copilot-mcp-agentops-prompts.md` for session, tool failure, benchmark, agent, hook, and MCP regression templates.\n\n[Run Replay](/d/agentops-v2-run-replay?${__url_time_range})" + "content": "### Ask AgentOps\nBuild a metadata-only context bundle for investigation.\n\n`agentops ask-context latest --last 2h --json`\n\nExported evidence bundle:\n\n`agentops ask-context latest --last 2h --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --privacy <AgentOpsPrivacy_CL.jsonl> --github <AgentOpsGitHubOutcome_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl> --recommendations <AgentOpsRecommendations_CL.jsonl> --json`\n\nUse `docs/copilot-mcp-agentops-prompts.md` for session, tool failure, benchmark, agent, hook, and MCP regression templates.\n\n[Run Story](/d/agentops-v2-run-replay?${__url_time_range})" } }, { @@ -227,10 +227,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=count() by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=count() by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Number of runs visible for the selected time range and filters." }, { "id": 3, @@ -290,14 +291,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=100.0 * countif(OutcomeStatus == 'success') / count() by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=100.0 * countif(OutcomeStatus == 'success') / count() by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Percentage of visible runs reporting a successful outcome. The label and value carry the meaning; colour is supplementary." }, { "id": 4, - "title": "Failed runs", + "title": "Runs needing review", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -353,14 +355,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(OutcomeStatus != 'success') by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(OutcomeStatus != 'success') by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Visible runs reporting failed, cancelled, blocked, or unknown outcomes. Open Runs to see the reported outcome." }, { "id": 5, - "title": "Privacy drops", + "title": "Content items blocked", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -416,10 +419,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Content items AgentOps dropped under its privacy rules. A higher number can mean the privacy guard is actively protecting data, not that content was stored." }, { "id": 6, @@ -479,14 +483,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=sum(EstimatedCostUsd) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=sum(EstimatedCostUsd) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Estimated model cost for visible runs. This is an estimate, not an Azure invoice." }, { "id": 7, - "title": "Collector health", + "title": "Healthy collector checks", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -545,7 +550,8 @@ "query": "union isfuzzy=true (AgentOpsCollectorHealth_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | summarize LastSpanReceived=max(TimeGenerated), SpanRows=count(), ExportErrors=countif(Success == false or tostring(Success) =~ 'false') | extend TimeGenerated=now(), Component='appinsights-compat', CheckName='live-ingestion', Status=iff(SpanRows > 0, 'healthy', 'empty'), Detail=strcat('Existing Application Insights telemetry rows: ', tostring(SpanRows)), PrivacyMode='strict', CollectorMode='compat', OtlpEndpoint='application-insights', AzureConfigured=true, GrafanaConfigured=true, DashboardVersion='v2', SchemaVersion='2', DroppedContentCount=0, LastExportSuccess=LastSpanReceived | project TimeGenerated, Component, CheckName, Status, Detail, PrivacyMode, CollectorMode, OtlpEndpoint, AzureConfigured, GrafanaConfigured, DashboardVersion, SchemaVersion, LastSpanReceived, LastExportSuccess, ExportErrors, DroppedContentCount) | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | summarize value=countif(Status == 'healthy') by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Collector checks reporting healthy. Open Collector for unhealthy, empty, or missing checks." }, { "id": 8, @@ -605,10 +611,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, McpServer, ToolName, ModelActual, Status, DurationMs, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureSignal) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where EventType == 'policy' | summarize value=countif(Status == 'denied' or Status == 'blocked') by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), Delivery='Visible in Azure', Coverage='AgentOps managed', AttributionConfidence='exact'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | extend Sequence=long(null), EventId='', ParentEventId='', CommandName='', ScriptName='', McpToolName='', TotalTokens=InputTokens + OutputTokens + ReasoningTokens, PermissionKind='', PermissionDecision='', ContentCaptureMode=iff(ContentCaptureSignal, 'signal only', 'off'), ContentAction=iff(ContentCaptureSignal, 'dropped', ''), ContentDroppedBytes=long(null), SecretLike=false, Delivery='Visible in Azure', Coverage='Native best effort', AttributionConfidence='inferred' | project TimeGenerated, Sequence, EventId, ParentEventId, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, BranchDurationMs, BranchTokens, BranchToolCount, McpServer, McpToolName, ToolName, CommandName, ScriptName, ModelActual, Status, DurationMs, InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd, PermissionKind, PermissionDecision, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike, Delivery, Coverage, AttributionConfidence) | extend Sequence=tolong(column_ifexists('Sequence', long(null))), EventId=tostring(column_ifexists('EventId', '')), ParentEventId=tostring(column_ifexists('ParentEventId', '')) | extend EventType=tostring(column_ifexists('EventType', '')), CommandName=tostring(column_ifexists('CommandName', '')), ScriptName=tostring(column_ifexists('ScriptName', '')), McpToolName=tostring(column_ifexists('McpToolName', '')) | extend InputTokens=todouble(column_ifexists('InputTokens', real(null))), OutputTokens=todouble(column_ifexists('OutputTokens', real(null))), ReasoningTokens=todouble(column_ifexists('ReasoningTokens', real(null))), TotalTokens=todouble(column_ifexists('TotalTokens', real(null))), EstimatedCostUsd=todouble(column_ifexists('EstimatedCostUsd', real(null))) | extend PermissionKind=tostring(column_ifexists('PermissionKind', '')), PermissionDecision=tostring(column_ifexists('PermissionDecision', '')), PrivacyMode=tostring(column_ifexists('PrivacyMode', '')), ContentCaptureMode=tostring(column_ifexists('ContentCaptureMode', '')), ContentCaptureSignal=tobool(column_ifexists('ContentCaptureSignal', false)), ContentAction=tostring(column_ifexists('ContentAction', '')), ContentDroppedBytes=tolong(column_ifexists('ContentDroppedBytes', long(null))), SecretLike=tobool(column_ifexists('SecretLike', false)) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | extend EventType=case(isnotempty(EventType), EventType, EventName startswith 'agentops.run.' or EventName startswith 'agentops.wrapper.' or EventName startswith 'agentops.collector.', 'lifecycle', isnotempty(McpToolName) or isnotempty(McpServer) or EventName has 'mcp', 'mcp_tool', EventName == 'execute_tool' or EventName has 'tool' or isnotempty(ToolName), 'tool', EventName has 'skill' or isnotempty(SkillName), 'skill', EventName has 'subagent' or isnotempty(SubAgentName), 'subagent', EventName has 'agent', 'agent', isnotempty(CommandName), 'cli', isnotempty(ScriptName), 'script', EventName == 'chat' or isnotempty(ModelActual), 'llm', 'span') | extend AttributionGap=case(EventType in ('agent', 'subagent') and isempty(AgentName) and isempty(SubAgentName), 'agent identity missing', EventType == 'skill' and isempty(SkillName), 'skill identity missing', EventType in ('tool', 'mcp_tool') and isempty(ToolName), 'tool identity missing', EventType == 'mcp_tool' and isempty(McpServer) and isempty(McpToolName), 'MCP attribution missing', EventType in ('command', 'cli') and isempty(CommandName), 'command identity missing', EventType == 'script' and isempty(ScriptName), 'script identity missing', EventType == 'llm' and isempty(ModelActual), 'model identity missing', '') | extend AttributionConfidence=case(isnotempty(AttributionGap), 'missing', isnotempty(EventId) and isnotnull(Sequence), 'exact', tostring(column_ifexists('AttributionConfidence', '')) == 'inferred', 'inferred', 'best effort') | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where EventType == 'policy' | summarize value=countif(Status == 'denied' or Status == 'blocked') by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Policy events explicitly reporting denied or blocked status." }, { "id": 9, @@ -668,10 +675,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=sum(InputTokens) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=sum(InputTokens) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Input tokens reported for visible runs." }, { "id": 14, @@ -731,10 +739,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=sum(OutputTokens) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=sum(OutputTokens) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Output tokens reported for visible runs." }, { "id": 15, @@ -794,10 +803,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=percentile(DurationMs, 95) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=percentile(DurationMs, 95) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "95th-percentile run duration. In plain language, about 95% of measured runs finished within this time." }, { "id": 16, @@ -857,10 +867,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=100.0 * countif(TestsRan == true) / count() by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=100.0 * countif(TestsRan == true) / count() by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Percentage of visible runs reporting that tests ran." }, { "id": 17, @@ -920,10 +931,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(PrOpened == true) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(PrOpened == true) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Visible runs reporting that a pull request was opened." }, { "id": 10, @@ -961,7 +973,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1267,7 +1279,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1387,10 +1399,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "let LatestRecommendations = union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize arg_max(TimeGenerated, Severity, Action, NextAction, PatternKey, BenchmarkRunId, BenchmarkDecision) by RunId; union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | join kind=leftouter LatestRecommendations on RunId | extend HealthStatus=case(OutcomeStatus != 'success', 'failed', RiskScore >= 60, 'high risk', RiskScore >= 20, 'review', ContentCaptureSignal == true, 'privacy review', 'healthy'), RootAgent=case(isnotempty(ParentAgentName), ParentAgentName, isnotempty(AgentName), AgentName, 'agent'), RecommendedNextAction=case(isnotempty(NextAction), NextAction, 'Open Run Replay and inspect the metadata timeline.'), OpenReplay='Replay' | project TimeGenerated, HealthStatus, RiskScore, RootAgent, ModelActual, ToolFailureCount, ToolDeniedCount, ContentCaptureSignal, ContextWindowPct, EvalOverall, BenchmarkRunId, BenchmarkDecision, RecommendedNextAction, RunId, SessionId, TraceId, OpenReplay | order by TimeGenerated desc | take 50" + "query": "let LatestRecommendations = union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize arg_max(TimeGenerated, Severity, Action, NextAction, PatternKey, BenchmarkRunId, BenchmarkDecision) by RunId; union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | join kind=leftouter LatestRecommendations on RunId | extend HealthStatus=case(OutcomeStatus != 'success', 'failed', RiskScore >= 60, 'high risk', RiskScore >= 20, 'review', ContentCaptureSignal == true, 'privacy review', 'healthy'), RootAgent=case(isnotempty(ParentAgentName), ParentAgentName, isnotempty(AgentName), AgentName, 'agent'), RecommendedNextAction=case(isnotempty(NextAction), NextAction, 'Open Run Story and inspect the metadata timeline.'), OpenReplay='Replay' | project TimeGenerated, Delivery, Coverage, HealthStatus, RiskScore, RootAgent, ModelActual, ToolFailureCount, ToolDeniedCount, ContentCaptureSignal, ContextWindowPct, EvalOverall, BenchmarkRunId, BenchmarkDecision, RecommendedNextAction, RunId, SessionId, TraceId, OpenReplay | order by TimeGenerated desc | take 50" } } - ] + ], + "description": "Newest run evidence first. Delivery and coverage are shown before health so missing or best-effort evidence is not mistaken for a complete run." }, { "id": 11, @@ -1428,7 +1441,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1734,7 +1747,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1872,10 +1885,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, DashboardCount, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by TimeGenerated desc | take 50" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, DashboardCount, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by TimeGenerated desc | take 50" } } - ] + ], + "description": "Evidence-backed follow-up suggestions. An empty table means no recommendation rows matched the current filters." }, { "id": 12, @@ -1913,7 +1927,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2219,7 +2233,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2339,10 +2353,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, RepoHash, TaskType, ModelActual, OutcomeStatus, EstimatedCostUsd, InputTokens, OutputTokens | order by EstimatedCostUsd desc | take 50" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, RepoHash, TaskType, ModelActual, OutcomeStatus, EstimatedCostUsd, InputTokens, OutputTokens | order by EstimatedCostUsd desc | take 50" } } - ] + ], + "description": "Highest estimated-cost runs first. An empty table means no matching run cost was reported." }, { "id": 13, @@ -2380,7 +2395,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2686,7 +2701,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2806,10 +2821,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | project TimeGenerated, RunId, RepoHash, PrOpened, PrMerged, PrReverted, CiStatus, TimeToPrMinutes, TimeToMergeMinutes, ReviewCommentCount, FilesChangedCount | order by TimeGenerated desc | take 50" + "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | project TimeGenerated, RunId, RepoHash, PrOpened, PrMerged, PrReverted, CiStatus, TimeToPrMinutes, TimeToMergeMinutes, ReviewCommentCount, FilesChangedCount | order by TimeGenerated desc | take 50" } } - ] + ], + "description": "Newest reported pull request and CI outcomes first." }, { "id": 21, @@ -2847,7 +2863,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -3153,7 +3169,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -3294,7 +3310,8 @@ "query": "union isfuzzy=true (AgentOpsSavedViews_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (print TimeGenerated=now(), SavedViewId='', Name='', Description='', Url='', QueryHash='', Tags=dynamic([]), SessionId='', CreatedAt='', ChangeAnnotations=dynamic([]), ChangeAnnotationCount=long(null), ChangeTargetRefs=dynamic([]) | where false) | extend TagsText=strcat_array(Tags, ', '), ChangeAnnotationCount=coalesce(ChangeAnnotationCount, array_length(ChangeAnnotations)), OpenSavedView=iff(isnotempty(Url), 'Open', ''), OpenReplay=iff(isnotempty(SessionId), 'Replay', ''), AskAgentOpsSharedLaunch=iff(isnotempty(SavedViewId), strcat('$actioner_url', '/ask-agentops/shared/saved-view/', url_encode(SavedViewId), '?session_id=', url_encode(SessionId), '&dashboard_url=', url_encode(Url), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(SavedViewId), 'Ask shared', '') | project TimeGenerated, SavedViewId, Name, Description, TagsText, SessionId, QueryHash, ChangeAnnotationCount, ChangeTargetRefs, CreatedAt, Url, AskSharedContext, AskAgentOpsSharedLaunch, OpenSavedView, OpenReplay | order by TimeGenerated desc | take 100" } } - ] + ], + "description": "Saved evidence views, newest first. Links preserve the selected time range where supported." } ], "refresh": "1m", @@ -3311,6 +3328,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -3322,6 +3340,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -3332,7 +3351,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -3344,6 +3364,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -3354,7 +3375,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3365,7 +3387,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3376,7 +3399,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3388,6 +3412,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -3398,7 +3423,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -3410,6 +3436,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3421,6 +3448,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -3431,7 +3459,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -3442,7 +3471,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3453,7 +3483,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3465,6 +3496,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3475,7 +3507,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -3486,7 +3519,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3497,7 +3531,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -3508,7 +3543,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3519,7 +3555,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 0, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -3530,7 +3567,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 0, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -3541,7 +3579,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, @@ -3557,7 +3596,7 @@ }, "timepicker": {}, "timezone": "browser", - "title": "AgentOps Home", + "title": "Today", "uid": "agentops-v2-home", "version": 1, "weekStart": "" diff --git a/grafana/dashboards/v2/02-runs-explorer.json b/grafana/dashboards/v2/02-runs-explorer.json index a48ce01..08cda01 100644 --- a/grafana/dashboards/v2/02-runs-explorer.json +++ b/grafana/dashboards/v2/02-runs-explorer.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -121,7 +121,7 @@ }, "options": { "mode": "markdown", - "content": "## Runs Explorer\nDatadog-style run list. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." + "content": "## Runs\nFind a Copilot run and open its story or outcome evidence. Results are newest first; use the linked Run, Session, or Trace value to continue without relying on colour. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." } }, { @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -586,14 +586,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend OpenReplay='Replay', OpenTrace='Trace', OpenGithub=iff(PrOpened == true or isnotempty(PrNumberHash) or CiStatus != 'not_run', 'Outcome', '') | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, CacheReadTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, OpenReplay, OpenTrace, OpenGithub | order by TimeGenerated desc | take 500" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend OpenReplay='Replay', OpenTrace='Trace', OpenGithub=iff(PrOpened == true or isnotempty(PrNumberHash) or CiStatus != 'not_run', 'Outcome', '') | project TimeGenerated, Delivery, Coverage, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, CacheReadTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, OpenReplay, OpenTrace, OpenGithub | order by TimeGenerated desc | take 500" } } - ] + ], + "description": "Newest runs first. Delivery and coverage appear before identity, outcome, cost, and action links. An empty table means no runs matched the current time range and filters." }, { "id": 20, - "title": "Runs by outcome", + "title": "Runs by reported outcome", "type": "timeseries", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -638,14 +639,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count() by TimeGenerated=bin(TimeGenerated, $__interval), OutcomeStatus | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count() by TimeGenerated=bin(TimeGenerated, $__interval), OutcomeStatus | order by TimeGenerated asc" } } - ] + ], + "description": "Run count over time, split by the written outcome label. Series labels and the legend carry meaning; colour is supplementary." }, { "id": 21, - "title": "Cost and tokens", + "title": "Token use", "type": "timeseries", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -690,10 +692,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Cost=sum(EstimatedCostUsd), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens) by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens) by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc" } } - ] + ], + "description": "Reported input and output tokens over time. Cost remains a separate column in the Runs table so unlike units are not plotted on one axis." } ], "refresh": "1m", @@ -710,6 +713,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -721,6 +725,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -731,7 +736,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -743,6 +749,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -753,7 +760,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -764,7 +772,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -775,7 +784,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -787,6 +797,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -797,7 +808,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -809,6 +821,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -820,6 +833,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -830,7 +844,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -841,7 +856,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -852,7 +868,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -864,6 +881,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -874,7 +892,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -885,7 +904,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -896,7 +916,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -907,7 +928,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -918,7 +940,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 2, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -929,7 +952,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 0, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -940,7 +964,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, @@ -956,7 +981,7 @@ }, "timepicker": {}, "timezone": "browser", - "title": "Runs Explorer", + "title": "Runs", "uid": "agentops-v2-runs-explorer", "version": 1, "weekStart": "" diff --git a/grafana/dashboards/v2/03-run-replay.json b/grafana/dashboards/v2/03-run-replay.json index 2a42a99..c30dd99 100644 --- a/grafana/dashboards/v2/03-run-replay.json +++ b/grafana/dashboards/v2/03-run-replay.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -111,7 +111,7 @@ "panels": [ { "id": 1, - "title": "Replay", + "title": "Run Story", "type": "text", "gridPos": { "h": 2, @@ -121,7 +121,7 @@ }, "options": { "mode": "markdown", - "content": "## Agent Run Replay\nTimeline of one Copilot run. Strict mode shows metadata only; prompt/response rows appear only when AgentOpsContent_CL is explicitly enabled. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." + "content": "## Run Story\nChoose a Run, Session, or Trace filter to isolate one Copilot story; with all three set to All, panels can mix matching runs. Events are ordered oldest first, with sequence and event ID breaking timestamp ties. Strict mode shows metadata only; prompt/response rows appear only when AgentOpsContent_CL is explicitly enabled. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." } }, { @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -586,14 +586,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, EstimatedCostUsd, ContextWindowPct, CacheReadTokens, TokensRemoved, PermissionWaitMs, TestsRan, TestsPassed, PrOpened, CiStatus, EvalOverall, RiskScore | order by TimeGenerated desc | take 20" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, Delivery, Coverage, RunId, SessionId, TraceId, Surface, RepoHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, EstimatedCostUsd, ContextWindowPct, CacheReadTokens, TokensRemoved, PermissionWaitMs, TestsRan, TestsPassed, PrOpened, CiStatus, EvalOverall, RiskScore | order by TimeGenerated desc | take 20" } } - ] + ], + "description": "Newest matching run summary first. Choose a Run, Session, or Trace filter to isolate one story." }, { "id": 20, - "title": "Replay timeline", + "title": "Ordered timeline", "type": "table", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -627,7 +628,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -933,7 +934,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1053,10 +1054,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, McpServer, ToolName, ModelActual, Status, DurationMs, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureSignal) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | project TimeGenerated, RunId, SessionId, TraceId, SpanId, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, McpServer, ToolName, ModelActual, Status, DurationMs, ErrorType, OutcomeStatus, Details | order by TimeGenerated asc | take 1000" + "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), Delivery='Visible in Azure', Coverage='AgentOps managed', AttributionConfidence='exact'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | extend Sequence=long(null), EventId='', ParentEventId='', CommandName='', ScriptName='', McpToolName='', TotalTokens=InputTokens + OutputTokens + ReasoningTokens, PermissionKind='', PermissionDecision='', ContentCaptureMode=iff(ContentCaptureSignal, 'signal only', 'off'), ContentAction=iff(ContentCaptureSignal, 'dropped', ''), ContentDroppedBytes=long(null), SecretLike=false, Delivery='Visible in Azure', Coverage='Native best effort', AttributionConfidence='inferred' | project TimeGenerated, Sequence, EventId, ParentEventId, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, BranchDurationMs, BranchTokens, BranchToolCount, McpServer, McpToolName, ToolName, CommandName, ScriptName, ModelActual, Status, DurationMs, InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd, PermissionKind, PermissionDecision, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike, Delivery, Coverage, AttributionConfidence) | extend Sequence=tolong(column_ifexists('Sequence', long(null))), EventId=tostring(column_ifexists('EventId', '')), ParentEventId=tostring(column_ifexists('ParentEventId', '')) | extend EventType=tostring(column_ifexists('EventType', '')), CommandName=tostring(column_ifexists('CommandName', '')), ScriptName=tostring(column_ifexists('ScriptName', '')), McpToolName=tostring(column_ifexists('McpToolName', '')) | extend InputTokens=todouble(column_ifexists('InputTokens', real(null))), OutputTokens=todouble(column_ifexists('OutputTokens', real(null))), ReasoningTokens=todouble(column_ifexists('ReasoningTokens', real(null))), TotalTokens=todouble(column_ifexists('TotalTokens', real(null))), EstimatedCostUsd=todouble(column_ifexists('EstimatedCostUsd', real(null))) | extend PermissionKind=tostring(column_ifexists('PermissionKind', '')), PermissionDecision=tostring(column_ifexists('PermissionDecision', '')), PrivacyMode=tostring(column_ifexists('PrivacyMode', '')), ContentCaptureMode=tostring(column_ifexists('ContentCaptureMode', '')), ContentCaptureSignal=tobool(column_ifexists('ContentCaptureSignal', false)), ContentAction=tostring(column_ifexists('ContentAction', '')), ContentDroppedBytes=tolong(column_ifexists('ContentDroppedBytes', long(null))), SecretLike=tobool(column_ifexists('SecretLike', false)) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | extend EventType=case(isnotempty(EventType), EventType, EventName startswith 'agentops.run.' or EventName startswith 'agentops.wrapper.' or EventName startswith 'agentops.collector.', 'lifecycle', isnotempty(McpToolName) or isnotempty(McpServer) or EventName has 'mcp', 'mcp_tool', EventName == 'execute_tool' or EventName has 'tool' or isnotempty(ToolName), 'tool', EventName has 'skill' or isnotempty(SkillName), 'skill', EventName has 'subagent' or isnotempty(SubAgentName), 'subagent', EventName has 'agent', 'agent', isnotempty(CommandName), 'cli', isnotempty(ScriptName), 'script', EventName == 'chat' or isnotempty(ModelActual), 'llm', 'span') | extend AttributionGap=case(EventType in ('agent', 'subagent') and isempty(AgentName) and isempty(SubAgentName), 'agent identity missing', EventType == 'skill' and isempty(SkillName), 'skill identity missing', EventType in ('tool', 'mcp_tool') and isempty(ToolName), 'tool identity missing', EventType == 'mcp_tool' and isempty(McpServer) and isempty(McpToolName), 'MCP attribution missing', EventType in ('command', 'cli') and isempty(CommandName), 'command identity missing', EventType == 'script' and isempty(ScriptName), 'script identity missing', EventType == 'llm' and isempty(ModelActual), 'model identity missing', '') | extend AttributionConfidence=case(isnotempty(AttributionGap), 'missing', isnotempty(EventId) and isnotnull(Sequence), 'exact', tostring(column_ifexists('AttributionConfidence', '')) == 'inferred', 'inferred', 'best effort') | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | extend SequenceSort=coalesce(Sequence, long(9223372036854775807)) | project TimeGenerated, Sequence, EventId, ParentEventId, Delivery, Coverage, AttributionConfidence, AttributionGap, EventName, EventType, AgentName, ParentAgentName, SubAgentName, SkillName, DelegationId, McpServer, McpToolName, ToolName, CommandName, ScriptName, ModelActual, Status, DurationMs, InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd, PermissionKind, PermissionDecision, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike, ErrorType, OutcomeStatus, RunId, SessionId, TraceId, SpanId, Details, SequenceSort | order by TimeGenerated asc, SequenceSort asc, EventId asc | project-away SequenceSort | take 1000" } } - ] + ], + "description": "Oldest matching event first, with timestamp ties ordered by sequence and event ID. Written status, attribution, privacy, and permission fields carry meaning; colour is not required." }, { "id": 23, @@ -1094,7 +1096,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1400,7 +1402,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1520,10 +1522,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, McpServer, ToolName, ModelActual, Status, DurationMs, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureSignal) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | extend Actor=case(isnotempty(SubAgentName), SubAgentName, isnotempty(AgentName), AgentName, 'agent'), Parent=iff(isempty(ParentAgentName), 'root', ParentAgentName), Skill=iff(isempty(SkillName), 'none', SkillName), Mcp=iff(isempty(McpServer), 'none', McpServer), Tool=iff(isempty(ToolName), 'none', ToolName) | summarize Events=count(), Tools=dcountif(Tool, Tool != 'none'), Failures=countif(Status != 'success'), P95DurationMs=percentile(DurationMs, 95), FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated) by Parent, Actor, Skill, Mcp, Tool, DelegationId | order by FirstSeen asc | take 200" + "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), Delivery='Visible in Azure', Coverage='AgentOps managed', AttributionConfidence='exact'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | extend Sequence=long(null), EventId='', ParentEventId='', CommandName='', ScriptName='', McpToolName='', TotalTokens=InputTokens + OutputTokens + ReasoningTokens, PermissionKind='', PermissionDecision='', ContentCaptureMode=iff(ContentCaptureSignal, 'signal only', 'off'), ContentAction=iff(ContentCaptureSignal, 'dropped', ''), ContentDroppedBytes=long(null), SecretLike=false, Delivery='Visible in Azure', Coverage='Native best effort', AttributionConfidence='inferred' | project TimeGenerated, Sequence, EventId, ParentEventId, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, BranchDurationMs, BranchTokens, BranchToolCount, McpServer, McpToolName, ToolName, CommandName, ScriptName, ModelActual, Status, DurationMs, InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd, PermissionKind, PermissionDecision, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike, Delivery, Coverage, AttributionConfidence) | extend Sequence=tolong(column_ifexists('Sequence', long(null))), EventId=tostring(column_ifexists('EventId', '')), ParentEventId=tostring(column_ifexists('ParentEventId', '')) | extend EventType=tostring(column_ifexists('EventType', '')), CommandName=tostring(column_ifexists('CommandName', '')), ScriptName=tostring(column_ifexists('ScriptName', '')), McpToolName=tostring(column_ifexists('McpToolName', '')) | extend InputTokens=todouble(column_ifexists('InputTokens', real(null))), OutputTokens=todouble(column_ifexists('OutputTokens', real(null))), ReasoningTokens=todouble(column_ifexists('ReasoningTokens', real(null))), TotalTokens=todouble(column_ifexists('TotalTokens', real(null))), EstimatedCostUsd=todouble(column_ifexists('EstimatedCostUsd', real(null))) | extend PermissionKind=tostring(column_ifexists('PermissionKind', '')), PermissionDecision=tostring(column_ifexists('PermissionDecision', '')), PrivacyMode=tostring(column_ifexists('PrivacyMode', '')), ContentCaptureMode=tostring(column_ifexists('ContentCaptureMode', '')), ContentCaptureSignal=tobool(column_ifexists('ContentCaptureSignal', false)), ContentAction=tostring(column_ifexists('ContentAction', '')), ContentDroppedBytes=tolong(column_ifexists('ContentDroppedBytes', long(null))), SecretLike=tobool(column_ifexists('SecretLike', false)) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | extend EventType=case(isnotempty(EventType), EventType, EventName startswith 'agentops.run.' or EventName startswith 'agentops.wrapper.' or EventName startswith 'agentops.collector.', 'lifecycle', isnotempty(McpToolName) or isnotempty(McpServer) or EventName has 'mcp', 'mcp_tool', EventName == 'execute_tool' or EventName has 'tool' or isnotempty(ToolName), 'tool', EventName has 'skill' or isnotempty(SkillName), 'skill', EventName has 'subagent' or isnotempty(SubAgentName), 'subagent', EventName has 'agent', 'agent', isnotempty(CommandName), 'cli', isnotempty(ScriptName), 'script', EventName == 'chat' or isnotempty(ModelActual), 'llm', 'span') | extend AttributionGap=case(EventType in ('agent', 'subagent') and isempty(AgentName) and isempty(SubAgentName), 'agent identity missing', EventType == 'skill' and isempty(SkillName), 'skill identity missing', EventType in ('tool', 'mcp_tool') and isempty(ToolName), 'tool identity missing', EventType == 'mcp_tool' and isempty(McpServer) and isempty(McpToolName), 'MCP attribution missing', EventType in ('command', 'cli') and isempty(CommandName), 'command identity missing', EventType == 'script' and isempty(ScriptName), 'script identity missing', EventType == 'llm' and isempty(ModelActual), 'model identity missing', '') | extend AttributionConfidence=case(isnotempty(AttributionGap), 'missing', isnotempty(EventId) and isnotnull(Sequence), 'exact', tostring(column_ifexists('AttributionConfidence', '')) == 'inferred', 'inferred', 'best effort') | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | extend Actor=case(isnotempty(SubAgentName), SubAgentName, isnotempty(AgentName), AgentName, 'attribution missing'), Parent=iff(isempty(ParentAgentName), 'root or missing', ParentAgentName), Skill=iff(isempty(SkillName), 'not observed', SkillName), Mcp=iff(isempty(McpServer), 'not observed', McpServer), Tool=iff(isempty(ToolName), 'not observed', ToolName) | summarize Events=count(), Tools=dcountif(Tool, Tool != 'not observed'), Failures=countif(Status in ('failed', 'error', 'denied', 'blocked')), MissingAttribution=countif(AttributionConfidence == 'missing'), P95DurationMs=percentile(DurationMs, 95), BranchDurationMs=max(BranchDurationMs), BranchTokens=max(BranchTokens), BranchToolCount=max(BranchToolCount), FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated) by Parent, Actor, Skill, Mcp, Tool, DelegationId, Coverage | order by FirstSeen asc | take 200" } } - ] + ], + "description": "Observed parent, agent, sub-agent, skill, MCP, and tool relationships in first-seen order. Missing attribution is reported explicitly." }, { "id": 24, @@ -1561,7 +1564,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1867,7 +1870,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1987,10 +1990,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, ContextState=case(ContextWindowPct >= 90 or TokensRemoved > 0, 'pressure', CacheReadTokens > 0, 'cache leverage', 'normal') | order by TimeGenerated desc | take 20" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, ContextState=case(ContextWindowPct >= 90 or TokensRemoved > 0, 'pressure', CacheReadTokens > 0, 'cache leverage', 'normal') | order by TimeGenerated desc | take 20" } } - ] + ], + "description": "Token, cache, context pressure, and permission-wait evidence for the selected run." }, { "id": 28, @@ -2028,7 +2032,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2334,7 +2338,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2454,10 +2458,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | extend Priority=case(Severity == 'critical', 0, Severity == 'high', 1, Severity == 'medium', 2, 3) | order by Priority asc, TimeGenerated desc | project TimeGenerated, Severity, InsightType, Summary, SuggestedNextStep, RunId, TraceId, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash | take 20" + "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | extend Priority=case(Severity == 'critical', 0, Severity == 'high', 1, Severity == 'medium', 2, 3) | order by Priority asc, TimeGenerated desc | project TimeGenerated, Severity, InsightType, Summary, SuggestedNextStep, RunId, TraceId, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash | take 20" } } - ] + ], + "description": "Highest-severity matching insight first. An empty table means no insight row matched; it does not prove the run succeeded." }, { "id": 31, @@ -2495,7 +2500,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2801,7 +2806,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2939,10 +2944,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend Priority=case(Severity == 'critical', 0, Severity == 'high', 1, Severity == 'medium', 2, 3), RecommendationCommand=strcat('agentops recommend ', RunId, ' --last $timeRange --json'), AskContextCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --json'), OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, RecommendationCommand, AskContextCommand, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by Priority asc, TimeGenerated desc | take 20" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend Priority=case(Severity == 'critical', 0, Severity == 'high', 1, Severity == 'medium', 2, 3), RecommendationCommand=strcat('agentops recommend ', RunId, ' --last $timeRange --json'), AskContextCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --json'), OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, RecommendationCommand, AskContextCommand, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by Priority asc, TimeGenerated desc | take 20" } } - ] + ], + "description": "Highest-priority matching recommendation first. Treat recommendations as evidence-backed suggestions, not automatic approval." }, { "id": 29, @@ -2980,7 +2986,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -3286,7 +3292,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -3406,10 +3412,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend RunReplayUrl=strcat('/d/agentops-v2-run-replay?var-run_id=', RunId, '&var-session_id=', SessionId, '&var-trace_id=', TraceId, '&${__url_time_range}'), InvestigationKql=strcat('AgentOpsRunSummary_CL | where TimeGenerated > ago($timeRange) | where RunId == \"', RunId, '\" or SessionId == \"', SessionId, '\" | project TimeGenerated, RunId, SessionId, TraceId, OutcomeStatus, OutcomeReason'), AskContextCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --json'), BundleCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --privacy <AgentOpsPrivacy_CL.jsonl> --github <AgentOpsGitHubOutcome_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl> --recommendations <AgentOpsRecommendations_CL.jsonl> --json'), TriageCommand=strcat('agentops triage ', RunId, ' --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl>'), OpenReplay='Replay' | extend AskAgentOpsLaunch=strcat('$actioner_url', '/ask-agentops?run_id=', url_encode(RunId), '&session_id=', url_encode(SessionId), '&trace_id=', url_encode(TraceId), '&dashboard_url=', url_encode(RunReplayUrl), '&last=$timeRange') | extend AskPrompt=strcat('Use the telemetry-investigator or AgentOps triage skill. Investigate AgentOps run ', RunId, '. Session ', SessionId, '. Trace ', TraceId, '. Dashboard ', RunReplayUrl, '. Start with KQL: ', InvestigationKql, '. Use only metadata in the dashboard. Return what happened, why it matters, the likely failure/cost/safety/context pattern, and one evidence-backed next action. Do not request or enable prompt, response, source code, file content, tool argument, tool result, URL, request body, response body, or secret capture.') | project TimeGenerated, RunId, SessionId, TraceId, OutcomeStatus, OutcomeReason, RunReplayUrl, InvestigationKql, AskContextCommand, BundleCommand, AskPrompt, TriageCommand, AskAgentOpsLaunch, OpenReplay | order by TimeGenerated desc | take 20" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend RunReplayUrl=strcat('/d/agentops-v2-run-replay?var-run_id=', RunId, '&var-session_id=', SessionId, '&var-trace_id=', TraceId, '&${__url_time_range}'), InvestigationKql=strcat('AgentOpsRunSummary_CL | where TimeGenerated > ago($timeRange) | where RunId == \"', RunId, '\" or SessionId == \"', SessionId, '\" | project TimeGenerated, RunId, SessionId, TraceId, OutcomeStatus, OutcomeReason'), AskContextCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --json'), BundleCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --privacy <AgentOpsPrivacy_CL.jsonl> --github <AgentOpsGitHubOutcome_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl> --recommendations <AgentOpsRecommendations_CL.jsonl> --json'), TriageCommand=strcat('agentops triage ', RunId, ' --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl>'), OpenReplay='Replay' | extend AskAgentOpsLaunch=strcat('$actioner_url', '/ask-agentops?run_id=', url_encode(RunId), '&session_id=', url_encode(SessionId), '&trace_id=', url_encode(TraceId), '&dashboard_url=', url_encode(RunReplayUrl), '&last=$timeRange') | extend AskPrompt=strcat('Use the telemetry-investigator or AgentOps triage skill. Investigate AgentOps run ', RunId, '. Session ', SessionId, '. Trace ', TraceId, '. Dashboard ', RunReplayUrl, '. Start with KQL: ', InvestigationKql, '. Use only metadata in the dashboard. Return what happened, why it matters, the likely failure/cost/safety/context pattern, and one evidence-backed next action. Do not request or enable prompt, response, source code, file content, tool argument, tool result, URL, request body, response body, or secret capture.') | project TimeGenerated, RunId, SessionId, TraceId, OutcomeStatus, OutcomeReason, RunReplayUrl, InvestigationKql, AskContextCommand, BundleCommand, AskPrompt, TriageCommand, AskAgentOpsLaunch, OpenReplay | order by TimeGenerated desc | take 20" } } - ] + ], + "description": "Metadata-only investigation commands and links for the selected run." }, { "id": 25, @@ -3447,7 +3454,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -3753,7 +3760,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -3873,10 +3880,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=dcount(RunId), ContentSignalRuns=countif(ContentCaptureSignal == true), Modes=make_set(ContentCaptureMode, 10), LatestRunId=take_any(RunId), LatestSessionId=take_any(SessionId), LatestTraceId=take_any(TraceId)), (union isfuzzy=true (AgentOpsContent_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (print TimeGenerated=now(), RunId='', SessionId='', TraceId='', SpanId='', TurnIndex=long(null), Role='', ContentKind='', CaptureMode='', RedactionStatus='', ModelActual='', ToolName='', PromptText='', ResponseText='', ContentHash='', ContentLength=long(null) | where false) | extend PromptText=tostring(column_ifexists('PromptText', '')), ResponseText=tostring(column_ifexists('ResponseText', '')), CaptureMode=tostring(column_ifexists('CaptureMode', '')), RedactionStatus=tostring(column_ifexists('RedactionStatus', '')) | extend MessageText=case(isnotempty(PromptText), PromptText, isnotempty(ResponseText), ResponseText, '') | extend ViewerNote=case(isempty(MessageText), 'no captured content', CaptureMode == 'full', 'explicit opt-in: full content row', CaptureMode == 'redacted', 'explicit opt-in: redacted content row', 'explicit opt-in content row') | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | summarize ContentRows=count(), FullContentRows=countif(CaptureMode == 'full'), RedactedContentRows=countif(CaptureMode == 'redacted'), ContentModes=make_set(CaptureMode, 10), RedactionStates=make_set(RedactionStatus, 10), LatestRunId=take_any(RunId), LatestSessionId=take_any(SessionId), LatestTraceId=take_any(TraceId)) | summarize Runs=sum(Runs), ContentSignalRuns=sum(ContentSignalRuns), ContentRows=sum(ContentRows), FullContentRows=sum(FullContentRows), RedactedContentRows=sum(RedactedContentRows), Modes=make_set(Modes, 10), ContentModes=make_set(ContentModes, 10), RedactionStates=make_set(RedactionStates, 10), RunId=take_anyif(LatestRunId, isnotempty(LatestRunId)), SessionId=take_anyif(LatestSessionId, isnotempty(LatestSessionId)), TraceId=take_anyif(LatestTraceId, isnotempty(LatestTraceId)) | extend Status=case(ContentRows == 0, 'strict metadata only', FullContentRows > 0, 'content viewer enabled: full opt-in', 'content viewer enabled: redacted opt-in'), SafetyNote='Content rows require explicit opt-in and restricted access.', OpenTranscript='Open viewer' | project Status, SafetyNote, OpenTranscript, ContentRows, FullContentRows, RedactedContentRows, ContentSignalRuns, Runs, RunId, SessionId, TraceId, Modes, ContentModes, RedactionStates" + "query": "union isfuzzy=true (union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=dcount(RunId), ContentSignalRuns=countif(ContentCaptureSignal == true), Modes=make_set(ContentCaptureMode, 10), LatestRunId=take_any(RunId), LatestSessionId=take_any(SessionId), LatestTraceId=take_any(TraceId)), (union isfuzzy=true (AgentOpsContent_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (print TimeGenerated=now(), RunId='', SessionId='', TraceId='', SpanId='', TurnIndex=long(null), Role='', ContentKind='', CaptureMode='', RedactionStatus='', ModelActual='', ToolName='', PromptText='', ResponseText='', ContentHash='', ContentLength=long(null) | where false) | extend PromptText=tostring(column_ifexists('PromptText', '')), ResponseText=tostring(column_ifexists('ResponseText', '')), CaptureMode=tostring(column_ifexists('CaptureMode', '')), RedactionStatus=tostring(column_ifexists('RedactionStatus', '')) | extend MessageText=case(isnotempty(PromptText), PromptText, isnotempty(ResponseText), ResponseText, '') | extend ViewerNote=case(isempty(MessageText), 'no captured content', CaptureMode == 'full', 'explicit opt-in: full content row', CaptureMode == 'redacted', 'explicit opt-in: redacted content row', 'explicit opt-in content row') | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | summarize ContentRows=count(), FullContentRows=countif(CaptureMode == 'full'), RedactedContentRows=countif(CaptureMode == 'redacted'), ContentModes=make_set(CaptureMode, 10), RedactionStates=make_set(RedactionStatus, 10), LatestRunId=take_any(RunId), LatestSessionId=take_any(SessionId), LatestTraceId=take_any(TraceId)) | summarize Runs=sum(Runs), ContentSignalRuns=sum(ContentSignalRuns), ContentRows=sum(ContentRows), FullContentRows=sum(FullContentRows), RedactedContentRows=sum(RedactedContentRows), Modes=make_set(Modes, 10), ContentModes=make_set(ContentModes, 10), RedactionStates=make_set(RedactionStates, 10), RunId=take_anyif(LatestRunId, isnotempty(LatestRunId)), SessionId=take_anyif(LatestSessionId, isnotempty(LatestSessionId)), TraceId=take_anyif(LatestTraceId, isnotempty(LatestTraceId)) | extend Status=case(ContentRows == 0, 'strict metadata only', FullContentRows > 0, 'content viewer enabled: full opt-in', 'content viewer enabled: redacted opt-in'), SafetyNote='Content rows require explicit opt-in and restricted access.', OpenTranscript='Open viewer' | project Status, SafetyNote, OpenTranscript, ContentRows, FullContentRows, RedactedContentRows, ContentSignalRuns, Runs, RunId, SessionId, TraceId, Modes, ContentModes, RedactionStates" } } - ] + ], + "description": "States whether AgentOps has opt-in content rows. Zero rows means no AgentOps transcript is available." }, { "id": 26, @@ -3914,7 +3922,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -4220,7 +4228,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -4343,7 +4351,8 @@ "query": "union isfuzzy=true (AgentOpsContent_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (print TimeGenerated=now(), RunId='', SessionId='', TraceId='', SpanId='', TurnIndex=long(null), Role='', ContentKind='', CaptureMode='', RedactionStatus='', ModelActual='', ToolName='', PromptText='', ResponseText='', ContentHash='', ContentLength=long(null) | where false) | extend PromptText=tostring(column_ifexists('PromptText', '')), ResponseText=tostring(column_ifexists('ResponseText', '')), CaptureMode=tostring(column_ifexists('CaptureMode', '')), RedactionStatus=tostring(column_ifexists('RedactionStatus', '')) | extend MessageText=case(isnotempty(PromptText), PromptText, isnotempty(ResponseText), ResponseText, '') | extend ViewerNote=case(isempty(MessageText), 'no captured content', CaptureMode == 'full', 'explicit opt-in: full content row', CaptureMode == 'redacted', 'explicit opt-in: redacted content row', 'explicit opt-in content row') | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | project TimeGenerated, TurnIndex, Role, ContentKind, MessageText, CaptureMode, RedactionStatus, ViewerNote, ModelActual, ToolName, ContentHash, ContentLength, RunId, SessionId, TraceId | order by TimeGenerated asc | take 200" } } - ] + ], + "description": "Displays AgentOpsContent_CL only. It remains empty in the default strict metadata-only mode." }, { "id": 30, @@ -4381,7 +4390,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -4687,7 +4696,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -4807,10 +4816,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | project TimeGenerated, RunId, TraceId, Event='privacy.signal', Status=Action, Detail=strcat(ContentKind, ': ', tostring(DroppedCount), ' dropped')), (union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, TraceId, Event='eval.completed', Status=tostring(EvalOverall), Detail=tostring(EvalReason)), (union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | project TimeGenerated, RunId, TraceId='', Event='github.outcome', Status=CiStatus, Detail=strcat('pr=', tostring(PrOpened), ' merged=', tostring(PrMerged), ' reverted=', tostring(PrReverted))) | where ('$run_id' == '__all' or RunId == '$run_id') and ('$trace_id' == '__all' or TraceId == '$trace_id') | order by TimeGenerated asc | take 500" + "query": "union isfuzzy=true (union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | project TimeGenerated, RunId, TraceId, Event='privacy.signal', Status=Action, Detail=strcat(ContentKind, ': ', tostring(DroppedCount), ' dropped')), (union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, TraceId, Event='eval.completed', Status=tostring(EvalOverall), Detail=tostring(EvalReason)), (union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | project TimeGenerated, RunId, TraceId='', Event='github.outcome', Status=CiStatus, Detail=strcat('pr=', tostring(PrOpened), ' merged=', tostring(PrMerged), ' reverted=', tostring(PrReverted))) | where ('$run_id' == '__all' or RunId == '$run_id') and ('$trace_id' == '__all' or TraceId == '$trace_id') | order by TimeGenerated asc | take 500" } } - ] + ], + "description": "Related evidence in time order, with written event and status fields." } ], "refresh": "1m", @@ -4827,6 +4837,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -4838,6 +4849,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -4848,7 +4860,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -4860,6 +4873,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -4870,7 +4884,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -4881,7 +4896,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -4892,7 +4908,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -4904,6 +4921,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -4914,7 +4932,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -4926,6 +4945,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -4937,6 +4957,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -4947,7 +4968,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -4958,7 +4980,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -4969,7 +4992,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -4981,6 +5005,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -4991,7 +5016,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -5002,7 +5028,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5013,7 +5040,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -5024,7 +5052,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5035,7 +5064,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 2, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -5046,7 +5076,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 2, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -5057,7 +5088,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, @@ -5073,7 +5105,7 @@ }, "timepicker": {}, "timezone": "browser", - "title": "Agent Run Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "version": 1, "weekStart": "" diff --git a/grafana/dashboards/v2/04-models-cost-tokens.json b/grafana/dashboards/v2/04-models-cost-tokens.json index 27a5394..d5e0b2e 100644 --- a/grafana/dashboards/v2/04-models-cost-tokens.json +++ b/grafana/dashboards/v2/04-models-cost-tokens.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -586,7 +586,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count(), Failed=countif(OutcomeStatus != 'success'), Cost=sum(EstimatedCostUsd), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), CacheReadTokens=sum(CacheReadTokens), AvgContextPct=avg(ContextWindowPct), ContextPressureRuns=countif(ContextWindowPct >= 90 or TokensRemoved > 0), P95DurationMs=percentile(DurationMs, 95), AvgEval=avg(EvalOverall), PRs=countif(PrOpened == true), TestPasses=countif(TestsPassed == true) by ModelActual, TaskType | extend CacheReadPct=100.0 * CacheReadTokens / iif(InputTokens == 0, real(null), InputTokens), CostPerSuccess=Cost / iif(Runs - Failed == 0, real(null), todouble(Runs - Failed)), CostPerPr=Cost / iif(PRs == 0, real(null), todouble(PRs)), CostPerTestPass=Cost / iif(TestPasses == 0, real(null), todouble(TestPasses)) | order by Cost desc" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count(), Failed=countif(OutcomeStatus != 'success'), Cost=sum(EstimatedCostUsd), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), CacheReadTokens=sum(CacheReadTokens), AvgContextPct=avg(ContextWindowPct), ContextPressureRuns=countif(ContextWindowPct >= 90 or TokensRemoved > 0), P95DurationMs=percentile(DurationMs, 95), AvgEval=avg(EvalOverall), PRs=countif(PrOpened == true), TestPasses=countif(TestsPassed == true) by ModelActual, TaskType | extend CacheReadPct=100.0 * CacheReadTokens / iif(InputTokens == 0, real(null), InputTokens), CostPerSuccess=Cost / iif(Runs - Failed == 0, real(null), todouble(Runs - Failed)), CostPerPr=Cost / iif(PRs == 0, real(null), todouble(PRs)), CostPerTestPass=Cost / iif(TestPasses == 0, real(null), todouble(TestPasses)) | order by Cost desc" } } ] @@ -638,7 +638,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Cost=sum(EstimatedCostUsd) by TimeGenerated=bin(TimeGenerated, $__interval), ModelActual | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Cost=sum(EstimatedCostUsd) by TimeGenerated=bin(TimeGenerated, $__interval), ModelActual | order by TimeGenerated asc" } } ] @@ -690,7 +690,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize FailureRate=100.0 * countif(OutcomeStatus != 'success') / count() by TimeGenerated=bin(TimeGenerated, $__interval), ModelActual | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize FailureRate=100.0 * countif(OutcomeStatus != 'success') / count() by TimeGenerated=bin(TimeGenerated, $__interval), ModelActual | order by TimeGenerated asc" } } ] @@ -710,6 +710,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -721,6 +722,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -731,7 +733,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -743,6 +746,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -753,7 +757,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -764,7 +769,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -775,7 +781,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -787,6 +794,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -797,7 +805,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -809,6 +818,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -820,6 +830,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -830,7 +841,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -841,7 +853,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -852,7 +865,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -864,6 +878,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -874,7 +889,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 0, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -885,7 +901,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -896,7 +913,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -907,7 +925,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -918,7 +937,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 2, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -929,7 +949,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 2, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -940,7 +961,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, diff --git a/grafana/dashboards/v2/05-tools-mcp-risk.json b/grafana/dashboards/v2/05-tools-mcp-risk.json index fe7d825..c18cca0 100644 --- a/grafana/dashboards/v2/05-tools-mcp-risk.json +++ b/grafana/dashboards/v2/05-tools-mcp-risk.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -586,7 +586,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "let Runs = union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project RunId, RunOutcomeStatus=OutcomeStatus, RunRiskScore=RiskScore; let Tools = union isfuzzy=true (AgentOpsToolCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), OutputSizeBytes=todouble(OutputSizeBytes)), (AgentOpsMcpCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), ResultSizeBytes=todouble(ResultSizeBytes)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where Operation == 'execute_tool' or isnotempty(ToolName) | extend ToolRisk=case(ToolName has_any ('secret', 'credential', 'token', 'ssh'), 'secret-access', ToolName has_any ('rm', 'delete', 'destroy'), 'destructive', ToolName has_any ('browser', 'playwright'), 'browser-control', ToolName has_any ('shell', 'bash', 'terminal'), 'shell', ToolName has_any ('edit', 'write', 'patch'), 'write-file', ToolName has_any ('http', 'fetch', 'curl'), 'network', 'read-only') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, Surface, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, ToolName, ToolType=ToolRisk, ToolRisk, McpServer, Allowed=true, DeniedReason='', Status=iff(Failed, 'failed', 'success'), DurationMs=todouble(DurationMs), ErrorType, OutputSizeBytes=real(null), ArgsSchemaHash='') | extend Surface=tostring(column_ifexists('Surface', '')) | extend AgentName=tostring(column_ifexists('AgentName', '')) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$tool_risk' == '__all' or tostring(column_ifexists('ToolRisk', '')) == '$tool_risk') | where ('$mcp_server' == '__all' or tostring(column_ifexists('McpServer', '')) == '$mcp_server'); Tools | join kind=leftouter Runs on RunId | extend OutputBytes=coalesce(todouble(column_ifexists('OutputSizeBytes', real(null))), todouble(column_ifexists('ResultSizeBytes', real(null)))) | summarize Calls=count(), Failures=countif(Status != 'success'), Denied=countif(Allowed == false), BadOutcomeRuns=countif(RunOutcomeStatus != 'success'), P95DurationMs=percentile(DurationMs, 95), AvgOutputSize=avg(OutputBytes), AvgRunRisk=avg(RunRiskScore) by ToolName, ToolType, ToolRisk, McpServer, AgentName | extend FailureRate=100.0 * Failures / Calls, DeniedRate=100.0 * Denied / Calls, BadOutcomeCorrelation=100.0 * BadOutcomeRuns / Calls | order by BadOutcomeCorrelation desc, FailureRate desc, ToolRisk asc" + "query": "let Runs = union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project RunId, RunOutcomeStatus=OutcomeStatus, RunRiskScore=RiskScore; let Tools = union isfuzzy=true (AgentOpsToolCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), OutputSizeBytes=todouble(OutputSizeBytes)), (AgentOpsMcpCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), ResultSizeBytes=todouble(ResultSizeBytes)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where Operation == 'execute_tool' or isnotempty(ToolName) | extend ToolRisk=case(ToolName has_any ('secret', 'credential', 'token', 'ssh'), 'secret-access', ToolName has_any ('rm', 'delete', 'destroy'), 'destructive', ToolName has_any ('browser', 'playwright'), 'browser-control', ToolName has_any ('shell', 'bash', 'terminal'), 'shell', ToolName has_any ('edit', 'write', 'patch'), 'write-file', ToolName has_any ('http', 'fetch', 'curl'), 'network', 'read-only') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, Surface, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, ToolName, ToolType=ToolRisk, ToolRisk, McpServer, Allowed=true, DeniedReason='', Status=iff(Failed, 'failed', 'success'), DurationMs=todouble(DurationMs), ErrorType, OutputSizeBytes=real(null), ArgsSchemaHash='') | extend Surface=tostring(column_ifexists('Surface', '')) | extend AgentName=tostring(column_ifexists('AgentName', '')) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$tool_risk' == '__all' or tostring(column_ifexists('ToolRisk', '')) == '$tool_risk') | where ('$mcp_server' == '__all' or tostring(column_ifexists('McpServer', '')) == '$mcp_server'); Tools | join kind=leftouter Runs on RunId | extend OutputBytes=coalesce(todouble(column_ifexists('OutputSizeBytes', real(null))), todouble(column_ifexists('ResultSizeBytes', real(null)))) | summarize Calls=count(), Failures=countif(Status != 'success'), Denied=countif(Allowed == false), BadOutcomeRuns=countif(RunOutcomeStatus != 'success'), P95DurationMs=percentile(DurationMs, 95), AvgOutputSize=avg(OutputBytes), AvgRunRisk=avg(RunRiskScore) by ToolName, ToolType, ToolRisk, McpServer, AgentName | extend FailureRate=100.0 * Failures / Calls, DeniedRate=100.0 * Denied / Calls, BadOutcomeCorrelation=100.0 * BadOutcomeRuns / Calls | order by BadOutcomeCorrelation desc, FailureRate desc, ToolRisk asc" } } ] @@ -638,7 +638,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsToolCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), OutputSizeBytes=todouble(OutputSizeBytes)), (AgentOpsMcpCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), ResultSizeBytes=todouble(ResultSizeBytes)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where Operation == 'execute_tool' or isnotempty(ToolName) | extend ToolRisk=case(ToolName has_any ('secret', 'credential', 'token', 'ssh'), 'secret-access', ToolName has_any ('rm', 'delete', 'destroy'), 'destructive', ToolName has_any ('browser', 'playwright'), 'browser-control', ToolName has_any ('shell', 'bash', 'terminal'), 'shell', ToolName has_any ('edit', 'write', 'patch'), 'write-file', ToolName has_any ('http', 'fetch', 'curl'), 'network', 'read-only') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, Surface, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, ToolName, ToolType=ToolRisk, ToolRisk, McpServer, Allowed=true, DeniedReason='', Status=iff(Failed, 'failed', 'success'), DurationMs=todouble(DurationMs), ErrorType, OutputSizeBytes=real(null), ArgsSchemaHash='') | extend Surface=tostring(column_ifexists('Surface', '')) | extend AgentName=tostring(column_ifexists('AgentName', '')) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$tool_risk' == '__all' or tostring(column_ifexists('ToolRisk', '')) == '$tool_risk') | where ('$mcp_server' == '__all' or tostring(column_ifexists('McpServer', '')) == '$mcp_server') | summarize Failures=countif(Status != 'success') by TimeGenerated=bin(TimeGenerated, $__interval), ToolName | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsToolCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), OutputSizeBytes=todouble(OutputSizeBytes)), (AgentOpsMcpCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), ResultSizeBytes=todouble(ResultSizeBytes)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where Operation == 'execute_tool' or isnotempty(ToolName) | extend ToolRisk=case(ToolName has_any ('secret', 'credential', 'token', 'ssh'), 'secret-access', ToolName has_any ('rm', 'delete', 'destroy'), 'destructive', ToolName has_any ('browser', 'playwright'), 'browser-control', ToolName has_any ('shell', 'bash', 'terminal'), 'shell', ToolName has_any ('edit', 'write', 'patch'), 'write-file', ToolName has_any ('http', 'fetch', 'curl'), 'network', 'read-only') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, Surface, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, ToolName, ToolType=ToolRisk, ToolRisk, McpServer, Allowed=true, DeniedReason='', Status=iff(Failed, 'failed', 'success'), DurationMs=todouble(DurationMs), ErrorType, OutputSizeBytes=real(null), ArgsSchemaHash='') | extend Surface=tostring(column_ifexists('Surface', '')) | extend AgentName=tostring(column_ifexists('AgentName', '')) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$tool_risk' == '__all' or tostring(column_ifexists('ToolRisk', '')) == '$tool_risk') | where ('$mcp_server' == '__all' or tostring(column_ifexists('McpServer', '')) == '$mcp_server') | summarize Failures=countif(Status != 'success') by TimeGenerated=bin(TimeGenerated, $__interval), ToolName | order by TimeGenerated asc" } } ] @@ -690,7 +690,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsToolCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), OutputSizeBytes=todouble(OutputSizeBytes)), (AgentOpsMcpCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), ResultSizeBytes=todouble(ResultSizeBytes)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where Operation == 'execute_tool' or isnotempty(ToolName) | extend ToolRisk=case(ToolName has_any ('secret', 'credential', 'token', 'ssh'), 'secret-access', ToolName has_any ('rm', 'delete', 'destroy'), 'destructive', ToolName has_any ('browser', 'playwright'), 'browser-control', ToolName has_any ('shell', 'bash', 'terminal'), 'shell', ToolName has_any ('edit', 'write', 'patch'), 'write-file', ToolName has_any ('http', 'fetch', 'curl'), 'network', 'read-only') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, Surface, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, ToolName, ToolType=ToolRisk, ToolRisk, McpServer, Allowed=true, DeniedReason='', Status=iff(Failed, 'failed', 'success'), DurationMs=todouble(DurationMs), ErrorType, OutputSizeBytes=real(null), ArgsSchemaHash='') | extend Surface=tostring(column_ifexists('Surface', '')) | extend AgentName=tostring(column_ifexists('AgentName', '')) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$tool_risk' == '__all' or tostring(column_ifexists('ToolRisk', '')) == '$tool_risk') | where ('$mcp_server' == '__all' or tostring(column_ifexists('McpServer', '')) == '$mcp_server') | summarize Denied=countif(Allowed == false) by TimeGenerated=bin(TimeGenerated, $__interval), ToolRisk | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsToolCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), OutputSizeBytes=todouble(OutputSizeBytes)), (AgentOpsMcpCalls_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), ResultSizeBytes=todouble(ResultSizeBytes)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where Operation == 'execute_tool' or isnotempty(ToolName) | extend ToolRisk=case(ToolName has_any ('secret', 'credential', 'token', 'ssh'), 'secret-access', ToolName has_any ('rm', 'delete', 'destroy'), 'destructive', ToolName has_any ('browser', 'playwright'), 'browser-control', ToolName has_any ('shell', 'bash', 'terminal'), 'shell', ToolName has_any ('edit', 'write', 'patch'), 'write-file', ToolName has_any ('http', 'fetch', 'curl'), 'network', 'read-only') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, Surface, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, ToolName, ToolType=ToolRisk, ToolRisk, McpServer, Allowed=true, DeniedReason='', Status=iff(Failed, 'failed', 'success'), DurationMs=todouble(DurationMs), ErrorType, OutputSizeBytes=real(null), ArgsSchemaHash='') | extend Surface=tostring(column_ifexists('Surface', '')) | extend AgentName=tostring(column_ifexists('AgentName', '')) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$tool_risk' == '__all' or tostring(column_ifexists('ToolRisk', '')) == '$tool_risk') | where ('$mcp_server' == '__all' or tostring(column_ifexists('McpServer', '')) == '$mcp_server') | summarize Denied=countif(Allowed == false) by TimeGenerated=bin(TimeGenerated, $__interval), ToolRisk | order by TimeGenerated asc" } } ] @@ -710,6 +710,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -721,6 +722,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -731,7 +733,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -743,6 +746,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -753,7 +757,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -764,7 +769,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -775,7 +781,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -787,6 +794,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -797,7 +805,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -809,6 +818,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -820,6 +830,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -830,7 +841,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -841,7 +853,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -852,7 +865,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -864,6 +878,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -874,7 +889,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -885,7 +901,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -896,7 +913,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 0, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -907,7 +925,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -918,7 +937,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 2, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -929,7 +949,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 2, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -940,7 +961,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, diff --git a/grafana/dashboards/v2/06-safety-privacy-policy.json b/grafana/dashboards/v2/06-safety-privacy-policy.json index 99f6399..96ae4c1 100644 --- a/grafana/dashboards/v2/06-safety-privacy-policy.json +++ b/grafana/dashboards/v2/06-safety-privacy-policy.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -114,19 +114,487 @@ "title": "Trust screen", "type": "text", "gridPos": { - "h": 2, + "h": 3, "w": 24, "x": 0, "y": 0 }, "options": { "mode": "markdown", - "content": "## Safety, Privacy & Policy\nStrict privacy should be visible and reassuring. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." + "content": "## Privacy\n**AgentOps default: strict metadata only · content capture off.** This screen describes AgentOps telemetry only. It does not prove what GitHub Copilot, an MCP server, or any other connected service stores. The capture posture below reflects the selected AgentOps runs. Uses AgentOps V2 custom tables when present and falls back to existing Copilot OpenTelemetry in Application Insights. If every panel is empty, run `agentops collector smoke --privacy strict --poison --json`; for local demos run `agentops demo generate --runs 50 --with-failures --with-privacy-drops --json`." } }, + { + "id": 13, + "title": "AgentOps capture posture", + "type": "table", + "datasource": { + "type": "grafana-azure-monitor-datasource", + "uid": "azure-monitor-oob" + }, + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 3 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "links": [] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "RunId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Run Story", + "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "SessionId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Session Replay", + "url": "/d/agentops-v2-run-replay?var-session_id=${__data.fields.SessionId}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "TraceId" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Trace Replay", + "url": "/d/agentops-v2-run-replay?var-trace_id=${__data.fields.TraceId}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ToolName" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Tools Risk", + "url": "/d/agentops-v2-tools-mcp-risk?var-tool_name=${__data.fields.ToolName}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "McpServerName" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open MCP Server", + "url": "/d/agentops-v2-tools-mcp-risk?var-mcp_server=${__data.fields.McpServerName}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "McpServer" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open MCP Server", + "url": "/d/agentops-v2-tools-mcp-risk?var-mcp_server=${__data.fields.McpServer}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ModelActual" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Models", + "url": "/d/agentops-v2-models-cost-tokens?var-model=${__data.fields.ModelActual}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "AgentName" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Agent Runs", + "url": "/d/agentops-v2-runs-explorer?var-agent_name=${__data.fields.AgentName}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "SkillName" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Skill Runs", + "url": "/d/agentops-v2-runs-explorer?var-skill_name=${__data.fields.SkillName}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "SubAgentName" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Sub-agent Replay", + "url": "/d/agentops-v2-run-replay?var-sub_agent=${__data.fields.SubAgentName}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ParentAgentName" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Parent Agent Runs", + "url": "/d/agentops-v2-runs-explorer?var-agent_name=${__data.fields.ParentAgentName}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "RepoHash" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Runs for Repo", + "url": "/d/agentops-v2-runs-explorer?var-repo_hash=${__data.fields.RepoHash}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "PrNumberHash" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Code Outcomes", + "url": "/d/agentops-v2-code-outcomes?var-repo_hash=${__data.fields.RepoHash}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "CiStatus" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Code Outcomes", + "url": "/d/agentops-v2-code-outcomes?var-outcome_status=${__data.fields.CiStatus}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "EvalOverall" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Evals", + "url": "/d/agentops-v2-evals-quality?var-run_id=${__data.fields.RunId}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "PatternKey" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Pattern", + "url": "/d/agentops-v2-insights-regressions?var-pattern_key=${__data.fields.PatternKey}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenTranscript" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open prompt/response viewer", + "url": "/d/agentops-v2-run-replay?viewPanel=26&var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenReplay" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Run Story", + "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "AskAgentOpsLaunch" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Ask AgentOps", + "url": "${__data.fields.AskAgentOpsLaunch}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenTrace" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Trace Replay", + "url": "/d/agentops-v2-run-replay?var-trace_id=${__data.fields.TraceId}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenGithub" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open GitHub Outcome", + "url": "/d/agentops-v2-code-outcomes?var-run_id=${__data.fields.RunId}&var-repo_hash=${__data.fields.RepoHash}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenPattern" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open Pattern", + "url": "/d/agentops-v2-insights-regressions?var-pattern_key=${__data.fields.PatternKey}&${__url_time_range}", + "targetBlank": false + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "OpenSavedView" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open saved view", + "url": "${__data.fields.Url}", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "showHeader": true, + "footer": { + "show": false + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-azure-monitor-datasource", + "uid": "azure-monitor-oob" + }, + "queryType": "Azure Log Analytics", + "azureLogAnalytics": { + "resources": [ + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" + ], + "resultFormat": "table", + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend PrivacyMode=coalesce(PrivacyMode, 'unknown'), ContentCaptureMode=coalesce(ContentCaptureMode, 'unknown') | summarize Runs=count() by PrivacyMode, ContentCaptureMode, Coverage | extend Scope='AgentOps telemetry only', Meaning=case(PrivacyMode == 'strict' and ContentCaptureMode == 'off', 'metadata only; prompt, response, code, file content, tool arguments, and tool results are not recorded by AgentOps', PrivacyMode == 'strict', 'strict AgentOps telemetry; review the reported capture mode', 'review this AgentOps capture posture before sharing or broader use') | project Scope, PrivacyMode, ContentCaptureMode, Coverage, Runs, Meaning | order by PrivacyMode asc, ContentCaptureMode asc" + } + } + ], + "description": "AgentOps telemetry scope, privacy mode, content-capture mode, coverage, run count, and a plain-language meaning. Unknown or non-strict rows require review." + }, { "id": 2, - "title": "Privacy drops", + "title": "Content items blocked", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -136,7 +604,7 @@ "h": 4, "w": 4, "x": 0, - "y": 2 + "y": 7 }, "fieldConfig": { "defaults": { @@ -182,14 +650,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Content items AgentOps reports dropping. This indicates a privacy action, not successful storage." }, { "id": 3, - "title": "Secret-like drops", + "title": "Secret-like items blocked", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -199,7 +668,7 @@ "h": 4, "w": 4, "x": 4, - "y": 2 + "y": 7 }, "fieldConfig": { "defaults": { @@ -245,14 +714,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ContentKind == 'secret_like' | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ContentKind == 'secret_like' | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Secret-like items AgentOps reports dropping. The written label and value carry meaning; colour is supplementary." }, { "id": 4, - "title": "Unsafe attempts", + "title": "Runs reporting unsafe mode", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -262,7 +732,7 @@ "h": 4, "w": 4, "x": 8, - "y": 2 + "y": 7 }, "fieldConfig": { "defaults": { @@ -308,10 +778,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(PrivacyMode == 'unsafe') by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(PrivacyMode == 'unsafe') by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Runs whose AgentOps privacy mode is explicitly reported as unsafe. Review every non-zero result." }, { "id": 5, @@ -325,7 +796,7 @@ "h": 4, "w": 4, "x": 12, - "y": 2 + "y": 7 }, "fieldConfig": { "defaults": { @@ -371,14 +842,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, McpServer, ToolName, ModelActual, Status, DurationMs, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureSignal) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where EventType == 'policy' | summarize value=countif(Status == 'denied' or Status == 'blocked') by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsEvents_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), Delivery='Visible in Azure', Coverage='AgentOps managed', AttributionConfidence='exact'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | extend EventName=case(isnotempty(Operation), Operation, isnotempty(Name), Name, 'span') | extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span') | extend Status=iff(Failed, 'failed', 'success') | extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '') | extend Sequence=long(null), EventId='', ParentEventId='', CommandName='', ScriptName='', McpToolName='', TotalTokens=InputTokens + OutputTokens + ReasoningTokens, PermissionKind='', PermissionDecision='', ContentCaptureMode=iff(ContentCaptureSignal, 'signal only', 'off'), ContentAction=iff(ContentCaptureSignal, 'dropped', ''), ContentDroppedBytes=long(null), SecretLike=false, Delivery='Visible in Azure', Coverage='Native best effort', AttributionConfidence='inferred' | project TimeGenerated, Sequence, EventId, ParentEventId, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, BranchDurationMs, BranchTokens, BranchToolCount, McpServer, McpToolName, ToolName, CommandName, ScriptName, ModelActual, Status, DurationMs, InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd, PermissionKind, PermissionDecision, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike, Delivery, Coverage, AttributionConfidence) | extend Sequence=tolong(column_ifexists('Sequence', long(null))), EventId=tostring(column_ifexists('EventId', '')), ParentEventId=tostring(column_ifexists('ParentEventId', '')) | extend EventType=tostring(column_ifexists('EventType', '')), CommandName=tostring(column_ifexists('CommandName', '')), ScriptName=tostring(column_ifexists('ScriptName', '')), McpToolName=tostring(column_ifexists('McpToolName', '')) | extend InputTokens=todouble(column_ifexists('InputTokens', real(null))), OutputTokens=todouble(column_ifexists('OutputTokens', real(null))), ReasoningTokens=todouble(column_ifexists('ReasoningTokens', real(null))), TotalTokens=todouble(column_ifexists('TotalTokens', real(null))), EstimatedCostUsd=todouble(column_ifexists('EstimatedCostUsd', real(null))) | extend PermissionKind=tostring(column_ifexists('PermissionKind', '')), PermissionDecision=tostring(column_ifexists('PermissionDecision', '')), PrivacyMode=tostring(column_ifexists('PrivacyMode', '')), ContentCaptureMode=tostring(column_ifexists('ContentCaptureMode', '')), ContentCaptureSignal=tobool(column_ifexists('ContentCaptureSignal', false)), ContentAction=tostring(column_ifexists('ContentAction', '')), ContentDroppedBytes=tolong(column_ifexists('ContentDroppedBytes', long(null))), SecretLike=tobool(column_ifexists('SecretLike', false)) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) | extend EventType=case(isnotempty(EventType), EventType, EventName startswith 'agentops.run.' or EventName startswith 'agentops.wrapper.' or EventName startswith 'agentops.collector.', 'lifecycle', isnotempty(McpToolName) or isnotempty(McpServer) or EventName has 'mcp', 'mcp_tool', EventName == 'execute_tool' or EventName has 'tool' or isnotempty(ToolName), 'tool', EventName has 'skill' or isnotempty(SkillName), 'skill', EventName has 'subagent' or isnotempty(SubAgentName), 'subagent', EventName has 'agent', 'agent', isnotempty(CommandName), 'cli', isnotempty(ScriptName), 'script', EventName == 'chat' or isnotempty(ModelActual), 'llm', 'span') | extend AttributionGap=case(EventType in ('agent', 'subagent') and isempty(AgentName) and isempty(SubAgentName), 'agent identity missing', EventType == 'skill' and isempty(SkillName), 'skill identity missing', EventType in ('tool', 'mcp_tool') and isempty(ToolName), 'tool identity missing', EventType == 'mcp_tool' and isempty(McpServer) and isempty(McpToolName), 'MCP attribution missing', EventType in ('command', 'cli') and isempty(CommandName), 'command identity missing', EventType == 'script' and isempty(ScriptName), 'script identity missing', EventType == 'llm' and isempty(ModelActual), 'model identity missing', '') | extend AttributionConfidence=case(isnotempty(AttributionGap), 'missing', isnotempty(EventId) and isnotnull(Sequence), 'exact', tostring(column_ifexists('AttributionConfidence', '')) == 'inferred', 'inferred', 'best effort') | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where EventType == 'policy' | summarize value=countif(Status == 'denied' or Status == 'blocked') by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Policy events explicitly reporting denied or blocked status." }, { "id": 6, - "title": "Poison tests OK", + "title": "Successful poison tests", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -388,7 +860,7 @@ "h": 4, "w": 4, "x": 16, - "y": 2 + "y": 7 }, "fieldConfig": { "defaults": { @@ -437,11 +909,12 @@ "query": "union isfuzzy=true (AgentOpsCollectorHealth_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | summarize LastSpanReceived=max(TimeGenerated), SpanRows=count(), ExportErrors=countif(Success == false or tostring(Success) =~ 'false') | extend TimeGenerated=now(), Component='appinsights-compat', CheckName='live-ingestion', Status=iff(SpanRows > 0, 'healthy', 'empty'), Detail=strcat('Existing Application Insights telemetry rows: ', tostring(SpanRows)), PrivacyMode='strict', CollectorMode='compat', OtlpEndpoint='application-insights', AzureConfigured=true, GrafanaConfigured=true, DashboardVersion='v2', SchemaVersion='2', DroppedContentCount=0, LastExportSuccess=LastSpanReceived | project TimeGenerated, Component, CheckName, Status, Detail, PrivacyMode, CollectorMode, OtlpEndpoint, AzureConfigured, GrafanaConfigured, DashboardVersion, SchemaVersion, LastSpanReceived, LastExportSuccess, ExportErrors, DroppedContentCount) | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where CheckName == 'privacy-poison' | summarize value=countif(Status == 'ok') by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Privacy poison checks explicitly reporting OK. A zero or empty result is not proof of privacy safety." }, { "id": 7, - "title": "Strict runs", + "title": "Strict-mode runs", "type": "stat", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -451,7 +924,7 @@ "h": 4, "w": 4, "x": 20, - "y": 2 + "y": 7 }, "fieldConfig": { "defaults": { @@ -497,14 +970,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(PrivacyMode == 'strict') by bin(TimeGenerated, $__interval)" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize value=countif(PrivacyMode == 'strict') by bin(TimeGenerated, $__interval)" } } - ] + ], + "description": "Runs explicitly reporting strict AgentOps privacy mode." }, { "id": 10, - "title": "Privacy drops by kind", + "title": "Blocked or redacted items by kind", "type": "table", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -514,7 +988,7 @@ "h": 9, "w": 12, "x": 0, - "y": 6 + "y": 11 }, "fieldConfig": { "defaults": { @@ -538,7 +1012,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -844,7 +1318,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -964,14 +1438,15 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | summarize Drops=sum(DroppedCount), Redactions=sum(RedactedCount), Runs=dcount(RunId) by ContentKind, Action, PrivacyMode | order by Drops desc" + "query": "union isfuzzy=true (AgentOpsPrivacy_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | where ContentCaptureSignal | extend ContentKind=case(tostring(Properties) has_any ('secret', 'token', 'credential', 'api_key'), 'secret_like', tostring(Properties) has_any ('gen_ai.output.messages', 'gen_ai.completion'), 'output', tostring(Properties) has_any ('tool.call.arguments', 'tool_args'), 'tool_args', 'prompt') | project TimeGenerated, RunId, TraceId, PrivacyMode, ContentKind, Observed=true, Action='dropped', DroppedCount=1, RedactedCount=0, LeakDetected=false) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | summarize Drops=sum(DroppedCount), Redactions=sum(RedactedCount), Runs=dcount(RunId) by ContentKind, Action, PrivacyMode | order by Drops desc" } } - ] + ], + "description": "AgentOps privacy actions grouped by content kind, action, and privacy mode." }, { "id": 11, - "title": "Runs with policy blocks or drops", + "title": "Runs needing privacy or policy review", "type": "table", "datasource": { "type": "grafana-azure-monitor-datasource", @@ -981,7 +1456,7 @@ "h": 9, "w": 12, "x": 12, - "y": 6 + "y": 11 }, "fieldConfig": { "defaults": { @@ -1005,7 +1480,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1311,7 +1786,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1431,10 +1906,11 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where PrivacyMode == 'unsafe' or RiskScore > 0 or ToolDeniedCount > 0 | project TimeGenerated, RunId, RepoHash, PrivacyMode, ContentCaptureMode, ToolDeniedCount, OutcomeStatus, RiskScore | order by TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where PrivacyMode == 'unsafe' or RiskScore > 0 or ToolDeniedCount > 0 | project TimeGenerated, RunId, RepoHash, PrivacyMode, ContentCaptureMode, ToolDeniedCount, OutcomeStatus, RiskScore | order by TimeGenerated desc | take 100" } } - ] + ], + "description": "Runs reporting unsafe mode, risk, or denied tools. An empty table means no matching rows were found; it is not a universal safety guarantee." }, { "id": 12, @@ -1448,7 +1924,7 @@ "h": 8, "w": 24, "x": 0, - "y": 15 + "y": 20 }, "fieldConfig": { "defaults": { @@ -1490,7 +1966,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-session_id=${__data.fields.SessionId}&${__url_time_range}", "targetBlank": false } @@ -1541,7 +2017,8 @@ "query": "union isfuzzy=true (AgentOpsAlertHandoffs_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (datatable(TimeGenerated:datetime, HandoffId:string, AlertRule:string, SessionId:string, Severity:string, Owner:string, State:string, Last:string, ConfigChangeCount:long, ChangeTargetRefs:dynamic) [])\n| extend HandoffId=coalesce(tostring(column_ifexists('HandoffId', '')), tostring(column_ifexists('AlertHandoffId', '')), tostring(column_ifexists('Id', '')), strcat(tostring(column_ifexists('AlertRule', '')), '-', tostring(column_ifexists('SessionId', ''))))\n| extend AlertRule=coalesce(tostring(column_ifexists('AlertRule', '')), tostring(column_ifexists('Rule', '')))\n| extend SessionId=coalesce(tostring(column_ifexists('SessionId', '')), tostring(column_ifexists('Session', '')))\n| extend Severity=coalesce(tostring(column_ifexists('Severity', '')), tostring(column_ifexists('AlertSeverity', '')))\n| extend Owner=coalesce(tostring(column_ifexists('Owner', '')), tostring(column_ifexists('AssignedOwner', '')))\n| extend State=coalesce(tostring(column_ifexists('State', '')), tostring(column_ifexists('Status', '')))\n| extend Last=coalesce(tostring(column_ifexists('Last', '')), tostring(column_ifexists('Lookback', '')))\n| extend ConfigChangeCount=tolong(column_ifexists('ConfigChangeCount', long(null)))\n| extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([]))\n| where ('$session_id' == '__all' or SessionId == '$session_id')\n| where ('$outcome_status' == '__all' or State == '$outcome_status')\n| extend AskAgentOpsSharedLaunch=iff(isnotempty(HandoffId), strcat('$actioner_url', '/ask-agentops/shared/alert-handoff/', url_encode(HandoffId), '?session_id=', url_encode(SessionId), '&last=$timeRange'), '')\n| extend AskSharedContext=iff(isnotempty(HandoffId), 'Ask shared', '')\n| extend OpenReplay='Replay'\n| project TimeGenerated, Severity, AlertRule, SessionId, Owner, State, Last, ConfigChangeCount, ChangeTargetRefs, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay\n| order by TimeGenerated desc\n| take 50" } } - ] + ], + "description": "Privacy-related alert handoffs with written severity, owner, state, and evidence links." } ], "refresh": "1m", @@ -1558,6 +2035,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -1569,6 +2047,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -1579,7 +2058,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -1591,6 +2071,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -1601,7 +2082,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1612,7 +2094,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1623,7 +2106,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1635,6 +2119,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -1645,7 +2130,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1657,6 +2143,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1668,6 +2155,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1678,7 +2166,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1689,7 +2178,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1700,7 +2190,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1712,6 +2203,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1722,7 +2214,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -1733,7 +2226,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1744,7 +2238,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 0, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -1755,7 +2250,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1766,7 +2262,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 0, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -1777,7 +2274,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 0, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -1788,7 +2286,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, @@ -1804,7 +2303,7 @@ }, "timepicker": {}, "timezone": "browser", - "title": "Safety, Privacy & Policy", + "title": "Privacy", "uid": "agentops-v2-safety-privacy-policy", "version": 1, "weekStart": "" diff --git a/grafana/dashboards/v2/07-code-outcomes.json b/grafana/dashboards/v2/07-code-outcomes.json index b2d6f19..fc44647 100644 --- a/grafana/dashboards/v2/07-code-outcomes.json +++ b/grafana/dashboards/v2/07-code-outcomes.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -586,7 +586,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened, PrNumberHash, PrMerged, PrClosed, PrReverted, CiStatus, TimeToPrMinutes, TimeToMergeMinutes, ReviewCommentCount, CommitCount, FilesChangedCount | order by TimeGenerated desc | take 500" + "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened, PrNumberHash, PrMerged, PrClosed, PrReverted, CiStatus, TimeToPrMinutes, TimeToMergeMinutes, ReviewCommentCount, CommitCount, FilesChangedCount | order by TimeGenerated desc | take 500" } } ] @@ -638,7 +638,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | summarize PRs=countif(PrOpened == true), Merged=countif(PrMerged == true), Reverted=countif(PrReverted == true), CiFailed=countif(CiStatus == 'failed') by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | summarize PRs=countif(PrOpened == true), Merged=countif(PrMerged == true), Reverted=countif(PrReverted == true), CiFailed=countif(CiStatus == 'failed') by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc" } } ] @@ -679,7 +679,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -985,7 +985,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1105,7 +1105,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | summarize PRs=countif(PrOpened == true), Merged=countif(PrMerged == true), AvgTimeToPrMinutes=avg(TimeToPrMinutes), P95TimeToPrMinutes=percentile(TimeToPrMinutes, 95), AvgTimeToMergeMinutes=avg(TimeToMergeMinutes), P95TimeToMergeMinutes=percentile(TimeToMergeMinutes, 95) by RepoHash, BranchHash | order by P95TimeToPrMinutes desc" + "query": "union isfuzzy=true (AgentOpsGithubOutcomes_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | project TimeGenerated, RunId, RepoHash, BranchHash, PrOpened=false, PrNumberHash='', PrMerged=false, PrClosed=false, PrReverted=false, CiStatus='not_run', ReviewCommentCount=0, CommitCount=0, FilesChangedCount=FilesEditedCount) | extend RunStartedAt=todatetime(column_ifexists('RunStartedAt', datetime(null))) | extend PrCreatedAt=todatetime(column_ifexists('PrCreatedAt', datetime(null))) | extend PrMergedAt=todatetime(column_ifexists('PrMergedAt', datetime(null))) | extend TimeToPrMinutes=coalesce(todouble(column_ifexists('TimeToPrMinutes', real(null))), todouble(datetime_diff('minute', PrCreatedAt, RunStartedAt))) | extend TimeToMergeMinutes=coalesce(todouble(column_ifexists('TimeToMergeMinutes', real(null))), todouble(datetime_diff('minute', PrMergedAt, RunStartedAt))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$outcome_status' == '__all' or tostring(column_ifexists('CiStatus', '')) == '$outcome_status') | summarize PRs=countif(PrOpened == true), Merged=countif(PrMerged == true), AvgTimeToPrMinutes=avg(TimeToPrMinutes), P95TimeToPrMinutes=percentile(TimeToPrMinutes, 95), AvgTimeToMergeMinutes=avg(TimeToMergeMinutes), P95TimeToMergeMinutes=percentile(TimeToMergeMinutes, 95) by RepoHash, BranchHash | order by P95TimeToPrMinutes desc" } } ] @@ -1146,7 +1146,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1452,7 +1452,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1572,7 +1572,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where FilesEditedCount > 0 and TestsRan != true | project TimeGenerated, RunId, RepoHash, FilesEditedCount, TestsRan, TestsPassed, OutcomeStatus, ModelActual, AgentName, SkillName | order by TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where FilesEditedCount > 0 and TestsRan != true | project TimeGenerated, RunId, RepoHash, FilesEditedCount, TestsRan, TestsPassed, OutcomeStatus, ModelActual, AgentName, SkillName | order by TimeGenerated desc | take 100" } } ] @@ -1592,6 +1592,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -1603,6 +1604,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -1613,7 +1615,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -1625,6 +1628,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -1635,7 +1639,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1646,7 +1651,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1657,7 +1663,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1669,6 +1676,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -1679,7 +1687,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -1691,6 +1700,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1702,6 +1712,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -1712,7 +1723,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -1723,7 +1735,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1734,7 +1747,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1746,6 +1760,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1756,7 +1771,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -1767,7 +1783,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1778,7 +1795,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -1789,7 +1807,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1800,7 +1819,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 2, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -1811,7 +1831,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 0, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -1822,7 +1843,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, diff --git a/grafana/dashboards/v2/08-evals-quality.json b/grafana/dashboards/v2/08-evals-quality.json index 5ea9f18..66db161 100644 --- a/grafana/dashboards/v2/08-evals-quality.json +++ b/grafana/dashboards/v2/08-evals-quality.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -586,7 +586,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalReason | order by EvalOverall asc, TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, RunId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalReason | order by EvalOverall asc, TimeGenerated desc | take 100" } } ] @@ -638,7 +638,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize EvalOverall=avg(EvalOverall), TestDiscipline=avg(TestDiscipline), Security=avg(Security), Reliability=avg(Reliability) by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize EvalOverall=avg(EvalOverall), TestDiscipline=avg(TestDiscipline), Security=avg(Security), Reliability=avg(Reliability) by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc" } } ] @@ -679,7 +679,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -985,7 +985,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1105,7 +1105,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count(), AvgEval=avg(EvalOverall), AvgSecurity=avg(Security), AvgTestDiscipline=avg(TestDiscipline) by ModelActual, TaskType, RepoHash | order by AvgEval asc" + "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count(), AvgEval=avg(EvalOverall), AvgSecurity=avg(Security), AvgTestDiscipline=avg(TestDiscipline) by ModelActual, TaskType, RepoHash | order by AvgEval asc" } } ] @@ -1146,7 +1146,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1452,7 +1452,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1572,7 +1572,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count(), AvgEval=round(avg(EvalOverall), 1), PoorRuns=countif(EvalBucket == 'poor'), ReviewRuns=countif(EvalBucket == 'review'), AvgTestDiscipline=round(avg(TestDiscipline), 1), AvgToolEfficiency=round(avg(ToolEfficiency), 1), AvgSecurity=round(avg(Security), 1), AvgReliability=round(avg(Reliability), 1), AvgCodeOutcome=round(avg(CodeOutcome), 1), LastSeen=max(TimeGenerated) by RepoHash, ModelActual, TaskType | extend ScorecardStatus=case(PoorRuns > 0, 'poor', ReviewRuns > 0 or AvgEval < 80, 'review', 'ok'), ScorecardPriority=case(PoorRuns > 0, 0, ReviewRuns > 0 or AvgEval < 80, 1, 2) | order by ScorecardPriority asc, AvgEval asc, Runs desc | project ScorecardStatus, Runs, AvgEval, PoorRuns, ReviewRuns, AvgTestDiscipline, AvgToolEfficiency, AvgSecurity, AvgReliability, AvgCodeOutcome, RepoHash, ModelActual, TaskType, LastSeen" + "query": "union isfuzzy=true (AgentOpsEval_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall), TestDiscipline=tolong(TestDiscipline), ToolEfficiency=tolong(ToolEfficiency), Security=tolong(Security), Reliability=tolong(Reliability), CodeOutcome=tolong(CodeOutcome)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | extend TestDiscipline=case(FilesEditedCount > 0 and TestsRan != true, 35, TestsRan == true and TestsPassed == true, 95, TestsRan == true and TestsPassed != true, 45, 70) | extend ToolEfficiency=case(ToolFailureCount > 0, 50, ToolCount > 12, 65, 85) | extend Security=case(ContentCaptureSignal == true or ToolDeniedCount > 0, 65, PrivacyMode == 'unsafe', 20, 90) | extend Reliability=case(OutcomeStatus != 'success', 45, 90) | extend CodeOutcome=case(PrOpened == true and CiStatus == 'passed', 85, PrOpened == true, 70, FilesEditedCount > 0 and TestsRan != true, 40, 60) | extend EvalOverall=tolong((TestDiscipline + ToolEfficiency + Security + Reliability + CodeOutcome) / 5) | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | extend EvalReason='compat score from existing Copilot OpenTelemetry metadata' | project TimeGenerated, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall, TestDiscipline, ToolEfficiency, Security, Reliability, CodeOutcome, EvalBucket, EvalReason) | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend EvalBucket=iff(isempty(EvalBucket), case(todouble(column_ifexists('EvalOverall', 0.0)) >= 80, 'good', todouble(column_ifexists('EvalOverall', 0.0)) >= 60, 'review', 'poor'), EvalBucket) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | summarize Runs=count(), AvgEval=round(avg(EvalOverall), 1), PoorRuns=countif(EvalBucket == 'poor'), ReviewRuns=countif(EvalBucket == 'review'), AvgTestDiscipline=round(avg(TestDiscipline), 1), AvgToolEfficiency=round(avg(ToolEfficiency), 1), AvgSecurity=round(avg(Security), 1), AvgReliability=round(avg(Reliability), 1), AvgCodeOutcome=round(avg(CodeOutcome), 1), LastSeen=max(TimeGenerated) by RepoHash, ModelActual, TaskType | extend ScorecardStatus=case(PoorRuns > 0, 'poor', ReviewRuns > 0 or AvgEval < 80, 'review', 'ok'), ScorecardPriority=case(PoorRuns > 0, 0, ReviewRuns > 0 or AvgEval < 80, 1, 2) | order by ScorecardPriority asc, AvgEval asc, Runs desc | project ScorecardStatus, Runs, AvgEval, PoorRuns, ReviewRuns, AvgTestDiscipline, AvgToolEfficiency, AvgSecurity, AvgReliability, AvgCodeOutcome, RepoHash, ModelActual, TaskType, LastSeen" } } ] @@ -1613,7 +1613,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1919,7 +1919,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2039,7 +2039,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where EvalBucket in ('poor', 'review') or Action has 'regression' or ObservedPattern has 'eval' | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations) | project TimeGenerated, RecommendationId, Severity, Action, EvalOverall, EvalBucket, ObservedPattern, NextAction, RunId, TraceId, PatternKey, ChangeAnnotationCount, ChangeTargetRefs, OpenReplay, OpenPattern | order by TimeGenerated desc, Severity asc | take 100" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where EvalBucket in ('poor', 'review') or Action has 'regression' or ObservedPattern has 'eval' | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations) | project TimeGenerated, RecommendationId, Severity, Action, EvalOverall, EvalBucket, ObservedPattern, NextAction, RunId, TraceId, PatternKey, ChangeAnnotationCount, ChangeTargetRefs, OpenReplay, OpenPattern | order by TimeGenerated desc, Severity asc | take 100" } } ] @@ -2080,7 +2080,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2386,7 +2386,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2506,7 +2506,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore) | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, AfterRunId=RunId, SessionId, TraceId, RepoHash, ModelActual, TaskType, OutcomeStatus, EvalOverall, EstimatedCostUsd, InputTokens, OutputTokens, ToolFailureCount, RiskScore | order by RepoHash asc, ModelActual asc, TaskType asc, TimeGenerated asc | serialize BeforeRunId=prev(AfterRunId), BeforeRepoHash=prev(RepoHash), BeforeModelActual=prev(ModelActual), BeforeTaskType=prev(TaskType), BeforeOutcomeStatus=prev(OutcomeStatus), BeforeEvalOverall=prev(EvalOverall), BeforeEstimatedCostUsd=prev(EstimatedCostUsd), BeforeInputTokens=prev(InputTokens), BeforeOutputTokens=prev(OutputTokens), BeforeToolFailureCount=prev(ToolFailureCount), BeforeRiskScore=prev(RiskScore) | where BeforeRepoHash == RepoHash and BeforeModelActual == ModelActual and BeforeTaskType == TaskType | extend EvalDelta=EvalOverall - BeforeEvalOverall, CostDelta=EstimatedCostUsd - BeforeEstimatedCostUsd, TokenDelta=(InputTokens + OutputTokens) - (BeforeInputTokens + BeforeOutputTokens), ToolFailureDelta=ToolFailureCount - BeforeToolFailureCount, RiskDelta=RiskScore - BeforeRiskScore | extend ComparisonStatus=case(OutcomeStatus != 'success' and BeforeOutcomeStatus == 'success', 'regressed', EvalDelta >= 5 and CostDelta <= 0 and ToolFailureDelta <= 0 and RiskDelta <= 0, 'improved', EvalDelta < -5 or ToolFailureDelta > 0 or RiskDelta > 0, 'review', 'similar'), OpenReplay='Replay' | project TimeGenerated, ComparisonStatus, BeforeRunId, AfterRunId, RepoHash, ModelActual, TaskType, BeforeOutcomeStatus, OutcomeStatus, BeforeEvalOverall, EvalOverall, EvalDelta, CostDelta, TokenDelta, ToolFailureDelta, RiskDelta, SessionId, TraceId, OpenReplay | order by TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsRunSummary_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount) | extend Delivery='Visible in Azure', Coverage='AgentOps managed'), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort') | extend SkillName=tostring(column_ifexists('SkillName', '')) | extend ParentAgentName=tostring(column_ifexists('ParentAgentName', '')) | extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '') | extend DelegationId=tostring(column_ifexists('DelegationId', '')) | extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0)) | extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0)) | extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0)) | extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0)) | extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0)) | extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0)) | extend TokensRemoved=todouble(column_ifexists('TokensRemoved', 0.0)) | extend PermissionWaitMs=todouble(column_ifexists('PermissionWaitMs', 0.0)) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$session_id' == '__all' or tostring(column_ifexists('SessionId', '')) == '$session_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$surface' == '__all' or tostring(column_ifexists('Surface', '')) == '$surface') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$branch_hash' == '__all' or tostring(column_ifexists('BranchHash', '')) == '$branch_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$agent_name' == '__all' or tostring(column_ifexists('AgentName', '')) == '$agent_name') | where ('$skill_name' == '__all' or tostring(column_ifexists('SkillName', '')) == '$skill_name') | where ('$sub_agent' == '__all' or tostring(column_ifexists('SubAgentName', '')) == '$sub_agent') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$privacy_mode' == '__all' or tostring(column_ifexists('PrivacyMode', '')) == '$privacy_mode') | where ('$outcome_status' == '__all' or tostring(column_ifexists('OutcomeStatus', '')) == '$outcome_status') | extend EvalBucket=case(EvalOverall >= 80, 'good', EvalOverall >= 60, 'review', 'poor') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | project TimeGenerated, AfterRunId=RunId, SessionId, TraceId, RepoHash, ModelActual, TaskType, OutcomeStatus, EvalOverall, EstimatedCostUsd, InputTokens, OutputTokens, ToolFailureCount, RiskScore | order by RepoHash asc, ModelActual asc, TaskType asc, TimeGenerated asc | serialize BeforeRunId=prev(AfterRunId), BeforeRepoHash=prev(RepoHash), BeforeModelActual=prev(ModelActual), BeforeTaskType=prev(TaskType), BeforeOutcomeStatus=prev(OutcomeStatus), BeforeEvalOverall=prev(EvalOverall), BeforeEstimatedCostUsd=prev(EstimatedCostUsd), BeforeInputTokens=prev(InputTokens), BeforeOutputTokens=prev(OutputTokens), BeforeToolFailureCount=prev(ToolFailureCount), BeforeRiskScore=prev(RiskScore) | where BeforeRepoHash == RepoHash and BeforeModelActual == ModelActual and BeforeTaskType == TaskType | extend EvalDelta=EvalOverall - BeforeEvalOverall, CostDelta=EstimatedCostUsd - BeforeEstimatedCostUsd, TokenDelta=(InputTokens + OutputTokens) - (BeforeInputTokens + BeforeOutputTokens), ToolFailureDelta=ToolFailureCount - BeforeToolFailureCount, RiskDelta=RiskScore - BeforeRiskScore | extend ComparisonStatus=case(OutcomeStatus != 'success' and BeforeOutcomeStatus == 'success', 'regressed', EvalDelta >= 5 and CostDelta <= 0 and ToolFailureDelta <= 0 and RiskDelta <= 0, 'improved', EvalDelta < -5 or ToolFailureDelta > 0 or RiskDelta > 0, 'review', 'similar'), OpenReplay='Replay' | project TimeGenerated, ComparisonStatus, BeforeRunId, AfterRunId, RepoHash, ModelActual, TaskType, BeforeOutcomeStatus, OutcomeStatus, BeforeEvalOverall, EvalOverall, EvalDelta, CostDelta, TokenDelta, ToolFailureDelta, RiskDelta, SessionId, TraceId, OpenReplay | order by TimeGenerated desc | take 100" } } ] @@ -2547,7 +2547,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2853,7 +2853,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2973,7 +2973,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where isnotempty(BenchmarkRunId) or BenchmarkArtifactTotalChanged > 0 | extend ReviewAction=case(BenchmarkArtifactTotalChanged > 0, 'Review artifact diff', isnotempty(BenchmarkRunId), 'Benchmark has no artifact changes', 'No benchmark evidence') | project TimeGenerated, RecommendationId, Severity, Action, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkPassRatePct, BenchmarkAverageScore, BenchmarkArtifactAdded, BenchmarkArtifactModified, BenchmarkArtifactDeleted, BenchmarkArtifactTotalChanged, ChangeTargetRefs, ReviewAction, NextAction | order by TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where isnotempty(BenchmarkRunId) or BenchmarkArtifactTotalChanged > 0 | extend ReviewAction=case(BenchmarkArtifactTotalChanged > 0, 'Review artifact diff', isnotempty(BenchmarkRunId), 'Benchmark has no artifact changes', 'No benchmark evidence') | project TimeGenerated, RecommendationId, Severity, Action, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkPassRatePct, BenchmarkAverageScore, BenchmarkArtifactAdded, BenchmarkArtifactModified, BenchmarkArtifactDeleted, BenchmarkArtifactTotalChanged, ChangeTargetRefs, ReviewAction, NextAction | order by TimeGenerated desc | take 100" } } ] @@ -3014,7 +3014,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -3320,7 +3320,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -3440,7 +3440,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand ArtifactFile=BenchmarkArtifactFiles | extend ArtifactTaskId=tostring(ArtifactFile.task_id), ArtifactChange=tostring(ArtifactFile.change), ArtifactPath=tostring(ArtifactFile.path) | where isnotempty(ArtifactPath) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, ArtifactTaskId, ArtifactChange, ArtifactPath, Severity, Action, NextAction | order by TimeGenerated desc, ArtifactTaskId asc, ArtifactChange asc, ArtifactPath asc | take 200" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand ArtifactFile=BenchmarkArtifactFiles | extend ArtifactTaskId=tostring(ArtifactFile.task_id), ArtifactChange=tostring(ArtifactFile.change), ArtifactPath=tostring(ArtifactFile.path) | where isnotempty(ArtifactPath) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, ArtifactTaskId, ArtifactChange, ArtifactPath, Severity, Action, NextAction | order by TimeGenerated desc, ArtifactTaskId asc, ArtifactChange asc, ArtifactPath asc | take 200" } } ] @@ -3481,7 +3481,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -3787,7 +3787,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -3907,7 +3907,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand ArtifactDiff=BenchmarkArtifactContentDiffs | extend ArtifactTaskId=tostring(ArtifactDiff.task_id), ArtifactChange=tostring(ArtifactDiff.change), ArtifactPath=tostring(ArtifactDiff.path), DiffPreview=tostring(ArtifactDiff.diff_preview) | where isnotempty(ArtifactPath) and isnotempty(DiffPreview) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, ArtifactTaskId, ArtifactChange, ArtifactPath, DiffPreview, Severity, Action, NextAction | order by TimeGenerated desc, ArtifactTaskId asc, ArtifactChange asc, ArtifactPath asc | take 100" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand ArtifactDiff=BenchmarkArtifactContentDiffs | extend ArtifactTaskId=tostring(ArtifactDiff.task_id), ArtifactChange=tostring(ArtifactDiff.change), ArtifactPath=tostring(ArtifactDiff.path), DiffPreview=tostring(ArtifactDiff.diff_preview) | where isnotempty(ArtifactPath) and isnotempty(DiffPreview) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, ArtifactTaskId, ArtifactChange, ArtifactPath, DiffPreview, Severity, Action, NextAction | order by TimeGenerated desc, ArtifactTaskId asc, ArtifactChange asc, ArtifactPath asc | take 100" } } ] @@ -3948,7 +3948,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -4254,7 +4254,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -4374,7 +4374,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand HiddenPack=BenchmarkHiddenCheckPacks | extend HiddenTaskId=tostring(HiddenPack['task_id']), HiddenPackId=tostring(HiddenPack['id']), HiddenPackTitle=tostring(HiddenPack['title']), HiddenCommandCount=tolong(HiddenPack['command_count']) | where isnotempty(HiddenPackId) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkHiddenChecksPassed, BenchmarkHiddenChecksFailed, HiddenTaskId, HiddenPackId, HiddenPackTitle, HiddenCommandCount, Severity, Action, NextAction | order by TimeGenerated desc, HiddenTaskId asc, HiddenPackId asc | take 200" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand HiddenPack=BenchmarkHiddenCheckPacks | extend HiddenTaskId=tostring(HiddenPack['task_id']), HiddenPackId=tostring(HiddenPack['id']), HiddenPackTitle=tostring(HiddenPack['title']), HiddenCommandCount=tolong(HiddenPack['command_count']) | where isnotempty(HiddenPackId) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkHiddenChecksPassed, BenchmarkHiddenChecksFailed, HiddenTaskId, HiddenPackId, HiddenPackTitle, HiddenCommandCount, Severity, Action, NextAction | order by TimeGenerated desc, HiddenTaskId asc, HiddenPackId asc | take 200" } } ] @@ -4415,7 +4415,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -4721,7 +4721,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -4841,7 +4841,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand PolicyTask=BenchmarkPolicyTasks | extend PolicyTaskId=tostring(PolicyTask.task_id), PermissionProfile=tostring(PolicyTask.permission_profile), OsSandboxMode=tostring(PolicyTask.os_sandbox_mode), OsSandboxActive=tobool(PolicyTask.os_sandbox_active), PolicyBlocks=tolong(PolicyTask.policy_blocks), BlockedRisks=strcat_array(PolicyTask.blocked_risks, ', '), ViolationCount=tolong(PolicyTask.violation_count), ViolationRisks=strcat_array(PolicyTask.violation_risks, ', ') | where isnotempty(PolicyTaskId) or isnotempty(PermissionProfile) or isnotnull(PolicyBlocks) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkPolicyBlocks, BenchmarkPermissionProfiles, PolicyTaskId, PermissionProfile, OsSandboxMode, OsSandboxActive, PolicyBlocks, BlockedRisks, ViolationCount, ViolationRisks, Severity, Action, NextAction | order by TimeGenerated desc, PolicyBlocks desc, PolicyTaskId asc | take 200" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand PolicyTask=BenchmarkPolicyTasks | extend PolicyTaskId=tostring(PolicyTask.task_id), PermissionProfile=tostring(PolicyTask.permission_profile), OsSandboxMode=tostring(PolicyTask.os_sandbox_mode), OsSandboxActive=tobool(PolicyTask.os_sandbox_active), PolicyBlocks=tolong(PolicyTask.policy_blocks), BlockedRisks=strcat_array(PolicyTask.blocked_risks, ', '), ViolationCount=tolong(PolicyTask.violation_count), ViolationRisks=strcat_array(PolicyTask.violation_risks, ', ') | where isnotempty(PolicyTaskId) or isnotempty(PermissionProfile) or isnotnull(PolicyBlocks) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkPolicyBlocks, BenchmarkPermissionProfiles, PolicyTaskId, PermissionProfile, OsSandboxMode, OsSandboxActive, PolicyBlocks, BlockedRisks, ViolationCount, ViolationRisks, Severity, Action, NextAction | order by TimeGenerated desc, PolicyBlocks desc, PolicyTaskId asc | take 200" } } ] @@ -4882,7 +4882,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -5188,7 +5188,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -5308,7 +5308,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand SemanticCheck=BenchmarkSemanticChecks | extend SemanticTaskId=tostring(SemanticCheck.task_id), SemanticCheckId=tostring(SemanticCheck.id), SemanticAdapter=tostring(SemanticCheck.adapter), SemanticFile=tostring(SemanticCheck.file), SemanticOk=tobool(SemanticCheck.ok), SemanticScore=todouble(SemanticCheck.score), SemanticDetail=tostring(SemanticCheck.detail) | where isnotempty(SemanticCheckId) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkSemanticCheckCount, BenchmarkSemanticAverageScore, SemanticTaskId, SemanticCheckId, SemanticAdapter, SemanticFile, SemanticOk, SemanticScore, SemanticDetail, Severity, Action, NextAction | order by TimeGenerated desc, SemanticOk asc, SemanticScore asc, SemanticTaskId asc | take 200" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | mv-expand SemanticCheck=BenchmarkSemanticChecks | extend SemanticTaskId=tostring(SemanticCheck.task_id), SemanticCheckId=tostring(SemanticCheck.id), SemanticAdapter=tostring(SemanticCheck.adapter), SemanticFile=tostring(SemanticCheck.file), SemanticOk=tobool(SemanticCheck.ok), SemanticScore=todouble(SemanticCheck.score), SemanticDetail=tostring(SemanticCheck.detail) | where isnotempty(SemanticCheckId) | project TimeGenerated, RecommendationId, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkSemanticCheckCount, BenchmarkSemanticAverageScore, SemanticTaskId, SemanticCheckId, SemanticAdapter, SemanticFile, SemanticOk, SemanticScore, SemanticDetail, Severity, Action, NextAction | order by TimeGenerated desc, SemanticOk asc, SemanticScore asc, SemanticTaskId asc | take 200" } } ] @@ -5349,7 +5349,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -5655,7 +5655,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -5775,7 +5775,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where isnotempty(BenchmarkRunId) or isnotempty(BenchmarkApprovalStatus) or isnotnull(BenchmarkRequiredApprovals) | extend ApprovalAction=case(BenchmarkRequiredApprovals > BenchmarkApprovalCount, 'Collect required approval evidence', BenchmarkApprovalStatus == 'rejected', 'Do not promote', BenchmarkApprovalStatus == 'approved', 'Approval evidence present', isnotnull(BenchmarkRequiredApprovals), 'Review approval evidence', 'No approval gate') | project TimeGenerated, RecommendationId, Severity, Action, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkApprovalStatus, BenchmarkApprovalCount, BenchmarkRequiredApprovals, BenchmarkApprovalApprovedAt, BenchmarkApprovalTicket, BenchmarkApprovalSource, ApprovalAction, NextAction | order by TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where isnotempty(BenchmarkRunId) or isnotempty(BenchmarkApprovalStatus) or isnotnull(BenchmarkRequiredApprovals) | extend ApprovalAction=case(BenchmarkRequiredApprovals > BenchmarkApprovalCount, 'Collect required approval evidence', BenchmarkApprovalStatus == 'rejected', 'Do not promote', BenchmarkApprovalStatus == 'approved', 'Approval evidence present', isnotnull(BenchmarkRequiredApprovals), 'Review approval evidence', 'No approval gate') | project TimeGenerated, RecommendationId, Severity, Action, RunId, BenchmarkRunId, BenchmarkDecision, BenchmarkApprovalStatus, BenchmarkApprovalCount, BenchmarkRequiredApprovals, BenchmarkApprovalApprovedAt, BenchmarkApprovalTicket, BenchmarkApprovalSource, ApprovalAction, NextAction | order by TimeGenerated desc | take 100" } } ] @@ -5795,6 +5795,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -5806,6 +5807,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -5816,7 +5818,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -5828,6 +5831,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -5838,7 +5842,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5849,7 +5854,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5860,7 +5866,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5872,6 +5879,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -5882,7 +5890,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -5894,6 +5903,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5905,6 +5915,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -5915,7 +5926,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5926,7 +5938,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5937,7 +5950,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5949,6 +5963,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5959,7 +5974,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 0, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -5970,7 +5986,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -5981,7 +5998,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -5992,7 +6010,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -6003,7 +6022,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 2, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -6014,7 +6034,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 2, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -6025,7 +6046,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 0, "query": "__all,ok,review,poor", "current": { "selected": true, diff --git a/grafana/dashboards/v2/09-insights-regressions.json b/grafana/dashboards/v2/09-insights-regressions.json index 7814f92..bb4e50e 100644 --- a/grafana/dashboards/v2/09-insights-regressions.json +++ b/grafana/dashboards/v2/09-insights-regressions.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -586,7 +586,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | project TimeGenerated, InsightType, Severity, Summary, RunId, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash, PatternRuns, SuggestedNextStep | order by TimeGenerated desc | take 500" + "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | project TimeGenerated, InsightType, Severity, Summary, RunId, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash, PatternRuns, SuggestedNextStep | order by TimeGenerated desc | take 500" } } ] @@ -627,7 +627,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -933,7 +933,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1053,7 +1053,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where isnotempty(PatternId) or InsightType startswith 'recurring-' | extend OpenPattern='Pattern', OpenReplay='Replay' | project TimeGenerated, InsightType, Severity, PatternRuns, PatternDimension, PatternKey, Summary, SuggestedNextStep, OpenPattern, OpenReplay, RunId, RepoHash, ModelActual, ToolName, CurrentValue | order by PatternRuns desc, TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where isnotempty(PatternId) or InsightType startswith 'recurring-' | extend OpenPattern='Pattern', OpenReplay='Replay' | project TimeGenerated, InsightType, Severity, PatternRuns, PatternDimension, PatternKey, Summary, SuggestedNextStep, OpenPattern, OpenReplay, RunId, RepoHash, ModelActual, ToolName, CurrentValue | order by PatternRuns desc, TimeGenerated desc | take 100" } } ] @@ -1105,7 +1105,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "time_series", - "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | summarize Insights=count() by TimeGenerated=bin(TimeGenerated, $__interval), Severity | order by TimeGenerated asc" + "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | summarize Insights=count() by TimeGenerated=bin(TimeGenerated, $__interval), Severity | order by TimeGenerated asc" } } ] @@ -1146,7 +1146,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1452,7 +1452,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1572,7 +1572,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where InsightType has 'regression' or InsightType has 'anomaly' | project TimeGenerated, InsightType, Severity, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash, Summary | order by TimeGenerated desc | take 100" + "query": "union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where InsightType has 'regression' or InsightType has 'anomaly' | project TimeGenerated, InsightType, Severity, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash, Summary | order by TimeGenerated desc | take 100" } } ] @@ -1613,7 +1613,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -1919,7 +1919,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2039,7 +2039,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where InsightType has_any ('eval', 'regression', 'anomaly') | project TimeGenerated, Source='insight', Severity, Action=InsightType, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall=long(null), EvalBucket='', BaselineValue, CurrentValue, PatternKey, Summary, NextAction=SuggestedNextStep), (union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where EvalBucket in ('poor', 'review') or Action has 'regression' or ObservedPattern has 'eval' | project TimeGenerated, Source='recommendation', Severity, Action, RunId, TraceId, RepoHash='', ModelActual='', TaskType='', EvalOverall, EvalBucket, BaselineValue=real(null), CurrentValue=todouble(EvalOverall), PatternKey, Summary=ObservedPattern, NextAction) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', '') | project TimeGenerated, Source, Severity, Action, EvalOverall, EvalBucket, BaselineValue, CurrentValue, Summary, NextAction, RunId, TraceId, RepoHash, ModelActual, TaskType, PatternKey, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200" + "query": "union isfuzzy=true (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$repo_hash' == '__all' or tostring(column_ifexists('RepoHash', '')) == '$repo_hash') | where ('$model' == '__all' or tostring(column_ifexists('ModelActual', '')) == '$model') | where ('$task_type' == '__all' or tostring(column_ifexists('TaskType', '')) == '$task_type') | where ('$tool_name' == '__all' or tostring(column_ifexists('ToolName', '')) == '$tool_name') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where InsightType has_any ('eval', 'regression', 'anomaly') | project TimeGenerated, Source='insight', Severity, Action=InsightType, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall=long(null), EvalBucket='', BaselineValue, CurrentValue, PatternKey, Summary, NextAction=SuggestedNextStep), (union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | where EvalBucket in ('poor', 'review') or Action has 'regression' or ObservedPattern has 'eval' | project TimeGenerated, Source='recommendation', Severity, Action, RunId, TraceId, RepoHash='', ModelActual='', TaskType='', EvalOverall, EvalBucket, BaselineValue=real(null), CurrentValue=todouble(EvalOverall), PatternKey, Summary=ObservedPattern, NextAction) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', '') | project TimeGenerated, Source, Severity, Action, EvalOverall, EvalBucket, BaselineValue, CurrentValue, Summary, NextAction, RunId, TraceId, RepoHash, ModelActual, TaskType, PatternKey, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200" } } ] @@ -2080,7 +2080,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2386,7 +2386,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -2524,7 +2524,7 @@ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev" ], "resultFormat": "table", - "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, PatternDimension, EvalOverall, EvalBucket, BenchmarkRunId, BenchmarkDecision, BenchmarkPassRatePct, BenchmarkAverageScore, BenchmarkSafetyViolationCount, BenchmarkArtifactAdded, BenchmarkArtifactModified, BenchmarkArtifactDeleted, BenchmarkArtifactTotalChanged, BenchmarkArtifactFiles, BenchmarkHiddenChecksPassed, BenchmarkHiddenChecksFailed, BenchmarkHiddenCheckPacks, BenchmarkPolicyBlocks, BenchmarkPermissionProfiles, BenchmarkPolicyTasks, BenchmarkSemanticCheckCount, BenchmarkSemanticAverageScore, BenchmarkSemanticChecks, BenchmarkApprovalStatus, BenchmarkApprovalCount, BenchmarkRequiredApprovals, ChangeAnnotationCount, ChangeAnnotations, ChangeTargetRefs, DashboardCount, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200" + "query": "union isfuzzy=true (AgentOpsRecommendations_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend EvalOverall=tolong(EvalOverall)), (union isfuzzy=true (AgentOpsInsights_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (AppDependencies | where TimeGenerated between ($__timeFrom() .. $__timeTo()) | where tostring(Properties) has_any ('github.copilot', 'gen_ai.operation.name', 'agentops.', 'codex') or AppRoleName in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') or tostring(Properties['service.name']) in ('github-copilot', 'copilot-chat', 'github-copilot-cli', 'codex', 'openai-codex', 'openai-codex-cli') | extend SessionId=case(isnotempty(tostring(Properties['agentops.session.id'])), tostring(Properties['agentops.session.id']), isnotempty(tostring(Properties['gen_ai.conversation.id'])), tostring(Properties['gen_ai.conversation.id']), isnotempty(tostring(Properties['github.copilot.interaction_id'])), tostring(Properties['github.copilot.interaction_id']), strcat(coalesce(AppRoleName, 'agent'), '_', coalesce(OperationId, 'session'), '_', format_datetime(bin(TimeGenerated, 1h), 'yyyyMMddHH'))) | extend RunId=case(isnotempty(tostring(Properties['agentops.run.id'])), tostring(Properties['agentops.run.id']), strcat('compat_', SessionId)) | extend TraceId=coalesce(OperationId, tostring(Properties['trace_id']), RunId) | extend Operation=tostring(Properties['gen_ai.operation.name']) | extend ToolName=tostring(Properties['gen_ai.tool.name']) | extend ModelActual=case(isnotempty(tostring(Properties['agentops.model.actual'])), tostring(Properties['agentops.model.actual']), isnotempty(tostring(Properties['gen_ai.response.model'])), tostring(Properties['gen_ai.response.model']), tostring(Properties['gen_ai.request.model'])) | extend AgentName=case(isnotempty(tostring(Properties['agentops.agent.name'])), tostring(Properties['agentops.agent.name']), isnotempty(tostring(Properties['agentops.cli.agent'])), tostring(Properties['agentops.cli.agent']), isnotempty(tostring(Properties['gen_ai.agent.name'])), tostring(Properties['gen_ai.agent.name']), coalesce(AppRoleName, 'agent')) | extend SkillName=coalesce(tostring(Properties['agentops.skill.name']), tostring(Properties['github.copilot.skill.name'])) | extend ParentAgentName=tostring(Properties['agentops.parent_agent.name']) | extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '') | extend DelegationId=tostring(Properties['agentops.delegation.id']) | extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count']) | extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli') | extend RepoHash=tostring(Properties['agentops.repo.hash']) | extend BranchHash=tostring(Properties['agentops.branch.hash']) | extend TaskType=case(isnotempty(tostring(Properties['agentops.task.type'])), tostring(Properties['agentops.task.type']), 'unknown') | extend PrivacyMode=case(isnotempty(tostring(Properties['agentops.privacy.mode'])), tostring(Properties['agentops.privacy.mode']), 'strict') | extend ContentCaptureSignal=tostring(Properties['agentops.content_capture.signal']) =~ 'true' or tostring(Properties) has_any ('gen_ai.input.messages', 'gen_ai.output.messages', 'gen_ai.prompt', 'gen_ai.completion') | extend InputTokens=todouble(Properties['gen_ai.usage.input_tokens']), OutputTokens=todouble(Properties['gen_ai.usage.output_tokens']), ReasoningTokens=todouble(Properties['gen_ai.usage.reasoning.output_tokens']) | extend CacheReadTokens=todouble(Properties['gen_ai.usage.cache_read.input_tokens']), CacheCreationTokens=todouble(Properties['gen_ai.usage.cache_creation.input_tokens']), ContextWindowPct=todouble(Properties['agentops.context.window_pct']), TokensRemoved=todouble(Properties['github.copilot.tokens_removed']), PermissionWaitMs=todouble(Properties['agentops.permission.wait_ms']) | extend EstimatedCostUsd=coalesce(todouble(Properties['agentops.cost.estimated_usd']), todouble(Properties['github.copilot.cost']) * 0.01, 0.0) | extend ErrorType=coalesce(tostring(Properties['error.type']), tostring(ResultCode)) | extend Failed=Success == false or tostring(Success) =~ 'false' or isnotempty(tostring(Properties['error.type'])) | summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId | extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started)) | extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15 | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort' | where OutcomeStatus != 'success' or RiskScore > 0 or EstimatedCostUsd >= 1.0 or ToolFailureCount > 0 or ContentCaptureSignal == true | extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal') | extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low') | extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.') | extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.' | project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash='') | extend RepoHash=tostring(column_ifexists('RepoHash', '')) | extend ModelActual=tostring(column_ifexists('ModelActual', '')) | extend TaskType=tostring(column_ifexists('TaskType', '')) | extend ToolName=tostring(column_ifexists('ToolName', '')) | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend BaselineValue=todouble(column_ifexists('BaselineValue', real(null))) | extend CurrentValue=todouble(column_ifexists('CurrentValue', real(null))) | extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate') | project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.') | extend PatternId=tostring(column_ifexists('PatternId', '')) | extend PatternKey=tostring(column_ifexists('PatternKey', '')) | extend PatternRuns=tolong(column_ifexists('PatternRuns', long(null))) | extend PatternDimension=tostring(column_ifexists('PatternDimension', '')) | extend EvalBucket=tostring(column_ifexists('EvalBucket', '')) | extend BenchmarkRunId=tostring(column_ifexists('BenchmarkRunId', '')) | extend BenchmarkDecision=tostring(column_ifexists('BenchmarkDecision', '')) | extend BenchmarkPassRatePct=todouble(column_ifexists('BenchmarkPassRatePct', real(null))) | extend BenchmarkAverageScore=todouble(column_ifexists('BenchmarkAverageScore', real(null))) | extend BenchmarkSafetyViolationCount=tolong(column_ifexists('BenchmarkSafetyViolationCount', long(null))) | extend BenchmarkArtifactAdded=tolong(column_ifexists('BenchmarkArtifactAdded', long(null))) | extend BenchmarkArtifactModified=tolong(column_ifexists('BenchmarkArtifactModified', long(null))) | extend BenchmarkArtifactDeleted=tolong(column_ifexists('BenchmarkArtifactDeleted', long(null))) | extend BenchmarkArtifactTotalChanged=tolong(column_ifexists('BenchmarkArtifactTotalChanged', long(null))) | extend BenchmarkArtifactFiles=column_ifexists('BenchmarkArtifactFiles', dynamic([])) | extend BenchmarkArtifactContentDiffs=column_ifexists('BenchmarkArtifactContentDiffs', dynamic([])) | extend BenchmarkHiddenChecksPassed=tolong(column_ifexists('BenchmarkHiddenChecksPassed', long(null))) | extend BenchmarkHiddenChecksFailed=tolong(column_ifexists('BenchmarkHiddenChecksFailed', long(null))) | extend BenchmarkHiddenCheckPacks=column_ifexists('BenchmarkHiddenCheckPacks', dynamic([])) | extend BenchmarkPolicyBlocks=tolong(column_ifexists('BenchmarkPolicyBlocks', long(null))) | extend BenchmarkPermissionProfiles=column_ifexists('BenchmarkPermissionProfiles', dynamic({})) | extend BenchmarkPolicyTasks=column_ifexists('BenchmarkPolicyTasks', dynamic([])) | extend BenchmarkSemanticCheckCount=tolong(column_ifexists('BenchmarkSemanticCheckCount', long(null))) | extend BenchmarkSemanticAverageScore=todouble(column_ifexists('BenchmarkSemanticAverageScore', real(null))) | extend BenchmarkSemanticChecks=column_ifexists('BenchmarkSemanticChecks', dynamic([])) | extend BenchmarkApprovalStatus=tostring(column_ifexists('BenchmarkApprovalStatus', '')) | extend BenchmarkApprovalCount=tolong(column_ifexists('BenchmarkApprovalCount', long(null))) | extend BenchmarkRequiredApprovals=tolong(column_ifexists('BenchmarkRequiredApprovals', long(null))) | extend BenchmarkApprovalApprovedAt=tostring(column_ifexists('BenchmarkApprovalApprovedAt', '')) | extend BenchmarkApprovalTicket=tostring(column_ifexists('BenchmarkApprovalTicket', '')) | extend BenchmarkApprovalSource=tostring(column_ifexists('BenchmarkApprovalSource', '')) | extend ChangeAnnotations=column_ifexists('ChangeAnnotations', dynamic([])) | extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) | where ('$run_id' == '__all' or tostring(column_ifexists('RunId', '')) == '$run_id') | where ('$trace_id' == '__all' or tostring(column_ifexists('TraceId', '')) == '$trace_id') | where ('$pattern_key' == '__all' or tostring(column_ifexists('PatternKey', '')) == '$pattern_key') | where ('$eval_bucket' == '__all' or tostring(column_ifexists('EvalBucket', '')) == iff('$eval_bucket' == 'ok', 'good', '$eval_bucket')) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, PatternDimension, EvalOverall, EvalBucket, BenchmarkRunId, BenchmarkDecision, BenchmarkPassRatePct, BenchmarkAverageScore, BenchmarkSafetyViolationCount, BenchmarkArtifactAdded, BenchmarkArtifactModified, BenchmarkArtifactDeleted, BenchmarkArtifactTotalChanged, BenchmarkArtifactFiles, BenchmarkHiddenChecksPassed, BenchmarkHiddenChecksFailed, BenchmarkHiddenCheckPacks, BenchmarkPolicyBlocks, BenchmarkPermissionProfiles, BenchmarkPolicyTasks, BenchmarkSemanticCheckCount, BenchmarkSemanticAverageScore, BenchmarkSemanticChecks, BenchmarkApprovalStatus, BenchmarkApprovalCount, BenchmarkRequiredApprovals, ChangeAnnotationCount, ChangeAnnotations, ChangeTargetRefs, DashboardCount, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200" } } ] @@ -2565,7 +2565,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -2871,7 +2871,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -3011,6 +3011,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -3022,6 +3023,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -3032,7 +3034,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -3044,6 +3047,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -3054,7 +3058,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3065,7 +3070,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3076,7 +3082,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3088,6 +3095,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 2, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -3098,7 +3106,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -3110,6 +3119,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3121,6 +3131,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -3131,7 +3142,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3142,7 +3154,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3153,7 +3166,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3165,6 +3179,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3175,7 +3190,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -3186,7 +3202,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -3197,7 +3214,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -3208,7 +3226,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 0, "query": "__all", "current": { "selected": true, @@ -3219,7 +3238,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 2, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -3230,7 +3250,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 0, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -3241,7 +3262,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, diff --git a/grafana/dashboards/v2/10-collector-health.json b/grafana/dashboards/v2/10-collector-health.json index f7c30ce..507d4bc 100644 --- a/grafana/dashboards/v2/10-collector-health.json +++ b/grafana/dashboards/v2/10-collector-health.json @@ -8,7 +8,7 @@ "id": null, "links": [ { - "title": "Home", + "title": "Today", "uid": "agentops-v2-home", "type": "link", "icon": "dashboard", @@ -28,7 +28,7 @@ "includeVars": true }, { - "title": "Replay", + "title": "Run Story", "uid": "agentops-v2-run-replay", "type": "link", "icon": "dashboard", @@ -160,7 +160,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -466,7 +466,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -679,7 +679,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}", "targetBlank": false } @@ -985,7 +985,7 @@ "id": "links", "value": [ { - "title": "Open Run Replay", + "title": "Open Run Story", "url": "/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}", "targetBlank": false } @@ -1229,6 +1229,7 @@ "name": "datasource", "type": "custom", "label": "datasource", + "hide": 2, "query": "azure-monitor-oob", "current": { "selected": true, @@ -1240,6 +1241,7 @@ "name": "workspace", "type": "custom", "label": "workspace", + "hide": 2, "query": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-agentops-dev/providers/Microsoft.OperationalInsights/workspaces/law-agentops-dev", "current": { "selected": true, @@ -1250,7 +1252,8 @@ { "name": "timeRange", "type": "custom", - "label": "timeRange", + "label": "Lookback", + "hide": 0, "query": "24h", "current": { "selected": true, @@ -1262,6 +1265,7 @@ "name": "actioner_url", "type": "custom", "label": "actioner url", + "hide": 2, "query": "/api", "current": { "selected": true, @@ -1272,7 +1276,8 @@ { "name": "run_id", "type": "custom", - "label": "run id", + "label": "Run", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1283,7 +1288,8 @@ { "name": "session_id", "type": "custom", - "label": "session id", + "label": "Session", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1294,7 +1300,8 @@ { "name": "trace_id", "type": "custom", - "label": "trace id", + "label": "Trace", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1306,6 +1313,7 @@ "name": "surface", "type": "custom", "label": "surface", + "hide": 0, "query": "__all,cli,sdk,vscode_mcp,github_action,cloud_agent,custom", "current": { "selected": true, @@ -1316,7 +1324,8 @@ { "name": "repo_hash", "type": "custom", - "label": "repo hash", + "label": "Repository", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1328,6 +1337,7 @@ "name": "branch_hash", "type": "custom", "label": "branch hash", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1339,6 +1349,7 @@ "name": "model", "type": "custom", "label": "model", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1349,7 +1360,8 @@ { "name": "agent_name", "type": "custom", - "label": "agent name", + "label": "Agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1360,7 +1372,8 @@ { "name": "skill_name", "type": "custom", - "label": "skill name", + "label": "Skill", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1371,7 +1384,8 @@ { "name": "mcp_server", "type": "custom", - "label": "mcp server", + "label": "MCP server", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1383,6 +1397,7 @@ "name": "sub_agent", "type": "custom", "label": "sub agent", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1393,7 +1408,8 @@ { "name": "task_type", "type": "custom", - "label": "task type", + "label": "Task type", + "hide": 2, "query": "__all,explain,review,test,fix,refactor,docs,debug_ci,unknown", "current": { "selected": true, @@ -1404,7 +1420,8 @@ { "name": "tool_name", "type": "custom", - "label": "tool name", + "label": "Tool", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1415,7 +1432,8 @@ { "name": "tool_risk", "type": "custom", - "label": "tool risk", + "label": "Tool risk", + "hide": 2, "query": "__all,read-only,write-file,shell,network,secret-access,browser-control,destructive,privileged", "current": { "selected": true, @@ -1426,7 +1444,8 @@ { "name": "pattern_key", "type": "custom", - "label": "pattern key", + "label": "Pattern", + "hide": 2, "query": "__all", "current": { "selected": true, @@ -1437,7 +1456,8 @@ { "name": "privacy_mode", "type": "custom", - "label": "privacy mode", + "label": "Privacy", + "hide": 0, "query": "__all,strict,compat,unsafe", "current": { "selected": true, @@ -1448,7 +1468,8 @@ { "name": "outcome_status", "type": "custom", - "label": "outcome status", + "label": "Outcome", + "hide": 2, "query": "__all,success,failed,cancelled,blocked,unknown", "current": { "selected": true, @@ -1459,7 +1480,8 @@ { "name": "eval_bucket", "type": "custom", - "label": "eval bucket", + "label": "Eval result", + "hide": 2, "query": "__all,ok,review,poor", "current": { "selected": true, diff --git a/infra/README.md b/infra/README.md index 5ce622c..47d0653 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,22 +1,38 @@ # Infrastructure -This folder contains AZD/Bicep infrastructure for the AgentOps v0.1 skeleton. +This folder contains AZD/Bicep infrastructure for the AgentOps developer-preview. ## Resources - Log Analytics Workspace - Application Insights -- Azure Monitor Workspace -- Azure Managed Grafana -- Key Vault +- Optional Azure Monitor Workspace, Azure Managed Grafana, and Key Vault - Optional Function App placeholder for alert actioner workflows - Optional Entra group RBAC assignments - Optional resource-group monthly budget ## Deployment +The default deployment is deliberately small: Log Analytics plus Application +Insights for the native Azure Monitor Agents experience. Grafana, Azure +Monitor Workspace, and Key Vault are advanced services and are disabled unless +`deployAdvancedServices=true` is explicitly supplied. + Do not deploy directly from this scaffold until validation has been run. +For the explicit, smallest deployment path, review and then run the guarded +helper with both approval flags: + +```bash +AGENTOPS_APPROVE_AZURE_CHANGES=yes AGENTOPS_CONFIRM_MINIMAL_DEPLOY=yes \ + AGENTOPS_AZURE_SUBSCRIPTION_ID="<approved-subscription-id>" \ + AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS="<approved-subscription-id>" \ + ./scripts/azure-minimal-deploy.sh +``` + +The helper always sets `deployAdvancedServices=false`; durable receipt tables +are separately opt-in with `AGENTOPS_DEPLOY_V2_INGESTION=true`. + ```bash azd provision ``` diff --git a/infra/bicep/main.bicep b/infra/bicep/main.bicep index e7f6b4c..7199564 100644 --- a/infra/bicep/main.bicep +++ b/infra/bicep/main.bicep @@ -17,6 +17,9 @@ param baseName string = 'copilot-agentops' @description('Security and cost posture preset. dev and team stay cost-capped; enterprise increases retention but keeps ingestion capped unless explicitly overridden.') param deploymentProfile string = 'team' +@description('Deploy optional advanced operator services: Azure Monitor Workspace, Azure Managed Grafana, and Key Vault. Keep false for the smallest native Application Insights path.') +param deployAdvancedServices bool = false + @description('Log Analytics retention in days. Use 0 to accept the deployment profile default.') @minValue(0) @maxValue(730) @@ -135,7 +138,7 @@ module appInsights 'app-insights.bicep' = { } } -module monitorWorkspace 'azure-monitor-workspace.bicep' = { +module monitorWorkspace 'azure-monitor-workspace.bicep' = if (deployAdvancedServices) { name: 'azure-monitor-workspace' params: { location: location @@ -144,7 +147,7 @@ module monitorWorkspace 'azure-monitor-workspace.bicep' = { } } -module grafana 'grafana.bicep' = { +module grafana 'grafana.bicep' = if (deployAdvancedServices) { name: 'grafana' params: { location: location @@ -155,7 +158,7 @@ module grafana 'grafana.bicep' = { } } -module keyVault 'key-vault.bicep' = { +module keyVault 'key-vault.bicep' = if (deployAdvancedServices) { name: 'key-vault' params: { location: location @@ -230,11 +233,11 @@ module alerts 'alerts.bicep' = if (deployAlerts) { } } -module rbac 'rbac.bicep' = if (deployRbacAssignments) { +module rbac 'rbac.bicep' = if (deployRbacAssignments && deployAdvancedServices) { name: 'enterprise-rbac' params: { logAnalyticsWorkspaceName: logAnalytics.outputs.name - grafanaName: grafana.outputs.name + grafanaName: grafana!.outputs.name observerPrincipalIds: observerPrincipalIds operatorPrincipalIds: operatorPrincipalIds adminPrincipalIds: adminPrincipalIds @@ -257,16 +260,18 @@ output BUDGET_DEPLOYED bool = effectiveDeployBudget output DEPLOYMENT_PROFILE string = deploymentProfile output LOG_ANALYTICS_RETENTION_DAYS int = logAnalytics.outputs.retentionInDays output LOG_ANALYTICS_DAILY_QUOTA_GB int = logAnalytics.outputs.dailyQuotaGb -output RBAC_ASSIGNMENTS_ENABLED bool = deployRbacAssignments +output ADVANCED_SERVICES_DEPLOYED bool = deployAdvancedServices +output RBAC_ASSIGNMENTS_ENABLED bool = deployRbacAssignments && deployAdvancedServices output LOG_ANALYTICS_WORKSPACE_ID string = logAnalytics.outputs.customerId output LOG_ANALYTICS_WORKSPACE_NAME string = logAnalytics.outputs.name -output AZURE_MONITOR_WORKSPACE_ID string = monitorWorkspace.outputs.resourceId -output GRAFANA_ENDPOINT string = grafana.outputs.endpoint -output GRAFANA_RESOURCE_ID string = grafana.outputs.resourceId -output GRAFANA_NAME string = grafana.outputs.name -output GRAFANA_PUBLIC_NETWORK_ACCESS string = grafana.outputs.publicNetworkAccess -output GRAFANA_ZONE_REDUNDANCY string = grafana.outputs.zoneRedundancy -output KEY_VAULT_RESOURCE_ID string = keyVault.outputs.resourceId +output AZURE_MONITOR_WORKSPACE_ID string = deployAdvancedServices ? monitorWorkspace!.outputs.resourceId : '' +output GRAFANA_DEPLOYED bool = deployAdvancedServices +output GRAFANA_ENDPOINT string = deployAdvancedServices ? grafana!.outputs.endpoint : '' +output GRAFANA_RESOURCE_ID string = deployAdvancedServices ? grafana!.outputs.resourceId : '' +output GRAFANA_NAME string = deployAdvancedServices ? grafana!.outputs.name : '' +output GRAFANA_PUBLIC_NETWORK_ACCESS string = deployAdvancedServices ? grafana!.outputs.publicNetworkAccess : '' +output GRAFANA_ZONE_REDUNDANCY string = deployAdvancedServices ? grafana!.outputs.zoneRedundancy : '' +output KEY_VAULT_RESOURCE_ID string = deployAdvancedServices ? keyVault!.outputs.resourceId : '' output SHARED_STORE_DEPLOYED bool = deploySharedStore output SHARED_STORE_ACCOUNT_NAME string = deploySharedStore ? sharedStore!.outputs.name : '' output SHARED_STORE_BLOB_ENDPOINT string = deploySharedStore ? sharedStore!.outputs.blobEndpoint : '' diff --git a/infra/bicep/v2-ingestion.bicep b/infra/bicep/v2-ingestion.bicep index 28e6dc6..458c8c7 100644 --- a/infra/bicep/v2-ingestion.bicep +++ b/infra/bicep/v2-ingestion.bicep @@ -42,7 +42,10 @@ var v2Tables = [ { name: 'ReasoningTokens', type: 'long' } { name: 'CacheReadTokens', type: 'long' } { name: 'CacheCreationTokens', type: 'long' } - { name: 'EstimatedCostUsd', type: 'real' } + // Compatibility: this column was originally deployed as long. Azure does + // not allow reusing an existing column name with a different type. + { name: 'EstimatedCostUsd', type: 'long' } + { name: 'EstimatedCostUsdReal', type: 'real' } { name: 'DurationMs', type: 'long' } { name: 'ToolCount', type: 'long' } { name: 'ToolFailureCount', type: 'long' } @@ -68,6 +71,9 @@ var v2Tables = [ stream: 'Custom-AgentOpsEvents_CL' columns: [ { name: 'TimeGenerated', type: 'datetime' } + { name: 'Sequence', type: 'long' } + { name: 'EventId', type: 'string' } + { name: 'ParentEventId', type: 'string' } { name: 'RunId', type: 'string' } { name: 'SessionId', type: 'string' } { name: 'TraceId', type: 'string' } @@ -75,6 +81,10 @@ var v2Tables = [ { name: 'SpanName', type: 'string' } { name: 'Status', type: 'string' } { name: 'ToolName', type: 'string' } + { name: 'McpServerName', type: 'string' } + { name: 'McpToolName', type: 'string' } + { name: 'CommandName', type: 'string' } + { name: 'ScriptName', type: 'string' } { name: 'AgentName', type: 'string' } { name: 'SkillName', type: 'string' } { name: 'SubAgentName', type: 'string' } @@ -82,10 +92,35 @@ var v2Tables = [ { name: 'ModelActual', type: 'string' } { name: 'InputTokens', type: 'long' } { name: 'OutputTokens', type: 'long' } + { name: 'ReasoningTokens', type: 'long' } + { name: 'CacheReadTokens', type: 'long' } + { name: 'CacheWriteTokens', type: 'long' } + { name: 'TotalTokens', type: 'long' } + { name: 'TotalToolCalls', type: 'long' } { name: 'DurationMs', type: 'long' } + { name: 'CopilotCost', type: 'real' } + // Compatibility: this column was originally deployed as long. Keep it + // immutable and use the additive real column for fractional values. { name: 'EstimatedCostUsd', type: 'long' } + { name: 'EstimatedCostUsdReal', type: 'real' } + { name: 'PermissionKind', type: 'string' } + { name: 'PermissionDecision', type: 'string' } + { name: 'ErrorType', type: 'string' } + { name: 'PremiumRequests', type: 'long' } + { name: 'TotalNanoAiu', type: 'long' } + { name: 'ApiDurationMs', type: 'long' } + { name: 'LinesAdded', type: 'long' } + { name: 'LinesRemoved', type: 'long' } + { name: 'FilesModified', type: 'long' } { name: 'ContentCaptureSignal', type: 'boolean' } + { name: 'ContentDroppedBytes', type: 'long' } + { name: 'ContentAction', type: 'string' } + { name: 'SecretLike', type: 'boolean' } + { name: 'ContentCaptureMode', type: 'string' } { name: 'PrivacyMode', type: 'string' } + { name: 'RepoHash', type: 'string' } + { name: 'BranchHash', type: 'string' } + { name: 'WorkingDirectoryHash', type: 'string' } { name: 'Surface', type: 'string' } { name: 'SchemaVersion', type: 'string' } ] diff --git a/install-agentops.ps1 b/install-agentops.ps1 index be92801..ba7ae91 100644 --- a/install-agentops.ps1 +++ b/install-agentops.ps1 @@ -14,7 +14,10 @@ if (-not $CollectorVersion) { $CollectorVersion = "0.151.0" } -$installShadow = $true +$installShadow = $false +if ($ShadowCopilot) { + $installShadow = $true +} if ($NoShadowCopilot) { $installShadow = $false } @@ -29,7 +32,7 @@ if (-not $NoCollector) { } $shimArgs = @() -if ($installShadow -or $ShadowCopilot) { +if ($installShadow) { $shimArgs += "-ShadowCopilot" } & (Join-Path $scriptDir "scripts/install-copilot-agentops-shim.ps1") @shimArgs @@ -46,8 +49,15 @@ Write-Host "" Write-Host "Next:" Write-Host ' $env:PATH = "$HOME/.local/bin;$env:PATH"' Write-Host " agentops configure import-azd" -Write-Host " agentops collector start --mode auto --privacy strict" -Write-Host ' copilot -p "Say AGENTOPS_READY in one short sentence."' +Write-Host " agentops collector start --mode local --privacy strict" +Write-Host ' agentops copilot -p "Say AGENTOPS_READY in one short sentence."' +Write-Host "" +if ($installShadow) { + Write-Host "Plain copilot is routed through AgentOps. Its original command is preserved for uninstall." +} else { + Write-Host "Plain copilot is unchanged. To route it through AgentOps too, reinstall with:" + Write-Host " ./install-agentops.ps1 -ShadowCopilot" +} Write-Host "" Write-Host "Remove later with:" Write-Host " ./uninstall-agentops.ps1" diff --git a/install-agentops.sh b/install-agentops.sh index e51dd90..4c6aad5 100755 --- a/install-agentops.sh +++ b/install-agentops.sh @@ -2,7 +2,11 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -shadow_copilot=true +cli_entry="${script_dir}/agentops-cli/src/index.js" +if [[ ! -f "${cli_entry}" ]]; then + cli_entry="${script_dir}/src/index.js" +fi +shadow_copilot=false install_collector=true install_plugin=false collector_force=false @@ -17,8 +21,8 @@ Installs AgentOps shims and, by default, the tested local Collector binary. Docker is not required for the normal path. Options: - --no-shadow-copilot Do not install the plain `copilot` shadow shim. - --shadow-copilot Install the plain `copilot` shadow shim. This is the default. + --no-shadow-copilot Do not install the plain `copilot` shadow shim (default). + --shadow-copilot Opt in to routing plain `copilot` through AgentOps. --no-collector Skip Collector binary installation. --collector-version VER Collector version to install. Default: 0.151.0. --force-collector Reinstall the Collector binary even if one exists. @@ -78,19 +82,19 @@ if [[ "${install_collector}" == true ]]; then if [[ "${collector_force}" == true ]]; then collector_args+=(--force) fi - node "${script_dir}/agentops-cli/src/index.js" "${collector_args[@]}" + node "${cli_entry}" "${collector_args[@]}" fi shim_args=() if [[ "${shadow_copilot}" == true ]]; then shim_args+=(--shadow-copilot) fi -"${script_dir}/scripts/install-copilot-agentops-shim.sh" "${shim_args[@]}" +"${script_dir}/scripts/install-copilot-agentops-shim.sh" ${shim_args[@]+"${shim_args[@]}"} if [[ "${install_plugin}" == true ]]; then echo echo "Installing AgentOps plugin files into COPILOT_HOME. Remove with: agentops plugin uninstall" - node "${script_dir}/agentops-cli/src/index.js" plugin install + node "${cli_entry}" plugin install fi cat <<'MSG' @@ -98,9 +102,12 @@ cat <<'MSG' Next: export PATH="$HOME/.local/bin:$PATH" agentops configure import-azd - agentops collector start --mode auto --privacy strict - copilot -p "Say AGENTOPS_READY in one short sentence." + agentops collector start --mode local --privacy strict + agentops copilot -p "Say AGENTOPS_READY in one short sentence." + +Plain `copilot` is unchanged. To route it through AgentOps too, reinstall with: + agentops install --shadow-copilot Remove later with: - ./uninstall-agentops.sh + agentops uninstall MSG diff --git a/packages/agentops-copilot-sdk/BENCHMARK.md b/packages/agentops-copilot-sdk/BENCHMARK.md new file mode 100644 index 0000000..f66b2d7 --- /dev/null +++ b/packages/agentops-copilot-sdk/BENCHMARK.md @@ -0,0 +1,28 @@ +# Local metadata performance benchmark + +Run the reproducible SDK benchmark with: + +```bash +npm run benchmark +``` + +It measures two local paths separately: + +- capture: normalization, privacy-safe field selection, hashing, ordering, and the application callback; +- export: OTLP JSON construction, enqueue latency, flush completion, and an in-process successful HTTP mock. + +The report includes p50 and p95 enqueue/capture latency, elapsed time, throughput, request counts, and precisely scoped exporter counters: + +- `queuedInMemory`: events admitted to this process's bounded memory queue; +- `collectorAccepted`: requests for which the configured OTLP HTTP endpoint returned a successful response; +- `retryAttempts`, `terminalFailures`, `queueOverflowed`, and `pendingInMemory`: local exporter lifecycle state. + +`collectorAccepted` does not prove that a collector exported the event downstream, that Azure ingested it, or that it is queryable. In this benchmark the endpoint is an in-process successful HTTP mock, so the counter proves only mock-endpoint acceptance. The benchmark performs no external network calls or Azure writes, and all generated events are synthetic metadata with `ContentCaptureMode=off`. + +The default workload is 100 warm-up events followed by 1,000 measured events. Both values are bounded at 10,000: + +```bash +AGENTOPS_BENCH_WARMUP=250 AGENTOPS_BENCH_EVENTS=5000 npm run benchmark +``` + +Results are descriptive. This project does not currently define or claim an approved performance threshold. Compare runs only on equivalent hardware, Node.js versions, workload sizes, and power conditions. The transport mock isolates adapter/serialization overhead; it does not represent collector, network, Azure ingestion, or dashboard latency. diff --git a/packages/agentops-copilot-sdk/LICENSE b/packages/agentops-copilot-sdk/LICENSE new file mode 100644 index 0000000..20f092d --- /dev/null +++ b/packages/agentops-copilot-sdk/LICENSE @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2026 Conor Mongan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/agentops-copilot-sdk/benchmark/metadata-benchmark.js b/packages/agentops-copilot-sdk/benchmark/metadata-benchmark.js new file mode 100644 index 0000000..00e2afe --- /dev/null +++ b/packages/agentops-copilot-sdk/benchmark/metadata-benchmark.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node +'use strict'; + +const { createAgentOpsSessionObserver, createOtlpJsonExporter } = require('../src'); + +const DEFAULT_EVENTS = 1000; +const DEFAULT_WARMUP = 100; +const MAX_EVENTS = 10000; + +function positiveInteger(value, fallback, maximum = MAX_EVENTS) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; + return Math.min(parsed, maximum); +} + +function percentile(values, fraction) { + if (!values.length) return 0; + const sorted = [...values].sort((left, right) => left - right); + const rank = Math.max(0, Math.ceil(fraction * sorted.length) - 1); + return sorted[rank]; +} + +function milliseconds(nanoseconds) { + return Number((nanoseconds / 1e6).toFixed(6)); +} + +function summarize(latenciesNs, elapsedNs, count) { + const seconds = elapsedNs / 1e9; + return { + events: count, + latency_ms: { + p50: milliseconds(percentile(latenciesNs, 0.50)), + p95: milliseconds(percentile(latenciesNs, 0.95)) + }, + elapsed_ms: milliseconds(elapsedNs), + throughput_events_per_second: seconds > 0 ? Number((count / seconds).toFixed(2)) : 0 + }; +} + +function syntheticEvent(index) { + return { + id: `benchmark-event-${index}`, + timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, 0, index)).toISOString(), + type: 'assistant.usage', + data: { + model: 'benchmark-model', + inputTokens: 100 + (index % 10), + outputTokens: 10 + (index % 3), + totalTokens: 110 + (index % 10), + durationMs: 25 + (index % 5) + } + }; +} + +function syntheticRow(index) { + return { + TimeGenerated: new Date(Date.UTC(2026, 0, 1, 0, 0, 0, index)).toISOString(), + EventId: `benchmark-event-${index}`, + RunId: 'benchmark-run', + SessionId: 'benchmark-session', + TraceId: 'benchmark-trace', + EventName: 'assistant.usage', + Surface: 'sdk', + PrivacyMode: 'strict', + ContentCaptureMode: 'off', + ModelActual: 'benchmark-model', + InputTokens: 100 + (index % 10), + OutputTokens: 10 + (index % 3) + }; +} + +async function runBenchmark(options = {}) { + const events = positiveInteger(options.events, DEFAULT_EVENTS); + const warmup = positiveInteger(options.warmup, DEFAULT_WARMUP); + const nowNs = options.nowNs || (() => Number(process.hrtime.bigint())); + const fetchImpl = options.fetchImpl || (async () => ({ ok: true, status: 200 })); + + let captured = 0; + const observer = createAgentOpsSessionObserver({ + runId: 'benchmark-run', + sessionId: 'benchmark-session', + traceId: 'benchmark-trace', + emit: () => { captured += 1; } + }); + for (let index = 0; index < warmup; index += 1) observer.observe(syntheticEvent(index)); + captured = 0; + + const captureLatencies = []; + const captureStart = nowNs(); + for (let index = 0; index < events; index += 1) { + const started = nowNs(); + observer.observe(syntheticEvent(index + warmup)); + captureLatencies.push(Math.max(0, nowNs() - started)); + } + const captureElapsed = Math.max(0, nowNs() - captureStart); + + const previousFetch = global.fetch; + const exportLatencies = []; + let requests = 0; + global.fetch = async (...args) => { + requests += 1; + return fetchImpl(...args); + }; + let exportSummary; + try { + const exporter = createOtlpJsonExporter({ + otlpEndpoint: 'http://127.0.0.1:4318', + maxAttempts: 1, + maxPendingEvents: events + }); + const exportStart = nowNs(); + for (let index = 0; index < events; index += 1) { + const started = nowNs(); + exporter.emit(syntheticRow(index)); + exportLatencies.push(Math.max(0, nowNs() - started)); + } + await exporter.flush(); + const exportElapsed = Math.max(0, nowNs() - exportStart); + exportSummary = { + ...summarize(exportLatencies, exportElapsed, events), + requests, + delivery: exporter.deliveryStatus() + }; + } finally { + global.fetch = previousFetch; + } + + return { + benchmark: 'agentops-copilot-sdk-metadata-only', + methodology: { + transport: 'in-process successful OTLP HTTP mock; no network or Azure writes', + content_capture: 'off', + thresholds: 'none; measurements are descriptive', + events, + warmup_events: warmup + }, + capture: { ...summarize(captureLatencies, captureElapsed, events), observed_events: captured }, + export: exportSummary + }; +} + +if (require.main === module) { + runBenchmark({ + events: positiveInteger(process.env.AGENTOPS_BENCH_EVENTS, DEFAULT_EVENTS), + warmup: positiveInteger(process.env.AGENTOPS_BENCH_WARMUP, DEFAULT_WARMUP) + }).then(result => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`), error => { + process.stderr.write(`${error.stack || error.message}\n`); + process.exitCode = 1; + }); +} + +module.exports = { percentile, positiveInteger, runBenchmark, summarize }; diff --git a/packages/agentops-copilot-sdk/examples/basic-sdk-agent/index.js b/packages/agentops-copilot-sdk/examples/basic-sdk-agent/index.js index e776564..3c7c697 100644 --- a/packages/agentops-copilot-sdk/examples/basic-sdk-agent/index.js +++ b/packages/agentops-copilot-sdk/examples/basic-sdk-agent/index.js @@ -10,7 +10,7 @@ function loadCopilotClient() { } async createSession(config) { - return { dryRun: true, config }; + return { dryRun: true, config, on: () => () => {} }; } }; } @@ -28,8 +28,15 @@ const client = createAgentOpsCopilotClient(CopilotClient, { }); async function main() { - const session = await client.createSession(client.createAgentOpsSessionConfig()); - console.log(`created session: ${Boolean(session)}`); + // One call composes privacy hooks, enables streaming, and attaches the + // ordered metadata-only session observer. + const session = await client.createAgentOpsSession(); + try { + console.log(`created session: ${Boolean(session)}`); + } finally { + if (typeof session?.destroy === 'function') await session.destroy(); + if (typeof client.stop === 'function') await client.stop(); + } } main().catch(error => { diff --git a/packages/agentops-copilot-sdk/examples/live-metadata-smoke/index.js b/packages/agentops-copilot-sdk/examples/live-metadata-smoke/index.js new file mode 100644 index 0000000..6535639 --- /dev/null +++ b/packages/agentops-copilot-sdk/examples/live-metadata-smoke/index.js @@ -0,0 +1,48 @@ +const { CopilotClient } = require('@github/copilot-sdk'); +const { createAgentOpsCopilotClient } = require('../../src'); + +const events = []; +const runId = process.env.AGENTOPS_SDK_SMOKE_RUN_ID || `sdk_live_${Date.now()}`; +const sessionId = process.env.AGENTOPS_SDK_SMOKE_SESSION_ID || `${runId}_session`; + +const client = createAgentOpsCopilotClient(CopilotClient, { + runId, + sessionId, + serviceName: 'agentops-sdk-live-smoke', + otlpEndpoint: process.env.AGENTOPS_OTLP_ENDPOINT || 'http://127.0.0.1:4318', + privacyMode: 'strict', + captureContent: false, + emit: event => events.push(event) +}); + +async function main() { + const session = await client.createAgentOpsSession({ model: 'auto', streaming: true }); + try { + await session.sendAndWait({ + prompt: 'Reply with exactly SDK_OBSERVABILITY_OK. Do not call tools or modify anything.' + }, 120000); + } finally { + if (typeof session.destroy === 'function') await session.destroy(); + await client.stop(); + } + + const eventNames = [...new Set(events.map(event => event.EventName).filter(Boolean))]; + process.stdout.write(`${JSON.stringify({ + ok: events.length > 0, + run_id: runId, + session_id: sessionId, + privacy_mode: 'strict', + content_capture: false, + ordered_events: events.length, + first_sequence: events.at(0)?.Sequence || null, + last_sequence: events.at(-1)?.Sequence || null, + event_names: eventNames + }, null, 2)}\n`); + if (!events.length) process.exitCode = 1; +} + +main().catch(async error => { + await client.stop().catch(() => {}); + process.stderr.write(`SDK metadata smoke failed: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/packages/agentops-copilot-sdk/package.json b/packages/agentops-copilot-sdk/package.json index ba19843..fd22dd8 100644 --- a/packages/agentops-copilot-sdk/package.json +++ b/packages/agentops-copilot-sdk/package.json @@ -5,11 +5,14 @@ "main": "src/index.js", "types": "src/index.d.ts", "files": [ + "LICENSE", "src", "examples" ], "scripts": { "test": "node --test", + "benchmark": "node benchmark/metadata-benchmark.js", + "smoke:live": "node examples/live-metadata-smoke/index.js", "publish:check": "node ../../scripts/check-sdk-publish.js" }, "peerDependencies": { diff --git a/packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js b/packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js index 7917715..ad3e5a5 100644 --- a/packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js +++ b/packages/agentops-copilot-sdk/src/createAgentOpsCopilotClient.js @@ -1,6 +1,9 @@ const { createAgentOpsHooks } = require('./hooks'); const { createTelemetryConfig, createTraceContextCallback } = require('./otel'); const { stableHash } = require('./privacy'); +const { createAgentOpsSessionObserver } = require('./session-events'); +const { createOtlpJsonExporter } = require('./otlp-exporter'); +const { createSafeEventNormalizer } = require('./event-envelope'); function composeHooks(agentOpsHooks = {}, userHooks = {}) { const composed = {}; @@ -21,47 +24,170 @@ function composeHooks(agentOpsHooks = {}, userHooks = {}) { return composed; } +function composeEventHandlers(agentOpsHandler, userHandler, onInstrumentationError) { + return event => { + try { + agentOpsHandler(event); + } catch (error) { + if (typeof onInstrumentationError === 'function') onInstrumentationError(error); + } + return typeof userHandler === 'function' ? userHandler(event) : undefined; + }; +} + +function denyPermissionsByDefault() { + return { + kind: 'reject', + feedback: 'AgentOps secure default: provide an explicit onPermissionRequest handler to approve this operation.' + }; +} + +function emptyDeliveryStatus() { + return { + queuedInMemory: 0, + collectorAccepted: 0, + retryAttempts: 0, + terminalFailures: 0, + pendingInMemory: 0, + queueOverflowed: 0, + lastCollectorAcceptedAt: null, + maxPendingEvents: 0 + }; +} + function createAgentOpsClientOptions(options = {}) { const privacyMode = options.privacyMode || 'strict'; - if (privacyMode === 'strict' && options.captureContent === true) { - throw new Error('strict privacy mode requires captureContent=false'); + if (options.captureContent === true || options.telemetry?.captureContent === true) { + throw new Error('AgentOps does not support SDK content capture; captureContent must remain false'); } const telemetry = { ...createTelemetryConfig(options), ...(options.telemetry || {}) }; - telemetry.captureContent = options.captureContent === true; + telemetry.captureContent = false; telemetry.otlpEndpoint = telemetry.otlpEndpoint || 'http://localhost:4318'; telemetry.sourceName = telemetry.sourceName || 'agentops-copilot-sdk'; const runId = options.runId || stableHash(`${Date.now()}:${Math.random()}`, 'run'); const sessionId = options.sessionId || stableHash(`${runId}:session`, 'session'); const traceId = options.traceId || stableHash(`${runId}:trace`, 'trace'); + const orderedExportEnabled = options.exportOrderedEvents !== false + && process.env.AGENTOPS_DISABLE_ORDERED_EXPORT !== '1'; + const exporter = orderedExportEnabled ? createOtlpJsonExporter({ + otlpEndpoint: telemetry.otlpEndpoint, + sourceName: telemetry.sourceName, + timeoutMs: options.exportTimeoutMs, + maxAttempts: options.maxAttempts, + retryDelayMs: options.retryDelayMs, + maxPendingEvents: options.maxPendingEvents, + onError: options.onExportError + }) : null; + const normalizeEvent = createSafeEventNormalizer({ context: { + RunId: runId, + SessionId: sessionId, + TraceId: traceId, + Surface: 'sdk', + PrivacyMode: privacyMode, + ContentCaptureMode: 'off' + } }); + const emit = event => { + try { + if (typeof options.emit === 'function') options.emit(event); + if (exporter) exporter.emit(event); + } catch (error) { + if (typeof options.onInstrumentationError === 'function') options.onInstrumentationError(error); + } + }; const hooks = createAgentOpsHooks({ hooks: options.hooks, - emit: options.emit, + emit, runId, sessionId, traceId, privacyMode, - captureContent: telemetry.captureContent + captureContent: telemetry.captureContent, + onInstrumentationError: options.onInstrumentationError, + normalizeEvent }); + const observer = createAgentOpsSessionObserver({ + emit, + runId, + sessionId, + traceId, + privacyMode, + captureContent: telemetry.captureContent, + usdPerCostUnit: options.usdPerCostUnit, + normalizeEvent + }); + + let isolatedSessionCount = 0; + function createIsolatedSessionConfig(sessionConfig = {}, resumeIdentity = '') { + isolatedSessionCount += 1; + const first = isolatedSessionCount === 1; + const isolatedSessionId = first && options.sessionId + ? sessionId + : stableHash(resumeIdentity || `${runId}:session:${isolatedSessionCount}`, 'session'); + const isolatedTraceId = first && options.traceId + ? traceId + : stableHash(`${runId}:${isolatedSessionId}:trace`, 'trace'); + const isolatedNormalizer = createSafeEventNormalizer({ context: { + RunId: runId, + SessionId: isolatedSessionId, + TraceId: isolatedTraceId, + Surface: 'sdk', + PrivacyMode: privacyMode, + ContentCaptureMode: 'off' + } }); + const isolatedHooks = createAgentOpsHooks({ + emit, + runId, + sessionId: isolatedSessionId, + traceId: isolatedTraceId, + privacyMode, + captureContent: telemetry.captureContent, + onInstrumentationError: options.onInstrumentationError, + normalizeEvent: isolatedNormalizer + }); + const isolatedObserver = createAgentOpsSessionObserver({ + emit, + runId, + sessionId: isolatedSessionId, + traceId: isolatedTraceId, + privacyMode, + captureContent: telemetry.captureContent, + usdPerCostUnit: options.usdPerCostUnit, + normalizeEvent: isolatedNormalizer + }); + return { + ...sessionConfig, + streaming: sessionConfig.streaming !== false, + onEvent: composeEventHandlers(isolatedObserver.observe, sessionConfig.onEvent, options.onInstrumentationError), + hooks: composeHooks(isolatedHooks, sessionConfig.hooks || {}) + }; + } return { telemetry, - onGetTraceContext: createTraceContextCallback(options.onGetTraceContext), + onGetTraceContext: createTraceContextCallback(options.onGetTraceContext, traceId), agentops: { runId, sessionId, traceId, privacyMode, - contentCaptureMode: telemetry.captureContent ? 'redacted' : 'off' + contentCaptureMode: 'off' }, hooks, + observer, + exporter, + deliveryStatus: () => exporter ? exporter.deliveryStatus() : emptyDeliveryStatus(), + flush: () => exporter ? exporter.flush() : Promise.resolve(emptyDeliveryStatus()), + createIsolatedSessionConfig, createSessionConfig: (sessionConfig = {}) => ({ ...sessionConfig, + streaming: sessionConfig.streaming !== false, + onEvent: composeEventHandlers(observer.observe, sessionConfig.onEvent, options.onInstrumentationError), hooks: composeHooks(hooks, sessionConfig.hooks || {}) }) }; @@ -78,12 +204,48 @@ function createAgentOpsCopilotClient(CopilotClient, options = {}) { }); client.agentops = clientOptions.agentops; client.agentopsHooks = clientOptions.hooks; + client.agentopsObserver = clientOptions.observer; + client.agentopsDeliveryStatus = clientOptions.deliveryStatus; + client.flushAgentOpsTelemetry = clientOptions.flush; client.createAgentOpsSessionConfig = clientOptions.createSessionConfig; + client.observeAgentOpsSession = session => clientOptions.observer.attach(session); + client.createAgentOpsSession = async sessionConfig => { + if (typeof client.createSession !== 'function') throw new Error('CopilotClient does not expose createSession()'); + return client.createSession(clientOptions.createIsolatedSessionConfig(sessionConfig)); + }; + client.resumeAgentOpsSession = async (sessionId, sessionConfig) => { + if (typeof client.resumeSession !== 'function') throw new Error('CopilotClient does not expose resumeSession()'); + return client.resumeSession(sessionId, clientOptions.createIsolatedSessionConfig(sessionConfig, sessionId)); + }; + if (typeof client.stop === 'function') { + const stop = client.stop.bind(client); + client.stop = async (...args) => { + let result; + let stopError; + let flushError; + try { + result = await stop(...args); + } catch (error) { + stopError = error; + } + try { + await clientOptions.flush(); + } catch (error) { + flushError = error; + } + if (stopError && flushError) throw new AggregateError([stopError, flushError], 'Copilot stop and AgentOps telemetry flush both failed'); + if (stopError) throw stopError; + if (flushError) throw flushError; + return result; + }; + } return client; } module.exports = { composeHooks, + composeEventHandlers, + denyPermissionsByDefault, createAgentOpsClientOptions, createAgentOpsCopilotClient }; diff --git a/packages/agentops-copilot-sdk/src/event-envelope.js b/packages/agentops-copilot-sdk/src/event-envelope.js new file mode 100644 index 0000000..f7d6ad3 --- /dev/null +++ b/packages/agentops-copilot-sdk/src/event-envelope.js @@ -0,0 +1,74 @@ +const { stableHash } = require('./privacy'); + +const safeEventFields = new Set([ + 'TimeGenerated', 'Sequence', 'EventId', 'ParentEventId', 'RunId', 'SessionId', + 'TraceId', 'Surface', 'SchemaVersion', 'EventName', 'SpanName', 'Status', + 'AgentName', 'ParentAgentName', 'SubAgentName', 'SkillName', 'ToolName', + 'McpServerName', 'McpToolName', 'ModelActual', 'InputTokens', 'OutputTokens', + 'ReasoningTokens', 'CacheReadTokens', 'CacheWriteTokens', 'TotalTokens', + 'TotalToolCalls', 'CopilotCost', 'EstimatedCostUsd', 'DurationMs', + 'PermissionKind', 'PermissionDecision', 'ErrorType', 'PremiumRequests', + 'TotalNanoAiu', 'ApiDurationMs', 'LinesAdded', 'LinesRemoved', 'FilesModified', + 'ContentCaptureSignal', 'ContentDroppedBytes', 'ContentKind', 'ContentAction', + 'SecretLike', 'PrivacyMode', 'ContentCaptureMode', 'RepoHash', 'BranchHash', + 'WorkingDirectoryHash', 'CommandName', 'ScriptName', 'PromptHash', + 'PromptSizeBytes', 'ArgsSchemaHash', 'ArgsSizeBytes', 'ResultSizeBytes', + 'ErrorSizeBytes' +]); + +const otelAttributeMap = { + RunId: 'agentops.run.id', SessionId: 'agentops.session.id', Surface: 'agentops.surface', + SchemaVersion: 'agentops.schema.version', PrivacyMode: 'agentops.privacy.mode', + ContentCaptureMode: 'agentops.content_capture.mode', ContentCaptureSignal: 'agentops.content_capture.signal', + ContentDroppedBytes: 'agentops.content.dropped_bytes', ContentKind: 'agentops.content.kind', + ContentAction: 'agentops.content.action', SecretLike: 'agentops.content.secret_like', + EventId: 'agentops.custom_event_id', ParentEventId: 'agentops.parent_event_id', + Sequence: 'agentops.event.sequence', EventName: 'agentops.event.name', + AgentName: 'agentops.agent.name', ParentAgentName: 'agentops.parent_agent.name', + SubAgentName: 'agentops.sub_agent.name', SkillName: 'agentops.skill.name', + ToolName: 'gen_ai.tool.name', McpServerName: 'agentops.mcp.server', McpToolName: 'agentops.mcp.tool', + ModelActual: 'gen_ai.response.model', InputTokens: 'gen_ai.usage.input_tokens', + OutputTokens: 'gen_ai.usage.output_tokens', ReasoningTokens: 'gen_ai.usage.reasoning.output_tokens', + CacheReadTokens: 'gen_ai.usage.cache_read.input_tokens', CacheWriteTokens: 'gen_ai.usage.cache_creation.input_tokens', + TotalTokens: 'gen_ai.usage.total_tokens', TotalToolCalls: 'agentops.tools.count', + CopilotCost: 'github.copilot.cost', EstimatedCostUsd: 'agentops.cost.estimated_usd', + DurationMs: 'agentops.duration.ms', PermissionKind: 'agentops.permission.kind', + PermissionDecision: 'agentops.permission.decision', ErrorType: 'error.type', + PremiumRequests: 'github.copilot.premium_requests', TotalNanoAiu: 'github.copilot.aiu.nano', + ApiDurationMs: 'agentops.api.duration_ms', LinesAdded: 'agentops.lines.added', + LinesRemoved: 'agentops.lines.removed', FilesModified: 'agentops.files.edited_count', + RepoHash: 'agentops.repo.hash', BranchHash: 'agentops.branch.hash', + WorkingDirectoryHash: 'agentops.workspace.hash', Status: 'agentops.outcome', + CommandName: 'agentops.command.name', ScriptName: 'agentops.script.name', + PromptHash: 'agentops.prompt.hash', PromptSizeBytes: 'agentops.prompt.size_bytes', + ArgsSchemaHash: 'agentops.tool.args_schema_hash', ArgsSizeBytes: 'agentops.tool.args_size_bytes', + ResultSizeBytes: 'agentops.tool.result_size_bytes', ErrorSizeBytes: 'agentops.error.size_bytes' +}; + +function createSafeEventNormalizer(options = {}) { + let sequence = 0; + const context = options.context || {}; + return function normalizeEvent(input = {}) { + const source = { ...context, ...input }; + const next = Number.isInteger(source.Sequence) && source.Sequence > sequence ? source.Sequence : sequence + 1; + sequence = next; + const parsed = new Date(source.TimeGenerated || Date.now()); + const time = Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString(); + const name = String(source.EventName || 'agentops.event').slice(0, 200); + const normalized = { + ...source, + TimeGenerated: time, + Sequence: next, + EventId: source.EventId || stableHash(`${source.SessionId || 'session'}:${next}:${name}:${time}`, 'event'), + ParentEventId: source.ParentEventId || '', + SchemaVersion: '2', + EventName: name, + SpanName: String(source.SpanName || name).slice(0, 200) + }; + return Object.fromEntries(Object.entries(normalized).filter(([key, value]) => ( + safeEventFields.has(key) && value !== undefined && value !== null + ))); + }; +} + +module.exports = { createSafeEventNormalizer, otelAttributeMap, safeEventFields }; diff --git a/packages/agentops-copilot-sdk/src/hooks.js b/packages/agentops-copilot-sdk/src/hooks.js index b627e53..d5155eb 100644 --- a/packages/agentops-copilot-sdk/src/hooks.js +++ b/packages/agentops-copilot-sdk/src/hooks.js @@ -1,7 +1,13 @@ -const { safePromptMetadata, safeToolMetadata, stableHash } = require('./privacy'); +const { byteSize, contentSignal, safePromptMetadata, safeToolMetadata, stableHash } = require('./privacy'); +const { createSafeEventNormalizer } = require('./event-envelope'); -function emitTelemetry(emit, event) { - if (typeof emit === 'function') emit(event); +function emitTelemetry(emit, normalizeEvent, event, onInstrumentationError) { + if (typeof emit !== 'function') return; + try { + emit(normalizeEvent(event)); + } catch (error) { + if (typeof onInstrumentationError === 'function') onInstrumentationError(error); + } } function baseEvent(type, context = {}, extra = {}) { @@ -27,12 +33,14 @@ function createAgentOpsHooks(options = {}) { contentCaptureMode: options.captureContent ? 'redacted' : 'off' }; const emit = options.emit; + const normalizeEvent = options.normalizeEvent || createSafeEventNormalizer({ context }); const userHooks = options.hooks || {}; + const emitSafe = event => emitTelemetry(emit, normalizeEvent, event, options.onInstrumentationError); return { onUserPromptSubmitted: async (input, invocation) => { const metadata = safePromptMetadata(input); - emitTelemetry(emit, baseEvent('agentops.prompt.submitted', context, { + emitSafe(baseEvent('agentops.prompt.submitted', context, { PromptHash: metadata.promptHash, PromptSizeBytes: metadata.promptSizeBytes, ContentCaptureSignal: metadata.contentSignal.observed, @@ -40,11 +48,11 @@ function createAgentOpsHooks(options = {}) { ContentAction: metadata.contentSignal.action, SecretLike: metadata.contentSignal.secretLike })); - return userHooks.onUserPromptSubmitted ? userHooks.onUserPromptSubmitted(input, invocation) : null; + return userHooks.onUserPromptSubmitted ? userHooks.onUserPromptSubmitted(input, invocation) : undefined; }, onPreToolUse: async (input, invocation) => { const metadata = safeToolMetadata(input); - emitTelemetry(emit, baseEvent('agentops.policy.decision', context, { + emitSafe(baseEvent('agentops.policy.decision', context, { ToolName: metadata.toolName, ArgsSchemaHash: metadata.argsSchemaHash, ArgsSizeBytes: metadata.argsSizeBytes, @@ -54,11 +62,11 @@ function createAgentOpsHooks(options = {}) { SecretLike: metadata.argsSignal.secretLike })); if (userHooks.onPreToolUse) return userHooks.onPreToolUse(input, invocation); - return { permissionDecision: 'allow' }; + return undefined; }, onPostToolUse: async (input, invocation) => { const metadata = safeToolMetadata(input); - emitTelemetry(emit, baseEvent('agentops.tool.result', context, { + emitSafe(baseEvent('agentops.tool.result', context, { ToolName: metadata.toolName, ResultSizeBytes: metadata.resultSizeBytes, ContentCaptureSignal: metadata.resultSignal.observed, @@ -66,21 +74,38 @@ function createAgentOpsHooks(options = {}) { ContentAction: metadata.resultSignal.action, SecretLike: metadata.resultSignal.secretLike })); - return userHooks.onPostToolUse ? userHooks.onPostToolUse(input, invocation) : null; + return userHooks.onPostToolUse ? userHooks.onPostToolUse(input, invocation) : undefined; + }, + onPostToolUseFailure: async (input, invocation) => { + const metadata = safeToolMetadata(input); + const error = input?.error || input?.failure || input?.toolError || ''; + const errorText = error instanceof Error ? `${error.name}:${error.message}` : error; + const errorSignal = contentSignal(errorText, 'error'); + emitSafe(baseEvent('agentops.tool.result', context, { + ToolName: metadata.toolName, + Status: 'failed', + ErrorType: String(input?.error?.name || input?.error?.code || input?.errorType || 'tool_error'), + ErrorSizeBytes: byteSize(errorText), + ContentCaptureSignal: errorSignal.observed, + ContentKind: errorSignal.kind, + ContentAction: errorSignal.action, + SecretLike: errorSignal.secretLike + })); + return userHooks.onPostToolUseFailure ? userHooks.onPostToolUseFailure(input, invocation) : undefined; }, onSessionStart: async (input, invocation) => { - emitTelemetry(emit, baseEvent('agentops.session.start', context)); - return userHooks.onSessionStart ? userHooks.onSessionStart(input, invocation) : null; + emitSafe(baseEvent('agentops.session.start', context)); + return userHooks.onSessionStart ? userHooks.onSessionStart(input, invocation) : undefined; }, onSessionEnd: async (input, invocation) => { - emitTelemetry(emit, baseEvent('agentops.session.end', context)); - return userHooks.onSessionEnd ? userHooks.onSessionEnd(input, invocation) : null; + emitSafe(baseEvent('agentops.session.end', context)); + return userHooks.onSessionEnd ? userHooks.onSessionEnd(input, invocation) : undefined; }, - onError: async (input, invocation) => { - emitTelemetry(emit, baseEvent('agentops.error', context, { + onErrorOccurred: async (input, invocation) => { + emitSafe(baseEvent('agentops.error', context, { ErrorType: String(input?.error?.name || input?.errorType || 'error') })); - return userHooks.onError ? userHooks.onError(input, invocation) : null; + return userHooks.onErrorOccurred ? userHooks.onErrorOccurred(input, invocation) : undefined; } }; } diff --git a/packages/agentops-copilot-sdk/src/index.d.ts b/packages/agentops-copilot-sdk/src/index.d.ts index c45379c..43479d8 100644 --- a/packages/agentops-copilot-sdk/src/index.d.ts +++ b/packages/agentops-copilot-sdk/src/index.d.ts @@ -1,25 +1,74 @@ export type AgentOpsEvent = Record<string, unknown>; +/** Local exporter state. Collector acceptance does not prove downstream or Azure ingestion. */ +export interface AgentOpsDeliveryStatus { + queuedInMemory: number; + collectorAccepted: number; + retryAttempts: number; + terminalFailures: number; + pendingInMemory: number; + queueOverflowed: number; + lastCollectorAcceptedAt: string | null; + maxPendingEvents: number; +} + export interface AgentOpsClientOptions { otlpEndpoint?: string; exporterType?: string; + otlpProtocol?: 'http/json' | 'http/protobuf'; + filePath?: string; sourceName?: string; + serviceName?: string; captureContent?: boolean; telemetry?: Record<string, unknown>; privacyMode?: 'strict' | 'compat' | 'unsafe'; runId?: string; sessionId?: string; traceId?: string; + usdPerCostUnit?: number; hooks?: Record<string, (...args: unknown[]) => unknown>; emit?: (event: AgentOpsEvent) => void; onGetTraceContext?: () => Record<string, string>; + exportOrderedEvents?: boolean; + exportTimeoutMs?: number; + onExportError?: (error: Error) => void; + onInstrumentationError?: (error: Error) => void; + maxAttempts?: number; + retryDelayMs?: number; + maxPendingEvents?: number; +} + +export interface AgentOpsSessionObserver { + attach(session: { on(handler: (event: unknown) => void): unknown }): () => void; + observe(event: { id?: string; timestamp?: string; parentId?: string; type: string; data?: Record<string, unknown> }): AgentOpsEvent; + context: Record<string, string>; + eventTypes: string[]; } export function createAgentOpsClientOptions(options?: AgentOpsClientOptions): Record<string, unknown>; -export function createAgentOpsCopilotClient<T>(CopilotClient: new (options: Record<string, unknown>) => T, options?: AgentOpsClientOptions): T; +export function createAgentOpsCopilotClient<T>(CopilotClient: new (options: Record<string, unknown>) => T, options?: AgentOpsClientOptions): T & { + agentops: Record<string, unknown>; + agentopsHooks: Record<string, (...args: unknown[]) => unknown>; + agentopsObserver: AgentOpsSessionObserver; + agentopsDeliveryStatus(): AgentOpsDeliveryStatus; + createAgentOpsSessionConfig(config?: Record<string, unknown>): Record<string, unknown>; + observeAgentOpsSession(session: { on(handler: (event: unknown) => void): unknown }): () => void; + createAgentOpsSession(config?: Record<string, unknown>): Promise<unknown>; + resumeAgentOpsSession(sessionId: string, config?: Record<string, unknown>): Promise<unknown>; + flushAgentOpsTelemetry(): Promise<AgentOpsDeliveryStatus>; +}; export function createAgentOpsHooks(options?: AgentOpsClientOptions): Record<string, (...args: unknown[]) => unknown>; +export function createAgentOpsSessionObserver(options?: AgentOpsClientOptions): AgentOpsSessionObserver; +export function createOtlpJsonExporter(options?: AgentOpsClientOptions): { emit(event: AgentOpsEvent): AgentOpsEvent; flush(): Promise<AgentOpsDeliveryStatus>; deliveryStatus(): AgentOpsDeliveryStatus; endpoint: string }; +export function createSafeEventNormalizer(options?: { context?: AgentOpsEvent }): (event: AgentOpsEvent) => AgentOpsEvent; +export const otelAttributeMap: Record<string, string>; +export const safeEventFields: Set<string>; +export const sessionEventTypes: string[]; export function composeHooks(agentOpsHooks?: Record<string, (...args: unknown[]) => unknown>, userHooks?: Record<string, (...args: unknown[]) => unknown>): Record<string, (...args: unknown[]) => unknown>; +export function composeEventHandlers(agentOpsHandler: (event: unknown) => unknown, userHandler?: (event: unknown) => unknown, onInstrumentationError?: (error: Error) => void): (event: unknown) => unknown; +export function denyPermissionsByDefault(): { kind: 'reject'; feedback: string }; export function createTelemetryConfig(options?: AgentOpsClientOptions): Record<string, unknown>; export function createTraceContext(): { traceparent: string; tracestate?: string }; -export function createTraceContextCallback(existing?: () => Record<string, string>): () => Record<string, string>; +export function createTraceContextCallback(existing?: () => Record<string, string>, traceSeed?: unknown): () => Record<string, string>; +export function traceIdHex(seed?: unknown): string; export function stableHash(value: unknown, prefix?: string): string; diff --git a/packages/agentops-copilot-sdk/src/index.js b/packages/agentops-copilot-sdk/src/index.js index 108a7c1..650c5f5 100644 --- a/packages/agentops-copilot-sdk/src/index.js +++ b/packages/agentops-copilot-sdk/src/index.js @@ -1,19 +1,31 @@ -const { composeHooks, createAgentOpsClientOptions, createAgentOpsCopilotClient } = require('./createAgentOpsCopilotClient'); +const { composeEventHandlers, composeHooks, createAgentOpsClientOptions, createAgentOpsCopilotClient, denyPermissionsByDefault } = require('./createAgentOpsCopilotClient'); const { createAgentOpsHooks } = require('./hooks'); -const { createTelemetryConfig, createTraceContext, createTraceContextCallback } = require('./otel'); +const { createTelemetryConfig, createTraceContext, createTraceContextCallback, traceIdHex } = require('./otel'); const { byteSize, contentSignal, safePromptMetadata, safeToolMetadata, stableHash } = require('./privacy'); +const { createAgentOpsSessionObserver, sessionEventTypes } = require('./session-events'); +const { createOtlpJsonExporter } = require('./otlp-exporter'); +const { createSafeEventNormalizer, otelAttributeMap, safeEventFields } = require('./event-envelope'); module.exports = { byteSize, composeHooks, + composeEventHandlers, contentSignal, createAgentOpsClientOptions, createAgentOpsCopilotClient, createAgentOpsHooks, + createAgentOpsSessionObserver, + createOtlpJsonExporter, + createSafeEventNormalizer, createTelemetryConfig, createTraceContext, createTraceContextCallback, + denyPermissionsByDefault, safePromptMetadata, safeToolMetadata, - stableHash + safeEventFields, + sessionEventTypes, + stableHash, + otelAttributeMap, + traceIdHex }; diff --git a/packages/agentops-copilot-sdk/src/otel.js b/packages/agentops-copilot-sdk/src/otel.js index a6e50aa..d2c4488 100644 --- a/packages/agentops-copilot-sdk/src/otel.js +++ b/packages/agentops-copilot-sdk/src/otel.js @@ -4,26 +4,39 @@ function randomHex(bytes) { return crypto.randomBytes(bytes).toString('hex'); } -function createTraceContext() { +function traceIdHex(seed) { + if (!seed) return randomHex(16); + if (/^[a-f0-9]{32}$/i.test(String(seed))) return String(seed).toLowerCase(); + return crypto.createHash('sha256').update(String(seed)).digest('hex').slice(0, 32); +} + +function createTraceContext(traceId = randomHex(16)) { return { - traceparent: `00-${randomHex(16)}-${randomHex(8)}-01` + traceparent: `00-${traceId}-${randomHex(8)}-01` }; } function createTelemetryConfig(options = {}) { - return { + if (options.captureContent === true) { + throw new Error('AgentOps does not support SDK content capture; captureContent must remain false'); + } + const config = { otlpEndpoint: options.otlpEndpoint || 'http://localhost:4318', exporterType: options.exporterType || 'otlp-http', - sourceName: options.sourceName || 'agentops-copilot-sdk', - captureContent: options.captureContent === true + sourceName: options.sourceName || options.serviceName || 'agentops-copilot-sdk', + captureContent: false }; + if (options.otlpProtocol) config.otlpProtocol = options.otlpProtocol; + if (options.filePath) config.filePath = options.filePath; + return config; } -function createTraceContextCallback(existing) { +function createTraceContextCallback(existing, traceSeed) { + const traceId = traceIdHex(traceSeed); return () => { const base = typeof existing === 'function' ? existing() : {}; return { - ...createTraceContext(), + ...createTraceContext(traceId), ...(base || {}) }; }; @@ -32,5 +45,6 @@ function createTraceContextCallback(existing) { module.exports = { createTelemetryConfig, createTraceContext, - createTraceContextCallback + createTraceContextCallback, + traceIdHex }; diff --git a/packages/agentops-copilot-sdk/src/otlp-exporter.js b/packages/agentops-copilot-sdk/src/otlp-exporter.js new file mode 100644 index 0000000..ffc11ba --- /dev/null +++ b/packages/agentops-copilot-sdk/src/otlp-exporter.js @@ -0,0 +1,167 @@ +const crypto = require('node:crypto'); +const { createSafeEventNormalizer, otelAttributeMap } = require('./event-envelope'); + +function hexId(value, bytes) { + return crypto.createHash('sha256').update(String(value || crypto.randomUUID())).digest('hex').slice(0, bytes * 2); +} + +function attribute(value) { + if (typeof value === 'boolean') return { boolValue: value }; + if (typeof value === 'number') return Number.isInteger(value) + ? { intValue: String(value) } + : { doubleValue: value }; + return { stringValue: String(value) }; +} + +function safeEndpoint(value) { + const endpoint = new URL(value || 'http://localhost:4318'); + const loopback = ['localhost', '127.0.0.1', '::1'].includes(endpoint.hostname); + if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && loopback)) { + throw new Error('AgentOps ordered-event OTLP requires HTTPS or a loopback HTTP endpoint'); + } + const path = endpoint.pathname.replace(/\/$/, ''); + return `${endpoint.origin}${path.endsWith('/v1/traces') ? path : `${path}/v1/traces`}`; +} + +function retryableStatus(status) { + return status === 408 || status === 429 || status >= 500; +} + +function wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function createOtlpJsonExporter(options = {}) { + const endpoint = safeEndpoint(options.otlpEndpoint); + const sourceName = String(options.sourceName || 'agentops-copilot-sdk').slice(0, 200); + const pending = new Set(); + const deliveryFailures = []; + const normalizeEvent = createSafeEventNormalizer(); + const maxAttempts = Math.max(1, Math.min(5, Number(options.maxAttempts) || 3)); + const retryDelayMs = Math.max(0, Math.min(5000, Number(options.retryDelayMs ?? 100))); + const maxPendingEvents = Math.max(1, Math.min(10000, Number(options.maxPendingEvents) || 1000)); + const delivery = { + queuedInMemory: 0, + collectorAccepted: 0, + retryAttempts: 0, + terminalFailures: 0, + queueOverflowed: 0, + lastCollectorAcceptedAt: null + }; + let activeFlush = null; + + async function send(payload) { + let lastError; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(options.timeoutMs || 5000) + }); + if (response.ok) return; + const error = new Error(`AgentOps OTLP export failed with HTTP ${response.status}`); + error.retryable = retryableStatus(response.status); + if (!error.retryable || attempt === maxAttempts) throw error; + lastError = error; + } catch (error) { + lastError = error; + if (error.retryable === false || attempt === maxAttempts) throw error; + } + delivery.retryAttempts += 1; + await wait(retryDelayMs * attempt); + } + throw lastError; + } + + function emit(row = {}) { + row = normalizeEvent(row); + if (pending.size >= maxPendingEvents) { + const error = new Error(`AgentOps telemetry queue is full (${maxPendingEvents} pending events)`); + delivery.queueOverflowed += 1; + delivery.terminalFailures += 1; + deliveryFailures.push(error); + if (typeof options.onError === 'function') options.onError(error); + return row; + } + const time = new Date(row.TimeGenerated || Date.now()); + const startMs = Number.isNaN(time.getTime()) ? Date.now() : time.getTime(); + const durationMs = Math.max(0, Number(row.DurationMs) || 0); + const traceId = hexId(row.TraceId || row.RunId, 16); + const spanId = hexId(row.EventId, 8); + const attributes = []; + for (const [field, key] of Object.entries(otelAttributeMap)) { + const value = row[field]; + if (value === undefined || value === null || value === '') continue; + attributes.push({ key, value: attribute(value) }); + } + attributes.push({ key: 'gen_ai.operation.name', value: { stringValue: String(row.EventName || 'agentops.event').slice(0, 200) } }); + if (row.SessionId) attributes.push({ key: 'gen_ai.conversation.id', value: { stringValue: String(row.SessionId).slice(0, 200) } }); + + const payload = { + resourceSpans: [{ + resource: { attributes: [ + { key: 'service.name', value: { stringValue: sourceName } }, + { key: 'agent.framework', value: { stringValue: 'github-copilot-sdk' } }, + { key: 'agent.runtime', value: { stringValue: 'nodejs' } } + ] }, + scopeSpans: [{ + scope: { name: '@agentops/copilot-sdk', version: '0.1.0' }, + spans: [{ + traceId, + spanId, + ...(row.ParentEventId ? { parentSpanId: hexId(row.ParentEventId, 8) } : {}), + name: String(row.SpanName || row.EventName || 'agentops.event').slice(0, 200), + kind: 1, + startTimeUnixNano: String(BigInt(startMs) * 1000000n), + endTimeUnixNano: String(BigInt(startMs + durationMs) * 1000000n), + attributes, + status: { code: row.Status === 'failed' ? 2 : 1 } + }] + }] + }] + }; + + const request = send(payload).then( + () => { + // This proves only that the configured OTLP HTTP endpoint returned a + // successful response. It does not prove downstream or Azure ingestion. + delivery.collectorAccepted += 1; + delivery.lastCollectorAcceptedAt = new Date().toISOString(); + return { ok: true }; + }, + error => { + if (typeof options.onError === 'function') options.onError(error); + delivery.terminalFailures += 1; + deliveryFailures.push(error); + return { ok: false, error }; + } + ).finally(() => pending.delete(request)); + pending.add(request); + delivery.queuedInMemory += 1; + return row; + } + + async function drain() { + while (pending.size) await Promise.all([...pending]); + const failures = deliveryFailures.splice(0); + if (failures.length) { + throw new AggregateError(failures, `AgentOps failed to export ${failures.length} telemetry request(s)`); + } + return deliveryStatus(); + } + + function flush() { + if (!activeFlush) activeFlush = drain().finally(() => { activeFlush = null; }); + return activeFlush; + } + + function deliveryStatus() { + return { ...delivery, pendingInMemory: pending.size, maxPendingEvents }; + } + + return { emit, flush, deliveryStatus, endpoint }; +} + +module.exports = { createOtlpJsonExporter, retryableStatus, safeEndpoint }; diff --git a/packages/agentops-copilot-sdk/src/session-events.js b/packages/agentops-copilot-sdk/src/session-events.js new file mode 100644 index 0000000..48eeba2 --- /dev/null +++ b/packages/agentops-copilot-sdk/src/session-events.js @@ -0,0 +1,247 @@ +const { byteSize, contentSignal, stableHash } = require('./privacy'); +const { createSafeEventNormalizer } = require('./event-envelope'); + +const sessionEventTypes = [ + 'assistant.turn_start', + 'assistant.intent', + 'assistant.reasoning', + 'assistant.reasoning_delta', + 'assistant.message', + 'assistant.message_delta', + 'assistant.turn_end', + 'assistant.usage', + 'assistant.streaming_delta', + 'permission.requested', + 'permission.completed', + 'user_input.requested', + 'user_input.completed', + 'elicitation.requested', + 'elicitation.completed', + 'tool.execution_start', + 'tool.execution_partial_result', + 'tool.execution_progress', + 'tool.execution_complete', + 'tool.user_requested', + 'subagent.started', + 'subagent.completed', + 'subagent.failed', + 'subagent.selected', + 'subagent.deselected', + 'skill.invoked', + 'session.context_changed', + 'session.idle', + 'session.title_changed', + 'session.usage_info', + 'session.session_limits_changed', + 'session.usage_checkpoint', + 'session.compaction_start', + 'session.compaction_complete', + 'session.task_complete', + 'session.shutdown', + 'session.error', + 'abort', + 'user.message', + 'system.message', + 'external_tool.requested', + 'external_tool.completed', + 'exit_plan_mode.requested', + 'exit_plan_mode.completed', + 'command.queued', + 'command.completed', + 'session_limits_exhausted.requested', + 'session_limits_exhausted.completed' +]; + +const contentKeys = new Set([ + 'content', 'message', 'reasoning', 'reasoningText', 'reasoningOpaque', + 'encryptedContent', 'deltaContent', 'intent', 'summary', 'summaryContent', + 'prompt', 'question', 'args', 'arguments', 'result', 'output', 'partialOutput', + 'progressMessage', 'toolRequests', 'title', 'path', 'cwd', 'gitRoot', 'repository', + 'branch', 'checkpointPath', 'stack', 'error', 'errorMessage', 'errorReason', + 'permissionRequest', 'requestedSchema', 'choices', 'attachments', 'planContent', + 'actions', 'transformedContent', 'metadata', 'agentDescription' +]); + +function number(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function timestamp(value) { + const date = value ? new Date(value) : new Date(); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); +} + +function safeName(value, fallback = '') { + if (value === undefined || value === null) return fallback; + return String(value).slice(0, 200); +} + +function droppedContent(data = {}) { + const observed = []; + for (const [key, value] of Object.entries(data)) { + if (!contentKeys.has(key) || value === undefined || value === null || byteSize(value) === 0) continue; + observed.push({ key, signal: contentSignal(value, key) }); + } + return { + observed: observed.length > 0, + bytes: observed.reduce((total, item) => total + byteSize(data[item.key]), 0), + secretLike: observed.some(item => item.signal.secretLike) + }; +} + +function commandMetadata(data = {}) { + const args = data.args || data.arguments || {}; + const raw = typeof args === 'object' + ? (args.command || args.cmd || args.script || (Array.isArray(args.argv) ? args.argv.join(' ') : '')) + : ''; + const commandText = raw || data.command || data.permissionRequest?.fullCommandText || ''; + if (typeof commandText !== 'string' || !commandText.trim()) return {}; + const first = commandText.trim().split(/\s+/)[0].replace(/^['"]|['"]$/g, ''); + const command = first.split('/').pop(); + if (!/^[A-Za-z0-9._+-]{1,100}$/.test(command)) return {}; + const fields = { CommandName: command }; + if (/\.(?:sh|bash|zsh|ps1|py|js|mjs|cjs|ts)$/i.test(command)) fields.ScriptName = command; + const tokens = commandText.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; + const interpreter = command.toLowerCase(); + if (['node', 'python', 'python3', 'bash', 'zsh', 'sh', 'pwsh', 'powershell'].includes(interpreter)) { + const token = tokens.slice(1).find(item => !item.startsWith('-')); + const script = token ? token.replace(/^['"]|['"]$/g, '').split('/').pop() : ''; + if (/^[A-Za-z0-9._+-]{1,100}\.(?:sh|bash|zsh|ps1|py|js|mjs|cjs|ts)$/i.test(script)) fields.ScriptName = script; + } else if (['npm', 'pnpm', 'yarn', 'bun'].includes(interpreter) && tokens[1] === 'run') { + const task = String(tokens[2] || '').replace(/^['"]|['"]$/g, ''); + if (/^[A-Za-z0-9._:+-]{1,100}$/.test(task)) fields.ScriptName = `${interpreter}:run:${task}`; + } + return fields; +} + +function statusFor(type, data = {}) { + if (type.endsWith('.failed') || type === 'session.error' || data.success === false) return 'failed'; + if (type === 'abort') return 'aborted'; + if (type.endsWith('_start') || type.endsWith('.started') || type.endsWith('.requested') || type.endsWith('.queued')) return 'started'; + if (type === 'permission.completed') return safeName(data.permissionDecision || data.decision || data.result?.kind, 'completed'); + return 'completed'; +} + +function createAgentOpsSessionObserver(options = {}) { + const context = { + runId: options.runId || stableHash(`${Date.now()}:${Math.random()}`, 'run'), + sessionId: options.sessionId || stableHash(`${Date.now()}:session`, 'session'), + traceId: options.traceId || stableHash(`${Date.now()}:trace`, 'trace'), + privacyMode: options.privacyMode || 'strict', + contentCaptureMode: options.captureContent ? 'redacted' : 'off' + }; + const emit = typeof options.emit === 'function' ? options.emit : () => {}; + const normalizeEvent = options.normalizeEvent || createSafeEventNormalizer({ context }); + const starts = new Map(); + + function observe(event = {}) { + const type = safeName(event.type, 'unknown'); + const data = event.data && typeof event.data === 'object' ? event.data : {}; + const time = timestamp(event.timestamp); + const eventId = stableHash(event.id || `${context.sessionId}:${type}:${time}`, 'event'); + const parentEventId = event.parentId ? stableHash(event.parentId, 'event') : ''; + const toolCallId = data.toolCallId || data.parentToolCallId || ''; + const identity = toolCallId || data.requestId || data.agentId || data.agentName || data.name || type; + const startKey = `${type.replace(/(?:_start|_complete|\.started|\.completed|\.failed|\.requested|\.queued)$/, '')}:${identity}`; + const eventTimeMs = new Date(time).getTime(); + const started = starts.get(startKey); + const duration = number(data.durationMs || data.duration) || (started ? Math.max(0, eventTimeMs - started.time) : 0); + const content = droppedContent(data); + const permission = data.permissionRequest && typeof data.permissionRequest === 'object' ? data.permissionRequest : {}; + const toolName = safeName(data.toolName || data.name || permission.toolName || started?.toolName); + const mcpServerName = safeName(data.mcpServerName || permission.serverName || started?.mcpServerName); + const mcpToolName = safeName(data.mcpToolName || permission.toolName || started?.mcpToolName); + const command = Object.keys(commandMetadata(data)).length ? commandMetadata(data) : (started?.command || {}); + // The official SDK repeats usage totals on several lifecycle events. Keep + // one canonical accounting row so Azure/dashboard sums do not double count. + const usage = type === 'assistant.usage' ? data : {}; + const row = { + TimeGenerated: time, + EventId: eventId, + ParentEventId: parentEventId, + RunId: context.runId, + SessionId: context.sessionId, + TraceId: context.traceId, + Surface: 'sdk', + SchemaVersion: '2', + EventName: type, + SpanName: type, + Status: statusFor(type, data), + AgentName: safeName(event.agentId || data.agentName || data.agentId), + ParentAgentName: safeName(data.parentAgentName || data.parentAgentId), + SubAgentName: type.startsWith('subagent.') ? safeName(data.agentName || data.agentId || data.name) : '', + SkillName: type === 'skill.invoked' ? safeName(data.name || data.skillName) : '', + ToolName: toolName, + McpServerName: mcpServerName, + McpToolName: mcpToolName, + ModelActual: safeName(data.model || data.currentModel || started?.model), + InputTokens: number(usage.inputTokens), + OutputTokens: number(usage.outputTokens), + ReasoningTokens: number(usage.reasoningTokens), + CacheReadTokens: number(usage.cacheReadTokens), + CacheWriteTokens: number(usage.cacheWriteTokens), + TotalTokens: number(usage.totalTokens), + TotalToolCalls: number(usage.totalToolCalls), + CopilotCost: number(usage.cost), + EstimatedCostUsd: options.usdPerCostUnit ? number(usage.cost) * number(options.usdPerCostUnit) : 0, + DurationMs: Math.round(duration), + PermissionKind: type === 'permission.requested' ? safeName(permission.kind) : '', + PermissionDecision: type === 'permission.completed' ? safeName(data.permissionDecision || data.decision || data.result?.kind) : '', + ErrorType: type === 'session.error' ? safeName(data.errorType, 'error') : '', + PremiumRequests: number(usage.totalPremiumRequests), + TotalNanoAiu: number(usage.totalNanoAiu), + ApiDurationMs: number(usage.totalApiDurationMs), + LinesAdded: number(data.codeChanges?.linesAdded), + LinesRemoved: number(data.codeChanges?.linesRemoved), + FilesModified: number(data.codeChanges?.filesModified), + ContentCaptureSignal: content.observed, + ContentDroppedBytes: content.bytes, + ContentAction: content.observed ? 'dropped' : 'none', + SecretLike: content.secretLike, + PrivacyMode: context.privacyMode, + ContentCaptureMode: context.contentCaptureMode, + RepoHash: data.repository ? stableHash(data.repository, 'repo') : '', + BranchHash: data.branch ? stableHash(data.branch, 'branch') : '', + WorkingDirectoryHash: data.cwd ? stableHash(data.cwd, 'cwd') : '', + ...command + }; + if (type.endsWith('_start') || type.endsWith('.started') || type.endsWith('.requested')) { + starts.set(startKey, { + time: eventTimeMs, + toolName, + mcpServerName, + mcpToolName, + model: row.ModelActual, + command + }); + } else if (type.endsWith('_complete') || type.endsWith('.completed') || type.endsWith('.failed')) { + starts.delete(startKey); + } + const normalized = normalizeEvent(row); + emit(normalized); + if (type === 'session.shutdown' || type === 'session.error') starts.clear(); + return normalized; + } + + function attach(session) { + if (!session || typeof session.on !== 'function') throw new Error('AgentOps session observer requires a Copilot session with session.on()'); + const handler = event => observe(event); + const result = session.on(handler); + let detach = () => {}; + if (typeof result === 'function') detach = result; + else if (result && typeof result.dispose === 'function') detach = () => result.dispose(); + else if (typeof session.off === 'function') detach = () => session.off(handler); + let attached = true; + return () => { + if (!attached) return; + attached = false; + detach(); + starts.clear(); + }; + } + + return { attach, observe, context: { ...context }, eventTypes: [...sessionEventTypes] }; +} + +module.exports = { createAgentOpsSessionObserver, sessionEventTypes }; diff --git a/packages/agentops-copilot-sdk/test/adapter.test.js b/packages/agentops-copilot-sdk/test/adapter.test.js index e6ed4d2..f600125 100644 --- a/packages/agentops-copilot-sdk/test/adapter.test.js +++ b/packages/agentops-copilot-sdk/test/adapter.test.js @@ -1,15 +1,25 @@ const assert = require('node:assert/strict'); const test = require('node:test'); +process.env.AGENTOPS_DISABLE_ORDERED_EXPORT = '1'; + const { createAgentOpsClientOptions, createAgentOpsCopilotClient, createAgentOpsHooks, + createAgentOpsSessionObserver, + createOtlpJsonExporter, + composeEventHandlers, composeHooks, safePromptMetadata, - safeToolMetadata + safeToolMetadata, + createTelemetryConfig } = require('../src'); +function fakeSecretValue() { + return ['ghp', '1234567890', '1234567890'].join('_'); +} + test('client options force local OTLP and content capture off by default', () => { const events = []; const options = createAgentOpsClientOptions({ emit: event => events.push(event) }); @@ -18,17 +28,56 @@ test('client options force local OTLP and content capture off by default', () => assert.equal(options.telemetry.captureContent, false); assert.equal(options.telemetry.sourceName, 'agentops-copilot-sdk'); assert.equal(options.agentops.privacyMode, 'strict'); + assert.equal(options.exporter, null); assert.equal(typeof options.onGetTraceContext().traceparent, 'string'); assert.ok(options.hooks.onPreToolUse); assert.ok(options.createSessionConfig({}).hooks.onPreToolUse); + assert.equal(options.createSessionConfig({}).streaming, true); + assert.equal(options.createSessionConfig({ streaming: false }).streaming, false); + assert.equal(typeof options.createSessionConfig({}).onEvent, 'function'); + assert.equal(options.createSessionConfig({}).onPermissionRequest, undefined); + const permissionHandler = () => ({ kind: 'approve-once' }); + assert.equal(options.createSessionConfig({ onPermissionRequest: permissionHandler }).onPermissionRequest, permissionHandler); }); -test('strict mode rejects content capture', () => { - assert.throws(() => createAgentOpsClientOptions({ privacyMode: 'strict', captureContent: true }), /captureContent=false/); +test('instrumentation preserves host permission and tool-hook semantics', async () => { + const errors = []; + const options = createAgentOpsClientOptions({ + exportOrderedEvents: false, + emit: () => { throw new Error('telemetry callback failed'); }, + onInstrumentationError: error => errors.push(error.message) + }); + const config = options.createSessionConfig({}); + + assert.equal(config.onPermissionRequest, undefined); + assert.equal(await config.hooks.onPreToolUse({ toolName: 'shell', toolArgs: {} }), undefined); + assert.deepEqual(errors, ['telemetry callback failed']); +}); + +test('instrumentation failures never suppress the host event handler', () => { + const seen = []; + const errors = []; + const handler = composeEventHandlers( + () => { throw new Error('observer failed'); }, + event => seen.push(event.type), + error => errors.push(error.message) + ); + + handler({ type: 'assistant.message' }); + assert.deepEqual(seen, ['assistant.message']); + assert.deepEqual(errors, ['observer failed']); +}); + +test('all modes fail closed when SDK content capture is requested', () => { + assert.throws(() => createAgentOpsClientOptions({ privacyMode: 'strict', captureContent: true }), /must remain false/); + assert.throws(() => createAgentOpsClientOptions({ privacyMode: 'compat', captureContent: true }), /must remain false/); + assert.throws(() => createAgentOpsClientOptions({ telemetry: { captureContent: true } }), /must remain false/); + assert.throws(() => createTelemetryConfig({ captureContent: true }), /must remain false/); }); test('hooks emit safe prompt and tool metadata only', async () => { const events = []; + const fakeSecret = fakeSecretValue(); const hooks = createAgentOpsHooks({ runId: 'run-sdk-test', sessionId: 'session-sdk-test', @@ -39,17 +88,21 @@ test('hooks emit safe prompt and tool metadata only', async () => { await hooks.onUserPromptSubmitted({ prompt: 'SECRET_FAKE_TEST_VALUE please inspect code' }); await hooks.onPreToolUse({ toolName: 'shell', toolArgs: { command: 'cat ~/.ssh/id_rsa' } }); await hooks.onPostToolUse({ toolName: 'shell', toolResult: { output: 'api_key=SECRET_FAKE_TEST_VALUE' } }); + await hooks.onPostToolUseFailure({ toolName: 'shell', error: Object.assign(new Error(`credential=${fakeSecret}`), { code: 'E_TOOL' }) }); await hooks.onSessionStart({}); await hooks.onSessionEnd({}); - await hooks.onError({ error: new TypeError('boom') }); + await hooks.onErrorOccurred({ error: new TypeError('boom') }); - assert.equal(events.length, 6); + assert.equal(events.length, 7); assert.equal(events[0].PromptHash.startsWith('prompt_'), true); assert.equal(events[1].ToolName, 'shell'); assert.equal(events[2].ResultSizeBytes > 0, true); - assert.equal(events[5].ErrorType, 'TypeError'); - assert.doesNotMatch(JSON.stringify(events), /cat ~\/\.ssh|api_key=SECRET|please inspect code/); + assert.equal(events[3].Status, 'failed'); + assert.equal(events[3].ErrorType, 'Error'); + assert.equal(events[6].ErrorType, 'TypeError'); + assert.doesNotMatch(JSON.stringify(events), /cat ~\/\.ssh|api_key=SECRET|please inspect code|ghp_123/); assert.equal(events.some(event => event.SecretLike === true), true); + assert.equal(hooks.onError, undefined); }); test('factory passes AgentOps options to a CopilotClient constructor', () => { @@ -69,6 +122,204 @@ test('factory passes AgentOps options to a CopilotClient constructor', () => { assert.equal(client.options.telemetry.captureContent, false); assert.ok(client.agentopsHooks.onPreToolUse); assert.ok(client.createAgentOpsSessionConfig({}).hooks.onSessionStart); + assert.equal(typeof client.observeAgentOpsSession, 'function'); + assert.equal(typeof client.createAgentOpsSession, 'function'); +}); + +test('client stop always stops Copilot before surfacing telemetry flush failure', async () => { + const originalFetch = global.fetch; + const previousDisable = process.env.AGENTOPS_DISABLE_ORDERED_EXPORT; + const calls = []; + delete process.env.AGENTOPS_DISABLE_ORDERED_EXPORT; + global.fetch = async () => ({ ok: false, status: 503 }); + class FakeCopilotClient { + async stop() { + calls.push('copilot-stop'); + return 'stopped'; + } + } + try { + const client = createAgentOpsCopilotClient(FakeCopilotClient, { + otlpEndpoint: 'http://127.0.0.1:4318', + maxAttempts: 1 + }); + client.agentopsHooks.onSessionStart({}); + + await assert.rejects(client.stop(), /failed to export 1 telemetry request/); + assert.deepEqual(calls, ['copilot-stop']); + } finally { + global.fetch = originalFetch; + if (previousDisable === undefined) delete process.env.AGENTOPS_DISABLE_ORDERED_EXPORT; + else process.env.AGENTOPS_DISABLE_ORDERED_EXPORT = previousDisable; + } +}); + +test('trace callback keeps one W3C trace id and creates child span ids', () => { + const options = createAgentOpsClientOptions({ traceId: 'agentops-shared-trace', exportOrderedEvents: false }); + const first = options.onGetTraceContext().traceparent.split('-'); + const second = options.onGetTraceContext().traceparent.split('-'); + assert.equal(first[1], second[1]); + assert.notEqual(first[2], second[2]); + assert.match(first[1], /^[a-f0-9]{32}$/); + assert.match(first[2], /^[a-f0-9]{16}$/); + const crypto = require('node:crypto'); + assert.equal(first[1], crypto.createHash('sha256').update('agentops-shared-trace').digest('hex').slice(0, 32)); +}); + +test('session observer preserves order and attributes usage, subagents, skills, MCP and commands', () => { + const events = []; + const fakeSecret = fakeSecretValue(); + const observer = createAgentOpsSessionObserver({ + runId: 'run-stream', + sessionId: 'session-stream', + traceId: 'trace-stream', + emit: event => events.push(event) + }); + + observer.observe({ id: 'one', timestamp: '2026-08-03T10:00:00.000Z', type: 'assistant.turn_start', data: {} }); + observer.observe({ id: 'two', parentId: 'one', timestamp: '2026-08-03T10:00:00.050Z', type: 'subagent.started', data: { agentName: 'test-reviewer', parentAgentName: 'main' } }); + observer.observe({ id: 'three', parentId: 'two', timestamp: '2026-08-03T10:00:00.100Z', type: 'skill.invoked', data: { name: 'qa', path: '/private/secret/qa/SKILL.md', content: 'SECRET_FAKE_TEST_VALUE' } }); + observer.observe({ id: 'four', parentId: 'two', timestamp: '2026-08-03T10:00:00.150Z', type: 'tool.execution_start', data: { toolCallId: 'tool-1', toolName: 'shell', mcpServerName: 'azure', mcpToolName: 'query', args: { command: `/usr/local/bin/az account show --auth ${fakeSecret}` } } }); + observer.observe({ id: 'five', parentId: 'four', timestamp: '2026-08-03T10:00:00.400Z', type: 'tool.execution_complete', data: { toolCallId: 'tool-1', toolName: 'shell', success: true, result: { output: 'sensitive result' } } }); + observer.observe({ id: 'six', timestamp: '2026-08-03T10:00:00.500Z', type: 'assistant.usage', data: { model: 'gpt-5.6-sol', inputTokens: 18729, outputTokens: 10, cost: 0.01, duration: 2329, apiEndpoint: 'copilot' } }); + + assert.deepEqual(events.map(event => event.Sequence), [1, 2, 3, 4, 5, 6]); + assert.equal(events[1].SubAgentName, 'test-reviewer'); + assert.equal(events[2].SkillName, 'qa'); + assert.equal(events[3].McpServerName, 'azure'); + assert.equal(events[3].McpToolName, 'query'); + assert.equal(events[3].CommandName, 'az'); + assert.equal(events[4].DurationMs, 250); + assert.equal(events[5].ModelActual, 'gpt-5.6-sol'); + assert.equal(events[5].InputTokens, 18729); + assert.equal(events[5].OutputTokens, 10); + assert.equal(events[5].CopilotCost, 0.01); + assert.equal(events[5].EstimatedCostUsd, 0); + assert.equal(events[2].ContentAction, 'dropped'); + assert.equal(events[3].SecretLike, true); + assert.doesNotMatch(JSON.stringify(events), /private\/secret|SECRET_FAKE|sensitive result|account show/); +}); + +test('session observer emits accounting totals only on the canonical assistant usage event', () => { + const rows = []; + const observer = createAgentOpsSessionObserver({ emit: row => rows.push(row) }); + observer.observe({ type: 'assistant.usage', data: { inputTokens: 100, outputTokens: 9, totalTokens: 109, cost: 0.2 } }); + observer.observe({ type: 'assistant.message', data: { outputTokens: 9, totalTokens: 109, cost: 0.2, content: 'not retained' } }); + + assert.equal(rows[0].InputTokens, 100); + assert.equal(rows[0].OutputTokens, 9); + assert.equal(rows[1].InputTokens, 0); + assert.equal(rows[1].OutputTokens, 0); + assert.equal(rows[1].CopilotCost, 0); + assert.equal(JSON.stringify(rows).includes('not retained'), false); +}); + +test('session observer attaches to official event names and detaches cleanly', () => { + let handler; + const detached = []; + const session = { + on(callback) { + handler = callback; + return () => detached.push('all'); + } + }; + const events = []; + const observer = createAgentOpsSessionObserver({ emit: event => events.push(event) }); + const detach = observer.attach(session); + handler({ type: 'assistant.usage', data: { inputTokens: 42, totalPremiumRequests: 2, totalApiDurationMs: 900 } }); + handler({ type: 'session.shutdown', data: { totalPremiumRequests: 2, totalApiDurationMs: 900 } }); + + assert.equal(events[0].InputTokens, 42); + assert.equal(events[1].EventName, 'session.shutdown'); + assert.equal(events[0].PremiumRequests, 2); + assert.equal(events[0].ApiDurationMs, 900); + assert.equal(events[1].PremiumRequests, 0); + detach(); + assert.deepEqual(detached, ['all']); +}); + +test('session observer keeps permission decisions and context hashes without sensitive values', () => { + const events = []; + const fakeSecret = fakeSecretValue(); + const observer = createAgentOpsSessionObserver({ emit: event => events.push(event) }); + observer.observe({ id: 'permission-a', type: 'permission.requested', data: { + requestId: 'request-a', + permissionRequest: { + kind: 'mcp', + serverName: 'github', + toolName: 'create_pull_request', + args: { credential: fakeSecret } + } + } }); + observer.observe({ id: 'permission-b', type: 'permission.completed', data: { + requestId: 'request-a', + result: { kind: 'denied-by-rules' } + } }); + observer.observe({ id: 'context-a', type: 'session.context_changed', data: { + cwd: '/workspace/example/customer-repo', + repository: 'private-org/private-repo', + branch: 'secret-feature' + } }); + + assert.equal(events[0].PermissionKind, 'mcp'); + assert.equal(events[0].McpServerName, 'github'); + assert.equal(events[0].ToolName, 'create_pull_request'); + assert.equal(events[0].ContentCaptureSignal, true); + assert.equal(events[1].PermissionDecision, 'denied-by-rules'); + assert.match(events[2].RepoHash, /^repo_/); + assert.match(events[2].BranchHash, /^branch_/); + assert.match(events[2].WorkingDirectoryHash, /^cwd_/); + assert.doesNotMatch(JSON.stringify(events), /ghp_|customer-repo|private-org|secret-feature/); +}); + +test('createAgentOpsSession composes hooks and automatically attaches streaming observer', async () => { + class FakeCopilotClient { + constructor(options) { this.options = options; } + async createSession(config) { + this.lastConfig = config; + config.onEvent({ type: 'session.idle', data: {} }); + return { on: () => () => {} }; + } + } + const client = createAgentOpsCopilotClient(FakeCopilotClient); + const session = await client.createAgentOpsSession({ hooks: { onSessionStart: async () => null } }); + assert.ok(session); + assert.equal(typeof client.lastConfig.hooks.onErrorOccurred, 'function'); + assert.equal(typeof client.lastConfig.onEvent, 'function'); +}); + +test('concurrent SDK sessions have independent identities and event sequences', async () => { + const events = []; + class FakeCopilotClient { + constructor() { this.configs = []; } + async createSession(config) { + this.configs.push(config); + return { on: () => () => {} }; + } + } + const client = createAgentOpsCopilotClient(FakeCopilotClient, { + runId: 'shared-run', + emit: event => events.push(event), + exportOrderedEvents: false + }); + await Promise.all([client.createAgentOpsSession({}), client.createAgentOpsSession({})]); + client.configs[0].onEvent({ id: 'event-a', type: 'session.start', data: {} }); + client.configs[1].onEvent({ id: 'event-b', type: 'session.start', data: {} }); + + assert.equal(events.length, 2); + assert.notEqual(events[0].SessionId, events[1].SessionId); + assert.notEqual(events[0].TraceId, events[1].TraceId); + assert.deepEqual(events.map(event => event.Sequence), [1, 1]); +}); + +test('early onEvent observer runs before the application handler', () => { + const calls = []; + const handler = composeEventHandlers( + () => calls.push('agentops'), + () => calls.push('application') + ); + handler({ type: 'session.idle', data: {} }); + assert.deepEqual(calls, ['agentops', 'application']); }); test('session hooks compose with AgentOps hooks instead of replacing telemetry', async () => { @@ -123,3 +374,246 @@ test('metadata helpers hash content and report sizes', () => { assert.equal(tool.toolName, 'read_file'); assert.equal(tool.argsSchemaHash.startsWith('schema_'), true); }); + +test('ordered event exporter sends allowlisted OTLP JSON and flushes', async () => { + const originalFetch = global.fetch; + const requests = []; + global.fetch = async (url, init) => { + requests.push({ url, init }); + return { ok: true }; + }; + try { + const exporter = createOtlpJsonExporter({ otlpEndpoint: 'http://127.0.0.1:4318' }); + exporter.emit({ + TimeGenerated: '2026-08-03T10:00:00.000Z', + Sequence: 7, + EventId: 'event-seven', + ParentEventId: 'event-six', + RunId: 'run-safe', + SessionId: 'session-safe', + TraceId: 'trace-safe', + EventName: 'skill.invoked', + SkillName: 'qa', + content: 'SECRET_FAKE_TEST_VALUE' + }); + await exporter.flush(); + + assert.equal(requests.length, 1); + assert.equal(requests[0].url, 'http://127.0.0.1:4318/v1/traces'); + const payload = JSON.parse(requests[0].init.body); + const span = payload.resourceSpans[0].scopeSpans[0].spans[0]; + assert.match(span.traceId, /^[a-f0-9]{32}$/); + assert.match(span.spanId, /^[a-f0-9]{16}$/); + assert.match(span.parentSpanId, /^[a-f0-9]{16}$/); + assert.equal(span.startTimeUnixNano, span.endTimeUnixNano); + assert.equal(span.attributes.find(item => item.key === 'agentops.event.sequence').value.intValue, '7'); + assert.equal(span.attributes.find(item => item.key === 'gen_ai.conversation.id').value.stringValue, 'session-safe'); + assert.doesNotMatch(requests[0].init.body, /SECRET_FAKE_TEST_VALUE|\"content\"/); + } finally { + global.fetch = originalFetch; + } +}); + +test('ordered event exporter rejects insecure non-loopback HTTP', () => { + assert.throws( + () => createOtlpJsonExporter({ otlpEndpoint: 'http://telemetry.example.test:4318' }), + /requires HTTPS or a loopback HTTP endpoint/ + ); + assert.equal( + createOtlpJsonExporter({ otlpEndpoint: 'https://telemetry.example.test/v1/traces' }).endpoint, + 'https://telemetry.example.test/v1/traces' + ); +}); + +test('ordered event exporter surfaces delivery failures when flushed', async () => { + const originalFetch = global.fetch; + const observed = []; + global.fetch = async () => ({ ok: false, status: 503 }); + try { + const exporter = createOtlpJsonExporter({ + otlpEndpoint: 'http://127.0.0.1:4318', + maxAttempts: 1, + onError: error => observed.push(error.message) + }); + exporter.emit({ EventName: 'session.start', RunId: 'run-failure', EventId: 'event-failure' }); + + await assert.rejects(exporter.flush(), /failed to export 1 telemetry request/); + assert.deepEqual(observed, ['AgentOps OTLP export failed with HTTP 503']); + } finally { + global.fetch = originalFetch; + } +}); + +test('ordered event exporter retries transient failures before reporting success', async () => { + const originalFetch = global.fetch; + let calls = 0; + global.fetch = async () => ({ ok: ++calls >= 3, status: 503 }); + try { + const exporter = createOtlpJsonExporter({ + otlpEndpoint: 'http://127.0.0.1:4318', + maxAttempts: 3, + retryDelayMs: 0 + }); + exporter.emit({ EventName: 'session.start', RunId: 'run-retry', EventId: 'event-retry' }); + + await exporter.flush(); + assert.equal(calls, 3); + const status = exporter.deliveryStatus(); + assert.equal(status.queuedInMemory, 1); + assert.equal(status.retryAttempts, 2); + assert.equal(status.collectorAccepted, 1); + assert.equal(status.pendingInMemory, 0); + assert.equal(status.lastCollectorAcceptedAt !== null, true); + for (const misleadingLegacyName of ['accepted', 'sent', 'retried', 'failed', 'pending', 'overflowed', 'lastSuccessAt']) { + assert.equal(Object.hasOwn(status, misleadingLegacyName), false); + } + } finally { + global.fetch = originalFetch; + } +}); + +test('ordered event exporter bounds its in-memory queue and reports overflow', async () => { + const originalFetch = global.fetch; + let release; + global.fetch = () => new Promise(resolve => { release = () => resolve({ ok: true }); }); + try { + const exporter = createOtlpJsonExporter({ + otlpEndpoint: 'http://127.0.0.1:4318', + maxPendingEvents: 1 + }); + exporter.emit({ EventName: 'session.start', RunId: 'run-cap', EventId: 'event-one' }); + exporter.emit({ EventName: 'session.idle', RunId: 'run-cap', EventId: 'event-two' }); + assert.equal(exporter.deliveryStatus().pendingInMemory, 1); + assert.equal(exporter.deliveryStatus().queueOverflowed, 1); + release(); + await assert.rejects(exporter.flush(), /failed to export 1 telemetry request/); + } finally { + global.fetch = originalFetch; + } +}); + +test('hook and session events share one normalized ordered envelope and complete OTLP fields', async () => { + const originalFetch = global.fetch; + const previousDisable = process.env.AGENTOPS_DISABLE_ORDERED_EXPORT; + const requests = []; + delete process.env.AGENTOPS_DISABLE_ORDERED_EXPORT; + global.fetch = async (url, init) => { + requests.push({ url, body: init.body }); + return { ok: true }; + }; + + try { + const local = []; + const options = createAgentOpsClientOptions({ + runId: 'run-realistic', + sessionId: 'session-realistic', + traceId: 'trace-realistic', + usdPerCostUnit: 2, + emit: event => local.push(event) + }); + const config = options.createSessionConfig({}); + await config.hooks.onUserPromptSubmitted({ prompt: 'SECRET_POISON prompt body' }); + config.onEvent({ + id: 'subagent-one', + type: 'subagent.started', + data: { agentName: 'reviewer', parentAgentName: 'orchestrator' } + }); + config.onEvent({ + id: 'skill-one', + parentId: 'subagent-one', + type: 'skill.invoked', + data: { name: 'qa', content: 'SECRET_POISON skill body' } + }); + config.onEvent({ + id: 'tool-one', + parentId: 'subagent-one', + type: 'tool.execution_start', + data: { + toolCallId: 'tool-one', + toolName: 'shell', + mcpServerName: 'azure', + mcpToolName: 'monitor_query', + args: { command: 'node /private/customer/check-results.js --token SECRET_POISON' } + } + }); + config.onEvent({ + id: 'tool-two', + parentId: 'tool-one', + type: 'tool.execution_complete', + timestamp: new Date(Date.now() + 25).toISOString(), + data: { toolCallId: 'tool-one', toolName: 'shell', success: true, result: 'SECRET_POISON result' } + }); + config.onEvent({ + id: 'usage-one', + type: 'assistant.usage', + data: { + model: 'gpt-test', inputTokens: 100, outputTokens: 20, reasoningTokens: 5, + cacheReadTokens: 7, cacheWriteTokens: 3, totalTokens: 135, totalToolCalls: 1, + cost: 0.25, durationMs: 42, totalPremiumRequests: 2, totalNanoAiu: 900, + totalApiDurationMs: 41, + codeChanges: { linesAdded: 8, linesRemoved: 2, filesModified: 1 }, + repository: 'private/customer', branch: 'secret-branch', cwd: '/private/customer' + } + }); + config.onEvent({ + id: 'permission-one', + type: 'permission.completed', + data: { requestId: 'request-one', permissionDecision: 'denied-by-rules' } + }); + await options.flush(); + + assert.deepEqual(local.map(event => event.Sequence), [1, 2, 3, 4, 5, 6, 7]); + assert.ok(local.every(event => event.SchemaVersion === '2' && event.EventId)); + assert.equal(local[0].EventName, 'agentops.prompt.submitted'); + assert.equal(local[3].CommandName, 'node'); + assert.equal(local[3].ScriptName, 'check-results.js'); + assert.equal(local[5].EstimatedCostUsd, 0.5); + assert.equal(local[6].PermissionDecision, 'denied-by-rules'); + + const wire = requests.map(request => { + const span = JSON.parse(request.body).resourceSpans[0].scopeSpans[0].spans[0]; + return Object.fromEntries(span.attributes.map(item => { + const value = Object.values(item.value)[0]; + return [item.key, value]; + })); + }); + assert.deepEqual(wire.map(attrs => Number(attrs['agentops.event.sequence'])), local.map(event => event.Sequence)); + assert.deepEqual(wire.map(attrs => attrs['agentops.custom_event_id']), local.map(event => event.EventId)); + const toolChildSpan = JSON.parse(requests[3].body).resourceSpans[0].scopeSpans[0].spans[0]; + assert.match(toolChildSpan.parentSpanId, /^[a-f0-9]{16}$/); + const usage = wire[5]; + assert.equal(Number(usage['gen_ai.usage.reasoning.output_tokens']), 5); + assert.equal(Number(usage['gen_ai.usage.total_tokens']), 135); + assert.equal(Number(usage['agentops.tools.count']), 1); + assert.equal(Number(usage['agentops.cost.estimated_usd']), 0.5); + assert.equal(Number(usage['github.copilot.premium_requests']), 2); + assert.equal(Number(usage['github.copilot.aiu.nano']), 900); + assert.equal(Number(usage['agentops.api.duration_ms']), 41); + assert.equal(Number(usage['agentops.lines.added']), 8); + assert.equal(Number(usage['agentops.lines.removed']), 2); + assert.equal(Number(usage['agentops.files.edited_count']), 1); + assert.match(usage['agentops.repo.hash'], /^repo_/); + assert.match(usage['agentops.branch.hash'], /^branch_/); + assert.match(usage['agentops.workspace.hash'], /^cwd_/); + assert.equal(wire[6]['agentops.permission.decision'], 'denied-by-rules'); + assert.equal(wire[2]['agentops.content.action'], 'dropped'); + assert.ok(requests.every(request => !/SECRET_POISON|private\/customer|secret-branch/.test(request.body))); + } finally { + global.fetch = originalFetch; + if (previousDisable === undefined) delete process.env.AGENTOPS_DISABLE_ORDERED_EXPORT; + else process.env.AGENTOPS_DISABLE_ORDERED_EXPORT = previousDisable; + } +}); + +test('script identity recognizes interpreter and package-run invocations without retaining paths or arguments', () => { + const events = []; + const observer = createAgentOpsSessionObserver({ emit: event => events.push(event) }); + observer.observe({ type: 'tool.execution_start', data: { toolCallId: 'a', args: { command: 'python3 /private/jobs/check.py --secret value' } } }); + observer.observe({ type: 'tool.execution_start', data: { toolCallId: 'b', args: { command: 'npm run test:unit -- --watch=false' } } }); + + assert.equal(events[0].CommandName, 'python3'); + assert.equal(events[0].ScriptName, 'check.py'); + assert.equal(events[1].CommandName, 'npm'); + assert.equal(events[1].ScriptName, 'npm:run:test:unit'); + assert.doesNotMatch(JSON.stringify(events), /private\/jobs|--secret|watch=false/); +}); diff --git a/packages/agentops-copilot-sdk/test/benchmark.test.js b/packages/agentops-copilot-sdk/test/benchmark.test.js new file mode 100644 index 0000000..4531e13 --- /dev/null +++ b/packages/agentops-copilot-sdk/test/benchmark.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { percentile, positiveInteger, runBenchmark } = require('../benchmark/metadata-benchmark'); + +test('benchmark helpers have deterministic bounds and nearest-rank percentiles', () => { + assert.equal(positiveInteger('25', 10), 25); + assert.equal(positiveInteger('bad', 10), 10); + assert.equal(positiveInteger('20000', 10), 10000); + assert.equal(percentile([5, 1, 4, 2, 3], 0.50), 3); + assert.equal(percentile([5, 1, 4, 2, 3], 0.95), 5); +}); + +test('metadata benchmark is bounded, local, content-free and reports descriptive p50/p95', async () => { + let tick = 0; + const requestBodies = []; + const report = await runBenchmark({ + events: 12, + warmup: 3, + nowNs: () => { tick += 10000; return tick; }, + fetchImpl: async (_url, init) => { + requestBodies.push(init.body); + return { ok: true, status: 200 }; + } + }); + + assert.equal(report.methodology.events, 12); + assert.equal(report.methodology.warmup_events, 3); + assert.match(report.methodology.transport, /no network or Azure writes/); + assert.equal(report.methodology.content_capture, 'off'); + assert.match(report.methodology.thresholds, /none/); + assert.equal(report.capture.observed_events, 12); + assert.equal(report.capture.latency_ms.p50, 0.01); + assert.equal(report.capture.latency_ms.p95, 0.01); + assert.equal(report.export.latency_ms.p50, 0.01); + assert.equal(report.export.latency_ms.p95, 0.01); + assert.equal(report.export.requests, 12); + assert.equal(report.export.delivery.queuedInMemory, 12); + assert.equal(report.export.delivery.collectorAccepted, 12); + assert.equal(report.export.delivery.terminalFailures, 0); + assert.equal(report.export.delivery.pendingInMemory, 0); + assert.equal(requestBodies.length, 12); + const attributeKeys = requestBodies.flatMap(body => ( + JSON.parse(body).resourceSpans[0].scopeSpans[0].spans[0].attributes.map(item => item.key) + )); + const forbiddenContentKeys = [ + 'gen_ai.prompt', 'gen_ai.completion', 'gen_ai.tool.arguments', 'gen_ai.tool.result', + 'agentops.prompt.content', 'agentops.tool.payload' + ]; + assert.deepEqual(attributeKeys.filter(key => forbiddenContentKeys.includes(key)), []); +}); diff --git a/plugin/scripts/agent-stop-quality-gate.js b/plugin/scripts/agent-stop-quality-gate.js index 9277fe2..ecb5d61 100755 --- a/plugin/scripts/agent-stop-quality-gate.js +++ b/plugin/scripts/agent-stop-quality-gate.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +const { recordScriptExecution } = require('./script-observability'); + async function readStdin() { return new Promise((resolve) => { let data = ''; @@ -119,6 +121,11 @@ function summarize(input = {}) { input = {}; } const result = summarize(input); + recordScriptExecution(input, { + scriptName: 'agent-stop-quality-gate', + hookType: result.hook, + outcome: result.warnings.length > 0 ? 'warned' : 'passed' + }); if (result.warnings.length > 0) { process.stdout.write(JSON.stringify(result)); } diff --git a/plugin/scripts/post-tool-failure-hints.js b/plugin/scripts/post-tool-failure-hints.js index 6318c74..de6ac9a 100755 --- a/plugin/scripts/post-tool-failure-hints.js +++ b/plugin/scripts/post-tool-failure-hints.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +const { recordScriptExecution } = require('./script-observability'); + async function readStdin() { return new Promise((resolve) => { let data = ''; @@ -27,6 +29,11 @@ async function readStdin() { hints.push('Recovery hint: run `pwd`, list the relevant directory, and verify repo-relative paths before retrying.'); } + recordScriptExecution(input, { + scriptName: 'post-tool-failure-hints', + hookType: 'postToolUseFailure', + outcome: hints.length === 0 ? 'observed' : 'recovery-hint' + }); if (hints.length === 0) process.exit(0); process.stdout.write(hints.join('\n')); diff --git a/plugin/scripts/pre-tool-policy.js b/plugin/scripts/pre-tool-policy.js index d228c97..051073a 100755 --- a/plugin/scripts/pre-tool-policy.js +++ b/plugin/scripts/pre-tool-policy.js @@ -1,5 +1,7 @@ #!/usr/bin/env node +const { recordScriptExecution } = require('./script-observability'); + async function readStdin() { return new Promise((resolve) => { let data = ''; @@ -9,7 +11,8 @@ async function readStdin() { }); } -function deny(reason) { +function deny(input, reason) { + recordScriptExecution(input, { scriptName: 'pre-tool-policy', hookType: 'preToolUse', outcome: 'blocked' }); process.stdout.write(JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: reason @@ -66,17 +69,18 @@ function truthy(value) { for (const pattern of blockedPatterns) { if (argText.includes(pattern)) { - return deny(`Blocked by AgentOps demo preToolUse guardrail: risky command or secret access pattern "${pattern}".`); + return deny(input, `Blocked by AgentOps demo preToolUse guardrail: risky command or secret access pattern "${pattern}".`); } } if (broadTools && contentCapture) { - return deny('Blocked by AgentOps demo preToolUse guardrail: broad tool permissions cannot run with content capture enabled.'); + return deny(input, 'Blocked by AgentOps demo preToolUse guardrail: broad tool permissions cannot run with content capture enabled.'); } if ((tool.includes('write') || tool.includes('edit')) && argText.includes('.env')) { - return deny('Blocked by AgentOps demo preToolUse guardrail: writing .env files is not allowed.'); + return deny(input, 'Blocked by AgentOps demo preToolUse guardrail: writing .env files is not allowed.'); } + recordScriptExecution(input, { scriptName: 'pre-tool-policy', hookType: 'preToolUse', outcome: 'allowed' }); process.exit(0); })(); diff --git a/plugin/scripts/script-observability.js b/plugin/scripts/script-observability.js new file mode 100644 index 0000000..955037d --- /dev/null +++ b/plugin/scripts/script-observability.js @@ -0,0 +1,43 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function safeName(value = '') { + const text = String(value || '').trim(); + return /^[A-Za-z0-9_.:/@+-]{1,200}$/.test(text) ? text : ''; +} + +function sidecarEventsPath() { + return process.env.AGENTOPS_SIDECAR_EVENTS_PATH || + process.env.AGENTOPS_HOOK_EVENTS_PATH || + path.join(process.cwd(), '.agentops', 'sidecar-events.jsonl'); +} + +function recordScriptExecution(input = {}, details = {}) { + const metadata = input.metadata || input.meta || {}; + const sessionId = safeName(input.sessionId || input.session_id || metadata.sessionId || metadata.session_id || ''); + const scriptName = safeName(details.scriptName); + const hookType = safeName(details.hookType || input.hookType || input.hook_type || input.type || 'hook'); + const outcome = safeName(details.outcome || 'observed'); + if (!sessionId || !scriptName) return null; + const event = { + timestamp: new Date().toISOString(), + type: 'agentops.script.executed', + data: { + sessionId, + scriptName, + hookType, + outcome, + contentCapture: false + } + }; + const file = sidecarEventsPath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, `${JSON.stringify(event)}\n`); + return { file, event }; +} + +module.exports = { + recordScriptExecution, + safeName, + sidecarEventsPath +}; diff --git a/plugin/skills/agentops-setup/SKILL.md b/plugin/skills/agentops-setup/SKILL.md index 15c9d65..51c9e7c 100644 --- a/plugin/skills/agentops-setup/SKILL.md +++ b/plugin/skills/agentops-setup/SKILL.md @@ -15,7 +15,9 @@ Preferred local commands: ```bash az login ./setup-agentops.sh -node agentops-cli/src/index.js init --full +agentops init --full +agentops init --full --yes +agentops copilot -p "Reply with exactly: agentops smoke." ``` PowerShell: @@ -23,17 +25,19 @@ PowerShell: ```powershell az login ./setup-agentops.ps1 -node agentops-cli/src/index.js init --full +agentops init --full +agentops init --full --yes +agentops copilot -p "Reply with exactly: agentops smoke." ``` Verify: - `copilot-agentops` is installed. -- Plain `copilot` shadowing is either observed or the user has the PATH command to enable it. +- Everyday observed work uses `agentops copilot ...`. Plain `copilot ...` stays unchanged unless the user explicitly enables transparent routing. - Content capture is off. - Collector endpoints are localhost. - The guided `init --full` output reports cloud provision, dashboard import, real smoke, and latest triage status. -- The response includes a Run Replay link when available, or the exact follow-up command when telemetry is missing. +- The response includes a Run Story link when available, or the exact follow-up command when telemetry is missing. - The response includes one evidence-backed next action or recommendation. Do not ask the user to enable content capture, paste secrets, or expose prompt/code/tool argument content. diff --git a/scripts/azure-deploy-enterprise-pilot.sh b/scripts/azure-deploy-enterprise-pilot.sh index 2702a25..8b7d368 100755 --- a/scripts/azure-deploy-enterprise-pilot.sh +++ b/scripts/azure-deploy-enterprise-pilot.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -subscription_id="${AZURE_SUBSCRIPTION_ID:-}" -resource_group="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + +subscription_id="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" location="${AZURE_LOCATION:-northeurope}" environment_name="${AZURE_ENV_NAME:-dev}" base_name="${AGENTOPS_BASE_NAME:-copilot-agentops}" deployment_name="${AGENTOPS_DEPLOYMENT_NAME:-agentops-enterprise-pilot}" deployment_profile="${AGENTOPS_DEPLOYMENT_PROFILE:-team}" +deploy_advanced_services="${AGENTOPS_DEPLOY_ADVANCED_SERVICES:-true}" log_retention_days="${AGENTOPS_LOG_RETENTION_DAYS:-0}" daily_ingestion_cap_gb="${AGENTOPS_DAILY_INGESTION_CAP_GB:-0}" deploy_alerts="${AGENTOPS_DEPLOY_ALERTS:-false}" @@ -23,10 +27,23 @@ deploy_budget="${AGENTOPS_DEPLOY_BUDGET:-false}" monthly_budget_amount="${AGENTOPS_MONTHLY_BUDGET_AMOUNT:-100}" budget_contact_emails="${AGENTOPS_BUDGET_CONTACT_EMAILS:-[]}" -if [[ -n "$subscription_id" ]]; then - az account set --subscription "$subscription_id" +if [[ "${AGENTOPS_APPROVE_AZURE_CHANGES:-}" != "yes" || "${AGENTOPS_CONFIRM_ENTERPRISE_DEPLOY:-}" != "yes" ]]; then + cat <<MSG +No Azure changes were made. + +This deployment is guarded to the explicitly configured subscription and +reviewed AgentOps target. To approve the resource +group/deployment write, rerun with both flags and explicit subscription: + + AGENTOPS_APPROVE_AZURE_CHANGES=yes AGENTOPS_CONFIRM_ENTERPRISE_DEPLOY=yes \\ + AGENTOPS_AZURE_SUBSCRIPTION_ID="<approved-subscription-id>" \\ + ./scripts/azure-deploy-enterprise-pilot.sh +MSG + exit 2 fi +agentops_require_azure_subscription + if ! az group exists --name "$resource_group" -o tsv | grep -q true; then az group create --name "$resource_group" --location "$location" >/dev/null fi @@ -35,7 +52,7 @@ az deployment group create \ --name "$deployment_name" \ --resource-group "$resource_group" \ --template-file infra/bicep/main.bicep \ - --parameters environmentName="$environment_name" location="$location" baseName="$base_name" deploymentProfile="$deployment_profile" logRetentionDays="$log_retention_days" dailyIngestionCapGb="$daily_ingestion_cap_gb" deployAlerts="$deploy_alerts" enableAlerts="$enable_alerts" alertActionGroupResourceIds="$alert_action_group_resource_ids" grafanaPublicNetworkAccess="$grafana_public_network_access" grafanaZoneRedundancy="$grafana_zone_redundancy" deployRbacAssignments="$deploy_rbac_assignments" observerPrincipalIds="$observer_principal_ids" operatorPrincipalIds="$operator_principal_ids" adminPrincipalIds="$admin_principal_ids" deployBudget="$deploy_budget" monthlyBudgetAmount="$monthly_budget_amount" budgetContactEmails="$budget_contact_emails" + --parameters environmentName="$environment_name" location="$location" baseName="$base_name" deploymentProfile="$deployment_profile" deployAdvancedServices="$deploy_advanced_services" logRetentionDays="$log_retention_days" dailyIngestionCapGb="$daily_ingestion_cap_gb" deployAlerts="$deploy_alerts" enableAlerts="$enable_alerts" alertActionGroupResourceIds="$alert_action_group_resource_ids" grafanaPublicNetworkAccess="$grafana_public_network_access" grafanaZoneRedundancy="$grafana_zone_redundancy" deployRbacAssignments="$deploy_rbac_assignments" observerPrincipalIds="$observer_principal_ids" operatorPrincipalIds="$operator_principal_ids" adminPrincipalIds="$admin_principal_ids" deployBudget="$deploy_budget" monthlyBudgetAmount="$monthly_budget_amount" budgetContactEmails="$budget_contact_emails" cat <<MSG Enterprise pilot deployment completed for resource group: $resource_group diff --git a/scripts/azure-minimal-deploy.sh b/scripts/azure-minimal-deploy.sh new file mode 100755 index 0000000..8ed459b --- /dev/null +++ b/scripts/azure-minimal-deploy.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + +subscription_id="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" +location="${AZURE_LOCATION:-northeurope}" +environment_name="${AZURE_ENV_NAME:-dev}" +base_name="${AGENTOPS_BASE_NAME:-copilot-agentops}" +deployment_name="${AGENTOPS_DEPLOYMENT_NAME:-agentops-minimal}" +deploy_v2_ingestion="${AGENTOPS_DEPLOY_V2_INGESTION:-false}" + +cat <<MSG +AgentOps minimal Azure deployment target: + subscription: ${subscription_id:-<missing>} + resource group: ${resource_group} + location: ${location} + advanced services: false + durable receipt ingestion: ${deploy_v2_ingestion} +MSG + +if [[ "${AGENTOPS_APPROVE_AZURE_CHANGES:-}" != "yes" || "${AGENTOPS_CONFIRM_MINIMAL_DEPLOY:-}" != "yes" ]]; then + cat <<MSG + +No Azure changes were made. +This guarded command creates or updates only the approved minimal AgentOps +path: Log Analytics and Application Insights, with optional durable receipt +tables when AGENTOPS_DEPLOY_V2_INGESTION=true. Grafana, Azure Monitor +Workspace, and Key Vault remain disabled. + +After reviewing the target above, rerun with both explicit approvals: + + AGENTOPS_APPROVE_AZURE_CHANGES=yes AGENTOPS_CONFIRM_MINIMAL_DEPLOY=yes \\ + AGENTOPS_AZURE_SUBSCRIPTION_ID="<approved-subscription-id>" \\ + ./scripts/azure-minimal-deploy.sh +MSG + exit 2 +fi + +: "${subscription_id:?Set AGENTOPS_AZURE_SUBSCRIPTION_ID before any Azure write.}" +agentops_require_azure_subscription + +if [[ "$(az group exists --name "${resource_group}" --subscription "${subscription_id}")" != "true" ]]; then + az group create \ + --name "${resource_group}" \ + --location "${location}" \ + --subscription "${subscription_id}" \ + --tags app=copilot-cli-agentops-azure environment="${environment_name}" telemetryContent=metadata-only \ + --only-show-errors >/dev/null +fi + +az deployment group what-if \ + --name "${deployment_name}-whatif" \ + --resource-group "${resource_group}" \ + --subscription "${subscription_id}" \ + --template-file "${repo_root}/infra/bicep/main.bicep" \ + --parameters environmentName="${environment_name}" location="${location}" baseName="${base_name}" \ + deployAdvancedServices=false deployV2Ingestion="${deploy_v2_ingestion}" \ + --no-pretty-print + +az deployment group create \ + --name "${deployment_name}" \ + --resource-group "${resource_group}" \ + --subscription "${subscription_id}" \ + --template-file "${repo_root}/infra/bicep/main.bicep" \ + --parameters environmentName="${environment_name}" location="${location}" baseName="${base_name}" \ + deployAdvancedServices=false deployV2Ingestion="${deploy_v2_ingestion}" \ + --only-show-errors + +cat <<MSG + +Minimal AgentOps Azure deployment completed. +Next: + agentops configure import-azd + agentops validate-azure --last 24h --json + agentops smoke --real-copilot --wait 2m --poll 10s --json +MSG diff --git a/scripts/azure-native-metric-query.sh b/scripts/azure-native-metric-query.sh new file mode 100755 index 0000000..fbbbe3b --- /dev/null +++ b/scripts/azure-native-metric-query.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Query one metadata-only metric emitted through the native Application +# Insights OTLP path. Azure Monitor Workspace metrics are queried through its +# Prometheus-compatible endpoint; `az monitor metrics list` is for platform +# resource metrics and is not the right surface for these OTel series. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + +subscription_id="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" +app_insights_name="${AGENTOPS_APPLICATIONINSIGHTS_NAME:-appi-copilot-agentops-dev}" +smoke_id="${AGENTOPS_SMOKE_ID:-}" +metric_name="${AGENTOPS_METRIC_NAME:-agentops.metric}" + +agentops_require_azure_subscription + +if [[ -z "${smoke_id}" ]]; then + echo "ERROR: set AGENTOPS_SMOKE_ID to the metric smoke correlation ID." >&2 + exit 2 +fi +if [[ ! "${smoke_id}" =~ ^[A-Za-z0-9_.:-]+$ ]]; then + echo "ERROR: AGENTOPS_SMOKE_ID contains unsupported query characters." >&2 + exit 2 +fi +if [[ ! "${metric_name}" =~ ^[A-Za-z0-9_.:-]+$ ]]; then + echo "ERROR: AGENTOPS_METRIC_NAME contains unsupported query characters." >&2 + exit 2 +fi + +app_id="$(az resource show --resource-group "${resource_group}" --resource-type Microsoft.Insights/components --name "${app_insights_name}" --query id -o tsv 2>/dev/null || true)" +if [[ -z "${app_id}" ]]; then + echo "ERROR: Application Insights resource not found: ${app_insights_name}" >&2 + exit 2 +fi + +amw_id="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.AzureMonitorWorkspaceResourceId -o tsv 2>/dev/null || true)" +if [[ -z "${amw_id}" ]]; then + echo "ERROR: Application Insights does not expose an Azure Monitor Workspace." >&2 + exit 2 +fi + +query_endpoint="$(az resource show --ids "${amw_id}" --api-version 2025-10-03-preview --query properties.metrics.prometheusQueryEndpoint -o tsv 2>/dev/null || true)" +if [[ -z "${query_endpoint}" ]]; then + echo "ERROR: Azure Monitor Workspace Prometheus query endpoint is unavailable." >&2 + exit 2 +fi + +token="$(az account get-access-token --resource https://prometheus.monitor.azure.com --query accessToken -o tsv)" +query="{\"${metric_name}\",\"agentops.custom_event_id\"=\"${smoke_id}\"}" + +curl --fail --silent --show-error --get \ + --data-urlencode "query=${query}" \ + --header "Authorization: Bearer ${token}" \ + "${query_endpoint}/api/v1/query" diff --git a/scripts/azure-native-otlp-env.sh b/scripts/azure-native-otlp-env.sh new file mode 100755 index 0000000..bf88d45 --- /dev/null +++ b/scripts/azure-native-otlp-env.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Read-only helper for the first-party Application Insights OTLP preview path. +# It prints shell exports only after the exact approved target, OTLP feature, +# managed connection info, and DCR role assignment have all been verified. +# +# Usage: +# eval "$(AGENTOPS_AZURE_SUBSCRIPTION_ID=<approved-subscription> ./scripts/azure-native-otlp-env.sh)" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + +subscription_id="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" +location="${AZURE_LOCATION:-northeurope}" +app_insights_name="${AGENTOPS_APPLICATIONINSIGHTS_NAME:-appi-copilot-agentops-dev}" + +agentops_require_azure_subscription + +if [[ "$(az group exists --name "${resource_group}")" != "true" ]]; then + echo "ERROR: Azure resource group does not exist: ${resource_group}" >&2 + exit 2 +fi + +group_location="$(az group show --name "${resource_group}" --query location -o tsv)" +group_location_lower="$(printf '%s' "${group_location}" | tr '[:upper:]' '[:lower:]')" +location_lower="$(printf '%s' "${location}" | tr '[:upper:]' '[:lower:]')" +if [[ "${group_location_lower}" != "${location_lower}" ]]; then + echo "ERROR: resource group ${resource_group} is in ${group_location}, expected ${location}." >&2 + exit 2 +fi + +app_id="$(az resource show --resource-group "${resource_group}" --resource-type Microsoft.Insights/components --name "${app_insights_name}" --query id -o tsv 2>/dev/null || true)" +if [[ -z "${app_id}" ]]; then + echo "ERROR: Application Insights resource not found: ${app_insights_name}" >&2 + exit 2 +fi + +feature_state="$(az feature show --namespace Microsoft.Insights --name OtlpApplicationInsights --query properties.state -o tsv 2>/dev/null || true)" +if [[ "${feature_state}" != "Registered" ]]; then + echo "ERROR: Microsoft.Insights/OtlpApplicationInsights is ${feature_state:-unknown}, not Registered." >&2 + exit 2 +fi + +dcr_resource_id="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.DataCollectionRuleResourceId -o tsv 2>/dev/null || true)" +traces_endpoint="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.OTLPTracesEndpoint -o tsv 2>/dev/null || true)" +logs_endpoint="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.OTLPLogsEndpoint -o tsv 2>/dev/null || true)" +metrics_endpoint="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.OTLPMetricsEndpoint -o tsv 2>/dev/null || true)" + +if [[ -z "${dcr_resource_id}" || -z "${traces_endpoint}" || -z "${logs_endpoint}" || -z "${metrics_endpoint}" ]]; then + echo "ERROR: Application Insights OTLP connection info is incomplete." >&2 + exit 2 +fi + +case "${dcr_resource_id}" in + "/subscriptions/${subscription_id}/resourceGroups/"*) ;; + *) echo "ERROR: OTLP DCR is outside the approved subscription." >&2; exit 2 ;; +esac + +principal_id="$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)" +role_id="$(az role definition list --name 'Monitoring Metrics Publisher' --query '[0].id' -o tsv)" +role_count=0 +if [[ -n "${principal_id}" && -n "${role_id}" ]]; then + role_count="$(az role assignment list --scope "${dcr_resource_id}" --assignee-object-id "${principal_id}" --query "length([?roleDefinitionId && contains(roleDefinitionId, '${role_id}')])" -o tsv 2>/dev/null || echo 0)" +fi +if [[ "${role_count}" -lt 1 ]]; then + echo "ERROR: signed-in identity lacks Monitoring Metrics Publisher on the OTLP DCR." >&2 + exit 2 +fi + +printf 'export AGENTOPS_AZURE_SUBSCRIPTION_ID=%q\n' "${subscription_id}" +printf 'export AZURE_RESOURCE_GROUP=%q\n' "${resource_group}" +printf 'export AZURE_LOCATION=%q\n' "${location}" +printf 'export AGENTOPS_APPLICATIONINSIGHTS_NAME=%q\n' "${app_insights_name}" +printf 'export AGENTOPS_AZURE_OTLP_DCR_RESOURCE_ID=%q\n' "${dcr_resource_id}" +printf 'export AZURE_MONITOR_OTLP_TRACES_ENDPOINT=%q\n' "${traces_endpoint}" +printf 'export AZURE_MONITOR_OTLP_LOGS_ENDPOINT=%q\n' "${logs_endpoint}" +printf 'export AZURE_MONITOR_OTLP_METRICS_ENDPOINT=%q\n' "${metrics_endpoint}" diff --git a/scripts/azure-native-otlp-readiness.sh b/scripts/azure-native-otlp-readiness.sh new file mode 100755 index 0000000..64f13b9 --- /dev/null +++ b/scripts/azure-native-otlp-readiness.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Read-only gate for the native Application Insights OTLP preview path. +# It deliberately does not register features, create resource groups, assign +# roles, run what-if, or deploy anything. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + +subscription_id="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" +location="${AZURE_LOCATION:-northeurope}" +app_insights_name="${AGENTOPS_APPLICATIONINSIGHTS_NAME:-appi-copilot-agentops-dev}" +dcr_resource_id="${AGENTOPS_AZURE_OTLP_DCR_RESOURCE_ID:-}" + +agentops_require_azure_subscription + +if [[ "$(az group exists --name "${resource_group}")" != "true" ]]; then + cat <<MSG +native_otlp_readiness: blocked +subscription: ${subscription_id} +resource_group: ${resource_group} +expected_location: ${location} +application_insights: ${app_insights_name} +reason: configured target resource group does not exist +write_intent: false + +No Azure changes were made. Confirm the exact target before using the guarded +prerequisite/deployment workflows. +MSG + exit 2 +fi + +group_location="$(az group show --name "${resource_group}" --query location -o tsv)" +app_id="$(az resource show --resource-group "${resource_group}" --resource-type Microsoft.Insights/components --name "${app_insights_name}" --query id -o tsv 2>/dev/null || true)" +feature_state="$(az feature show --namespace Microsoft.Insights --name OtlpApplicationInsights --query properties.state -o tsv 2>/dev/null || true)" + +# The first-party OTLP onboarding stores the connection-info values on the +# Application Insights resource. Discover them here so a junior operator does +# not need to transcribe long Azure URLs by hand. The values are endpoints and +# resource IDs, not bearer credentials. +discovered_dcr_resource_id="" +traces_endpoint="" +logs_endpoint="" +metrics_endpoint="" +if [[ -n "${app_id}" ]]; then + discovered_dcr_resource_id="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.DataCollectionRuleResourceId -o tsv 2>/dev/null || true)" + traces_endpoint="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.OTLPTracesEndpoint -o tsv 2>/dev/null || true)" + logs_endpoint="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.OTLPLogsEndpoint -o tsv 2>/dev/null || true)" + metrics_endpoint="$(az resource show --ids "${app_id}" --api-version 2020-02-02 --query properties.OTLPMetricsEndpoint -o tsv 2>/dev/null || true)" +fi + +if [[ -z "${dcr_resource_id}" ]]; then + dcr_resource_id="${discovered_dcr_resource_id}" +fi + +cat <<MSG +native_otlp_readiness: discovered +subscription: ${subscription_id} +resource_group: ${resource_group} +actual_location: ${group_location} +expected_location: ${location} +application_insights: ${app_insights_name} +application_insights_id: ${app_id:-NOT_FOUND} +otlp_application_insights_feature: ${feature_state:-UNKNOWN} +write_intent: false +MSG + +group_location_lower="$(printf '%s' "${group_location}" | tr '[:upper:]' '[:lower:]')" +location_lower="$(printf '%s' "${location}" | tr '[:upper:]' '[:lower:]')" +if [[ "${group_location_lower}" != "${location_lower}" ]]; then + echo "gate: FAIL location mismatch" >&2 + exit 2 +fi +if [[ -z "${app_id}" ]]; then + echo "gate: FAIL Application Insights resource not found" >&2 + exit 2 +fi +if [[ "${feature_state}" != "Registered" ]]; then + echo "gate: FAIL OtlpApplicationInsights feature is not Registered" >&2 + exit 2 +fi + +if [[ -z "${dcr_resource_id}" || -z "${traces_endpoint}" || -z "${logs_endpoint}" || -z "${metrics_endpoint}" ]]; then + cat <<MSG +gate: PENDING +next: finish first-party OTLP onboarding and confirm the resource exposes its +DCR resource ID plus traces/logs/metrics endpoints. +MSG + exit 2 +fi + +case "${dcr_resource_id}" in + "/subscriptions/${subscription_id}/resourceGroups/"*) ;; + *) + echo "gate: FAIL DCR resource ID is not in the approved subscription" >&2 + exit 2 + ;; +esac + +role_id="$(az role definition list --name 'Monitoring Metrics Publisher' --query '[0].id' -o tsv)" +principal_id="$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)" +role_count=0 +if [[ -n "${principal_id}" && -n "${role_id}" ]]; then + role_count="$(az role assignment list --scope "${dcr_resource_id}" --assignee-object-id "${principal_id}" --query "length([?roleDefinitionId && contains(roleDefinitionId, '${role_id}')])" -o tsv 2>/dev/null || echo 0)" +fi + +cat <<MSG +dcr_resource_id: ${dcr_resource_id} +signed_in_principal_id: ${principal_id:-UNKNOWN} +monitoring_metrics_publisher_assignments: ${role_count} +gate: $([[ "${role_count}" -gt 0 ]] && echo PASS || echo FAIL) +traces_endpoint: ${traces_endpoint} +logs_endpoint: ${logs_endpoint} +metrics_endpoint: ${metrics_endpoint} +write_intent: false +MSG + +[[ "${role_count}" -gt 0 ]] diff --git a/scripts/azure-prereqs.sh b/scripts/azure-prereqs.sh index b5af869..e6b1033 100755 --- a/scripts/azure-prereqs.sh +++ b/scripts/azure-prereqs.sh @@ -1,9 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID before running scripts/azure-prereqs.sh}" -subscription_id="$AZURE_SUBSCRIPTION_ID" -resource_group="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + +: "${AGENTOPS_AZURE_SUBSCRIPTION_ID:?Set AGENTOPS_AZURE_SUBSCRIPTION_ID before running scripts/azure-prereqs.sh}" +subscription_id="$AGENTOPS_AZURE_SUBSCRIPTION_ID" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" location="${AZURE_LOCATION:-northeurope}" cat <<MSG @@ -24,7 +27,7 @@ MSG exit 2 fi -az account set --subscription "$subscription_id" +agentops_require_azure_subscription az provider register --namespace Microsoft.Monitor az provider register --namespace Microsoft.Dashboard az group create --name "$resource_group" --location "$location" --tags app=copilot-cli-agentops-azure environment=dev diff --git a/scripts/azure-readiness.sh b/scripts/azure-readiness.sh index 1f70d86..6e55017 100755 --- a/scripts/azure-readiness.sh +++ b/scripts/azure-readiness.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -subscription_id="${AZURE_SUBSCRIPTION_ID:-}" -resource_group="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" -if [[ -n "$subscription_id" ]]; then - az account set --subscription "$subscription_id" -fi +subscription_id="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" + +agentops_require_azure_subscription az account show --query '{name:name,id:id,tenantId:tenantId,user:user.name}' -o table for ns in Microsoft.OperationalInsights Microsoft.Insights Microsoft.Monitor Microsoft.Dashboard Microsoft.KeyVault Microsoft.Web Microsoft.Storage; do diff --git a/scripts/azure-smoke-appinsights.sh b/scripts/azure-smoke-appinsights.sh index 3f89784..e442087 100755 --- a/scripts/azure-smoke-appinsights.sh +++ b/scripts/azure-smoke-appinsights.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -subscription_id="${AZURE_SUBSCRIPTION_ID:-}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + resource_group="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" app_insights_name="${APPLICATIONINSIGHTS_NAME:-appi-agentops-dev}" smoke_id="${AGENTOPS_SMOKE_ID:-agentops-$(date +%Y%m%d%H%M%S)}" -if [[ -n "$subscription_id" ]]; then - az account set --subscription "$subscription_id" -fi +agentops_require_azure_subscription connection_string="$(az monitor app-insights component show \ --resource-group "$resource_group" \ @@ -31,9 +31,7 @@ NODE instrumentation_key="$(printf '%s' "$parsed" | node -pe "JSON.parse(require('fs').readFileSync(0, 'utf8')).instrumentationKey")" ingestion_endpoint="$(printf '%s' "$parsed" | node -pe "JSON.parse(require('fs').readFileSync(0, 'utf8')).endpoint")" -payload_file="/tmp/${smoke_id}.appinsights.json" - -SMOKE_ID="$smoke_id" INSTRUMENTATION_KEY="$instrumentation_key" node >"$payload_file" <<'NODE' +SMOKE_ID="$smoke_id" INSTRUMENTATION_KEY="$instrumentation_key" node <<'NODE' | const smokeId = process.env.SMOKE_ID; const instrumentationKey = process.env.INSTRUMENTATION_KEY; process.stdout.write(JSON.stringify({ @@ -55,11 +53,10 @@ process.stdout.write(JSON.stringify({ } })); NODE - -curl --fail --silent --show-error \ + curl --fail --silent --show-error \ --header 'Content-Type: application/json' \ - --data-binary "@$payload_file" \ - "${ingestion_endpoint}v2/track" >/tmp/${smoke_id}.appinsights.response + --data-binary @- \ + "${ingestion_endpoint}v2/track" >/dev/null cat <<MSG Sent Application Insights smoke event. diff --git a/scripts/azure-what-if.sh b/scripts/azure-what-if.sh index 732647f..6def1df 100755 --- a/scripts/azure-what-if.sh +++ b/scripts/azure-what-if.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -subscription_id="${AZURE_SUBSCRIPTION_ID:-}" -resource_group="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" + +subscription_id="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" +resource_group="${AZURE_RESOURCE_GROUP:-rg-copilot-agentops-dev}" location="${AZURE_LOCATION:-northeurope}" environment_name="${AZURE_ENV_NAME:-dev}" base_name="${AGENTOPS_BASE_NAME:-copilot-agentops}" deployment_profile="${AGENTOPS_DEPLOYMENT_PROFILE:-team}" +deploy_advanced_services="${AGENTOPS_DEPLOY_ADVANCED_SERVICES:-false}" log_retention_days="${AGENTOPS_LOG_RETENTION_DAYS:-0}" daily_ingestion_cap_gb="${AGENTOPS_DAILY_INGESTION_CAP_GB:-0}" deploy_alerts="${AGENTOPS_DEPLOY_ALERTS:-false}" @@ -22,9 +26,7 @@ deploy_budget="${AGENTOPS_DEPLOY_BUDGET:-false}" monthly_budget_amount="${AGENTOPS_MONTHLY_BUDGET_AMOUNT:-100}" budget_contact_emails="${AGENTOPS_BUDGET_CONTACT_EMAILS:-[]}" -if [[ -n "$subscription_id" ]]; then - az account set --subscription "$subscription_id" -fi +agentops_require_azure_subscription if [[ "$(az group exists --name "$resource_group")" != "true" ]]; then cat <<MSG @@ -39,4 +41,4 @@ fi az deployment group what-if \ --resource-group "$resource_group" \ --template-file infra/bicep/main.bicep \ - --parameters environmentName="$environment_name" location="$location" baseName="$base_name" deploymentProfile="$deployment_profile" logRetentionDays="$log_retention_days" dailyIngestionCapGb="$daily_ingestion_cap_gb" deployAlerts="$deploy_alerts" enableAlerts="$enable_alerts" alertActionGroupResourceIds="$alert_action_group_resource_ids" grafanaPublicNetworkAccess="$grafana_public_network_access" grafanaZoneRedundancy="$grafana_zone_redundancy" deployRbacAssignments="$deploy_rbac_assignments" observerPrincipalIds="$observer_principal_ids" operatorPrincipalIds="$operator_principal_ids" adminPrincipalIds="$admin_principal_ids" deployBudget="$deploy_budget" monthlyBudgetAmount="$monthly_budget_amount" budgetContactEmails="$budget_contact_emails" + --parameters environmentName="$environment_name" location="$location" baseName="$base_name" deploymentProfile="$deployment_profile" deployAdvancedServices="$deploy_advanced_services" logRetentionDays="$log_retention_days" dailyIngestionCapGb="$daily_ingestion_cap_gb" deployAlerts="$deploy_alerts" enableAlerts="$enable_alerts" alertActionGroupResourceIds="$alert_action_group_resource_ids" grafanaPublicNetworkAccess="$grafana_public_network_access" grafanaZoneRedundancy="$grafana_zone_redundancy" deployRbacAssignments="$deploy_rbac_assignments" observerPrincipalIds="$observer_principal_ids" operatorPrincipalIds="$operator_principal_ids" adminPrincipalIds="$admin_principal_ids" deployBudget="$deploy_budget" monthlyBudgetAmount="$monthly_budget_amount" budgetContactEmails="$budget_contact_emails" diff --git a/scripts/benchmark-collector-local.js b/scripts/benchmark-collector-local.js new file mode 100644 index 0000000..b7e9269 --- /dev/null +++ b/scripts/benchmark-collector-local.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +'use strict'; + +const { runCollectorBenchmark } = require('../agentops-cli/src/lib/collector-benchmark'); + +runCollectorBenchmark({ + events: process.env.AGENTOPS_COLLECTOR_BENCH_EVENTS, + warmup: process.env.AGENTOPS_COLLECTOR_BENCH_WARMUP +}).then(report => { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +}, error => { + process.stderr.write(`${error.stack || error.message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/build-grafana-v2-dashboard-pack.js b/scripts/build-grafana-v2-dashboard-pack.js index eb06f08..594e0d7 100644 --- a/scripts/build-grafana-v2-dashboard-pack.js +++ b/scripts/build-grafana-v2-dashboard-pack.js @@ -43,9 +43,9 @@ const globalVariables = [ ]; const nav = [ - ['Home', 'agentops-v2-home'], + ['Today', 'agentops-v2-home'], ['Runs', 'agentops-v2-runs-explorer'], - ['Replay', 'agentops-v2-run-replay'], + ['Run Story', 'agentops-v2-run-replay'], ['Models', 'agentops-v2-models-cost-tokens'], ['Tools', 'agentops-v2-tools-mcp-risk'], ['Privacy', 'agentops-v2-safety-privacy-policy'], @@ -55,11 +55,43 @@ const nav = [ ['Collector', 'agentops-v2-collector-health'] ].map(([title, uid]) => ({ title, uid, type: 'link', icon: 'dashboard', url: `/d/${uid}`, targetBlank: false, keepTime: true, includeVars: true })); -function variable(name, value) { +const visibleVariablesByUid = { + 'agentops-v2-home': ['timeRange', 'repo_hash', 'model', 'agent_name', 'outcome_status', 'privacy_mode'], + 'agentops-v2-runs-explorer': ['timeRange', 'repo_hash', 'agent_name', 'skill_name', 'outcome_status'], + 'agentops-v2-run-replay': ['timeRange', 'run_id', 'session_id', 'trace_id'], + 'agentops-v2-models-cost-tokens': ['timeRange', 'repo_hash', 'model', 'agent_name', 'task_type'], + 'agentops-v2-tools-mcp-risk': ['timeRange', 'repo_hash', 'agent_name', 'tool_name', 'tool_risk', 'mcp_server'], + 'agentops-v2-safety-privacy-policy': ['timeRange', 'privacy_mode', 'tool_risk', 'outcome_status'], + 'agentops-v2-code-outcomes': ['timeRange', 'repo_hash', 'model', 'agent_name', 'outcome_status'], + 'agentops-v2-evals-quality': ['timeRange', 'repo_hash', 'model', 'task_type', 'eval_bucket'], + 'agentops-v2-insights-regressions': ['timeRange', 'repo_hash', 'model', 'pattern_key', 'outcome_status'], + 'agentops-v2-collector-health': ['timeRange', 'surface', 'privacy_mode'] +}; + +const friendlyVariableLabels = { + timeRange: 'Lookback', + repo_hash: 'Repository', + agent_name: 'Agent', + skill_name: 'Skill', + outcome_status: 'Outcome', + privacy_mode: 'Privacy', + run_id: 'Run', + session_id: 'Session', + trace_id: 'Trace', + task_type: 'Task type', + tool_name: 'Tool', + tool_risk: 'Tool risk', + mcp_server: 'MCP server', + eval_bucket: 'Eval result', + pattern_key: 'Pattern' +}; + +function variable(name, value, visible = false) { return { name, type: 'custom', - label: name.replace(/_/g, ' '), + label: friendlyVariableLabels[name] || name.replace(/_/g, ' '), + hide: visible ? 0 : 2, query: value, current: { selected: true, text: value === '__all' ? 'All' : value, value } }; @@ -115,6 +147,11 @@ function statPanel(id, title, x, y, query, unit = 'short', color = 'blue') { } function tablePanel(id, title, x, y, w, h, query, links = []) { + const sharedLinkTitle = query.includes('/shared/saved-view/') + ? 'Ask AgentOps with shared saved view' + : query.includes('/shared/alert-handoff/') + ? 'Ask AgentOps with shared alert handoff' + : 'Ask AgentOps with shared recommendation'; return { id, title, @@ -129,7 +166,7 @@ function tablePanel(id, title, x, y, w, h, query, links = []) { overrides: [ { matcher: { id: 'byName', options: 'RunId' }, - properties: [{ id: 'links', value: [{ title: 'Open Run Replay', url: '/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}', targetBlank: false }] }] + properties: [{ id: 'links', value: [{ title: 'Open Run Story', url: '/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&${__url_time_range}', targetBlank: false }] }] }, { matcher: { id: 'byName', options: 'SessionId' }, @@ -197,7 +234,7 @@ function tablePanel(id, title, x, y, w, h, query, links = []) { }, { matcher: { id: 'byName', options: 'OpenReplay' }, - properties: [{ id: 'links', value: [{ title: 'Open Run Replay', url: '/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}', targetBlank: false }] }] + properties: [{ id: 'links', value: [{ title: 'Open Run Story', url: '/d/agentops-v2-run-replay?var-run_id=${__data.fields.RunId}&var-session_id=${__data.fields.SessionId}&var-trace_id=${__data.fields.TraceId}&${__url_time_range}', targetBlank: false }] }] }, { matcher: { id: 'byName', options: 'AskAgentOpsLaunch' }, @@ -218,7 +255,11 @@ function tablePanel(id, title, x, y, w, h, query, links = []) { { matcher: { id: 'byName', options: 'OpenSavedView' }, properties: [{ id: 'links', value: [{ title: 'Open saved view', url: '${__data.fields.Url}', targetBlank: true }] }] - } + }, + ...(query.includes('AskSharedContext') ? [{ + matcher: { id: 'byName', options: 'AskSharedContext' }, + properties: [{ id: 'links', value: [{ title: sharedLinkTitle, url: '${__data.fields.AskAgentOpsSharedLaunch}', targetBlank: true }] }] + }] : []) ] }, options: { cellHeight: 'sm', showHeader: true, footer: { show: false } }, @@ -226,6 +267,11 @@ function tablePanel(id, title, x, y, w, h, query, links = []) { }; } +function withOverrides(panel, overrides) { + panel.fieldConfig.overrides = overrides; + return panel; +} + function timeseriesPanel(id, title, x, y, w, h, query, unit = 'short') { return { id, @@ -240,6 +286,61 @@ function timeseriesPanel(id, title, x, y, w, h, query, unit = 'short') { } function dashboard(uid, title, panels) { + const visibleVariables = new Set(visibleVariablesByUid[uid] || ['timeRange']); + const primaryPanelDescriptions = { + 'agentops-v2-home': { + 'Runs': 'Number of runs visible for the selected time range and filters.', + 'Success rate': 'Percentage of visible runs reporting a successful outcome. The label and value carry the meaning; colour is supplementary.', + 'Runs needing review': 'Visible runs reporting failed, cancelled, blocked, or unknown outcomes. Open Runs to see the reported outcome.', + 'Content items blocked': 'Content items AgentOps dropped under its privacy rules. A higher number can mean the privacy guard is actively protecting data, not that content was stored.', + 'Estimated cost': 'Estimated model cost for visible runs. This is an estimate, not an Azure invoice.', + 'Healthy collector checks': 'Collector checks reporting healthy. Open Collector for unhealthy, empty, or missing checks.', + 'Policy blocks': 'Policy events explicitly reporting denied or blocked status.', + 'Input tokens': 'Input tokens reported for visible runs.', + 'Output tokens': 'Output tokens reported for visible runs.', + 'p95 duration': '95th-percentile run duration. In plain language, about 95% of measured runs finished within this time.', + 'Tests ran %': 'Percentage of visible runs reporting that tests ran.', + 'PRs opened': 'Visible runs reporting that a pull request was opened.', + 'Session Health': 'Newest run evidence first. Delivery and coverage are shown before health so missing or best-effort evidence is not mistaken for a complete run.', + 'Recommended next actions': 'Evidence-backed follow-up suggestions. An empty table means no recommendation rows matched the current filters.', + 'Most expensive runs': 'Highest estimated-cost runs first. An empty table means no matching run cost was reported.', + 'GitHub outcomes summary': 'Newest reported pull request and CI outcomes first.', + 'Saved investigations': 'Saved evidence views, newest first. Links preserve the selected time range where supported.' + }, + 'agentops-v2-runs-explorer': { + 'Runs': 'Newest runs first. Delivery and coverage appear before identity, outcome, cost, and action links. An empty table means no runs matched the current time range and filters.', + 'Runs by reported outcome': 'Run count over time, split by the written outcome label. Series labels and the legend carry meaning; colour is supplementary.', + 'Token use': 'Reported input and output tokens over time. Cost remains a separate column in the Runs table so unlike units are not plotted on one axis.' + }, + 'agentops-v2-run-replay': { + 'Run summary': 'Newest matching run summary first. Choose a Run, Session, or Trace filter to isolate one story.', + 'Ordered timeline': 'Oldest matching event first, with timestamp ties ordered by sequence and event ID. Written status, attribution, privacy, and permission fields carry meaning; colour is not required.', + 'Agent, skill, and MCP lineage': 'Observed parent, agent, sub-agent, skill, MCP, and tool relationships in first-seen order. Missing attribution is reported explicitly.', + 'Context and cache posture': 'Token, cache, context pressure, and permission-wait evidence for the selected run.', + 'Why this failed / next check': 'Highest-severity matching insight first. An empty table means no insight row matched; it does not prove the run succeeded.', + 'Latest recommendation': 'Highest-priority matching recommendation first. Treat recommendations as evidence-backed suggestions, not automatic approval.', + 'Ask AgentOps context': 'Metadata-only investigation commands and links for the selected run.', + 'Transcript availability': 'States whether AgentOps has opt-in content rows. Zero rows means no AgentOps transcript is available.', + 'Prompt and response viewer (explicit opt-in)': 'Displays AgentOpsContent_CL only. It remains empty in the default strict metadata-only mode.', + 'Policy, privacy, tests, and GitHub outcome': 'Related evidence in time order, with written event and status fields.' + }, + 'agentops-v2-safety-privacy-policy': { + 'AgentOps capture posture': 'AgentOps telemetry scope, privacy mode, content-capture mode, coverage, run count, and a plain-language meaning. Unknown or non-strict rows require review.', + 'Content items blocked': 'Content items AgentOps reports dropping. This indicates a privacy action, not successful storage.', + 'Secret-like items blocked': 'Secret-like items AgentOps reports dropping. The written label and value carry meaning; colour is supplementary.', + 'Runs reporting unsafe mode': 'Runs whose AgentOps privacy mode is explicitly reported as unsafe. Review every non-zero result.', + 'Policy blocks': 'Policy events explicitly reporting denied or blocked status.', + 'Successful poison tests': 'Privacy poison checks explicitly reporting OK. A zero or empty result is not proof of privacy safety.', + 'Strict-mode runs': 'Runs explicitly reporting strict AgentOps privacy mode.', + 'Blocked or redacted items by kind': 'AgentOps privacy actions grouped by content kind, action, and privacy mode.', + 'Runs needing privacy or policy review': 'Runs reporting unsafe mode, risk, or denied tools. An empty table means no matching rows were found; it is not a universal safety guarantee.', + 'Alert handoff review': 'Privacy-related alert handoffs with written severity, owner, state, and evidence links.' + } + }; + const descriptions = primaryPanelDescriptions[uid] || {}; + for (const panel of panels) { + if (descriptions[panel.title]) panel.description = descriptions[panel.title]; + } return { annotations: { list: [] }, editable: true, @@ -251,7 +352,7 @@ function dashboard(uid, title, panels) { refresh: '1m', schemaVersion: 39, tags: ['agentops', 'agentops-v2', 'copilot', 'azure'], - templating: { list: globalVariables.map(([name, value]) => variable(name, value)) }, + templating: { list: globalVariables.map(([name, value]) => variable(name, value, visibleVariables.has(name))) }, time: { from: 'now-24h', to: 'now' }, timepicker: {}, timezone: 'browser', @@ -278,6 +379,9 @@ function runNormalize() { "| extend ParentAgentName=tostring(column_ifexists('ParentAgentName', ''))", "| extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '')", "| extend DelegationId=tostring(column_ifexists('DelegationId', ''))", + "| extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0))", + "| extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0))", + "| extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0))", "| extend CacheReadTokens=todouble(column_ifexists('CacheReadTokens', 0.0))", "| extend CacheCreationTokens=todouble(column_ifexists('CacheCreationTokens', 0.0))", "| extend ContextWindowPct=todouble(column_ifexists('ContextWindowPct', 0.0))", @@ -288,11 +392,21 @@ function runNormalize() { function eventNormalize() { return [ + "| extend Sequence=tolong(column_ifexists('Sequence', long(null))), EventId=tostring(column_ifexists('EventId', '')), ParentEventId=tostring(column_ifexists('ParentEventId', ''))", + "| extend EventType=tostring(column_ifexists('EventType', '')), CommandName=tostring(column_ifexists('CommandName', '')), ScriptName=tostring(column_ifexists('ScriptName', '')), McpToolName=tostring(column_ifexists('McpToolName', ''))", + "| extend InputTokens=todouble(column_ifexists('InputTokens', real(null))), OutputTokens=todouble(column_ifexists('OutputTokens', real(null))), ReasoningTokens=todouble(column_ifexists('ReasoningTokens', real(null))), TotalTokens=todouble(column_ifexists('TotalTokens', real(null))), EstimatedCostUsd=todouble(column_ifexists('EstimatedCostUsd', real(null)))", + "| extend PermissionKind=tostring(column_ifexists('PermissionKind', '')), PermissionDecision=tostring(column_ifexists('PermissionDecision', '')), PrivacyMode=tostring(column_ifexists('PrivacyMode', '')), ContentCaptureMode=tostring(column_ifexists('ContentCaptureMode', '')), ContentCaptureSignal=tobool(column_ifexists('ContentCaptureSignal', false)), ContentAction=tostring(column_ifexists('ContentAction', '')), ContentDroppedBytes=tolong(column_ifexists('ContentDroppedBytes', long(null))), SecretLike=tobool(column_ifexists('SecretLike', false))", "| extend SkillName=tostring(column_ifexists('SkillName', ''))", "| extend ParentAgentName=tostring(column_ifexists('ParentAgentName', ''))", "| extend SubAgentName=case(isnotempty(tostring(column_ifexists('SubAgentName', ''))), tostring(column_ifexists('SubAgentName', '')), isnotempty(ParentAgentName) and isnotempty(tostring(column_ifexists('AgentName', ''))) and tostring(column_ifexists('AgentName', '')) != ParentAgentName, tostring(column_ifexists('AgentName', '')), '')", "| extend DelegationId=tostring(column_ifexists('DelegationId', ''))", - "| extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', '')))" + "| extend BranchDurationMs=todouble(column_ifexists('BranchDurationMs', 0.0))", + "| extend BranchTokens=todouble(column_ifexists('BranchTokens', 0.0))", + "| extend BranchToolCount=tolong(column_ifexists('BranchToolCount', 0))", + "| extend McpServer=case(isnotempty(tostring(column_ifexists('McpServer', ''))), tostring(column_ifexists('McpServer', '')), isnotempty(tostring(column_ifexists('McpServerName', ''))), tostring(column_ifexists('McpServerName', '')), tostring(column_ifexists('ServerName', ''))) ", + "| extend EventType=case(isnotempty(EventType), EventType, EventName startswith 'agentops.run.' or EventName startswith 'agentops.wrapper.' or EventName startswith 'agentops.collector.', 'lifecycle', isnotempty(McpToolName) or isnotempty(McpServer) or EventName has 'mcp', 'mcp_tool', EventName == 'execute_tool' or EventName has 'tool' or isnotempty(ToolName), 'tool', EventName has 'skill' or isnotempty(SkillName), 'skill', EventName has 'subagent' or isnotempty(SubAgentName), 'subagent', EventName has 'agent', 'agent', isnotempty(CommandName), 'cli', isnotempty(ScriptName), 'script', EventName == 'chat' or isnotempty(ModelActual), 'llm', 'span')", + "| extend AttributionGap=case(EventType in ('agent', 'subagent') and isempty(AgentName) and isempty(SubAgentName), 'agent identity missing', EventType == 'skill' and isempty(SkillName), 'skill identity missing', EventType in ('tool', 'mcp_tool') and isempty(ToolName), 'tool identity missing', EventType == 'mcp_tool' and isempty(McpServer) and isempty(McpToolName), 'MCP attribution missing', EventType in ('command', 'cli') and isempty(CommandName), 'command identity missing', EventType == 'script' and isempty(ScriptName), 'script identity missing', EventType == 'llm' and isempty(ModelActual), 'model identity missing', '')", + "| extend AttributionConfidence=case(isnotempty(AttributionGap), 'missing', isnotempty(EventId) and isnotnull(Sequence), 'exact', tostring(column_ifexists('AttributionConfidence', '')) == 'inferred', 'inferred', 'best effort')" ].join(' '); } @@ -506,6 +620,7 @@ const compatNormalize = [ "| extend ParentAgentName=tostring(Properties['agentops.parent_agent.name'])", "| extend SubAgentName=case(isnotempty(tostring(Properties['agentops.sub_agent.name'])), tostring(Properties['agentops.sub_agent.name']), isnotempty(tostring(Properties['agentops.child_agent.name'])), tostring(Properties['agentops.child_agent.name']), isnotempty(ParentAgentName) and isnotempty(AgentName) and AgentName != ParentAgentName, AgentName, '')", "| extend DelegationId=tostring(Properties['agentops.delegation.id'])", + "| extend BranchDurationMs=todouble(Properties['agentops.subagent.duration_ms']), BranchTokens=todouble(Properties['agentops.subagent.total_tokens']), BranchToolCount=tolong(Properties['agentops.subagent.tool_count'])", "| extend Surface=case(isnotempty(tostring(Properties['agentops.surface'])), tostring(Properties['agentops.surface']), AppRoleName has 'codex', 'custom', AppRoleName has 'copilot', 'cli', 'cli')", "| extend RepoHash=tostring(Properties['agentops.repo.hash'])", "| extend BranchHash=tostring(Properties['agentops.branch.hash'])", @@ -522,13 +637,14 @@ const compatNormalize = [ const v2RunSummary = [ 'AgentOpsRunSummary_CL', '| where TimeGenerated between ($__timeFrom() .. $__timeTo())', - '| extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)' + '| extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), ReasoningTokens=todouble(ReasoningTokens), CacheReadTokens=todouble(CacheReadTokens), CacheCreationTokens=todouble(CacheCreationTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), ToolCount=tolong(ToolCount), ToolFailureCount=tolong(ToolFailureCount), ToolDeniedCount=tolong(ToolDeniedCount)', + "| extend Delivery='Visible in Azure', Coverage='AgentOps managed'" ].join(' '); const v2Events = [ 'AgentOpsEvents_CL', '| where TimeGenerated between ($__timeFrom() .. $__timeTo())', - '| extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd)' + "| extend DurationMs=todouble(DurationMs), InputTokens=todouble(InputTokens), OutputTokens=todouble(OutputTokens), EstimatedCostUsd=todouble(EstimatedCostUsd), Delivery='Visible in Azure', Coverage='AgentOps managed', AttributionConfidence='exact'" ].join(' '); const v2Tools = [ @@ -562,7 +678,7 @@ function compatRunSummary() { "| summarize TimeGenerated=max(TimeGenerated), Started=min(TimeGenerated), TraceId=take_any(TraceId), Surface=take_any(Surface), RepoHash=take_any(RepoHash), BranchHash=take_any(BranchHash), TaskType=take_any(TaskType), AgentName=take_any(AgentName), SkillName=take_any(SkillName), ParentAgentName=take_any(ParentAgentName), SubAgentName=take_any(SubAgentName), DelegationId=take_any(DelegationId), ModelActual=take_any(ModelActual), PrivacyMode=take_any(PrivacyMode), ContentCaptureSignal=max(toint(ContentCaptureSignal)), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens), ReasoningTokens=sum(ReasoningTokens), CacheReadTokens=sum(CacheReadTokens), CacheCreationTokens=sum(CacheCreationTokens), ContextWindowPct=max(ContextWindowPct), TokensRemoved=sum(TokensRemoved), PermissionWaitMs=sum(PermissionWaitMs), EstimatedCostUsd=sum(EstimatedCostUsd), ToolCount=countif(Operation == 'execute_tool' or isnotempty(ToolName)), ToolFailureCount=countif((Operation == 'execute_tool' or isnotempty(ToolName)) and Failed), Failures=countif(Failed), ToolDeniedCount=countif(tostring(Properties['agentops.mcp.allowed']) =~ 'false'), FilesReadCount=countif(ToolName has 'read'), FilesEditedCount=countif(ToolName has_any ('edit', 'write', 'patch')), TestsRan=countif(ToolName has_any ('test', 'lint', 'typecheck')) > 0 by RunId, SessionId", "| extend DurationMs=todouble(datetime_diff('millisecond', TimeGenerated, Started))", "| extend ModelRequested=ModelActual, ContentCaptureMode=iff(ContentCaptureSignal > 0, 'signal_only', 'off'), OutcomeStatus=iff(Failures > 0, 'failed', 'success'), OutcomeReason=iff(Failures > 0, 'span_failure', 'completed'), TestsPassed=TestsRan and ToolFailureCount == 0, PrOpened=false, PrNumberHash='', CiStatus='not_run', EvalOverall=tolong(iff(Failures > 0, 50, 85)), RiskScore=ToolFailureCount * 20 + ToolDeniedCount * 30 + ContentCaptureSignal * 15", - "| project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore" + "| project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelRequested, ModelActual, PrivacyMode, ContentCaptureMode, ContentCaptureSignal=ContentCaptureSignal > 0, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, FilesReadCount, FilesEditedCount, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, Delivery='Visible in Azure', Coverage='Native best effort'" ].join(' '); } @@ -574,7 +690,8 @@ function compatEvents() { "| extend EventType=case(Operation == 'chat', 'llm', Operation == 'execute_tool' or isnotempty(ToolName), 'tool', ContentCaptureSignal, 'content', Failed, 'error', 'span')", "| extend Status=iff(Failed, 'failed', 'success')", "| extend McpServer=case(ToolName startswith 'mcp__', extract('^mcp__([^_]+)__', 1, ToolName), ToolName contains '/', tostring(split(ToolName, '/')[0]), ToolName startswith 'azure-mcp-', 'azure-mcp', '')", - "| project TimeGenerated, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, McpServer, ToolName, ModelActual, Status, DurationMs, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureSignal" + "| extend Sequence=long(null), EventId='', ParentEventId='', CommandName='', ScriptName='', McpToolName='', TotalTokens=InputTokens + OutputTokens + ReasoningTokens, PermissionKind='', PermissionDecision='', ContentCaptureMode=iff(ContentCaptureSignal, 'signal only', 'off'), ContentAction=iff(ContentCaptureSignal, 'dropped', ''), ContentDroppedBytes=long(null), SecretLike=false, Delivery='Visible in Azure', Coverage='Native best effort', AttributionConfidence='inferred'", + "| project TimeGenerated, Sequence, EventId, ParentEventId, RunId, SessionId, TraceId, SpanId=Id, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, BranchDurationMs, BranchTokens, BranchToolCount, McpServer, McpToolName, ToolName, CommandName, ScriptName, ModelActual, Status, DurationMs, InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd, PermissionKind, PermissionDecision, ErrorType, OutcomeStatus=Status, Details=ResultCode, Surface, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike, Delivery, Coverage, AttributionConfidence" ].join(' '); } @@ -628,7 +745,7 @@ function compatInsights() { "| extend InsightType=case(OutcomeStatus != 'success', 'failure-anomaly', ContentCaptureSignal == true, 'privacy-signal', ToolFailureCount > 0, 'tool-failure-anomaly', EstimatedCostUsd >= 1.0, 'cost-anomaly', 'risk-signal')", "| extend Severity=case(OutcomeStatus != 'success' or RiskScore >= 60, 'high', RiskScore >= 20, 'medium', 'low')", "| extend Summary=case(OutcomeStatus != 'success', strcat('Run failed from existing Copilot OpenTelemetry: ', OutcomeReason), ContentCaptureSignal == true, 'Content-like fields were observed and represented as privacy signals.', ToolFailureCount > 0, strcat('Tool failures observed: ', tostring(ToolFailureCount)), EstimatedCostUsd >= 1.0, strcat('Estimated cost is elevated: $', tostring(round(EstimatedCostUsd, 2))), 'Risk score is elevated.')", - "| extend SuggestedNextStep='Open Run Replay and inspect the linked metadata-only timeline.'", + "| extend SuggestedNextStep='Open Run Story and inspect the linked metadata-only timeline.'", "| project TimeGenerated, InsightId=strcat('compat_', RunId, '_', InsightType), RunId, TraceId, InsightType, Severity, Title=InsightType, Summary, SuggestedNextStep, RepoHash, ModelActual, TaskType, ToolName='', BaselineValue=real(null), CurrentValue=todouble(RiskScore), ConfigHash=''" ].join(' '); } @@ -639,7 +756,7 @@ function compatRecommendations() { `(${compatInsights()})`, insightsNormalize(), "| extend Action=case(InsightType startswith 'recurring-', 'triage_recurring_pattern', InsightType has 'test', 'run_validation', InsightType has 'tool', 'investigate_tool', InsightType has 'collector', 'check_collector', InsightType has_any ('policy', 'privacy'), 'review_policy', InsightType has_any ('cost', 'context'), 'reduce_context_or_cost', InsightType has 'ci', 'fix_ci', InsightType has_any ('eval', 'instruction', 'config'), 'compare_regression', 'investigate')", - "| project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Replay', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.'" + "| project TimeGenerated, RecommendationId=coalesce(tostring(column_ifexists('RecommendationId', '')), tostring(column_ifexists('InsightId', ''))), RunId, SessionId='', TraceId, Action, Severity, ObservedPattern=Summary, NextAction=SuggestedNextStep, PatternId, PatternKey, PatternRuns, PatternDimension, EvalOverall=long(null), EvalBucket='', BenchmarkRunId='', BenchmarkDecision='', BenchmarkPassRatePct=real(null), BenchmarkAverageScore=real(null), BenchmarkSafetyViolationCount=long(null), BenchmarkArtifactAdded=long(null), BenchmarkArtifactModified=long(null), BenchmarkArtifactDeleted=long(null), BenchmarkArtifactTotalChanged=long(null), BenchmarkArtifactFiles=dynamic([]), BenchmarkArtifactContentDiffs=dynamic([]), BenchmarkHiddenChecksPassed=long(null), BenchmarkHiddenChecksFailed=long(null), BenchmarkHiddenCheckPacks=dynamic([]), BenchmarkPolicyBlocks=long(null), BenchmarkPermissionProfiles=dynamic({}), BenchmarkPolicyTasks=dynamic([]), BenchmarkSemanticCheckCount=long(null), BenchmarkSemanticAverageScore=real(null), BenchmarkSemanticChecks=dynamic([]), BenchmarkApprovalStatus='', BenchmarkApprovalCount=long(null), BenchmarkRequiredApprovals=long(null), BenchmarkApprovalApprovedAt='', BenchmarkApprovalTicket='', BenchmarkApprovalSource='', ChangeAnnotations=dynamic([]), ChangeTargetRefs=dynamic([]), DashboardTitles=dynamic(['Run Story', 'Insights & Regressions']), DashboardCount=2, Validation=dynamic(['agentops dashboard kql-check --last 24h --json']), RollbackCondition='Rollback the agent, skill, MCP, model, instruction, or benchmark artifact change if eval score drops, failures rise, privacy drops appear unexpectedly, or CI worsens.'" ].join(' '); } @@ -675,45 +792,45 @@ function configAnnotationsQuery() { } const dashboards = { - '01-agentops-home.json': dashboard('agentops-v2-home', 'AgentOps Home', [ - textPanel(1, 'What happened?', 0, 0, 24, 3, `## AgentOps Home\nCopilot AgentOps control room for Azure. ${emptyState}`), - textPanel(18, 'Open latest run', 0, 3, 8, 3, "### Open latest run\nStart with the newest session, then drill into Run Replay.\n\n`agentops open latest --last 2h --json`\n\n[Run Replay](/d/agentops-v2-run-replay?${__url_time_range})"), + '01-agentops-home.json': dashboard('agentops-v2-home', 'Today', [ + textPanel(1, 'What happened?', 0, 0, 24, 3, `## Today\nThese runs are visible in Azure. If a recent run is missing, check \`agentops delivery status\`. Coverage is marked as AgentOps managed or Native best effort. ${emptyState}`), + textPanel(18, 'Open latest run', 0, 3, 8, 3, "### Open latest run\nStart with the newest session, then drill into Run Story.\n\n`agentops open latest --last 2h --json`\n\n[Run Story](/d/agentops-v2-run-replay?${__url_time_range})"), textPanel(19, 'Get recommendation', 8, 3, 8, 3, "### Get recommendation\nGenerate one evidence-backed next action for the current run set.\n\n`agentops recommend latest --last 2h`\n\n[Insights](/d/agentops-v2-insights-regressions?${__url_time_range})"), - textPanel(20, 'Ask AgentOps', 16, 3, 8, 3, "### Ask AgentOps\nBuild a metadata-only context bundle for investigation.\n\n`agentops ask-context latest --last 2h --json`\n\nExported evidence bundle:\n\n`agentops ask-context latest --last 2h --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --privacy <AgentOpsPrivacy_CL.jsonl> --github <AgentOpsGitHubOutcome_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl> --recommendations <AgentOpsRecommendations_CL.jsonl> --json`\n\nUse `docs/copilot-mcp-agentops-prompts.md` for session, tool failure, benchmark, agent, hook, and MCP regression templates.\n\n[Run Replay](/d/agentops-v2-run-replay?${__url_time_range})"), + textPanel(20, 'Ask AgentOps', 16, 3, 8, 3, "### Ask AgentOps\nBuild a metadata-only context bundle for investigation.\n\n`agentops ask-context latest --last 2h --json`\n\nExported evidence bundle:\n\n`agentops ask-context latest --last 2h --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --privacy <AgentOpsPrivacy_CL.jsonl> --github <AgentOpsGitHubOutcome_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl> --recommendations <AgentOpsRecommendations_CL.jsonl> --json`\n\nUse `docs/copilot-mcp-agentops-prompts.md` for session, tool failure, benchmark, agent, hook, and MCP regression templates.\n\n[Run Story](/d/agentops-v2-run-replay?${__url_time_range})"), statPanel(2, 'Runs', 0, 6, `${q.runSummary} | summarize value=count() by bin(TimeGenerated, $__interval)`), statPanel(3, 'Success rate', 4, 6, `${q.runSummary} | summarize value=100.0 * countif(OutcomeStatus == 'success') / count() by bin(TimeGenerated, $__interval)`, 'percent', 'green'), - statPanel(4, 'Failed runs', 8, 6, `${q.runSummary} | summarize value=countif(OutcomeStatus != 'success') by bin(TimeGenerated, $__interval)`, 'short', 'red'), - statPanel(5, 'Privacy drops', 12, 6, `${q.privacy} | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)`, 'short', 'yellow'), + statPanel(4, 'Runs needing review', 8, 6, `${q.runSummary} | summarize value=countif(OutcomeStatus != 'success') by bin(TimeGenerated, $__interval)`, 'short', 'red'), + statPanel(5, 'Content items blocked', 12, 6, `${q.privacy} | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)`, 'short', 'yellow'), statPanel(6, 'Estimated cost', 16, 6, `${q.runSummary} | summarize value=sum(EstimatedCostUsd) by bin(TimeGenerated, $__interval)`, 'currencyUSD', 'yellow'), - statPanel(7, 'Collector health', 20, 6, `${q.health} | summarize value=countif(Status == 'healthy') by bin(TimeGenerated, $__interval)`, 'short', 'green'), + statPanel(7, 'Healthy collector checks', 20, 6, `${q.health} | summarize value=countif(Status == 'healthy') by bin(TimeGenerated, $__interval)`, 'short', 'green'), statPanel(8, 'Policy blocks', 0, 10, `${q.events} | where EventType == 'policy' | summarize value=countif(Status == 'denied' or Status == 'blocked') by bin(TimeGenerated, $__interval)`, 'short', 'red'), statPanel(9, 'Input tokens', 4, 10, `${q.runSummary} | summarize value=sum(InputTokens) by bin(TimeGenerated, $__interval)`), statPanel(14, 'Output tokens', 8, 10, `${q.runSummary} | summarize value=sum(OutputTokens) by bin(TimeGenerated, $__interval)`), statPanel(15, 'p95 duration', 12, 10, `${q.runSummary} | summarize value=percentile(DurationMs, 95) by bin(TimeGenerated, $__interval)`, 'ms', 'yellow'), statPanel(16, 'Tests ran %', 16, 10, `${q.runSummary} | summarize value=100.0 * countif(TestsRan == true) / count() by bin(TimeGenerated, $__interval)`, 'percent', 'green'), statPanel(17, 'PRs opened', 20, 10, `${q.runSummary} | summarize value=countif(PrOpened == true) by bin(TimeGenerated, $__interval)`, 'short', 'green'), - tablePanel(10, 'Session Health', 0, 14, 12, 9, `let LatestRecommendations = ${q.recommendations} | summarize arg_max(TimeGenerated, Severity, Action, NextAction, PatternKey, BenchmarkRunId, BenchmarkDecision) by RunId; ${q.runSummary} | join kind=leftouter LatestRecommendations on RunId | extend HealthStatus=case(OutcomeStatus != 'success', 'failed', RiskScore >= 60, 'high risk', RiskScore >= 20, 'review', ContentCaptureSignal == true, 'privacy review', 'healthy'), RootAgent=case(isnotempty(ParentAgentName), ParentAgentName, isnotempty(AgentName), AgentName, 'agent'), RecommendedNextAction=case(isnotempty(NextAction), NextAction, 'Open Run Replay and inspect the metadata timeline.'), OpenReplay='Replay' | project TimeGenerated, HealthStatus, RiskScore, RootAgent, ModelActual, ToolFailureCount, ToolDeniedCount, ContentCaptureSignal, ContextWindowPct, EvalOverall, BenchmarkRunId, BenchmarkDecision, RecommendedNextAction, RunId, SessionId, TraceId, OpenReplay | order by TimeGenerated desc | take 50`), - tablePanel(11, 'Recommended next actions', 12, 14, 12, 9, `${q.recommendations} | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations) | project TimeGenerated, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, DashboardCount, OpenReplay, OpenPattern | order by TimeGenerated desc | take 50`), + tablePanel(10, 'Session Health', 0, 14, 12, 9, `let LatestRecommendations = ${q.recommendations} | summarize arg_max(TimeGenerated, Severity, Action, NextAction, PatternKey, BenchmarkRunId, BenchmarkDecision) by RunId; ${q.runSummary} | join kind=leftouter LatestRecommendations on RunId | extend HealthStatus=case(OutcomeStatus != 'success', 'failed', RiskScore >= 60, 'high risk', RiskScore >= 20, 'review', ContentCaptureSignal == true, 'privacy review', 'healthy'), RootAgent=case(isnotempty(ParentAgentName), ParentAgentName, isnotempty(AgentName), AgentName, 'agent'), RecommendedNextAction=case(isnotempty(NextAction), NextAction, 'Open Run Story and inspect the metadata timeline.'), OpenReplay='Replay' | project TimeGenerated, Delivery, Coverage, HealthStatus, RiskScore, RootAgent, ModelActual, ToolFailureCount, ToolDeniedCount, ContentCaptureSignal, ContextWindowPct, EvalOverall, BenchmarkRunId, BenchmarkDecision, RecommendedNextAction, RunId, SessionId, TraceId, OpenReplay | order by TimeGenerated desc | take 50`), + tablePanel(11, 'Recommended next actions', 12, 14, 12, 9, `${q.recommendations} | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, DashboardCount, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by TimeGenerated desc | take 50`), tablePanel(12, 'Most expensive runs', 0, 23, 12, 9, `${q.runSummary} | project TimeGenerated, RunId, RepoHash, TaskType, ModelActual, OutcomeStatus, EstimatedCostUsd, InputTokens, OutputTokens | order by EstimatedCostUsd desc | take 50`), tablePanel(13, 'GitHub outcomes summary', 12, 23, 12, 9, `${q.github} | project TimeGenerated, RunId, RepoHash, PrOpened, PrMerged, PrReverted, CiStatus, TimeToPrMinutes, TimeToMergeMinutes, ReviewCommentCount, FilesChangedCount | order by TimeGenerated desc | take 50`), - tablePanel(21, 'Saved investigations', 0, 32, 24, 8, `${q.savedViews} | extend TagsText=strcat_array(Tags, ', '), ChangeAnnotationCount=coalesce(ChangeAnnotationCount, array_length(ChangeAnnotations)), OpenSavedView=iff(isnotempty(Url), 'Open', ''), OpenReplay=iff(isnotempty(SessionId), 'Replay', '') | project TimeGenerated, Name, Description, TagsText, SessionId, QueryHash, ChangeAnnotationCount, ChangeTargetRefs, CreatedAt, Url, OpenSavedView, OpenReplay | order by TimeGenerated desc | take 100`) + tablePanel(21, 'Saved investigations', 0, 32, 24, 8, `${q.savedViews} | extend TagsText=strcat_array(Tags, ', '), ChangeAnnotationCount=coalesce(ChangeAnnotationCount, array_length(ChangeAnnotations)), OpenSavedView=iff(isnotempty(Url), 'Open', ''), OpenReplay=iff(isnotempty(SessionId), 'Replay', ''), AskAgentOpsSharedLaunch=iff(isnotempty(SavedViewId), strcat('$actioner_url', '/ask-agentops/shared/saved-view/', url_encode(SavedViewId), '?session_id=', url_encode(SessionId), '&dashboard_url=', url_encode(Url), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(SavedViewId), 'Ask shared', '') | project TimeGenerated, SavedViewId, Name, Description, TagsText, SessionId, QueryHash, ChangeAnnotationCount, ChangeTargetRefs, CreatedAt, Url, AskSharedContext, AskAgentOpsSharedLaunch, OpenSavedView, OpenReplay | order by TimeGenerated desc | take 100`) ]), - '02-runs-explorer.json': dashboard('agentops-v2-runs-explorer', 'Runs Explorer', [ - textPanel(1, 'Find a run', 0, 0, 24, 2, `## Runs Explorer\nDatadog-style run list. ${emptyState}`), - tablePanel(10, 'Runs', 0, 2, 24, 17, `${q.runSummary} | extend OpenReplay='Replay', OpenTrace='Trace', OpenGithub=iff(PrOpened == true or isnotempty(PrNumberHash) or CiStatus != 'not_run', 'Outcome', '') | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, CacheReadTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, OpenReplay, OpenTrace, OpenGithub | order by TimeGenerated desc | take 500`), - timeseriesPanel(20, 'Runs by outcome', 0, 19, 12, 8, `${q.runSummary} | summarize Runs=count() by TimeGenerated=bin(TimeGenerated, $__interval), OutcomeStatus | order by TimeGenerated asc`), - timeseriesPanel(21, 'Cost and tokens', 12, 19, 12, 8, `${q.runSummary} | summarize Cost=sum(EstimatedCostUsd), InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens) by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc`) + '02-runs-explorer.json': dashboard('agentops-v2-runs-explorer', 'Runs', [ + textPanel(1, 'Find a run', 0, 0, 24, 2, `## Runs\nFind a Copilot run and open its story or outcome evidence. Results are newest first; use the linked Run, Session, or Trace value to continue without relying on colour. ${emptyState}`), + tablePanel(10, 'Runs', 0, 2, 24, 17, `${q.runSummary} | extend OpenReplay='Replay', OpenTrace='Trace', OpenGithub=iff(PrOpened == true or isnotempty(PrNumberHash) or CiStatus != 'not_run', 'Outcome', '') | project TimeGenerated, Delivery, Coverage, RunId, SessionId, TraceId, Surface, RepoHash, BranchHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, InputTokens, OutputTokens, CacheReadTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, EstimatedCostUsd, ToolCount, ToolFailureCount, ToolDeniedCount, TestsRan, TestsPassed, PrOpened, PrNumberHash, CiStatus, EvalOverall, RiskScore, OpenReplay, OpenTrace, OpenGithub | order by TimeGenerated desc | take 500`), + timeseriesPanel(20, 'Runs by reported outcome', 0, 19, 12, 8, `${q.runSummary} | summarize Runs=count() by TimeGenerated=bin(TimeGenerated, $__interval), OutcomeStatus | order by TimeGenerated asc`), + timeseriesPanel(21, 'Token use', 12, 19, 12, 8, `${q.runSummary} | summarize InputTokens=sum(InputTokens), OutputTokens=sum(OutputTokens) by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc`) ]), - '03-run-replay.json': dashboard('agentops-v2-run-replay', 'Agent Run Replay', [ - textPanel(1, 'Replay', 0, 0, 24, 2, `## Agent Run Replay\nTimeline of one Copilot run. Strict mode shows metadata only; prompt/response rows appear only when AgentOpsContent_CL is explicitly enabled. ${emptyState}`), - tablePanel(10, 'Run summary', 0, 2, 24, 5, `${q.runSummary} | project TimeGenerated, RunId, SessionId, TraceId, Surface, RepoHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, EstimatedCostUsd, ContextWindowPct, CacheReadTokens, TokensRemoved, PermissionWaitMs, TestsRan, TestsPassed, PrOpened, CiStatus, EvalOverall, RiskScore | order by TimeGenerated desc | take 20`), - tablePanel(20, 'Replay timeline', 0, 7, 24, 10, `${q.events} | project TimeGenerated, RunId, SessionId, TraceId, SpanId, EventName, EventType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, McpServer, ToolName, ModelActual, Status, DurationMs, ErrorType, OutcomeStatus, Details | order by TimeGenerated asc | take 1000`), - tablePanel(23, 'Agent, skill, and MCP lineage', 0, 17, 24, 6, `${q.events} | extend Actor=case(isnotempty(SubAgentName), SubAgentName, isnotempty(AgentName), AgentName, 'agent'), Parent=iff(isempty(ParentAgentName), 'root', ParentAgentName), Skill=iff(isempty(SkillName), 'none', SkillName), Mcp=iff(isempty(McpServer), 'none', McpServer), Tool=iff(isempty(ToolName), 'none', ToolName) | summarize Events=count(), Tools=dcountif(Tool, Tool != 'none'), Failures=countif(Status != 'success'), P95DurationMs=percentile(DurationMs, 95), FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated) by Parent, Actor, Skill, Mcp, Tool, DelegationId | order by FirstSeen asc | take 200`), + '03-run-replay.json': dashboard('agentops-v2-run-replay', 'Run Story', [ + textPanel(1, 'Run Story', 0, 0, 24, 2, `## Run Story\nChoose a Run, Session, or Trace filter to isolate one Copilot story; with all three set to All, panels can mix matching runs. Events are ordered oldest first, with sequence and event ID breaking timestamp ties. Strict mode shows metadata only; prompt/response rows appear only when AgentOpsContent_CL is explicitly enabled. ${emptyState}`), + tablePanel(10, 'Run summary', 0, 2, 24, 5, `${q.runSummary} | project TimeGenerated, Delivery, Coverage, RunId, SessionId, TraceId, Surface, RepoHash, TaskType, AgentName, SkillName, ParentAgentName, SubAgentName, DelegationId, ModelActual, OutcomeStatus, OutcomeReason, DurationMs, EstimatedCostUsd, ContextWindowPct, CacheReadTokens, TokensRemoved, PermissionWaitMs, TestsRan, TestsPassed, PrOpened, CiStatus, EvalOverall, RiskScore | order by TimeGenerated desc | take 20`), + tablePanel(20, 'Ordered timeline', 0, 7, 24, 10, `${q.events} | extend SequenceSort=coalesce(Sequence, long(9223372036854775807)) | project TimeGenerated, Sequence, EventId, ParentEventId, Delivery, Coverage, AttributionConfidence, AttributionGap, EventName, EventType, AgentName, ParentAgentName, SubAgentName, SkillName, DelegationId, McpServer, McpToolName, ToolName, CommandName, ScriptName, ModelActual, Status, DurationMs, InputTokens, OutputTokens, ReasoningTokens, TotalTokens, EstimatedCostUsd, PermissionKind, PermissionDecision, PrivacyMode, ContentCaptureMode, ContentCaptureSignal, ContentAction, ContentDroppedBytes, SecretLike, ErrorType, OutcomeStatus, RunId, SessionId, TraceId, SpanId, Details, SequenceSort | order by TimeGenerated asc, SequenceSort asc, EventId asc | project-away SequenceSort | take 1000`), + tablePanel(23, 'Agent, skill, and MCP lineage', 0, 17, 24, 6, `${q.events} | extend Actor=case(isnotempty(SubAgentName), SubAgentName, isnotempty(AgentName), AgentName, 'attribution missing'), Parent=iff(isempty(ParentAgentName), 'root or missing', ParentAgentName), Skill=iff(isempty(SkillName), 'not observed', SkillName), Mcp=iff(isempty(McpServer), 'not observed', McpServer), Tool=iff(isempty(ToolName), 'not observed', ToolName) | summarize Events=count(), Tools=dcountif(Tool, Tool != 'not observed'), Failures=countif(Status in ('failed', 'error', 'denied', 'blocked')), MissingAttribution=countif(AttributionConfidence == 'missing'), P95DurationMs=percentile(DurationMs, 95), BranchDurationMs=max(BranchDurationMs), BranchTokens=max(BranchTokens), BranchToolCount=max(BranchToolCount), FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated) by Parent, Actor, Skill, Mcp, Tool, DelegationId, Coverage | order by FirstSeen asc | take 200`), tablePanel(24, 'Context and cache posture', 0, 23, 24, 4, `${q.runSummary} | project TimeGenerated, RunId, InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheCreationTokens, ContextWindowPct, TokensRemoved, PermissionWaitMs, ContextState=case(ContextWindowPct >= 90 or TokensRemoved > 0, 'pressure', CacheReadTokens > 0, 'cache leverage', 'normal') | order by TimeGenerated desc | take 20`), tablePanel(28, 'Why this failed / next check', 0, 27, 24, 5, `${q.insights} | extend Priority=case(Severity == 'critical', 0, Severity == 'high', 1, Severity == 'medium', 2, 3) | order by Priority asc, TimeGenerated desc | project TimeGenerated, Severity, InsightType, Summary, SuggestedNextStep, RunId, TraceId, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash | take 20`), - tablePanel(31, 'Latest recommendation', 0, 32, 24, 5, `${q.recommendations} | extend Priority=case(Severity == 'critical', 0, Severity == 'high', 1, Severity == 'medium', 2, 3), RecommendationCommand=strcat('agentops recommend ', RunId, ' --last $timeRange --json'), AskContextCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --json'), OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations) | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, RecommendationCommand, AskContextCommand, OpenReplay, OpenPattern | order by Priority asc, TimeGenerated desc | take 20`), + tablePanel(31, 'Latest recommendation', 0, 32, 24, 5, `${q.recommendations} | extend Priority=case(Severity == 'critical', 0, Severity == 'high', 1, Severity == 'medium', 2, 3), RecommendationCommand=strcat('agentops recommend ', RunId, ' --last $timeRange --json'), AskContextCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --json'), OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, BenchmarkRunId, BenchmarkDecision, ChangeAnnotationCount, ChangeTargetRefs, RecommendationCommand, AskContextCommand, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by Priority asc, TimeGenerated desc | take 20`), tablePanel(29, 'Ask AgentOps context', 0, 37, 24, 5, `${q.runSummary} | extend RunReplayUrl=strcat('/d/agentops-v2-run-replay?var-run_id=', RunId, '&var-session_id=', SessionId, '&var-trace_id=', TraceId, '&\${__url_time_range}'), InvestigationKql=strcat('AgentOpsRunSummary_CL | where TimeGenerated > ago($timeRange) | where RunId == "', RunId, '" or SessionId == "', SessionId, '" | project TimeGenerated, RunId, SessionId, TraceId, OutcomeStatus, OutcomeReason'), AskContextCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --json'), BundleCommand=strcat('agentops ask-context ', RunId, ' --last $timeRange --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --privacy <AgentOpsPrivacy_CL.jsonl> --github <AgentOpsGitHubOutcome_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl> --recommendations <AgentOpsRecommendations_CL.jsonl> --json'), TriageCommand=strcat('agentops triage ', RunId, ' --runs <AgentOpsRunSummary_CL.jsonl> --events <AgentOpsEvents_CL.jsonl> --tools <AgentOpsToolCalls_CL.jsonl> --evals <AgentOpsEval_CL.jsonl> --insights <AgentOpsInsights_CL.jsonl>'), OpenReplay='Replay' | extend AskAgentOpsLaunch=strcat('$actioner_url', '/ask-agentops?run_id=', url_encode(RunId), '&session_id=', url_encode(SessionId), '&trace_id=', url_encode(TraceId), '&dashboard_url=', url_encode(RunReplayUrl), '&last=$timeRange') | extend AskPrompt=strcat('Use the telemetry-investigator or AgentOps triage skill. Investigate AgentOps run ', RunId, '. Session ', SessionId, '. Trace ', TraceId, '. Dashboard ', RunReplayUrl, '. Start with KQL: ', InvestigationKql, '. Use only metadata in the dashboard. Return what happened, why it matters, the likely failure/cost/safety/context pattern, and one evidence-backed next action. Do not request or enable prompt, response, source code, file content, tool argument, tool result, URL, request body, response body, or secret capture.') | project TimeGenerated, RunId, SessionId, TraceId, OutcomeStatus, OutcomeReason, RunReplayUrl, InvestigationKql, AskContextCommand, BundleCommand, AskPrompt, TriageCommand, AskAgentOpsLaunch, OpenReplay | order by TimeGenerated desc | take 20`), tablePanel(25, 'Transcript availability', 0, 42, 24, 4, `union isfuzzy=true (${q.runSummary} | summarize Runs=dcount(RunId), ContentSignalRuns=countif(ContentCaptureSignal == true), Modes=make_set(ContentCaptureMode, 10), LatestRunId=take_any(RunId), LatestSessionId=take_any(SessionId), LatestTraceId=take_any(TraceId)), (${q.content} | summarize ContentRows=count(), FullContentRows=countif(CaptureMode == 'full'), RedactedContentRows=countif(CaptureMode == 'redacted'), ContentModes=make_set(CaptureMode, 10), RedactionStates=make_set(RedactionStatus, 10), LatestRunId=take_any(RunId), LatestSessionId=take_any(SessionId), LatestTraceId=take_any(TraceId)) | summarize Runs=sum(Runs), ContentSignalRuns=sum(ContentSignalRuns), ContentRows=sum(ContentRows), FullContentRows=sum(FullContentRows), RedactedContentRows=sum(RedactedContentRows), Modes=make_set(Modes, 10), ContentModes=make_set(ContentModes, 10), RedactionStates=make_set(RedactionStates, 10), RunId=take_anyif(LatestRunId, isnotempty(LatestRunId)), SessionId=take_anyif(LatestSessionId, isnotempty(LatestSessionId)), TraceId=take_anyif(LatestTraceId, isnotempty(LatestTraceId)) | extend Status=case(ContentRows == 0, 'strict metadata only', FullContentRows > 0, 'content viewer enabled: full opt-in', 'content viewer enabled: redacted opt-in'), SafetyNote='Content rows require explicit opt-in and restricted access.', OpenTranscript='Open viewer' | project Status, SafetyNote, OpenTranscript, ContentRows, FullContentRows, RedactedContentRows, ContentSignalRuns, Runs, RunId, SessionId, TraceId, Modes, ContentModes, RedactionStates`), tablePanel(26, 'Prompt and response viewer (explicit opt-in)', 0, 46, 24, 8, `${q.content} | project TimeGenerated, TurnIndex, Role, ContentKind, MessageText, CaptureMode, RedactionStatus, ViewerNote, ModelActual, ToolName, ContentHash, ContentLength, RunId, SessionId, TraceId | order by TimeGenerated asc | take 200`), @@ -734,16 +851,39 @@ const dashboards = { timeseriesPanel(21, 'Denied tools', 12, 14, 12, 8, `${q.tools} | summarize Denied=countif(Allowed == false) by TimeGenerated=bin(TimeGenerated, $__interval), ToolRisk | order by TimeGenerated asc`) ]), - '06-safety-privacy-policy.json': dashboard('agentops-v2-safety-privacy-policy', 'Safety, Privacy & Policy', [ - textPanel(1, 'Trust screen', 0, 0, 24, 2, `## Safety, Privacy & Policy\nStrict privacy should be visible and reassuring. ${emptyState}`), - statPanel(2, 'Privacy drops', 0, 2, `${q.privacy} | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)`, 'short', 'yellow'), - statPanel(3, 'Secret-like drops', 4, 2, `${q.privacy} | where ContentKind == 'secret_like' | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)`, 'short', 'red'), - statPanel(4, 'Unsafe attempts', 8, 2, `${q.runSummary} | summarize value=countif(PrivacyMode == 'unsafe') by bin(TimeGenerated, $__interval)`, 'short', 'red'), - statPanel(5, 'Policy blocks', 12, 2, `${q.events} | where EventType == 'policy' | summarize value=countif(Status == 'denied' or Status == 'blocked') by bin(TimeGenerated, $__interval)`, 'short', 'red'), - statPanel(6, 'Poison tests OK', 16, 2, `${q.health} | where CheckName == 'privacy-poison' | summarize value=countif(Status == 'ok') by bin(TimeGenerated, $__interval)`, 'short', 'green'), - statPanel(7, 'Strict runs', 20, 2, `${q.runSummary} | summarize value=countif(PrivacyMode == 'strict') by bin(TimeGenerated, $__interval)`, 'short', 'green'), - tablePanel(10, 'Privacy drops by kind', 0, 6, 12, 9, `${q.privacy} | summarize Drops=sum(DroppedCount), Redactions=sum(RedactedCount), Runs=dcount(RunId) by ContentKind, Action, PrivacyMode | order by Drops desc`), - tablePanel(11, 'Runs with policy blocks or drops', 12, 6, 12, 9, `${q.runSummary} | where PrivacyMode == 'unsafe' or RiskScore > 0 or ToolDeniedCount > 0 | project TimeGenerated, RunId, RepoHash, PrivacyMode, ContentCaptureMode, ToolDeniedCount, OutcomeStatus, RiskScore | order by TimeGenerated desc | take 100`) + '06-safety-privacy-policy.json': dashboard('agentops-v2-safety-privacy-policy', 'Privacy', [ + textPanel(1, 'Trust screen', 0, 0, 24, 3, `## Privacy\n**AgentOps default: strict metadata only · content capture off.** This screen describes AgentOps telemetry only. It does not prove what GitHub Copilot, an MCP server, or any other connected service stores. The capture posture below reflects the selected AgentOps runs. ${emptyState}`), + tablePanel(13, 'AgentOps capture posture', 0, 3, 24, 4, `${q.runSummary} | extend PrivacyMode=coalesce(PrivacyMode, 'unknown'), ContentCaptureMode=coalesce(ContentCaptureMode, 'unknown') | summarize Runs=count() by PrivacyMode, ContentCaptureMode, Coverage | extend Scope='AgentOps telemetry only', Meaning=case(PrivacyMode == 'strict' and ContentCaptureMode == 'off', 'metadata only; prompt, response, code, file content, tool arguments, and tool results are not recorded by AgentOps', PrivacyMode == 'strict', 'strict AgentOps telemetry; review the reported capture mode', 'review this AgentOps capture posture before sharing or broader use') | project Scope, PrivacyMode, ContentCaptureMode, Coverage, Runs, Meaning | order by PrivacyMode asc, ContentCaptureMode asc`), + statPanel(2, 'Content items blocked', 0, 7, `${q.privacy} | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)`, 'short', 'yellow'), + statPanel(3, 'Secret-like items blocked', 4, 7, `${q.privacy} | where ContentKind == 'secret_like' | summarize value=sum(DroppedCount) by bin(TimeGenerated, $__interval)`, 'short', 'red'), + statPanel(4, 'Runs reporting unsafe mode', 8, 7, `${q.runSummary} | summarize value=countif(PrivacyMode == 'unsafe') by bin(TimeGenerated, $__interval)`, 'short', 'red'), + statPanel(5, 'Policy blocks', 12, 7, `${q.events} | where EventType == 'policy' | summarize value=countif(Status == 'denied' or Status == 'blocked') by bin(TimeGenerated, $__interval)`, 'short', 'red'), + statPanel(6, 'Successful poison tests', 16, 7, `${q.health} | where CheckName == 'privacy-poison' | summarize value=countif(Status == 'ok') by bin(TimeGenerated, $__interval)`, 'short', 'green'), + statPanel(7, 'Strict-mode runs', 20, 7, `${q.runSummary} | summarize value=countif(PrivacyMode == 'strict') by bin(TimeGenerated, $__interval)`, 'short', 'green'), + tablePanel(10, 'Blocked or redacted items by kind', 0, 11, 12, 9, `${q.privacy} | summarize Drops=sum(DroppedCount), Redactions=sum(RedactedCount), Runs=dcount(RunId) by ContentKind, Action, PrivacyMode | order by Drops desc`), + tablePanel(11, 'Runs needing privacy or policy review', 12, 11, 12, 9, `${q.runSummary} | where PrivacyMode == 'unsafe' or RiskScore > 0 or ToolDeniedCount > 0 | project TimeGenerated, RunId, RepoHash, PrivacyMode, ContentCaptureMode, ToolDeniedCount, OutcomeStatus, RiskScore | order by TimeGenerated desc | take 100`), + withOverrides(tablePanel(12, 'Alert handoff review', 0, 20, 24, 8, `union isfuzzy=true (AgentOpsAlertHandoffs_CL | where TimeGenerated between ($__timeFrom() .. $__timeTo())), (datatable(TimeGenerated:datetime, HandoffId:string, AlertRule:string, SessionId:string, Severity:string, Owner:string, State:string, Last:string, ConfigChangeCount:long, ChangeTargetRefs:dynamic) []) +| extend HandoffId=coalesce(tostring(column_ifexists('HandoffId', '')), tostring(column_ifexists('AlertHandoffId', '')), tostring(column_ifexists('Id', '')), strcat(tostring(column_ifexists('AlertRule', '')), '-', tostring(column_ifexists('SessionId', '')))) +| extend AlertRule=coalesce(tostring(column_ifexists('AlertRule', '')), tostring(column_ifexists('Rule', ''))) +| extend SessionId=coalesce(tostring(column_ifexists('SessionId', '')), tostring(column_ifexists('Session', ''))) +| extend Severity=coalesce(tostring(column_ifexists('Severity', '')), tostring(column_ifexists('AlertSeverity', ''))) +| extend Owner=coalesce(tostring(column_ifexists('Owner', '')), tostring(column_ifexists('AssignedOwner', ''))) +| extend State=coalesce(tostring(column_ifexists('State', '')), tostring(column_ifexists('Status', ''))) +| extend Last=coalesce(tostring(column_ifexists('Last', '')), tostring(column_ifexists('Lookback', ''))) +| extend ConfigChangeCount=tolong(column_ifexists('ConfigChangeCount', long(null))) +| extend ChangeTargetRefs=column_ifexists('ChangeTargetRefs', dynamic([])) +| where ('$session_id' == '__all' or SessionId == '$session_id') +| where ('$outcome_status' == '__all' or State == '$outcome_status') +| extend AskAgentOpsSharedLaunch=iff(isnotempty(HandoffId), strcat('$actioner_url', '/ask-agentops/shared/alert-handoff/', url_encode(HandoffId), '?session_id=', url_encode(SessionId), '&last=$timeRange'), '') +| extend AskSharedContext=iff(isnotempty(HandoffId), 'Ask shared', '') +| extend OpenReplay='Replay' +| project TimeGenerated, Severity, AlertRule, SessionId, Owner, State, Last, ConfigChangeCount, ChangeTargetRefs, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay +| order by TimeGenerated desc +| take 50`), [ + { matcher: { id: 'byName', options: 'SessionId' }, properties: [{ id: 'links', value: [{ title: 'Open Session Replay', url: '/d/agentops-v2-run-replay?var-session_id=${__data.fields.SessionId}&${__url_time_range}', targetBlank: false }] }] }, + { matcher: { id: 'byName', options: 'OpenReplay' }, properties: [{ id: 'links', value: [{ title: 'Open Run Story', url: '/d/agentops-v2-run-replay?var-session_id=${__data.fields.SessionId}&${__url_time_range}', targetBlank: false }] }] }, + { matcher: { id: 'byName', options: 'AskSharedContext' }, properties: [{ id: 'links', value: [{ title: 'Ask AgentOps with shared alert handoff', url: '${__data.fields.AskAgentOpsSharedLaunch}', targetBlank: true }] }] } + ]) ]), '07-code-outcomes.json': dashboard('agentops-v2-code-outcomes', 'Code Outcomes', [ @@ -777,8 +917,8 @@ const dashboards = { tablePanel(11, 'Recurring patterns', 0, 12, 24, 8, `${q.insights} | where isnotempty(PatternId) or InsightType startswith 'recurring-' | extend OpenPattern='Pattern', OpenReplay='Replay' | project TimeGenerated, InsightType, Severity, PatternRuns, PatternDimension, PatternKey, Summary, SuggestedNextStep, OpenPattern, OpenReplay, RunId, RepoHash, ModelActual, ToolName, CurrentValue | order by PatternRuns desc, TimeGenerated desc | take 100`), timeseriesPanel(20, 'Insight volume', 0, 20, 12, 8, `${q.insights} | summarize Insights=count() by TimeGenerated=bin(TimeGenerated, $__interval), Severity | order by TimeGenerated asc`), tablePanel(21, 'Regression evidence', 12, 20, 12, 8, `${q.insights} | where InsightType has 'regression' or InsightType has 'anomaly' | project TimeGenerated, InsightType, Severity, RepoHash, ModelActual, ToolName, BaselineValue, CurrentValue, ConfigHash, Summary | order by TimeGenerated desc | take 100`), - tablePanel(23, 'Eval regression queue', 0, 28, 24, 8, `union isfuzzy=true (${q.insights} | where InsightType has_any ('eval', 'regression', 'anomaly') | project TimeGenerated, Source='insight', Severity, Action=InsightType, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall=real(null), EvalBucket='', BaselineValue, CurrentValue, PatternKey, Summary, NextAction=SuggestedNextStep), (${q.recommendations} | where EvalBucket in ('poor', 'review') or Action has 'regression' or ObservedPattern has 'eval' | project TimeGenerated, Source='recommendation', Severity, Action, RunId, TraceId, RepoHash='', ModelActual='', TaskType='', EvalOverall, EvalBucket, BaselineValue=real(null), CurrentValue=todouble(EvalOverall), PatternKey, Summary=ObservedPattern, NextAction) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', '') | project TimeGenerated, Source, Severity, Action, EvalOverall, EvalBucket, BaselineValue, CurrentValue, Summary, NextAction, RunId, TraceId, RepoHash, ModelActual, TaskType, PatternKey, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200`), - tablePanel(22, 'Recommendation artifacts', 0, 36, 24, 8, `${q.recommendations} | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations) | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, PatternDimension, EvalOverall, EvalBucket, BenchmarkRunId, BenchmarkDecision, BenchmarkPassRatePct, BenchmarkAverageScore, BenchmarkSafetyViolationCount, BenchmarkArtifactAdded, BenchmarkArtifactModified, BenchmarkArtifactDeleted, BenchmarkArtifactTotalChanged, BenchmarkArtifactFiles, BenchmarkHiddenChecksPassed, BenchmarkHiddenChecksFailed, BenchmarkHiddenCheckPacks, BenchmarkPolicyBlocks, BenchmarkPermissionProfiles, BenchmarkPolicyTasks, BenchmarkSemanticCheckCount, BenchmarkSemanticAverageScore, BenchmarkSemanticChecks, BenchmarkApprovalStatus, BenchmarkApprovalCount, BenchmarkRequiredApprovals, ChangeAnnotationCount, ChangeAnnotations, ChangeTargetRefs, DashboardCount, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200`), + tablePanel(23, 'Eval regression queue', 0, 28, 24, 8, `union isfuzzy=true (${q.insights} | where InsightType has_any ('eval', 'regression', 'anomaly') | project TimeGenerated, Source='insight', Severity, Action=InsightType, RunId, TraceId, RepoHash, ModelActual, TaskType, EvalOverall=long(null), EvalBucket='', BaselineValue, CurrentValue, PatternKey, Summary, NextAction=SuggestedNextStep), (${q.recommendations} | where EvalBucket in ('poor', 'review') or Action has 'regression' or ObservedPattern has 'eval' | project TimeGenerated, Source='recommendation', Severity, Action, RunId, TraceId, RepoHash='', ModelActual='', TaskType='', EvalOverall, EvalBucket, BaselineValue=real(null), CurrentValue=todouble(EvalOverall), PatternKey, Summary=ObservedPattern, NextAction) | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', '') | project TimeGenerated, Source, Severity, Action, EvalOverall, EvalBucket, BaselineValue, CurrentValue, Summary, NextAction, RunId, TraceId, RepoHash, ModelActual, TaskType, PatternKey, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200`), + tablePanel(22, 'Recommendation artifacts', 0, 36, 24, 8, `${q.recommendations} | extend OpenReplay='Replay', OpenPattern=iff(isnotempty(PatternKey), 'Pattern', ''), ChangeAnnotationCount=array_length(ChangeAnnotations), AskAgentOpsSharedLaunch=iff(isnotempty(RecommendationId), strcat('$actioner_url', '/ask-agentops/shared/recommendation/', url_encode(RecommendationId), '?run_id=', url_encode(RunId), '&trace_id=', url_encode(TraceId), '&last=$timeRange'), ''), AskSharedContext=iff(isnotempty(RecommendationId), 'Ask shared', '') | project TimeGenerated, RecommendationId, Severity, Action, ObservedPattern, NextAction, RunId, TraceId, PatternKey, PatternRuns, PatternDimension, EvalOverall, EvalBucket, BenchmarkRunId, BenchmarkDecision, BenchmarkPassRatePct, BenchmarkAverageScore, BenchmarkSafetyViolationCount, BenchmarkArtifactAdded, BenchmarkArtifactModified, BenchmarkArtifactDeleted, BenchmarkArtifactTotalChanged, BenchmarkArtifactFiles, BenchmarkHiddenChecksPassed, BenchmarkHiddenChecksFailed, BenchmarkHiddenCheckPacks, BenchmarkPolicyBlocks, BenchmarkPermissionProfiles, BenchmarkPolicyTasks, BenchmarkSemanticCheckCount, BenchmarkSemanticAverageScore, BenchmarkSemanticChecks, BenchmarkApprovalStatus, BenchmarkApprovalCount, BenchmarkRequiredApprovals, ChangeAnnotationCount, ChangeAnnotations, ChangeTargetRefs, DashboardCount, AskSharedContext, AskAgentOpsSharedLaunch, OpenReplay, OpenPattern | order by TimeGenerated desc | take 200`), tablePanel(24, 'Config change annotations', 0, 44, 24, 8, `${configAnnotationsQuery()} | order by TimeGenerated desc | take 200`) ]), @@ -786,16 +926,37 @@ const dashboards = { textPanel(1, 'Supportability', 0, 0, 24, 2, `## Collector Health\nLocal collector, export, privacy poison, Azure, Grafana, schema, and dashboard version status. ${emptyState}`), tablePanel(10, 'Collector checks', 0, 2, 24, 12, `${q.health} | project TimeGenerated, CheckName, Status, Detail, PrivacyMode, CollectorMode, OtlpEndpoint, AzureConfigured, GrafanaConfigured, DashboardVersion, SchemaVersion | order by TimeGenerated desc | take 500`), timeseriesPanel(20, 'Export errors and drops', 0, 14, 12, 8, `${q.health} | summarize ExportErrors=sum(ExportErrors), DroppedContent=sum(DroppedContentCount) by TimeGenerated=bin(TimeGenerated, $__interval) | order by TimeGenerated asc`), - tablePanel(21, 'Last received/exported', 12, 14, 12, 8, `${q.health} | summarize LastSpanReceived=max(LastSpanReceived), LastExportSuccess=max(LastExportSuccess), LatestStatus=arg_max(TimeGenerated, Status) by CollectorMode, PrivacyMode, OtlpEndpoint | order by LastSpanReceived desc`) + tablePanel(21, 'Last received/exported', 12, 14, 12, 8, `${q.health} | summarize LastSpanReceived=max(LastSpanReceived), LastExportSuccess=max(LastExportSuccess), LatestStatus=arg_max(TimeGenerated, Status) by CollectorMode, PrivacyMode, OtlpEndpoint | order by LastSpanReceived desc`), + withOverrides(tablePanel(22, 'Schema version coverage', 0, 22, 24, 8, `let SchemaCoverageSeed = datatable(TimeGenerated:datetime, SchemaVersion:string)[datetime(null), '2']; union isfuzzy=true withsource=TableName SchemaCoverageSeed, AgentOpsRunSummary_CL, AgentOpsEvents_CL, AgentOpsToolCalls_CL, AgentOpsMcpCalls_CL, AgentOpsPrivacy_CL, AgentOpsEval_CL, AgentOpsGithubOutcomes_CL, AgentOpsInsights_CL, AgentOpsRecommendations_CL, AgentOpsSavedViews_CL, AgentOpsCollectorHealth_CL | where isnull(TimeGenerated) or TimeGenerated between ($__timeFrom() .. $__timeTo()) | extend SchemaVersion=tostring(column_ifexists('SchemaVersion', '')) | summarize Rows=count(), MissingSchemaVersion=countif(isempty(SchemaVersion)), MismatchedSchemaVersion=countif(isnotempty(SchemaVersion) and SchemaVersion != '2'), Versions=make_set(SchemaVersion), LastSeen=max(TimeGenerated) by TableName | where isnotnull(LastSeen) | extend ExpectedSchemaVersion='2' | extend SchemaStatus=case(MissingSchemaVersion > 0, 'missing-version', MismatchedSchemaVersion > 0, 'version-review', 'ok') | project TableName, SchemaStatus, Rows, MissingSchemaVersion, MismatchedSchemaVersion, Versions, ExpectedSchemaVersion, LastSeen | order by SchemaStatus asc, TableName asc`), []), + withOverrides(tablePanel(23, 'Exporter failure review', 0, 30, 24, 8, `${q.health.replace("Status=iff(SpanRows > 0, 'healthy', 'empty')", "Status=iff(ExportErrors > 0, 'degraded', iff(SpanRows > 0, 'healthy', 'empty'))")} | extend ExportErrors=tolong(column_ifexists('ExportErrors', 0)), LastExportSuccess=todatetime(column_ifexists('LastExportSuccess', datetime(null))), LastSpanReceived=todatetime(column_ifexists('LastSpanReceived', datetime(null))) | extend ExplicitExportFailureReason=tostring(column_ifexists('ExportFailureReason', '')), ExplicitExportFailureAction=tostring(column_ifexists('ExportFailureAction', '')) | extend ExportFailureReason=iff(ExplicitExportFailureReason != '', ExplicitExportFailureReason, case(ExportErrors > 0, 'export-error-count', isnotnull(LastSpanReceived) and isnull(LastExportSuccess), 'missing-export-success', tostring(Status) !in ('healthy', 'ok', 'empty'), strcat('collector-status-', tostring(Status)), '')) | extend ExportFailureAction=iff(ExplicitExportFailureAction != '', ExplicitExportFailureAction, case(ExportErrors > 0, 'Check collector exporter logs, Azure Monitor DCR/DCE routing, credentials, and network egress.', isnotnull(LastSpanReceived) and isnull(LastExportSuccess), 'Confirm the exporter is configured and can reach Azure Monitor.', tostring(Status) !in ('healthy', 'ok', 'empty'), 'Open collector health details and rerun agentops collector smoke --privacy strict --poison --json.', '')) | where ExportFailureReason != '' | project TimeGenerated, Component, Status, ExportErrors, ExportFailureReason, ExportFailureAction, LastSpanReceived, LastExportSuccess, OtlpEndpoint, CollectorMode, PrivacyMode | order by TimeGenerated desc | take 100`), []) ]) }; +const checkOnly = process.argv.includes('--check'); +let driftCount = 0; + fs.mkdirSync(outDir, { recursive: true }); for (const [fileName, content] of Object.entries(dashboards)) { - fs.writeFileSync(path.join(outDir, fileName), `${JSON.stringify(content, null, 2)}\n`); + const dashboardPath = path.join(outDir, fileName); + const generated = `${JSON.stringify(content, null, 2)}\n`; + if (checkOnly) { + const current = fs.existsSync(dashboardPath) ? fs.readFileSync(dashboardPath, 'utf8') : ''; + if (current !== generated) { + driftCount += 1; + console.error(`dashboard generator drift: grafana/dashboards/v2/${fileName}`); + } + continue; + } + fs.writeFileSync(dashboardPath, generated); console.log(`wrote grafana/dashboards/v2/${fileName}`); } +if (checkOnly) { + if (driftCount > 0) process.exitCode = 1; + else console.log(`dashboard generator drift check passed (${Object.keys(dashboards).length} dashboards)`); + return; +} + fs.mkdirSync(provisioningDashboardsDir, { recursive: true }); fs.writeFileSync(path.join(provisioningDashboardsDir, 'agentops-v2.yaml'), [ 'apiVersion: 1', diff --git a/scripts/build-native-evidence.js b/scripts/build-native-evidence.js new file mode 100644 index 0000000..870cce0 --- /dev/null +++ b/scripts/build-native-evidence.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +'use strict'; + +// Build a small, sealed, machine-readable summary of a native OTLP pilot. +// Raw receipts, query responses, endpoint URLs, and Azure identifiers stay out +// of this artifact by design. + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +function option(name, fallback = null) { + const prefix = `--${name}=`; + const value = process.argv.find(arg => arg.startsWith(prefix)); + return value ? value.slice(prefix.length) : fallback; +} + +function gitValue(args) { + const result = childProcess.spawnSync('git', args, { encoding: 'utf8' }); + return result.status === 0 ? String(result.stdout || '').trim() : null; +} + +function status(value, name) { + const allowed = new Set(['verified', 'not-run', 'failed', 'blocked']); + if (!allowed.has(value)) throw new Error(`${name} must be verified, not-run, failed, or blocked`); + return value; +} + +function writeSealed(filePath, document) { + const absolute = path.resolve(filePath); + fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 }); + const temporary = `${absolute}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; + fs.writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }); + try { fs.chmodSync(temporary, 0o600); } catch {} + fs.renameSync(temporary, absolute); + try { fs.chmodSync(absolute, 0o600); } catch {} + return absolute; +} + +function buildEvidence() { + const checks = { + traces: status(option('traces', 'not-run'), 'traces'), + logs: status(option('logs', 'not-run'), 'logs'), + metrics: status(option('metrics', 'not-run'), 'metrics'), + collector_replay: status(option('replay', 'not-run'), 'replay'), + package: status(option('package', 'not-run'), 'package') + }; + const leaks = []; + const sealed = Object.values(checks).every(value => value === 'verified') && leaks.length === 0; + const sourceRevision = gitValue(['rev-parse', 'HEAD']); + const dirty = Boolean(gitValue(['status', '--porcelain'])); + return { + schema_version: 1, + evidence_id: `native-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${crypto.randomBytes(4).toString('hex')}`, + generated_at: new Date().toISOString(), + evidence_class: sealed ? 'query-verified' : 'review-only', + sealed, + product: 'Copilot CLI AgentOps for Azure', + architecture: 'native-copilot-otel-local-strict-collector', + target: { + profile: 'development-preview', + region: option('region', 'redacted'), + subscription: 'redacted', + resource_group: 'redacted', + application_insights: 'redacted' + }, + checks, + privacy: { + content_capture: false, + leaks + }, + metric_query_surface: 'azure-monitor-workspace-promql', + promotion: { + npm_publish_authorized: false, + hosted_staging_verified: false, + production_verified: false + }, + source: { + revision: sourceRevision, + worktree_dirty: dirty, + node: process.version, + platform: `${process.platform}/${os.arch()}` + } + }; +} + +if (require.main === module) { + try { + const out = option('out'); + if (!out) throw new Error('Usage: build-native-evidence.js --out=<file> [--traces=verified] [--logs=verified] [--metrics=verified] [--replay=verified] [--package=verified]'); + const document = buildEvidence(); + const file = writeSealed(out, document); + process.stdout.write(`${JSON.stringify({ ...document, file }, null, 2)}\n`); + process.exit(document.sealed ? 0 : 2); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(2); + } +} + +module.exports = { buildEvidence, writeSealed }; diff --git a/scripts/check-cli-publish.js b/scripts/check-cli-publish.js index 94aa8cb..66b26c2 100644 --- a/scripts/check-cli-publish.js +++ b/scripts/check-cli-publish.js @@ -56,13 +56,21 @@ function checkCliPublish(options = {}) { if (pkg.name !== 'copilot-agentops-cli') failures.push('package name must stay copilot-agentops-cli'); if (pkg.bin?.agentops !== 'src/index.js') failures.push('bin.agentops must point to src/index.js'); const requiredPackageFiles = [ + 'LICENSE', 'README.md', - '.azure', + 'actioner', + 'benchmark-judges', + 'benchmark-runners', 'azure.yaml', 'collector', 'copilot', 'docs', + 'examples', + 'fixtures', 'grafana', + 'infra', + 'kql', + 'packages', 'plugin', 'scripts', 'src' @@ -96,18 +104,23 @@ function checkCliPublish(options = {}) { if (!pack.ok) failures.push(pack.error); const expectedFiles = [ + 'LICENSE', 'README.md', - '.azure/deployment-plan.md', + 'actioner/index.js', 'azure.yaml', 'collector/otelcol.binary.strict.yaml', 'collector/processors/strict-allowlist.yaml', - 'collector/tests/privacy-poison-fixtures/content-poison.json', 'copilot/copilot-observe', 'docs/release-distribution.md', + 'docs/images/agentops-architecture-dataflow.png', + 'fixtures/sample-otel/copilot-cli-wrapper-snapshot.ndjson.fixture', 'grafana/dashboards/v2/01-agentops-home.json', + 'infra/bicep/main.bicep', + 'kql/00-discover-tables.kql', 'package.json', 'plugin/plugin.json', 'plugin/hooks.json', + 'packages/agentops-copilot-sdk/src/index.js', 'scripts/copilot-agentops', 'scripts/install-copilot-agentops-shim.sh', 'src/index.js', @@ -122,7 +135,11 @@ function checkCliPublish(options = {}) { 'package-lock.json', 'test/index.test.js', 'test/commands.test.js', - 'test/core-helpers.test.js' + 'test/core-helpers.test.js', + 'packages/agentops-copilot-sdk/package-lock.json', + 'packages/agentops-copilot-sdk/test/adapter.test.js', + 'packages/agentops-copilot-sdk/test/benchmark.test.js', + 'benchmark-judges/hosted-judge/test/server.test.js' ]; if (pack.ok && !options.skipPack) { const files = new Set(pack.files); @@ -132,6 +149,11 @@ function checkCliPublish(options = {}) { for (const file of forbiddenFiles) { if (files.has(file)) failures.push(`npm package should not include ${file}`); } + for (const file of pack.files) { + const packagePath = String(file).replaceAll('\\', '/'); + if (/(^|\/)tests?\//.test(packagePath)) failures.push(`npm package should not include test directory ${packagePath}`); + if (/raw[-_]telemetry|\.jsonl$/i.test(packagePath)) failures.push(`npm package should not include raw telemetry ${packagePath}`); + } } return { diff --git a/scripts/check-homebrew-formula.js b/scripts/check-homebrew-formula.js index cf0e432..1cb253e 100644 --- a/scripts/check-homebrew-formula.js +++ b/scripts/check-homebrew-formula.js @@ -67,7 +67,7 @@ function checkHomebrewFormula(options = {}) { failures.push(...validateTemplate(template).map(term => `formula template missing ${term}`)); const outDir = options.outDir || fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-homebrew-')); - const distribution = checkReleaseDistribution({ outDir, skipDocs: options.skipDocs }); + const distribution = checkReleaseDistribution({ outDir, skipDocs: options.skipDocs, requireGitIdentity: false }); const artifact = distribution.artifacts.find(item => item.package === 'cli' && item.ok); if (!distribution.ok) failures.push(...distribution.failures); if (!artifact) failures.push('CLI release artifact was not generated'); diff --git a/scripts/check-install-smoke.js b/scripts/check-install-smoke.js index 364f2f4..d250107 100644 --- a/scripts/check-install-smoke.js +++ b/scripts/check-install-smoke.js @@ -65,7 +65,7 @@ function checkInstallSmoke(options = {}) { fs.mkdirSync(artifactsDir, { recursive: true }); fs.mkdirSync(prefix, { recursive: true }); - const distribution = checkReleaseDistribution({ outDir: artifactsDir, skipDocs: options.skipDocs }); + const distribution = checkReleaseDistribution({ outDir: artifactsDir, skipDocs: options.skipDocs, requireGitIdentity: false }); const cliArtifact = distribution.artifacts.find(artifact => artifact.package === 'cli' && artifact.ok); const failures = []; const commands = []; @@ -106,6 +106,9 @@ function checkInstallSmoke(options = {}) { commands.push(commandRecord('agentops security audit --json', run(agentops, ['security', 'audit', '--json'], { env }), ({ result, parsed }) => ( result.status === 0 && parsed?.ok === true ))); + commands.push(commandRecord('agentops product audit --json', run(agentops, ['product', 'audit', '--json'], { env }), ({ result, parsed }) => ( + result.status === 0 && parsed?.ok === true && parsed?.summary?.failed === 0 + ))); commands.push(commandRecord('agentops collector validate --mode none --json', run(agentops, ['collector', 'validate', '--mode', 'none', '--privacy', 'strict', '--json'], { env }), ({ parsed }) => ( parsed?.artifact_validation?.ok === true ))); diff --git a/scripts/check-packaged-lifecycle.js b/scripts/check-packaged-lifecycle.js new file mode 100644 index 0000000..0ded697 --- /dev/null +++ b/scripts/check-packaged-lifecycle.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node +'use strict'; + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { checkReleaseDistribution } = require('./check-release-distribution'); + +const root = path.resolve(__dirname, '..'); +const cliName = 'copilot-agentops-cli'; + +function run(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + cwd: options.cwd || root, + env: options.env || process.env, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }); + return { + ok: result.status === 0, + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + error: result.error?.message || null + }; +} + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function executable(file, text) { + fs.writeFileSync(file, text, { mode: 0o755 }); +} + +function sanitizedEnvironment(paths) { + const env = {}; + for (const [key, value] of Object.entries(process.env)) { + if (/^(AGENTOPS|AZURE|APPLICATIONINSIGHTS|COPILOT|OTEL)_/.test(key)) continue; + env[key] = value; + } + return { + ...env, + PATH: `${paths.installBin}:${paths.realBin}:${paths.prefixBin}:${process.env.PATH || '/usr/bin:/bin'}`, + AGENTOPS_BIN_DIR: paths.installBin, + AGENTOPS_HOME: paths.agentopsHome, + AGENTOPS_COLLECTOR_HOME: paths.collectorHome, + AGENTOPS_CONFIG_PATH: paths.config, + AGENTOPS_DURABLE_SPOOL_DIR: paths.spool, + COPILOT_HOME: paths.copilotHome, + FAKE_COPILOT_ARGS_FILE: paths.fakeArgs + }; +} + +function derivedVersionArtifact(baseArtifact, tempDir, version) { + const extractDir = path.join(tempDir, `package-${version}`); + fs.mkdirSync(extractDir, { recursive: true }); + const extracted = run('tar', ['-xzf', baseArtifact, '-C', extractDir]); + if (!extracted.ok) throw new Error(extracted.stderr || 'could not extract base CLI artifact'); + const packageDir = path.join(extractDir, 'package'); + const packageFile = path.join(packageDir, 'package.json'); + const metadata = JSON.parse(fs.readFileSync(packageFile, 'utf8')); + metadata.version = version; + fs.writeFileSync(packageFile, `${JSON.stringify(metadata, null, 2)}\n`); + const packed = run('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', tempDir], { cwd: packageDir }); + if (!packed.ok) throw new Error(packed.stderr || packed.stdout || 'could not pack derived CLI artifact'); + const detail = JSON.parse(packed.stdout)[0]; + return path.join(tempDir, detail.filename); +} + +function commandStep(steps, name, command, args, options = {}, validate = result => result.ok) { + const result = run(command, args, options); + const ok = Boolean(validate(result)); + steps.push({ + name, + ok, + status: result.status, + error: ok ? null : (result.error || result.stderr || result.stdout || `command exited ${result.status}`).trim() + }); + return result; +} + +function recursiveText(directory) { + if (!fs.existsSync(directory)) return ''; + const chunks = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) chunks.push(recursiveText(file)); + else if (entry.isFile() && fs.statSync(file).size <= 2 * 1024 * 1024) chunks.push(fs.readFileSync(file, 'utf8')); + } + return chunks.join('\n'); +} + +function checkPackagedLifecycle(options = {}) { + if (process.platform === 'win32') { + return { + ok: false, + skipped: true, + platform: process.platform, + failures: ['POSIX packaged lifecycle gate is not a Windows proof; run the PowerShell/Windows lane separately.'] + }; + } + + const tempDir = options.tempDir || fs.mkdtempSync(path.join(os.tmpdir(), 'agentops-packaged-lifecycle-')); + const paths = { + tempDir, + artifacts: path.join(tempDir, 'artifacts'), + prefix: path.join(tempDir, 'npm-prefix'), + prefixBin: path.join(tempDir, 'npm-prefix', 'bin'), + installBin: path.join(tempDir, 'installed-bin'), + realBin: path.join(tempDir, 'real-bin'), + agentopsHome: path.join(tempDir, 'agentops-home'), + collectorHome: path.join(tempDir, 'collector-home'), + copilotHome: path.join(tempDir, 'copilot-home'), + config: path.join(tempDir, 'config.json'), + spool: path.join(tempDir, 'delivery-spool'), + fakeArgs: path.join(tempDir, 'fake-copilot-args.txt') + }; + for (const directory of [paths.artifacts, paths.prefix, paths.installBin, paths.realBin, paths.agentopsHome, paths.copilotHome]) { + fs.mkdirSync(directory, { recursive: true }); + } + + const fake = '#!/usr/bin/env bash\nset -euo pipefail\nprintf \'%s\\n\' "$@" >"${FAKE_COPILOT_ARGS_FILE}"\nprintf \'SAFE_FAKE_COPILOT_OK\\n\'\n'; + const originalCopilot = path.join(paths.installBin, 'copilot'); + const realCopilot = path.join(paths.realBin, 'copilot'); + executable(originalCopilot, fake); + executable(realCopilot, fake); + const originalBytes = fs.readFileSync(originalCopilot); + const originalHash = sha256(originalBytes); + const env = sanitizedEnvironment(paths); + const failures = []; + const steps = []; + const poison = 'AGENTOPS_LIFECYCLE_POISON_7d3a91_prompt_must_not_persist'; + + try { + const distribution = checkReleaseDistribution({ outDir: paths.artifacts, skipDocs: true, requireGitIdentity: false }); + const base = distribution.artifacts.find(artifact => artifact.package === 'cli' && artifact.ok); + if (!distribution.ok) failures.push(...distribution.failures); + if (!base) throw new Error('CLI release artifact was not generated'); + const baseVersion = JSON.parse(fs.readFileSync(path.join(root, 'agentops-cli', 'package.json'), 'utf8')).version; + const [major, minor, patch] = baseVersion.split('.').map(Number); + const upgradeVersion = `${major}.${minor}.${patch + 1}`; + const upgradeArtifact = derivedVersionArtifact(base.path, tempDir, upgradeVersion); + + commandStep(steps, `install packed CLI ${baseVersion}`, 'npm', ['install', '-g', '--prefix', paths.prefix, base.path], { env }); + const npmAgentops = path.join(paths.prefixBin, 'agentops'); + commandStep(steps, 'install transparent shadow from packed CLI', npmAgentops, ['install', '--shadow-copilot', '--no-collector'], { env }); + const backup = `${originalCopilot}.agentops-original`; + if (!fs.existsSync(backup) || sha256(fs.readFileSync(backup)) !== originalHash) failures.push('pre-existing Copilot backup was not preserved byte-for-byte'); + + const observed = commandStep( + steps, + 'normal copilot strict metadata-only receipt', + originalCopilot, + ['--collector-mode', 'none', '--unsafe-no-collector', '--privacy', 'strict', '-p', poison], + { env }, + result => result.ok && result.stdout.includes('SAFE_FAKE_COPILOT_OK') + && result.stderr.includes('AgentOps receipt') + && result.stderr.includes('AgentOps did not record prompts, answers, code, or tool payloads') + ); + if (!observed.ok) failures.push('normal Copilot shadow did not invoke the safe fake command'); + const persisted = [paths.agentopsHome, paths.spool, paths.copilotHome, paths.installBin] + .map(recursiveText).join('\n'); + if (persisted.includes(poison)) failures.push('prompt poison appeared in AgentOps-owned persisted files'); + const queueText = recursiveText(paths.spool); + if (!queueText.includes('agentops.run.start') || !queueText.includes('agentops.run.end')) failures.push('ordered start/end lifecycle evidence was not durably queued'); + if (/prompt|answer|completion|tool.?payload/i.test(queueText)) failures.push('durable queue contains a content-like field name'); + + commandStep(steps, `upgrade packed CLI to ${upgradeVersion}`, 'npm', ['install', '-g', '--prefix', paths.prefix, upgradeArtifact], { env }); + commandStep(steps, 'upgraded CLI remains executable', npmAgentops, ['--help'], { env }, result => result.ok && result.stdout.includes('Core commands:')); + if (sha256(fs.readFileSync(backup)) !== originalHash) failures.push('upgrade changed the preserved Copilot backup'); + + commandStep(steps, `downgrade packed CLI to ${baseVersion}`, 'npm', ['install', '-g', '--prefix', paths.prefix, base.path], { env }); + commandStep(steps, 'downgraded CLI remains executable', npmAgentops, ['--help'], { env }, result => result.ok && result.stdout.includes('Core commands:')); + if (sha256(fs.readFileSync(backup)) !== originalHash) failures.push('downgrade changed the preserved Copilot backup'); + + commandStep(steps, 'uninstall AgentOps lifecycle', npmAgentops, ['uninstall', '--keep-plugin', '--keep-collector', '--keep-binary'], { env }); + if (!fs.existsSync(originalCopilot) || sha256(fs.readFileSync(originalCopilot)) !== originalHash) failures.push('uninstall did not restore the pre-existing Copilot command byte-for-byte'); + if (fs.existsSync(backup)) failures.push('AgentOps backup remained after successful restoration'); + for (const name of ['agentops', 'copilot-agentops', 'agentops-codex']) { + if (fs.existsSync(path.join(paths.installBin, name))) failures.push(`${name} still intercepts commands after uninstall`); + } + commandStep(steps, 'restored Copilot runs without AgentOps interception', originalCopilot, ['--version'], { env }, result => ( + result.ok && result.stdout.includes('SAFE_FAKE_COPILOT_OK') && !result.stderr.includes('AgentOps receipt') + )); + commandStep(steps, 'remove packed npm CLI', 'npm', ['uninstall', '-g', '--prefix', paths.prefix, cliName], { env }); + + for (const step of steps) if (!step.ok) failures.push(`${step.name}: ${step.error}`); + return { + ok: failures.length === 0, + platform: process.platform, + scope: 'hermetic POSIX packaged CLI lifecycle; no Azure writes and no real user configuration', + tempDir, + versions: { baseline: baseVersion, upgrade: upgradeVersion, downgrade: baseVersion }, + privacy: { mode: 'strict', content_capture: false, poison_persisted: persisted.includes(poison) }, + restoration: { original_sha256: originalHash, restored_sha256: fs.existsSync(originalCopilot) ? sha256(fs.readFileSync(originalCopilot)) : null }, + steps, + unproven_lanes: ['Windows PowerShell live lifecycle', 'Linux distribution matrix', 'WSL lifecycle', 'containerized clean-machine lifecycle'], + failures + }; + } catch (error) { + failures.push(error.message); + return { ok: false, platform: process.platform, tempDir, steps, failures }; + } +} + +if (require.main === module) { + const result = checkPackagedLifecycle(); + if (process.argv.includes('--json')) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + else { + process.stdout.write(`AgentOps packaged lifecycle: ${result.ok ? 'ok' : result.skipped ? 'skipped' : 'failed'}\n`); + for (const step of result.steps || []) process.stdout.write(`- ${step.name}: ${step.ok ? 'ok' : 'failed'}\n`); + for (const failure of result.failures || []) process.stdout.write(`- failed: ${failure}\n`); + } + process.exit(result.ok || result.skipped ? 0 : 1); +} + +module.exports = { checkPackagedLifecycle, derivedVersionArtifact, sanitizedEnvironment }; diff --git a/scripts/check-release-distribution.js b/scripts/check-release-distribution.js index 1a8d904..ee29b0c 100644 --- a/scripts/check-release-distribution.js +++ b/scripts/check-release-distribution.js @@ -51,6 +51,74 @@ function sha256(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +function packageJson(pkg) { + return JSON.parse(fs.readFileSync(path.join(pkg.dir, 'package.json'), 'utf8')); +} + +function purlName(name) { + if (!String(name).startsWith('@')) return name; + const [scope, packageName] = String(name).split('/'); + return `${encodeURIComponent(scope)}/${packageName}`; +} + +function buildCycloneDxSbom(pkg, artifact, outDir) { + const metadata = packageJson(pkg); + const components = []; + for (const [name, version] of Object.entries(metadata.dependencies || {})) { + components.push({ type: 'library', name, version, scope: 'required', purl: `pkg:npm/${purlName(name)}@${encodeURIComponent(version)}` }); + } + for (const [name, version] of Object.entries(metadata.peerDependencies || {})) { + components.push({ type: 'library', name, version, scope: 'optional', purl: `pkg:npm/${purlName(name)}@${encodeURIComponent(version)}` }); + } + components.sort((left, right) => left.name.localeCompare(right.name)); + const document = { + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + component: { + type: 'application', + name: metadata.name, + version: metadata.version, + purl: `pkg:npm/${purlName(metadata.name)}@${metadata.version}`, + hashes: [{ alg: 'SHA-256', content: artifact.sha256 }] + } + }, + components + }; + const filename = `${artifact.filename}.cdx.json`; + const sbomPath = path.join(outDir, filename); + fs.writeFileSync(sbomPath, `${JSON.stringify(document, null, 2)}\n`); + return { package: pkg.id, filename, path: sbomPath, size: fs.statSync(sbomPath).size, sha256: sha256(sbomPath), format: 'CycloneDX', spec_version: '1.5' }; +} + +function sourceState() { + const revision = run('git', ['rev-parse', 'HEAD']); + const status = run('git', ['status', '--porcelain']); + const gitAvailable = revision.ok && status.ok; + return { + revision: revision.ok ? revision.stdout.trim() : null, + git_available: gitAvailable, + // An exported tree has no Git identity. Treat it as dirty for release + // authorization so package checks can run without claiming a frozen commit. + worktree_dirty: status.ok ? Boolean(status.stdout.trim()) : true + }; +} + +function writeReleaseManifest(outDir, artifacts, sboms, source) { + const document = { + schema_version: 1, + product: 'Copilot CLI AgentOps for Azure', + source, + publish_authorized: false, + artifacts: artifacts.map(({ package: packageId, filename, size, sha256: digest }) => ({ package: packageId, filename, size, sha256: digest })), + sboms: sboms.map(({ package: packageId, filename, size, sha256: digest, format, spec_version: specVersion }) => ({ package: packageId, filename, size, sha256: digest, format, spec_version: specVersion })) + }; + const manifestPath = path.join(outDir, 'release-manifest.json'); + fs.writeFileSync(manifestPath, `${JSON.stringify(document, null, 2)}\n`); + return { filename: path.basename(manifestPath), path: manifestPath, sha256: sha256(manifestPath), ...document }; +} + function runPackageCheck(pkg) { const [command, commandArgs] = pkg.checker; const resolvedCommand = command === 'npm' ? npmBin() : command; @@ -131,8 +199,18 @@ function checkReleaseDistribution(options = {}) { const packageChecks = packages.map(runPackageCheck); const artifacts = packages.map(pkg => packPackage(pkg, outDir)); + const sboms = artifacts.filter(artifact => artifact.ok).map(artifact => buildCycloneDxSbom( + packages.find(pkg => pkg.id === artifact.package), artifact, outDir + )); + const source = sourceState(); + const manifest = writeReleaseManifest(outDir, artifacts.filter(artifact => artifact.ok), sboms, source); const docs = options.skipDocs ? { ok: true, evidence: [], failures: [] } : docsEvidence(); const failures = []; + const warnings = []; + + if (options.requireGitIdentity !== false && (!source.git_available || !source.revision)) { + failures.push('Source Git identity is unavailable; build the release from an exact reviewed commit.'); + } for (const check of packageChecks) { if (!check.ok) failures.push(`${check.package} publish check failed: ${check.error}`); @@ -150,16 +228,25 @@ function checkReleaseDistribution(options = {}) { if (!artifact.size || artifact.size <= 0) failures.push(`${artifact.package} artifact is empty`); } failures.push(...docs.failures); + if (source.worktree_dirty) warnings.push('Source worktree is dirty; artifacts are review-only and must not be published as a clean release.'); return { ok: failures.length === 0, + release_candidate_ready: failures.length === 0 && source.worktree_dirty === false, + publish_authorized: false, outDir, + source, package_checks: packageChecks, artifacts, + sboms, + manifest, docs, failures, - next: failures.length === 0 - ? 'Release distribution readiness passed. Use the SHA256 values for GitHub release assets and Homebrew formula updates.' + warnings, + next: failures.length === 0 && source.worktree_dirty === false + ? 'Release distribution readiness passed. Review and approve the manifest before any separate publish action.' + : failures.length === 0 + ? 'Review bundle created with checksums and SBOMs. Rebuild from a clean reviewed commit before publishing.' : 'Fix package checks, artifact generation, or release documentation before publishing.' }; } @@ -177,14 +264,20 @@ if (require.main === module) { for (const artifact of result.artifacts) { if (artifact.ok) process.stdout.write(`- ${artifact.filename} sha256=${artifact.sha256}\n`); } + for (const sbom of result.sboms) process.stdout.write(`- ${sbom.filename} sha256=${sbom.sha256}\n`); + process.stdout.write(`- manifest ${result.manifest.filename} sha256=${result.manifest.sha256}\n`); + for (const warning of result.warnings) process.stdout.write(`- warning: ${warning}\n`); for (const failure of result.failures) process.stdout.write(`- failed: ${failure}\n`); } process.exit(result.ok ? 0 : 1); } module.exports = { + buildCycloneDxSbom, checkReleaseDistribution, docsEvidence, packPackage, - runPackageCheck + runPackageCheck, + sourceState, + writeReleaseManifest }; diff --git a/scripts/check-sdk-publish.js b/scripts/check-sdk-publish.js index 2e8c1c1..db06cdb 100644 --- a/scripts/check-sdk-publish.js +++ b/scripts/check-sdk-publish.js @@ -59,8 +59,8 @@ function checkSdkPublish(options = {}) { if (pkg.name !== '@agentops/copilot-sdk') failures.push('package name must stay @agentops/copilot-sdk'); if (pkg.main !== 'src/index.js') failures.push('main must point to src/index.js'); if (pkg.types !== 'src/index.d.ts') failures.push('types must point to src/index.d.ts'); - if (!Array.isArray(pkg.files) || !pkg.files.includes('src') || !pkg.files.includes('examples')) { - failures.push('files must include src and examples'); + if (!Array.isArray(pkg.files) || !pkg.files.includes('LICENSE') || !pkg.files.includes('src') || !pkg.files.includes('examples')) { + failures.push('files must include LICENSE, src, and examples'); } if (!String(pkg.engines?.node || '').includes('>=20')) failures.push('engines.node must require Node >=20'); @@ -79,6 +79,7 @@ function checkSdkPublish(options = {}) { if (!pack.ok) failures.push(pack.error); const expectedFiles = [ + 'LICENSE', 'package.json', 'src/index.js', 'src/index.d.ts', diff --git a/scripts/collector-azuremonitor-up.ps1 b/scripts/collector-azuremonitor-up.ps1 index b78134c..e0b946d 100644 --- a/scripts/collector-azuremonitor-up.ps1 +++ b/scripts/collector-azuremonitor-up.ps1 @@ -1,5 +1,6 @@ param( - [string]$SubscriptionId = $(if ($env:AZURE_SUBSCRIPTION_ID) { $env:AZURE_SUBSCRIPTION_ID } else { "" }), + [string]$SubscriptionId = $(if ($env:AGENTOPS_AZURE_SUBSCRIPTION_ID) { $env:AGENTOPS_AZURE_SUBSCRIPTION_ID } else { "" }), + [string]$ApprovedSubscriptionIds = $(if ($env:AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS) { $env:AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS } else { "" }), [string]$ResourceGroup = $(if ($env:AZURE_RESOURCE_GROUP) { $env:AZURE_RESOURCE_GROUP } else { "rg-agentops-dev" }), [string]$ApplicationInsightsName = $(if ($env:APPLICATIONINSIGHTS_NAME) { $env:APPLICATIONINSIGHTS_NAME } else { "appi-agentops-dev" }) ) @@ -9,10 +10,26 @@ $ErrorActionPreference = "Stop" $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent $scriptDir $composeFile = Join-Path $repoRoot "collector/docker-compose.azuremonitor.yaml" +if (-not $SubscriptionId) { + throw "Set AGENTOPS_AZURE_SUBSCRIPTION_ID before any Azure write or privileged lookup." +} +if (-not $ApprovedSubscriptionIds) { + throw "Set AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS before any Azure write or privileged lookup." +} +$approved = @($ApprovedSubscriptionIds -split '[,\s]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +$approvedMatch = $approved | Where-Object { $_.Equals($SubscriptionId, [System.StringComparison]::OrdinalIgnoreCase) } +if (-not $approvedMatch) { + throw "Azure subscription guard refused this operation. Configured subscription $SubscriptionId is not in AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS." +} -if ($SubscriptionId) { - az account set --subscription $SubscriptionId | Out-Null +$activeSubscriptionId = (az account show --query id -o tsv).Trim() +if (-not $activeSubscriptionId) { + throw "Could not verify the active Azure subscription. Run az login, then retry." +} +if (-not $activeSubscriptionId.Equals($SubscriptionId, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Azure subscription guard refused this operation. Expected $SubscriptionId; active $activeSubscriptionId." } +Write-Host "Azure subscription guard: verified $activeSubscriptionId" $connectionString = az monitor app-insights component show ` --resource-group $ResourceGroup ` diff --git a/scripts/collector-azuremonitor-up.sh b/scripts/collector-azuremonitor-up.sh index c78a374..05e5f89 100755 --- a/scripts/collector-azuremonitor-up.sh +++ b/scripts/collector-azuremonitor-up.sh @@ -1,19 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -subscription_id="${AZURE_SUBSCRIPTION_ID:-}" resource_group="${AZURE_RESOURCE_GROUP:-rg-agentops-dev}" app_insights_name="${APPLICATIONINSIGHTS_NAME:-appi-agentops-dev}" privacy_mode="${AGENTOPS_PRIVACY_MODE:-strict}" export AGENTOPS_PRIVACY_MODE="${privacy_mode}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/lib/azure-subscription-guard.sh" repo_root="$(cd "${script_dir}/.." && pwd)" collector_dir="${repo_root}/collector" compose_file="${collector_dir}/docker-compose.azuremonitor.yaml" -if [[ -n "$subscription_id" ]]; then - az account set --subscription "$subscription_id" -fi +agentops_require_azure_subscription export APPLICATIONINSIGHTS_CONNECTION_STRING="$(az monitor app-insights component show \ --resource-group "$resource_group" \ diff --git a/scripts/copilot-agentops b/scripts/copilot-agentops index ea770c4..5b50f1e 100755 --- a/scripts/copilot-agentops +++ b/scripts/copilot-agentops @@ -13,5 +13,9 @@ while [[ -L "${script_source}" ]]; do done script_dir="$(cd "$(dirname "${script_source}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" +cli_entry="${repo_root}/agentops-cli/src/index.js" +if [[ ! -f "${cli_entry}" ]]; then + cli_entry="${repo_root}/src/index.js" +fi -exec node "${repo_root}/agentops-cli/src/index.js" copilot "$@" +exec node "${cli_entry}" copilot "$@" diff --git a/scripts/grafana-import-dashboard.sh b/scripts/grafana-import-dashboard.sh index 674f860..aa3faeb 100755 --- a/scripts/grafana-import-dashboard.sh +++ b/scripts/grafana-import-dashboard.sh @@ -7,6 +7,7 @@ # Required env (auto-resolved from azd env when run as an azd hook): # AZURE_RESOURCE_GROUP e.g. rg-agentops-dev # GRAFANA_NAME e.g. graf-agentops-dev +# GRAFANA_DEPLOYED false skips this optional advanced path # Optional: # GRAFANA_FOLDER folder title (default: "AgentOps for Azure") # DASHBOARD_JSON path to one dashboard JSON (default: import the full dashboard pack) @@ -18,6 +19,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +source "${SCRIPT_DIR}/lib/azure-subscription-guard.sh" if [[ "${AGENTOPS_V2_ONLY:-true}" == "true" ]]; then AGENTOPS_INCLUDE_V2="true" @@ -38,10 +40,28 @@ fi # Pull from azd env if not set explicitly if [[ -z "${AZURE_RESOURCE_GROUP:-}" || -z "${GRAFANA_NAME:-}" ]]; then if command -v azd >/dev/null 2>&1 && azd env get-values >/dev/null 2>&1; then - eval "$(azd env get-values | grep -E '^(AZURE_RESOURCE_GROUP|GRAFANA_NAME)=' || true)" + eval "$(azd env get-values | grep -E '^(AZURE_RESOURCE_GROUP|GRAFANA_NAME|GRAFANA_DEPLOYED)=' || true)" fi fi +if [[ -z "${GRAFANA_DEPLOYED:-}" ]]; then + # Keep the optional path fail-closed when deployment outputs are unavailable. + # An explicitly supplied Grafana name is enough to opt back in for legacy + # or manually configured environments. + if [[ -n "${GRAFANA_NAME:-}" ]]; then + GRAFANA_DEPLOYED="true" + else + GRAFANA_DEPLOYED="false" + fi +fi + +if [[ "${GRAFANA_DEPLOYED}" == "false" ]]; then + echo "Azure Managed Grafana is disabled for this deployment; skipping optional dashboard import." >&2 + exit 0 +fi + +agentops_require_azure_subscription + if [[ -z "${AZURE_RESOURCE_GROUP:-}" ]]; then echo "ERROR: AZURE_RESOURCE_GROUP not set (and azd env has no value)." >&2 exit 2 @@ -108,10 +128,7 @@ az grafana folder show -n "${GRAFANA_NAME}" --folder "${GRAFANA_FOLDER}" >/dev/n URL="$(az grafana show -n "${GRAFANA_NAME}" -g "${AZURE_RESOURCE_GROUP}" --query 'properties.endpoint' -o tsv)" -SUBSCRIPTION_ID="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-${AZURE_SUBSCRIPTION_ID:-}}" -if [[ -z "${SUBSCRIPTION_ID}" ]]; then - SUBSCRIPTION_ID="$(az account show --query id -o tsv)" -fi +SUBSCRIPTION_ID="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" WORKSPACE_RESOURCE_ID="${AGENTOPS_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID:-}" WORKSPACE_NAME="${AGENTOPS_LOG_ANALYTICS_WORKSPACE_NAME:-${LOG_ANALYTICS_WORKSPACE_NAME:-}}" diff --git a/scripts/install-copilot-agentops-shim.ps1 b/scripts/install-copilot-agentops-shim.ps1 index 4a2ac70..e3104d6 100644 --- a/scripts/install-copilot-agentops-shim.ps1 +++ b/scripts/install-copilot-agentops-shim.ps1 @@ -38,8 +38,9 @@ if ($ShadowCopilot) { $commands = Get-Command copilot -All -ErrorAction SilentlyContinue $realCopilot = $commands | Where-Object { - $_.Source -and - (-not [System.IO.Path]::GetFullPath($_.Source).StartsWith($installDirFull, [System.StringComparison]::OrdinalIgnoreCase)) + if (-not $_.Source) { return $false } + $candidateDir = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($_.Source)).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + -not $candidateDir.Equals($installDirFull, [System.StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1 @@ -48,8 +49,22 @@ if ($ShadowCopilot) { } $shadowCmd = Join-Path $InstallDir "copilot.cmd" + $shadowBackup = Join-Path $InstallDir "copilot.cmd.agentops-original" + $shadowMarker = "REM AgentOps managed shadow shim" + + if (Test-Path $shadowCmd) { + [string]$existingShadow = Get-Content -Raw -Path $shadowCmd + if (-not $existingShadow.Contains($shadowMarker)) { + if (Test-Path $shadowBackup) { + throw "Refusing to overwrite $shadowCmd because the AgentOps backup already exists at $shadowBackup." + } + Move-Item -LiteralPath $shadowCmd -Destination $shadowBackup + } + } + @" @echo off +$shadowMarker set "COPILOT_CLI_BIN=$($realCopilot.Source)" "$powershell" -NoProfile -ExecutionPolicy Bypass -File "$agentopsScript" %* "@ | Set-Content -Path $shadowCmd -Encoding ASCII diff --git a/scripts/install-copilot-agentops-shim.sh b/scripts/install-copilot-agentops-shim.sh index 1cc224c..5c393ef 100755 --- a/scripts/install-copilot-agentops-shim.sh +++ b/scripts/install-copilot-agentops-shim.sh @@ -3,6 +3,10 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" +cli_entry="${repo_root}/agentops-cli/src/index.js" +if [[ ! -f "${cli_entry}" ]]; then + cli_entry="${repo_root}/src/index.js" +fi install_dir="${AGENTOPS_BIN_DIR:-${HOME}/.local/bin}" mode="command" @@ -38,8 +42,8 @@ while [[ $# -gt 0 ]]; do done mkdir -p "${install_dir}" -chmod +x "${repo_root}/agentops-cli/src/index.js" "${repo_root}/scripts/copilot-agentops" "${repo_root}/scripts/agentops-codex" "${repo_root}/scripts/collector-azuremonitor-up.sh" "${repo_root}/copilot/copilot-observe" -ln -sf "${repo_root}/agentops-cli/src/index.js" "${install_dir}/agentops" +chmod +x "${cli_entry}" "${repo_root}/scripts/copilot-agentops" "${repo_root}/scripts/agentops-codex" "${repo_root}/scripts/collector-azuremonitor-up.sh" "${repo_root}/copilot/copilot-observe" +ln -sf "${cli_entry}" "${install_dir}/agentops" ln -sf "${repo_root}/scripts/copilot-agentops" "${install_dir}/copilot-agentops" ln -sf "${repo_root}/scripts/agentops-codex" "${install_dir}/agentops-codex" @@ -56,12 +60,26 @@ if [[ "${mode}" == "shadow" ]]; then exit 2 fi - cat >"${install_dir}/copilot" <<SH + shadow_cmd="${install_dir}/copilot" + shadow_backup="${install_dir}/copilot.agentops-original" + shadow_marker="# AgentOps managed shadow shim" + if [[ -e "${shadow_cmd}" || -L "${shadow_cmd}" ]]; then + if ! grep -Fq "${shadow_marker}" "${shadow_cmd}" 2>/dev/null; then + if [[ -e "${shadow_backup}" || -L "${shadow_backup}" ]]; then + echo "ERROR: refusing to overwrite ${shadow_cmd} because the AgentOps backup already exists at ${shadow_backup}." >&2 + exit 2 + fi + mv "${shadow_cmd}" "${shadow_backup}" + fi + fi + + cat >"${shadow_cmd}" <<SH #!/usr/bin/env bash +${shadow_marker} export COPILOT_CLI_BIN="${real_copilot}" exec "${repo_root}/scripts/copilot-agentops" "\$@" SH - chmod +x "${install_dir}/copilot" + chmod +x "${shadow_cmd}" fi cat <<MSG @@ -84,7 +102,7 @@ Run observed Copilot sessions with: copilot-agentops To make plain \`copilot\` observed too, rerun: - ./scripts/install-copilot-agentops-shim.sh --shadow-copilot + agentops install --shadow-copilot MSG fi diff --git a/scripts/lib/azure-subscription-guard.sh b/scripts/lib/azure-subscription-guard.sh new file mode 100644 index 0000000..a4825df --- /dev/null +++ b/scripts/lib/azure-subscription-guard.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +agentops_require_azure_subscription() { + local approved_csv="${AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS:-}" + local expected="${AGENTOPS_AZURE_SUBSCRIPTION_ID:-}" + local active="" + local expected_normalized="" + local active_normalized="" + local approved_normalized="" + local approved_match="false" + local approved="" + + if [[ -z "${expected}" ]]; then + echo "ERROR: set AGENTOPS_AZURE_SUBSCRIPTION_ID before any Azure write or privileged lookup." >&2 + return 2 + fi + + if [[ -z "${approved_csv}" ]]; then + echo "ERROR: set AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS before any Azure write or privileged lookup." >&2 + return 2 + fi + + if ! active="$(az account show --query id -o tsv 2>/dev/null)" || [[ -z "${active}" ]]; then + echo "ERROR: could not verify the active Azure subscription. Run az login, then retry." >&2 + return 2 + fi + + expected_normalized="$(printf '%s' "${expected}" | tr '[:upper:]' '[:lower:]')" + active_normalized="$(printf '%s' "${active}" | tr '[:upper:]' '[:lower:]')" + approved_normalized="$(printf '%s' "${approved_csv}" | tr '[:upper:]' '[:lower:]' | tr ',' ' ')" + for approved in ${approved_normalized}; do + if [[ "${expected_normalized}" == "${approved}" ]]; then + approved_match="true" + break + fi + done + if [[ "${approved_match}" != "true" ]]; then + echo "ERROR: Azure subscription guard refused this operation." >&2 + echo "Configured subscription ${expected} is not in AGENTOPS_APPROVED_AZURE_SUBSCRIPTION_IDS." >&2 + return 2 + fi + if [[ "${active_normalized}" != "${expected_normalized}" ]]; then + echo "ERROR: Azure subscription guard refused this operation." >&2 + echo "Expected: ${expected}" >&2 + echo "Active: ${active}" >&2 + echo "Switch explicitly with: az account set --subscription ${expected}" >&2 + return 2 + fi + + printf 'Azure subscription guard: verified %s\n' "${active}" >&2 +} diff --git a/scripts/lib/strict-collector-attributes.js b/scripts/lib/strict-collector-attributes.js new file mode 100644 index 0000000..5b45d12 --- /dev/null +++ b/scripts/lib/strict-collector-attributes.js @@ -0,0 +1,71 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const { otelAttributeMap } = require(path.join(repoRoot, 'packages', 'agentops-copilot-sdk', 'src', 'event-envelope')); + +const canonicalSdkAttributes = Object.freeze([...new Set(Object.values(otelAttributeMap))].sort()); +const forbiddenContentAttributes = Object.freeze([ + 'gen_ai.input.messages', + 'gen_ai.output.messages', + 'gen_ai.prompt', + 'gen_ai.completion', + 'gen_ai.tool.call.arguments', + 'gen_ai.tool.call.result', + 'http.request.body.content', + 'http.response.body.content', + 'url.full', + 'code.filepath' +]); + +function attributesForContext(text, context) { + const normalized = String(text).replace(/\r\n/g, '\n'); + const match = normalized.match(new RegExp(`- context: ${context}\\n[\\s\\S]*?- keep_keys\\(attributes, (\\[[^\\n]+\\])\\)`)); + return match ? JSON.parse(match[1]) : []; +} + +function syncContext(text, context) { + const normalized = String(text).replace(/\r\n/g, '\n'); + const expression = new RegExp(`(- context: ${context}\\n[\\s\\S]*?- keep_keys\\(attributes, )(\\[[^\\n]+\\])(\\))`); + if (!expression.test(normalized)) throw new Error(`Missing ${context} keep_keys allowlist`); + return normalized.replace(expression, (whole, prefix, raw, suffix) => { + const existing = JSON.parse(raw); + const merged = [...existing, ...canonicalSdkAttributes.filter(attribute => !existing.includes(attribute))]; + return `${prefix}${JSON.stringify(merged)}${suffix}`; + }); +} + +function strictCollectorFiles() { + return [ + 'collector/processors/strict-allowlist.yaml', + 'collector/otelcol.local.strict.yaml', + 'collector/otelcol.binary.strict.yaml', + 'collector/otelcol.azuremonitor.strict.yaml' + ].map(file => path.join(repoRoot, file)); +} + +function syncStrictCollectorFiles(options = {}) { + const write = options.write !== false; + const changed = []; + for (const file of strictCollectorFiles()) { + const original = fs.readFileSync(file, 'utf8'); + const normalized = original.replace(/\r\n/g, '\n'); + let rendered = syncContext(normalized, 'span'); + if (/- context: log\n/.test(rendered)) rendered = syncContext(rendered, 'log'); + if (rendered !== normalized) { + changed.push(path.relative(repoRoot, file)); + if (write) fs.writeFileSync(file, original.includes('\r\n') ? rendered.replace(/\n/g, '\r\n') : rendered); + } + } + return { ok: true, changed, attributes: canonicalSdkAttributes.length }; +} + +module.exports = { + attributesForContext, + canonicalSdkAttributes, + forbiddenContentAttributes, + repoRoot, + strictCollectorFiles, + syncContext, + syncStrictCollectorFiles +}; diff --git a/scripts/otlp-smoke-log.sh b/scripts/otlp-smoke-log.sh index 6345c03..448803e 100755 --- a/scripts/otlp-smoke-log.sh +++ b/scripts/otlp-smoke-log.sh @@ -18,7 +18,7 @@ const payload = { { key: 'service.namespace', value: { stringValue: 'copilot-agentops' } }, { key: 'agent.runtime', value: { stringValue: 'github-copilot-cli' } }, { key: 'agentops.profile', value: { stringValue: 'safe-default' } }, - { key: 'agentops.smoke_id', value: { stringValue: smokeId } } + { key: 'agentops.e2e.id', value: { stringValue: smokeId } } ] }, scopeLogs: [ @@ -31,7 +31,7 @@ const payload = { severityText: 'INFO', body: { stringValue: `AgentOps OTLP smoke log ${smokeId}` }, attributes: [ - { key: 'agentops.smoke_id', value: { stringValue: smokeId } }, + { key: 'agentops.custom_event_id', value: { stringValue: smokeId } }, { key: 'gen_ai.operation.name', value: { stringValue: 'smoke_test' } }, { key: 'content.capture.enabled', value: { boolValue: false } } ] @@ -59,5 +59,5 @@ endpoint=${endpoint} Query it with: az monitor log-analytics query \\ --workspace "\${AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID}" \\ - --analytics-query "AppTraces | where TimeGenerated > ago(2h) | where Properties has '${smoke_id}' or Message has '${smoke_id}' | take 20" + --analytics-query "OTelLogs | where TimeGenerated > ago(2h) | where tostring(Attributes) contains '${smoke_id}' or tostring(ResourceAttributes) contains '${smoke_id}' | project TimeGenerated, Body, Attributes, ResourceAttributes | take 20" MSG diff --git a/scripts/otlp-smoke-metrics.sh b/scripts/otlp-smoke-metrics.sh new file mode 100755 index 0000000..34235f0 --- /dev/null +++ b/scripts/otlp-smoke-metrics.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +endpoint="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://127.0.0.1:4318}" +smoke_id="${AGENTOPS_SMOKE_ID:-otlp-metric-agentops-$(date +%Y%m%d%H%M%S)}" +payload_file="/tmp/${smoke_id}.otlp-metric.json" + +SMOKE_ID="$smoke_id" node >"$payload_file" <<'NODE' +const now = BigInt(Date.now()) * 1000000n; +const start = now - 100000000n; +const smokeId = process.env.SMOKE_ID; + +const payload = { + resourceMetrics: [ + { + resource: { + attributes: [ + { key: 'service.name', value: { stringValue: 'github-copilot' } }, + { key: 'service.namespace', value: { stringValue: 'copilot-agentops' } }, + { key: 'agent.runtime', value: { stringValue: 'github-copilot-cli' } }, + { key: 'agentops.profile', value: { stringValue: 'safe-default' } }, + { key: 'agentops.e2e.id', value: { stringValue: smokeId } } + ] + }, + scopeMetrics: [ + { + scope: { name: 'agentops.otlp-smoke', version: '0.1.0' }, + metrics: [ + { + name: 'agentops.native.smoke', + description: 'Metadata-only AgentOps native OTLP smoke metric', + unit: '1', + sum: { + dataPoints: [ + { + attributes: [ + { key: 'agentops.custom_event_id', value: { stringValue: smokeId } }, + { key: 'gen_ai.operation.name', value: { stringValue: 'smoke_test' } }, + { key: 'content.capture.enabled', value: { boolValue: false } } + ], + startTimeUnixNano: start.toString(), + timeUnixNano: now.toString(), + asDouble: 1 + } + ], + aggregationTemporality: 2, + isMonotonic: true + } + } + ] + } + ] + } + ] +}; + +process.stdout.write(JSON.stringify(payload)); +NODE + +curl --fail --silent --show-error \ + --header 'Content-Type: application/json' \ + --data-binary "@$payload_file" \ + "${endpoint%/}/v1/metrics" >/tmp/${smoke_id}.otlp-response + +cat <<MSG +Sent OTLP smoke metric. +smokeId=${smoke_id} +endpoint=${endpoint} +temporality=DELTA +value=1 + +Query the Azure Monitor Workspace PromQL surface for the accepted metadata row with: +AGENTOPS_SMOKE_ID="${smoke_id}" ./scripts/azure-native-metric-query.sh +MSG diff --git a/scripts/otlp-smoke-trace.sh b/scripts/otlp-smoke-trace.sh index e63839c..25d7621 100755 --- a/scripts/otlp-smoke-trace.sh +++ b/scripts/otlp-smoke-trace.sh @@ -25,7 +25,7 @@ const payload = { { key: 'service.namespace', value: { stringValue: 'copilot-agentops' } }, { key: 'agent.runtime', value: { stringValue: 'github-copilot-cli' } }, { key: 'agentops.profile', value: { stringValue: 'safe-default' } }, - { key: 'agentops.smoke_id', value: { stringValue: smokeId } } + { key: 'agentops.e2e.id', value: { stringValue: smokeId } } ] }, scopeSpans: [ @@ -40,7 +40,7 @@ const payload = { startTimeUnixNano: now.toString(), endTimeUnixNano: end.toString(), attributes: [ - { key: 'agentops.smoke_id', value: { stringValue: smokeId } }, + { key: 'agentops.custom_event_id', value: { stringValue: smokeId } }, { key: 'gen_ai.operation.name', value: { stringValue: 'smoke_test' } }, { key: 'content.capture.enabled', value: { boolValue: false } } ], @@ -69,5 +69,5 @@ endpoint=${endpoint} Query it with: az monitor log-analytics query \\ --workspace "\${AGENTOPS_LOG_ANALYTICS_WORKSPACE_ID}" \\ - --analytics-query "AppDependencies | where TimeGenerated > ago(2h) | where Properties has '${smoke_id}' or Name has '${smoke_id}' | project TimeGenerated, Name, Properties | order by TimeGenerated desc | take 20" + --analytics-query "OTelSpans | where TimeGenerated > ago(2h) | where tostring(Attributes) contains '${smoke_id}' or tostring(ResourceAttributes) contains '${smoke_id}' | project TimeGenerated, Name, TraceId, SpanId, Attributes, ResourceAttributes | order by TimeGenerated desc | take 20" MSG diff --git a/scripts/prepare-cli-package-assets.js b/scripts/prepare-cli-package-assets.js index ac63593..68106c9 100644 --- a/scripts/prepare-cli-package-assets.js +++ b/scripts/prepare-cli-package-assets.js @@ -8,12 +8,36 @@ const root = path.resolve(__dirname, '..'); const packageDir = path.join(root, 'agentops-cli'); const lockDir = path.join(packageDir, '.package-assets.lock'); -const assetDirs = ['.azure', 'collector', 'copilot', 'docs', 'grafana', 'plugin', 'scripts']; -const assetFiles = ['azure.yaml']; - +const assetDirs = [ + 'actioner', + 'benchmark-judges', + 'benchmark-runners', + 'collector', + 'copilot', + 'docs', + 'examples', + 'fixtures', + 'grafana', + 'infra', + 'kql', + 'packages', + 'plugin', + 'scripts' +]; +const assetFiles = [ + 'LICENSE', + 'azure.yaml', + 'install-agentops.ps1', + 'install-agentops.sh', + 'uninstall-agentops.ps1', + 'uninstall-agentops.sh' +]; function shouldCopy(src) { const relative = path.relative(root, src).replaceAll('\\', '/'); - if (relative.startsWith('docs/images/')) return false; + if (relative.split('/').includes('node_modules')) return false; + if (relative.split('/').some(segment => segment === 'test' || segment === 'tests') || path.basename(relative) === 'package-lock.json') return false; + if (relative.endsWith('.tgz')) return false; + if (relative.startsWith('docs/images/') && relative !== 'docs/images/agentops-architecture-dataflow.png') return false; if (relative.startsWith('docs/screenshots/')) return false; if (relative.startsWith('scripts/check-')) return false; if (relative === 'scripts/coverage-check.js' || relative === 'scripts/static-check.js') return false; @@ -23,6 +47,7 @@ function shouldCopy(src) { function clean() { for (const dir of assetDirs) fs.rmSync(path.join(packageDir, dir), { recursive: true, force: true }); for (const file of assetFiles) fs.rmSync(path.join(packageDir, file), { force: true }); + fs.rmSync(path.join(packageDir, 'src', 'fixtures'), { recursive: true, force: true }); return { ok: true, action: 'clean', removed: [...assetDirs, ...assetFiles] }; } diff --git a/scripts/static-check.js b/scripts/static-check.js index c7ffe5f..f62f857 100644 --- a/scripts/static-check.js +++ b/scripts/static-check.js @@ -3,6 +3,7 @@ const childProcess = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); +const { validateAgentOpsEventsBicepMigration } = require('../agentops-cli/src/lib/azure/v2-ingestion-schema-safety'); const repoRoot = path.resolve(__dirname, '..'); const skipDirs = new Set([ @@ -11,18 +12,48 @@ const skipDirs = new Set([ '.git', 'node_modules' ]); +const generatedCliAssetDirs = new Set([ + '.azure', + '.package-assets.lock', + 'actioner', + 'benchmark-judges', + 'benchmark-runners', + 'collector', + 'copilot', + 'docs', + 'examples', + 'grafana', + 'infra', + 'packages', + 'plugin', + 'scripts', + 'tests' +]); const generatedOrBinaryExts = new Set([ '.jpg', '.jpeg', '.png', '.svg' ]); - -function walk(dir, files = []) { +const requiredNonEmptyFiles = [ + 'agentops-cli/src/alerts.js', + 'agentops-cli/src/primitives.js', + 'docs/release-distribution.md', + 'kql/20-copilot-primitives-inventory.kql', + 'plugin/agents/agentops-orchestrator.agent.md', + 'plugin/skills/agentops-attribution/SKILL.md', + 'plugin/skills/agentops-live-triage/SKILL.md', + 'plugin/skills/agentops-mcp-tool-triage/SKILL.md' +]; + +function walk(dir, files = [], root = repoRoot) { + const relativeDir = path.relative(root, dir).replaceAll('\\', '/'); for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (skipDirs.has(entry.name)) continue; + if (relativeDir === 'agentops-cli' && generatedCliAssetDirs.has(entry.name)) continue; + if (relativeDir === 'agentops-cli/src' && entry.name === 'fixtures') continue; const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) walk(fullPath, files); + if (entry.isDirectory()) walk(fullPath, files, root); else files.push(fullPath); } return files; @@ -145,6 +176,36 @@ function checkTextFiles(files) { return failures; } +function checkRequiredNonEmptyFiles(root = repoRoot, required = requiredNonEmptyFiles) { + const failures = []; + for (const file of required) { + const fullPath = path.join(root, file); + if (!fs.existsSync(fullPath)) { + failures.push({ file, error: 'required file is missing' }); + continue; + } + if (!fs.readFileSync(fullPath, 'utf8').trim()) { + failures.push({ file, error: 'required file is empty' }); + } + } + return failures; +} + +function checkV2IngestionSchema(root = repoRoot) { + const file = path.join(root, 'infra', 'bicep', 'v2-ingestion.bicep'); + try { + const result = validateAgentOpsEventsBicepMigration(fs.readFileSync(file, 'utf8'), [ + { name: 'EstimatedCostUsd', type: 'long' } + ]); + return result.ok ? [] : [{ file: 'infra/bicep/v2-ingestion.bicep', error: JSON.stringify({ + violations: result.violations, + contract_violations: result.contract_violations + }) }]; + } catch (error) { + return [{ file: 'infra/bicep/v2-ingestion.bicep', error: error.message }]; + } +} + function main() { const files = walk(repoRoot); const shell = checkShellSyntax(files); @@ -153,7 +214,9 @@ function main() { json: checkJson(files), shell: shell.failures, markdown: checkMarkdownLinks(files), - text: checkTextFiles(files) + text: checkTextFiles(files), + required: checkRequiredNonEmptyFiles(), + v2_ingestion_schema: checkV2IngestionSchema() }; const failures = Object.values(results).flat(); const summary = { @@ -182,4 +245,11 @@ function main() { process.exitCode = summary.ok ? 0 : 1; } -main(); +if (require.main === module) main(); + +module.exports = { + checkRequiredNonEmptyFiles, + checkV2IngestionSchema, + requiredNonEmptyFiles, + walk +}; diff --git a/scripts/sync-strict-collector-attributes.js b/scripts/sync-strict-collector-attributes.js new file mode 100644 index 0000000..c054bd3 --- /dev/null +++ b/scripts/sync-strict-collector-attributes.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +const { syncStrictCollectorFiles } = require('./lib/strict-collector-attributes'); + +const check = process.argv.includes('--check'); +const result = syncStrictCollectorFiles({ write: !check }); +if (check && result.changed.length) { + process.stderr.write(`Strict collector attribute drift: ${result.changed.join(', ')}\n`); + process.exitCode = 1; +} else { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/scripts/uninstall-copilot-agentops-shim.ps1 b/scripts/uninstall-copilot-agentops-shim.ps1 index 52d9311..cab9f34 100644 --- a/scripts/uninstall-copilot-agentops-shim.ps1 +++ b/scripts/uninstall-copilot-agentops-shim.ps1 @@ -6,19 +6,36 @@ param( $ErrorActionPreference = "Stop" $shadowCmd = Join-Path $InstallDir "copilot.cmd" +$shadowBackup = Join-Path $InstallDir "copilot.cmd.agentops-original" +$shadowMarker = "REM AgentOps managed shadow shim" $agentopsCliCmd = Join-Path $InstallDir "agentops.cmd" $agentopsCmd = Join-Path $InstallDir "copilot-agentops.cmd" $agentopsCodexCmd = Join-Path $InstallDir "agentops-codex.cmd" if (Test-Path $shadowCmd) { - Remove-Item $shadowCmd -Force - Write-Host "Removed plain copilot shadow shim:" - Write-Host " $shadowCmd" + [string]$existingShadow = Get-Content -Raw -Path $shadowCmd + if ($existingShadow.Contains($shadowMarker)) { + Remove-Item -LiteralPath $shadowCmd -Force + Write-Host "Removed plain copilot shadow shim:" + Write-Host " $shadowCmd" + } else { + Write-Host "Preserved non-AgentOps copilot command:" + Write-Host " $shadowCmd" + } } else { Write-Host "No plain copilot shadow shim found at:" Write-Host " $shadowCmd" } +if (Test-Path $shadowBackup) { + if (Test-Path $shadowCmd) { + throw "Cannot restore the original Copilot command because $shadowCmd is occupied. Original remains at $shadowBackup." + } + Move-Item -LiteralPath $shadowBackup -Destination $shadowCmd + Write-Host "Restored original copilot command:" + Write-Host " $shadowCmd" +} + if (-not $KeepAgentopsCommand) { if (Test-Path $agentopsCliCmd) { Remove-Item $agentopsCliCmd -Force diff --git a/scripts/uninstall-copilot-agentops-shim.sh b/scripts/uninstall-copilot-agentops-shim.sh index c8cef31..cd21063 100755 --- a/scripts/uninstall-copilot-agentops-shim.sh +++ b/scripts/uninstall-copilot-agentops-shim.sh @@ -36,19 +36,36 @@ while [[ $# -gt 0 ]]; do done shadow_cmd="${install_dir}/copilot" +shadow_backup="${install_dir}/copilot.agentops-original" +shadow_marker="# AgentOps managed shadow shim" agentops_cli_cmd="${install_dir}/agentops" agentops_cmd="${install_dir}/copilot-agentops" agentops_codex_cmd="${install_dir}/agentops-codex" if [[ -e "${shadow_cmd}" || -L "${shadow_cmd}" ]]; then - rm -f "${shadow_cmd}" - echo "Removed plain copilot shadow shim:" - echo " ${shadow_cmd}" + if grep -Fq "${shadow_marker}" "${shadow_cmd}" 2>/dev/null; then + rm -f "${shadow_cmd}" + echo "Removed plain copilot shadow shim:" + echo " ${shadow_cmd}" + else + echo "Preserved non-AgentOps copilot command:" + echo " ${shadow_cmd}" + fi else echo "No plain copilot shadow shim found at:" echo " ${shadow_cmd}" fi +if [[ -e "${shadow_backup}" || -L "${shadow_backup}" ]]; then + if [[ -e "${shadow_cmd}" || -L "${shadow_cmd}" ]]; then + echo "ERROR: cannot restore the original Copilot command because ${shadow_cmd} is occupied. Original remains at ${shadow_backup}." >&2 + exit 2 + fi + mv "${shadow_backup}" "${shadow_cmd}" + echo "Restored original copilot command:" + echo " ${shadow_cmd}" +fi + if [[ "${keep_agentops}" != true ]]; then if [[ -e "${agentops_cli_cmd}" || -L "${agentops_cli_cmd}" ]]; then rm -f "${agentops_cli_cmd}" diff --git a/scripts/validate-v2-ingestion-schema.js b/scripts/validate-v2-ingestion-schema.js new file mode 100644 index 0000000..4a68231 --- /dev/null +++ b/scripts/validate-v2-ingestion-schema.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { validateAgentOpsEventsBicepMigration } = require('../agentops-cli/src/lib/azure/v2-ingestion-schema-safety'); + +function option(name) { + const index = process.argv.indexOf(name); + return index < 0 ? null : process.argv[index + 1]; +} + +const livePath = option('--live-schema'); +if (!livePath) { + process.stderr.write('Usage: node scripts/validate-v2-ingestion-schema.js --live-schema <table-schema.json> [--bicep <path>]\n'); + process.exitCode = 2; +} else { + try { + const bicepPath = path.resolve(option('--bicep') || path.join(__dirname, '../infra/bicep/v2-ingestion.bicep')); + const livePayload = JSON.parse(fs.readFileSync(path.resolve(livePath), 'utf8')); + const liveColumns = livePayload?.properties?.schema?.columns || livePayload?.schema?.columns || livePayload?.columns; + const result = validateAgentOpsEventsBicepMigration(fs.readFileSync(bicepPath, 'utf8'), liveColumns); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exitCode = result.ok ? 0 : 1; + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 2; + } +} diff --git a/setup-agentops.ps1 b/setup-agentops.ps1 index 213fb2d..2dc30aa 100644 --- a/setup-agentops.ps1 +++ b/setup-agentops.ps1 @@ -28,11 +28,11 @@ if (Get-Command azd -ErrorAction SilentlyContinue) { if ($startCollector) { Write-Host "" - & node (Join-Path $scriptDir "agentops-cli/src/index.js") collector start --mode auto --privacy strict + & node (Join-Path $scriptDir "agentops-cli/src/index.js") collector start --mode local --privacy strict if ($LASTEXITCODE -eq 0) { Write-Host "Collector is running." } else { - Write-Host "Collector did not start yet. Check Azure config with: agentops configure import-azd" + Write-Host "Collector did not start yet. Install the local binary with: agentops collector install-binary" } } diff --git a/setup-agentops.sh b/setup-agentops.sh index 1b10a9b..1544eb1 100755 --- a/setup-agentops.sh +++ b/setup-agentops.sh @@ -2,11 +2,23 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ "$#" -eq 1 && ( "$1" == "--help" || "$1" == "-h" ) ]]; then + exec "${script_dir}/install-agentops.sh" --help +fi + start_collector=true +shadow_copilot=false for arg in "$@"; do if [[ "${arg}" == "--no-collector" ]]; then start_collector=false fi + if [[ "${arg}" == "--shadow-copilot" || "${arg}" == "--shadow" ]]; then + shadow_copilot=true + fi + if [[ "${arg}" == "--no-shadow-copilot" || "${arg}" == "--no-shadow" ]]; then + shadow_copilot=false + fi done "${script_dir}/install-agentops.sh" "$@" @@ -27,10 +39,10 @@ fi if [[ "${start_collector}" == true ]]; then echo - if node "${script_dir}/agentops-cli/src/index.js" collector start --mode auto --privacy strict; then + if node "${script_dir}/agentops-cli/src/index.js" collector start --mode local --privacy strict; then echo "Collector is running." else - echo "Collector did not start yet. Check Azure config with: agentops configure import-azd" + echo "Collector did not start yet. Install the local binary with: agentops collector install-binary" fi fi @@ -42,8 +54,8 @@ cat <<'MSG' Next: make sure ~/.local/bin is on PATH for this shell: export PATH="$HOME/.local/bin:$PATH" -Then run Copilot normally: - copilot --no-ask-user --no-remote --add-dir . --allow-tool='shell(pwd)' --allow-tool='shell(ls:*)' -p "Do not edit files. Run pwd and ls docs | head, then summarize." +Then run Copilot with AgentOps: + agentops copilot --no-ask-user --no-remote --add-dir . --allow-tool='shell(pwd)' --allow-tool='shell(ls:*)' -p "Do not edit files. Run pwd and ls docs | head, then summarize." Useful checks: agentops latest --last 2h @@ -58,3 +70,9 @@ If configure import-azd did not find Azure outputs, run: azd provision agentops configure import-azd MSG + +if [[ "${shadow_copilot}" == true ]]; then + echo "Plain copilot is also routed through AgentOps because --shadow-copilot was selected." +else + echo "Plain copilot is unchanged. Opt in later with: agentops experimental enable-shadow" +fi diff --git a/uninstall-agentops.sh b/uninstall-agentops.sh index 161245b..770bf10 100755 --- a/uninstall-agentops.sh +++ b/uninstall-agentops.sh @@ -2,6 +2,10 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cli_entry="${script_dir}/agentops-cli/src/index.js" +if [[ ! -f "${cli_entry}" ]]; then + cli_entry="${script_dir}/src/index.js" +fi remove_plugin=true stop_collector=true remove_binary=true @@ -61,11 +65,11 @@ while [[ $# -gt 0 ]]; do done if [[ "${remove_plugin}" == true ]]; then - node "${script_dir}/agentops-cli/src/index.js" plugin uninstall || true + node "${cli_entry}" plugin uninstall || true fi if [[ "${stop_collector}" == true ]]; then - node "${script_dir}/agentops-cli/src/index.js" collector stop --mode auto --json || true + node "${cli_entry}" collector stop --mode auto --json || true fi if [[ "${remove_binary}" == true ]]; then @@ -73,14 +77,14 @@ if [[ "${remove_binary}" == true ]]; then if [[ "${purge}" == true ]]; then binary_args+=(--purge) fi - node "${script_dir}/agentops-cli/src/index.js" "${binary_args[@]}" || true + node "${cli_entry}" "${binary_args[@]}" || true fi -"${script_dir}/scripts/uninstall-copilot-agentops-shim.sh" "${shim_args[@]}" +"${script_dir}/scripts/uninstall-copilot-agentops-shim.sh" ${shim_args[@]+"${shim_args[@]}"} cat <<'MSG' AgentOps uninstall finished. Reinstall later with: - ./setup-agentops.sh + agentops install MSG