From b9be0a02a5fdf6104162d9f704f7d79873068e58 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Wed, 25 Mar 2026 22:24:25 -0400 Subject: [PATCH 1/7] feat: redesign test selection cards with expand/collapse and badges Test cards now have a clickable header that expands to show description, signal event name, pass criteria, dependencies, and sub-steps. Added color-coded badges for test group, dependency, and feature-flag status. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../data-capture-suite.html | 111 ++++++++++++++++-- 1 file changed, 98 insertions(+), 13 deletions(-) diff --git a/src/SimSteward.Dashboard/data-capture-suite.html b/src/SimSteward.Dashboard/data-capture-suite.html index e239650..9614088 100644 --- a/src/SimSteward.Dashboard/data-capture-suite.html +++ b/src/SimSteward.Dashboard/data-capture-suite.html @@ -123,16 +123,40 @@ .test-group-status { margin-left: auto; font-size: 0.72rem; font-weight: 600; } .test-cards { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; overflow: hidden; max-height: 2000px; transition: max-height 0.3s ease, margin-top 0.3s ease, opacity 0.25s ease; opacity: 1; } .test-cards.collapsed { max-height: 0; margin-top: 0; opacity: 0; } -.test-card { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--border); background: rgba(0,0,0,0.12); cursor: default; transition: border-color 0.2s, background 0.2s; } -.test-card:hover { border-color: var(--border-hover); background: rgba(0,0,0,0.18); } +.test-card { border-radius: 10px; border: 1px solid var(--border); background: rgba(0,0,0,0.12); overflow: hidden; transition: border-color 0.2s, background 0.2s, box-shadow 0.2s; } +.test-card:hover { border-color: var(--border-hover); } +.test-card.expanded { border-color: rgba(31,156,255,0.2); box-shadow: 0 0 12px rgba(31,156,255,0.06); } .test-card.filtered-out { display: none; } -.test-card input[type="checkbox"] { margin-top: 3px; flex-shrink: 0; cursor: pointer; } -.test-card-body { flex: 1; min-width: 0; } -.test-card-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.test-card-top { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; transition: background 0.15s; user-select: none; } +.test-card-top:hover { background: rgba(255,255,255,0.02); } +.test-card-top input[type="checkbox"] { flex-shrink: 0; cursor: pointer; } +.test-card-body { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .test-id { font-family: "Cascadia Code", "Fira Code", "Courier New", monospace; font-weight: 700; color: var(--accent); font-size: 0.84rem; } .test-name { font-weight: 600; font-size: 0.85rem; } -.test-desc { font-size: 0.78rem; color: var(--muted); margin-top: 4px; line-height: 1.45; } -.test-card-pill { margin-left: auto; flex-shrink: 0; } +.test-card-badges { display: flex; flex-wrap: wrap; gap: 4px; margin-left: 4px; } +.test-badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 99px; font-size: 0.6rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; border: 1px solid transparent; } +.test-badge.bg-incident { color: var(--red); background: rgba(255,95,95,0.1); border-color: rgba(255,95,95,0.2); } +.test-badge.bg-sdk { color: var(--accent); background: rgba(31,156,255,0.1); border-color: rgba(31,156,255,0.2); } +.test-badge.bg-camera { color: var(--cyan); background: rgba(13,216,255,0.1); border-color: rgba(13,216,255,0.2); } +.test-badge.bg-session { color: var(--green); background: rgba(14,242,114,0.1); border-color: rgba(14,242,114,0.2); } +.test-badge.bg-disc { color: var(--yellow); background: rgba(255,200,64,0.1); border-color: rgba(255,200,64,0.2); } +.test-badge.bg-60hz { color: #bd34fe; background: rgba(189,52,254,0.1); border-color: rgba(189,52,254,0.2); } +.test-badge.bg-depends { color: #ff9d3d; background: rgba(255,157,61,0.08); border-color: rgba(255,157,61,0.25); } +.test-badge.bg-flag { color: var(--yellow); background: rgba(255,200,64,0.06); border-color: rgba(255,200,64,0.25); } +.test-card-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; } +.test-card-pill { flex-shrink: 0; } +.test-card-chevron { font-size: 0.7rem; color: var(--muted); transition: transform 0.25s ease; } +.test-card.expanded .test-card-chevron { transform: rotate(180deg); } +.test-card-detail { max-height: 0; overflow: hidden; padding: 0 14px; border-top: 1px solid transparent; background: rgba(0,0,0,0.22); font-size: 0.78rem; line-height: 1.55; opacity: 0; transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.2s ease, border-top-color 0.2s; } +.test-card-detail.open { max-height: 400px; padding: 12px 14px 14px; border-top-color: var(--border); opacity: 1; } +.test-detail-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; } +.test-detail-row:last-child { margin-bottom: 0; } +.test-detail-label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); min-width: 72px; flex-shrink: 0; padding-top: 2px; font-weight: 500; } +.test-detail-val { font-size: 0.78rem; color: var(--text); } +.test-detail-val .mono { font-size: 0.72rem; padding: 2px 8px; border-radius: 6px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); } +.test-detail-substeps { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } +.test-detail-substep { display: inline-flex; align-items: center; gap: 3px; font-size: 0.68rem; color: var(--muted); padding: 2px 8px; border-radius: 6px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); } +.test-detail-substep .substep-num { font-weight: 700; color: rgba(255,255,255,0.25); font-size: 0.6rem; } /* ── Status pills ─────────────────────────────────────────────── */ .pill { display: inline-block; padding: 4px 11px; border-radius: 99px; font-size: 0.67rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap; } @@ -560,7 +584,7 @@ if (!card) return; if (!q) { card.classList.remove('filtered-out'); visible++; return; } const meta = TEST_META[id] || {}; - const text = (id + ' ' + (meta.name || '') + ' ' + (meta.desc || '')).toLowerCase(); + const text = (id + ' ' + (meta.name || '') + ' ' + (meta.desc || '') + ' ' + (meta.event || '') + ' ' + (meta.depends || '')).toLowerCase(); const match = text.includes(q); card.classList.toggle('filtered-out', !match); if (match) visible++; @@ -570,6 +594,11 @@ } // ── Test selection ─────────────────────────────────────────────────── +// ── Badge helpers ───────────────────────────────────────────────────── +const GROUP_BADGE_CLASS = { incident: 'bg-incident', sdk: 'bg-sdk', camera: 'bg-camera', session: 'bg-session', disc: 'bg-disc', '60hz': 'bg-60hz' }; +const GROUP_BADGE_LABEL = { incident: 'Incident', sdk: 'SDK', camera: 'Camera', session: 'Session', disc: 'Discovery', '60hz': 'High-Rate' }; +function _testGroupId(testId) { for (const g of TEST_GROUPS) { if (g.tests.includes(testId)) return g.id; } return null; } + function buildTestSelect() { const el = document.getElementById('test-select'); el.innerHTML = ''; @@ -623,13 +652,20 @@ }); g.tests.forEach(t => { - const meta = TEST_META[t] || { name: t, desc: '' }; + const meta = TEST_META[t] || { name: t, desc: '', event: '', pass: '', depends: null }; + const substeps = TEST_SUBSTEPS[t] || []; + const gid = _testGroupId(t); const card = document.createElement('div'); card.className = 'test-card'; card.id = 'tcard-' + t; + // ── Header row ── + const top = document.createElement('div'); + top.className = 'test-card-top'; + const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.id = 'cb-' + t; + cb.addEventListener('click', (e) => e.stopPropagation()); cb.addEventListener('change', () => { selectedTests[cb.checked ? 'add' : 'delete'](t); updateGroupState(g); @@ -637,17 +673,66 @@ flashEl(card, 'toggled', 350); logUI('cb-' + t, 'change', 'Test ' + t + ' ' + (cb.checked ? 'selected' : 'deselected')); }); - card.appendChild(cb); + top.appendChild(cb); const body = document.createElement('div'); body.className = 'test-card-body'; - body.innerHTML = '
' + esc(t) + '' + esc(meta.name) + '
' + esc(meta.desc) + '
'; - card.appendChild(body); + body.innerHTML = '' + esc(t) + '' + esc(meta.name) + ''; + + // badges + const badges = document.createElement('span'); + badges.className = 'test-card-badges'; + if (gid) badges.innerHTML += '' + esc(GROUP_BADGE_LABEL[gid]) + ''; + if (meta.depends && meta.depends.startsWith('SIMSTEWARD')) { + badges.innerHTML += '\u{1F512} Env Flag'; + } else if (meta.depends) { + badges.innerHTML += '\u2192 ' + esc(meta.depends) + ''; + } + body.appendChild(badges); + top.appendChild(body); + const right = document.createElement('div'); + right.className = 'test-card-right'; const pillWrap = document.createElement('div'); pillWrap.className = 'test-card-pill'; pillWrap.id = 'sel-pill-' + t; - card.appendChild(pillWrap); + right.appendChild(pillWrap); + const chev = document.createElement('span'); + chev.className = 'test-card-chevron'; + chev.textContent = '\u25BE'; + right.appendChild(chev); + top.appendChild(right); + + top.addEventListener('click', () => { + const wasExpanded = card.classList.contains('expanded'); + card.classList.toggle('expanded'); + detail.classList.toggle('open'); + logUI('tcard-' + t, 'click', 'Test ' + t + ' ' + (wasExpanded ? 'collapsed' : 'expanded')); + }); + + card.appendChild(top); + + // ── Detail panel ── + const detail = document.createElement('div'); + detail.className = 'test-card-detail'; + + let detailHTML = ''; + detailHTML += '
Description' + esc(meta.desc) + '
'; + detailHTML += '
Signal' + esc(meta.event) + '
'; + detailHTML += '
Pass criteria' + esc(meta.pass) + '
'; + if (meta.depends) { + const depLabel = meta.depends.startsWith('SIMSTEWARD') ? '\u{1F512} ' + esc(meta.depends) : '\u2192 Requires test ' + esc(meta.depends); + detailHTML += '
Depends on' + depLabel + '
'; + } + if (substeps.length > 1) { + detailHTML += '
Sub-steps'; + substeps.forEach((s, i) => { + detailHTML += '' + (i + 1) + '' + esc(s.label) + ''; + }); + detailHTML += '
'; + } + detail.innerHTML = detailHTML; + card.appendChild(detail); cards.appendChild(card); }); From dce32dd4328a955e5da62a88109d316ebcedf0ff Mon Sep 17 00:00:00 2001 From: win gutmann Date: Wed, 25 Mar 2026 22:41:27 -0400 Subject: [PATCH 2/7] feat: add Claude GitHub Actions and consolidate secret scanning Add claude.yml for @claude PR/issue mentions and claude-fix-tests.yml to auto-fix failing tests. Remove redundant secrets-scan.yml (Gitleaks already runs in security-scan.yml). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/claude-fix-tests.yml | 44 ++++++++++++++++++++++++++ .github/workflows/claude.yml | 30 ++++++++++++++++++ .github/workflows/secrets-scan.yml | 30 ------------------ 3 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/claude-fix-tests.yml create mode 100644 .github/workflows/claude.yml delete mode 100644 .github/workflows/secrets-scan.yml diff --git a/.github/workflows/claude-fix-tests.yml b/.github/workflows/claude-fix-tests.yml new file mode 100644 index 0000000..13110a0 --- /dev/null +++ b/.github/workflows/claude-fix-tests.yml @@ -0,0 +1,44 @@ +name: Claude Fix Failed Tests + +on: + workflow_run: + workflows: ["Tests"] + types: [completed] + +jobs: + fix-tests: + if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.head_branch != 'main' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_branch }} + fetch-depth: 1 + + - name: Download test results + uses: actions/download-artifact@v4 + with: + name: test-results + path: TestResults/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + prompt: | + The CI tests failed on branch ${{ github.event.workflow_run.head_branch }}. + + 1. Check TestResults/ for .trx files with failure details + 2. If no artifacts, run: dotnet test src/SimSteward.Plugin.Tests/SimSteward.Plugin.Tests.csproj -c Release -v normal + 3. Analyze the root cause of each failure + 4. Fix the failing tests or the code they test + 5. Verify fixes by running the tests again + + Do NOT modify tests just to make them pass — fix the underlying code unless the test itself is wrong. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..acab3c0 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,30 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'issues' && contains(github.event.issue.body, '@claude')) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/secrets-scan.yml b/.github/workflows/secrets-scan.yml deleted file mode 100644 index ec9dd98..0000000 --- a/.github/workflows/secrets-scan.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Reproduce locally: pnpm run secrets:lint ; pnpm run secrets:gitleaks (Docker required for Gitleaks script). -name: Secrets scan - -on: - push: - branches: [main, master] - pull_request: - -jobs: - secretlint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run secrets:lint - - gitleaks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From d5ae64365f2155b4acd0174afdffd951c9ddc0b6 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Thu, 26 Mar 2026 00:37:39 -0400 Subject: [PATCH 3/7] feat: retire Alloy, reconcile hook files, add LokiPushClient - Merged ~/.claude/hooks/loki-log.js and scripts/hooks/loki-log.js into a single canonical file. Both now identical. Key changes: - Incremental transcript parsing (O(new bytes)) from stop hook - Full parse at session-end with cost_usd + effort detection - app="claude-token-metrics" push (session-end, one per session) - app="claude-dev-logging" component=tokens (stop hook, intermediate) - session_duration_ms/compaction_count excluded from token metrics stream - cleanupTokenFiles() at session-end to remove offset tracking state - Added LokiPushClient.cs: fire-and-forget Loki push for C# plugin. Activated by SIMSTEWARD_LOKI_URL env var; replaces Alloy file-tailing. - PluginLogger.cs: push each 500ms flush batch to Loki via LokiPushClient. On-disk plugin-structured.jsonl still written as local backup. - docker-compose.yml: removed otel-collector, prometheus, alloy services. Stack is now: loki, loki-gateway, grafana, data-api (4 containers). Grafana no longer depends on prometheus. - config.alloy: retired with tombstone comment explaining replacement. - .env.observability.example: removed SIMSTEWARD_DATA_PATH (Alloy-only). Co-Authored-By: Claude Sonnet 4.6 --- .../local/.env.observability.example | 4 - observability/local/config.alloy | 52 +- observability/local/docker-compose.yml | 45 - .../dashboards/claude-token-usage.json | 879 ++++++++++++++++++ scripts/hooks/loki-log.js | 206 +++- src/SimSteward.Plugin/LokiPushClient.cs | 103 ++ src/SimSteward.Plugin/PluginLogger.cs | 13 + 7 files changed, 1191 insertions(+), 111 deletions(-) create mode 100644 observability/local/grafana/provisioning/dashboards/claude-token-usage.json create mode 100644 src/SimSteward.Plugin/LokiPushClient.cs diff --git a/observability/local/.env.observability.example b/observability/local/.env.observability.example index e30ff1c..ee1b2b7 100644 --- a/observability/local/.env.observability.example +++ b/observability/local/.env.observability.example @@ -9,10 +9,6 @@ GRAFANA_STORAGE_PATH= # Example (PowerShell): [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 })) LOKI_PUSH_TOKEN= -# Path to the SimHub plugin data directory on the host. Alloy tails plugin-structured.jsonl from here. -# Example Windows: C:/Users//AppData/Local/SimHubWpf/PluginsData/SimSteward -SIMSTEWARD_DATA_PATH= - # Grafana login (compose substitutes into GF_SECURITY_ADMIN_*). Used only when Grafana has no DB yet. # If you forgot the password, stop the stack, wipe the Grafana volume (npm run obs:wipe -- -Force -Grafana), then up again. GRAFANA_ADMIN_USER=admin diff --git a/observability/local/config.alloy b/observability/local/config.alloy index 277f2a5..45f26c6 100644 --- a/observability/local/config.alloy +++ b/observability/local/config.alloy @@ -1,43 +1,9 @@ -// Grafana Alloy — tail plugin-structured.jsonl → Loki -// Docs: https://grafana.com/docs/alloy/latest/ - -local.file_match "simsteward_structured" { - path_targets = [{"__path__" = "/var/log/simsteward/plugin-structured.jsonl"}] - sync_period = "5s" -} - -loki.source.file "simsteward_structured" { - targets = local.file_match.simsteward_structured.targets - forward_to = [loki.process.simsteward.receiver] - - tail_from_end = true -} - -loki.process "simsteward" { - forward_to = [loki.write.local.receiver] - - // Extract low-cardinality labels from JSON; everything else stays in the log line. - stage.json { - expressions = { - level = "level", - component = "component", - event = "event", - domain = "domain", - } - } - - stage.labels { - values = { - level = "", - component = "", - event = "", - domain = "", - } - } -} - -loki.write "local" { - endpoint { - url = "http://loki:3100/loki/api/v1/push" - } -} +// RETIRED — Alloy is no longer part of the observability stack. +// +// The SimHub plugin (PluginLogger.cs) now pushes plugin-structured.jsonl entries directly +// to Loki via LokiPushClient at flush time (~500ms batches), replacing this file-tail pipeline. +// +// Claude Code token metrics are pushed directly from ~/.claude/hooks/loki-log.js at session-end. +// +// This file is kept for reference only. Remove the alloy service from docker-compose.yml +// before starting the stack. diff --git a/observability/local/docker-compose.yml b/observability/local/docker-compose.yml index ebde21e..df097fa 100644 --- a/observability/local/docker-compose.yml +++ b/observability/local/docker-compose.yml @@ -32,45 +32,11 @@ services: timeout: 5s retries: 10 - otel-collector: - image: otel/opentelemetry-collector-contrib:0.115.1 - command: ["--config=/etc/otel-collector-config.yaml"] - volumes: - - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro - ports: - - "4317:4317" - - "4318:4318" - # Host 18889 avoids conflict with other tools binding Windows :8889; Prometheus still scrapes otel-collector:8889 on the Docker network. - - "18889:8889" - - "13133:13133" - - prometheus: - image: prom/prometheus:v2.55.1 - depends_on: - - otel-collector - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--storage.tsdb.path=/prometheus" - - "--storage.tsdb.retention.time=15d" - - "--web.enable-lifecycle" - ports: - - "9090:9090" - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ${GRAFANA_STORAGE_PATH:-S:/sim-steward-grafana-storage}/prometheus:/prometheus - healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:9090/-/healthy"] - interval: 10s - timeout: 5s - retries: 10 - grafana: image: grafana/grafana:11.2.0 depends_on: loki: condition: service_healthy - prometheus: - condition: service_healthy ports: - "3000:3000" environment: @@ -81,17 +47,6 @@ services: - ${GRAFANA_STORAGE_PATH:-S:/sim-steward-grafana-storage}/grafana:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning:ro - alloy: - image: grafana/alloy:v1.5.1 - depends_on: - loki: - condition: service_healthy - volumes: - - ./config.alloy:/etc/alloy/config.alloy:ro - - ${SIMSTEWARD_DATA_PATH}:/var/log/simsteward:ro - - ${GRAFANA_STORAGE_PATH:-S:/sim-steward-grafana-storage}/alloy:/tmp/positions - command: ["run", "/etc/alloy/config.alloy", "--storage.path=/tmp/positions"] - data-api: build: ./data-api ports: diff --git a/observability/local/grafana/provisioning/dashboards/claude-token-usage.json b/observability/local/grafana/provisioning/dashboards/claude-token-usage.json new file mode 100644 index 0000000..585227d --- /dev/null +++ b/observability/local/grafana/provisioning/dashboards/claude-token-usage.json @@ -0,0 +1,879 @@ +{ + "id": null, + "uid": "claude-token-usage", + "title": "Claude Code — Token Usage", + "description": "Token consumption, estimated cost, cache efficiency, and session trends across Claude Code sessions.", + "tags": ["claude-code", "tokens", "cost", "observability"], + "timezone": "browser", + "editable": true, + "graphTooltip": 1, + "time": { "from": "now-7d", "to": "now" }, + "refresh": "1m", + "schemaVersion": 39, + "fiscalYearStartMonth": 0, + "liveNow": false, + "style": "dark", + "templating": { + "list": [ + { + "name": "model", + "label": "Model", + "type": "query", + "datasource": { "type": "loki", "uid": "loki_local" }, + "query": "{app=\"claude-token-metrics\"} | json", + "regex": "\"model\":\"([^\"]+)\"", + "refresh": 2, + "includeAll": true, + "multi": true, + "allValue": ".*", + "current": { "text": "All", "value": "$__all" }, + "sort": 1 + }, + { + "name": "project", + "label": "Project", + "type": "query", + "datasource": { "type": "loki", "uid": "loki_local" }, + "query": "{app=\"claude-token-metrics\"} | json", + "regex": "\"project\":\"([^\"]+)\"", + "refresh": 2, + "includeAll": true, + "multi": true, + "allValue": ".*", + "current": { "text": "All", "value": "$__all" }, + "sort": 1 + }, + { + "name": "effort", + "label": "Effort", + "type": "query", + "datasource": { "type": "loki", "uid": "loki_local" }, + "query": "{app=\"claude-token-metrics\"} | json", + "regex": "\"effort\":\"([^\"]+)\"", + "refresh": 2, + "includeAll": true, + "multi": true, + "allValue": ".*", + "current": { "text": "All", "value": "$__all" }, + "sort": 1 + } + ] + }, + "panels": [ + { + "type": "row", + "title": "Cost Summary", + "collapsed": false, + "gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 } + }, + { + "id": 1, + "title": "Output Tokens", + "description": "Total output (generated) tokens in the selected time range.", + "type": "stat", + "transparent": true, + "gridPos": { "x": 0, "y": 1, "w": 5, "h": 5 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_output_tokens [$__range]))", + "queryType": "range" + } + ], + "options": { + "colorMode": "background-gradient", + "graphMode": "area", + "textMode": "auto", + "wideLayout": true, + "justifyMode": "auto", + "orientation": "auto", + "text": { "titleSize": 12, "valueSize": 32 }, + "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "fixed", "fixedColor": "#5794F2" }, + "unit": "short", + "decimals": 0, + "thresholds": { "mode": "absolute", "steps": [{ "value": null, "color": "#5794F2" }] } + }, + "overrides": [] + } + }, + { + "id": 2, + "title": "Est. Cost (USD)", + "description": "Estimated total spend based on Anthropic public pricing. Cache reads are priced at 10% of input rate.", + "type": "stat", + "transparent": true, + "gridPos": { "x": 5, "y": 1, "w": 5, "h": 5 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [$__range]))", + "queryType": "range" + } + ], + "options": { + "colorMode": "background-gradient", + "graphMode": "area", + "textMode": "auto", + "wideLayout": true, + "justifyMode": "auto", + "orientation": "auto", + "text": { "titleSize": 12, "valueSize": 32 }, + "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "unit": "currencyUSD", + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { "value": null, "color": "#73BF69" }, + { "value": 5, "color": "#FADE2A" }, + { "value": 20, "color": "#FF9830" }, + { "value": 50, "color": "#F2495C" } + ] + } + }, + "overrides": [] + } + }, + { + "id": 3, + "title": "Cache Hit Rate", + "description": "Fraction of read tokens served from cache (cache_read / (input + cache_creation + cache_read)). Higher is better — reduces cost and latency.", + "type": "stat", + "transparent": true, + "gridPos": { "x": 10, "y": 1, "w": 5, "h": 5 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_read_tokens [$__range]))", + "queryType": "range", + "hide": true + }, + { + "refId": "B", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_input_tokens [$__range]))", + "queryType": "range", + "hide": true + }, + { + "refId": "C", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_creation_tokens [$__range]))", + "queryType": "range", + "hide": true + }, + { + "refId": "D", + "datasource": { "type": "__expr__", "uid": "__expr__" }, + "type": "math", + "expression": "($A / ($A + $B + $C)) * 100", + "hide": false + } + ], + "options": { + "colorMode": "background-gradient", + "graphMode": "none", + "textMode": "auto", + "wideLayout": true, + "justifyMode": "auto", + "orientation": "auto", + "text": { "titleSize": 12, "valueSize": 32 }, + "reduceOptions": { "values": false, "calcs": ["lastNotNull"], "fields": "" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "unit": "percent", + "decimals": 1, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { "value": null, "color": "#F2495C" }, + { "value": 40, "color": "#FF9830" }, + { "value": 70, "color": "#73BF69" } + ] + } + }, + "overrides": [] + } + }, + { + "id": 4, + "title": "Sessions", + "description": "Number of completed Claude Code sessions in the selected time range.", + "type": "stat", + "transparent": true, + "gridPos": { "x": 15, "y": 1, "w": 4, "h": 5 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(count_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} [$__range]))", + "queryType": "range" + } + ], + "options": { + "colorMode": "none", + "graphMode": "area", + "textMode": "auto", + "wideLayout": true, + "justifyMode": "auto", + "orientation": "auto", + "text": { "titleSize": 12, "valueSize": 32 }, + "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "fixed", "fixedColor": "#A0A0A0" }, + "unit": "short", + "decimals": 0, + "thresholds": { "mode": "absolute", "steps": [{ "value": null, "color": "#A0A0A0" }] } + }, + "overrides": [] + } + }, + { + "id": 5, + "title": "Avg Cost / Session", + "description": "Average estimated cost per completed session.", + "type": "stat", + "transparent": true, + "gridPos": { "x": 19, "y": 1, "w": 5, "h": 5 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "avg_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [$__range])", + "queryType": "range" + } + ], + "options": { + "colorMode": "background-gradient", + "graphMode": "none", + "textMode": "auto", + "wideLayout": true, + "justifyMode": "auto", + "orientation": "auto", + "text": { "titleSize": 12, "valueSize": 32 }, + "reduceOptions": { "values": false, "calcs": ["mean"], "fields": "" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "unit": "currencyUSD", + "decimals": 3, + "thresholds": { + "mode": "absolute", + "steps": [ + { "value": null, "color": "#73BF69" }, + { "value": 1, "color": "#FADE2A" }, + { "value": 5, "color": "#FF9830" }, + { "value": 15, "color": "#F2495C" } + ] + } + }, + "overrides": [] + } + }, + + { + "type": "row", + "title": "Token Burn — All Types", + "collapsed": false, + "gridPos": { "x": 0, "y": 6, "w": 24, "h": 1 } + }, + { + "id": 6, + "title": "Token Consumption Over Time", + "description": "Stacked view of all four token categories per session window. Cache reads typically dominate — that's a good sign.", + "type": "timeseries", + "transparent": true, + "gridPos": { "x": 0, "y": 7, "w": 24, "h": 10 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "Cache Read", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_read_tokens [$__interval]))", + "legendFormat": "Cache Read", + "queryType": "range" + }, + { + "refId": "Output", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_output_tokens [$__interval]))", + "legendFormat": "Output", + "queryType": "range" + }, + { + "refId": "Cache Create", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_creation_tokens [$__interval]))", + "legendFormat": "Cache Create", + "queryType": "range" + }, + { + "refId": "Input", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_input_tokens [$__interval]))", + "legendFormat": "Input", + "queryType": "range" + } + ], + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": ["sum", "mean", "max"] + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "lineWidth": 2, + "fillOpacity": 20, + "gradientMode": "opacity", + "showPoints": "never", + "spanNulls": false, + "axisBorderShow": false, + "stacking": { "mode": "normal", "group": "A" } + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Cache Read" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }, + { "id": "custom.fillOpacity", "value": 25 } + ] + }, + { + "matcher": { "id": "byName", "options": "Output" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "Cache Create" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "Input" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#6C7280", "mode": "fixed" } }] + } + ] + } + }, + + { + "type": "row", + "title": "Daily Usage by Model", + "collapsed": false, + "gridPos": { "x": 0, "y": 17, "w": 24, "h": 1 } + }, + { + "id": 7, + "title": "Output Tokens per Day — by Model", + "description": "Stacked daily bars showing output token volume per model. Reveals model switching and high-burn days at a glance.", + "type": "timeseries", + "transparent": true, + "gridPos": { "x": 0, "y": 18, "w": 16, "h": 9 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum by (model) (sum_over_time({app=\"claude-token-metrics\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_output_tokens [$__interval]))", + "legendFormat": "{{model}}", + "queryType": "range" + } + ], + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": ["sum", "max"] + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "unit": "short", + "custom": { + "drawStyle": "bars", + "lineWidth": 1, + "fillOpacity": 80, + "gradientMode": "none", + "showPoints": "never", + "spanNulls": false, + "axisBorderShow": false, + "stacking": { "mode": "normal", "group": "A" }, + "barAlignment": 0 + } + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*opus.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#B877D9", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*sonnet.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*haiku.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] + } + ] + } + }, + { + "id": 8, + "title": "Spend by Model", + "description": "Cumulative estimated cost share per model over the selected period.", + "type": "piechart", + "transparent": true, + "gridPos": { "x": 16, "y": 18, "w": 8, "h": 9 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum by (model) (sum_over_time({app=\"claude-token-metrics\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [$__range]))", + "legendFormat": "{{model}}", + "queryType": "range" + } + ], + "options": { + "pieType": "donut", + "displayLabels": ["name", "percent"], + "legend": { "displayMode": "table", "placement": "right", "values": ["value", "percent"] }, + "tooltip": { "mode": "multi" }, + "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "unit": "currencyUSD", + "decimals": 3 + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*opus.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#B877D9", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*sonnet.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*haiku.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] + } + ] + } + }, + + { + "type": "row", + "title": "Effort & Cache Efficiency", + "collapsed": false, + "gridPos": { "x": 0, "y": 27, "w": 24, "h": 1 } + }, + { + "id": 9, + "title": "Cost by Effort Level", + "description": "Standard = default mode. Extended thinking = thinking blocks enabled. Fast = /fast mode.", + "type": "barchart", + "transparent": true, + "gridPos": { "x": 0, "y": 28, "w": 8, "h": 9 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum by (effort) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\"} | json | unwrap cost_usd [$__range]))", + "legendFormat": "{{effort}}", + "queryType": "range" + } + ], + "transformations": [ + { "id": "reduce", "options": { "reducers": ["sum"] } }, + { "id": "sortBy", "options": { "fields": [{ "desc": true, "displayName": "Sum" }] } } + ], + "options": { + "orientation": "horizontal", + "barWidth": 0.7, + "groupWidth": 0.7, + "showValue": "always", + "stacking": "none", + "xTickLabelMaxLength": 24, + "legend": { "displayMode": "hidden" }, + "tooltip": { "mode": "multi" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "unit": "currencyUSD", + "decimals": 3, + "custom": { "fillOpacity": 80, "gradientMode": "none" } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "standard" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "extended_thinking" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#B877D9", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "fast" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] + } + ] + } + }, + { + "id": 10, + "title": "Output Tokens by Effort Level", + "description": "Session count and output token volume per effort mode.", + "type": "barchart", + "transparent": true, + "gridPos": { "x": 8, "y": 28, "w": 8, "h": 9 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum by (effort) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\"} | json | unwrap total_output_tokens [$__range]))", + "legendFormat": "{{effort}}", + "queryType": "range" + } + ], + "transformations": [ + { "id": "reduce", "options": { "reducers": ["sum"] } }, + { "id": "sortBy", "options": { "fields": [{ "desc": true, "displayName": "Sum" }] } } + ], + "options": { + "orientation": "horizontal", + "barWidth": 0.7, + "groupWidth": 0.7, + "showValue": "always", + "stacking": "none", + "xTickLabelMaxLength": 24, + "legend": { "displayMode": "hidden" }, + "tooltip": { "mode": "multi" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "unit": "short", + "decimals": 0, + "custom": { "fillOpacity": 80, "gradientMode": "none" } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "standard" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "extended_thinking" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#B877D9", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "fast" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] + } + ] + } + }, + { + "id": 11, + "title": "Cache Efficiency Over Time", + "description": "Cache hit rate (%) per session window. Sustained high rates mean context is being efficiently reused across turns.", + "type": "timeseries", + "transparent": true, + "gridPos": { "x": 16, "y": 28, "w": 8, "h": 9 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_read_tokens [$__interval]))", + "queryType": "range", + "hide": true + }, + { + "refId": "B", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_input_tokens [$__interval]))", + "queryType": "range", + "hide": true + }, + { + "refId": "C", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_creation_tokens [$__interval]))", + "queryType": "range", + "hide": true + }, + { + "refId": "CacheRate", + "datasource": { "type": "__expr__", "uid": "__expr__" }, + "type": "math", + "expression": "($A / ($A + $B + $C)) * 100", + "legendFormat": "Cache Hit %" + } + ], + "options": { + "legend": { "displayMode": "hidden" }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "unit": "percent", + "min": 0, + "max": 100, + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "lineWidth": 2, + "fillOpacity": 20, + "gradientMode": "scheme", + "showPoints": "always", + "pointSize": 5, + "spanNulls": false, + "axisBorderShow": false, + "thresholdsStyle": { "mode": "area" } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "value": null, "color": "#F2495C" }, + { "value": 40, "color": "#FF9830" }, + { "value": 70, "color": "#73BF69" } + ] + } + }, + "overrides": [] + } + }, + + { + "type": "row", + "title": "Session Leaderboard", + "collapsed": false, + "gridPos": { "x": 0, "y": 37, "w": 24, "h": 1 } + }, + { + "id": 12, + "title": "Top Sessions by Cost", + "description": "Most expensive sessions in the selected period. Bar length = estimated USD spend. Identify long/costly outlier sessions here.", + "type": "barchart", + "transparent": true, + "gridPos": { "x": 0, "y": 38, "w": 14, "h": 12 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "topk(15, sum by (session_id) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json session_id=\"session_id\" | unwrap cost_usd [$__range])))", + "legendFormat": "{{session_id}}", + "queryType": "range" + } + ], + "transformations": [ + { "id": "reduce", "options": { "reducers": ["sum"] } }, + { "id": "sortBy", "options": { "fields": [{ "desc": true, "displayName": "Sum" }] } } + ], + "options": { + "orientation": "horizontal", + "barWidth": 0.7, + "groupWidth": 0.7, + "showValue": "always", + "stacking": "none", + "xTickLabelMaxLength": 28, + "legend": { "displayMode": "hidden" }, + "tooltip": { "mode": "single" } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "unit": "currencyUSD", + "decimals": 3, + "custom": { "fillOpacity": 85, "gradientMode": "none" } + }, + "overrides": [] + } + }, + { + "id": 13, + "title": "Recent Session Log", + "description": "Raw session records. Each line = one completed Claude Code session. Includes model, effort, cost, token counts, and turns.", + "type": "logs", + "transparent": true, + "gridPos": { "x": 14, "y": 38, "w": 10, "h": 12 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "{app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | line_format \"{{.model}} | {{.effort}} | ${{.cost_usd}} | out={{.total_output_tokens}} | turns={{.assistant_turns}} | cache={{.total_cache_read_tokens}} | {{.session_id}}\"", + "queryType": "range" + } + ], + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + } + }, + + { + "type": "row", + "title": "Cost Trend", + "collapsed": false, + "gridPos": { "x": 0, "y": 50, "w": 24, "h": 1 } + }, + { + "id": 14, + "title": "Daily Spend Trend", + "description": "Estimated USD cost per day. Spot cost spikes and track efficiency gains over time.", + "type": "timeseries", + "transparent": true, + "gridPos": { "x": 0, "y": 51, "w": 16, "h": 8 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [1d]))", + "legendFormat": "Daily Cost", + "queryType": "range" + } + ], + "options": { + "legend": { "displayMode": "hidden" }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "fixed", "fixedColor": "#FADE2A" }, + "unit": "currencyUSD", + "decimals": 2, + "custom": { + "drawStyle": "bars", + "lineWidth": 1, + "fillOpacity": 70, + "gradientMode": "opacity", + "showPoints": "never", + "spanNulls": false, + "axisBorderShow": false, + "barAlignment": 0 + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "value": null, "color": "#73BF69" }, + { "value": 5, "color": "#FF9830" }, + { "value": 15, "color": "#F2495C" } + ] + } + }, + "overrides": [] + } + }, + { + "id": 15, + "title": "Assistant Turns per Session", + "description": "Distribution of session depth (number of back-and-forth turns). Long sessions = more complex work or exploration.", + "type": "timeseries", + "transparent": true, + "gridPos": { "x": 16, "y": 51, "w": 8, "h": 8 }, + "datasource": { "type": "loki", "uid": "loki_local" }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "avg_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap assistant_turns [$__interval])", + "legendFormat": "Avg Turns", + "queryType": "range" + }, + { + "refId": "B", + "datasource": { "type": "loki", "uid": "loki_local" }, + "expr": "max_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap assistant_turns [$__interval])", + "legendFormat": "Max Turns", + "queryType": "range" + } + ], + "options": { + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "unit": "short", + "decimals": 0, + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "lineWidth": 2, + "fillOpacity": 10, + "gradientMode": "none", + "showPoints": "always", + "pointSize": 5, + "spanNulls": false, + "axisBorderShow": false + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Max Turns" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }, + { "id": "custom.lineWidth", "value": 1 }, + { "id": "custom.lineStyle", "value": { "dash": [4, 4], "fill": "dash" } } + ] + }, + { + "matcher": { "id": "byName", "options": "Avg Turns" }, + "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] + } + ] + } + } + ] +} diff --git a/scripts/hooks/loki-log.js b/scripts/hooks/loki-log.js index a27259a..5adc66e 100644 --- a/scripts/hooks/loki-log.js +++ b/scripts/hooks/loki-log.js @@ -1,8 +1,16 @@ -// Claude Code hook -> Loki push (app=claude-dev-logging) +// Claude Code hook -> Loki push // Self-contained: loads .env, reads stdin, pushes to Loki. No shell wrapper needed. // Enrichments: tool duration, payload sizes, retry detection, error classification, // agent topology, session lifecycle, token sidecar. // Usage: node loki-log.js +// +// DATA ARCHITECTURE: +// app="claude-dev-logging" — ALL hook events: tool calls, lifecycle, agents, user prompts, +// intermediate token snapshots (component="tokens", stop hook) +// app="claude-token-metrics" — ONE entry per completed session: token totals, cost_usd, effort +// Join key: session_id (present in both streams) +// On-disk: logs/claude-session-metrics.jsonl — local backup only (not ingested by Alloy). +// Includes session_duration_ms + compaction_count for local debugging. const http = require('http'); const https = require('https'); @@ -128,8 +136,11 @@ function cleanStaleFiles() { const fp = path.join(TIMING_DIR, f); try { const age = now - fs.statSync(fp).mtimeMs; - // Retry markers expire at RETRY_WINDOW_MS; everything else at STALE_MS - const threshold = f.startsWith('retry-') ? RETRY_WINDOW_MS : STALE_MS; + // Token tracking files live for the whole session (24h max) + // Retry markers expire quickly; everything else at STALE_MS + const threshold = (f.startsWith('token-offset-') || f.startsWith('token-totals-')) + ? 24 * 60 * 60 * 1000 + : f.startsWith('retry-') ? RETRY_WINDOW_MS : STALE_MS; if (age > threshold) fs.unlinkSync(fp); } catch {} } @@ -186,7 +197,105 @@ function classifyError(toolResponse) { } catch { return 'unknown'; } } -// --- Session token extraction from transcript --- +// --- Model pricing (per 1M tokens) --- +const MODEL_PRICING = { + 'claude-opus-4': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.50 }, + 'claude-sonnet-4': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.30 }, + 'claude-haiku-4': { input: 0.80, output: 4, cacheWrite: 1.00, cacheRead: 0.08 }, +}; + +function getPricing(model) { + if (!model) return MODEL_PRICING['claude-opus-4']; + const m = model.toLowerCase(); + if (m.includes('haiku')) return MODEL_PRICING['claude-haiku-4']; + if (m.includes('sonnet')) return MODEL_PRICING['claude-sonnet-4']; + return MODEL_PRICING['claude-opus-4']; +} + +function computeCostUsd(tokenData) { + try { + const p = getPricing(tokenData.model); + const M = 1_000_000; + return Math.round( + ((tokenData.total_input_tokens || 0) / M * p.input + + (tokenData.total_output_tokens || 0) / M * p.output + + (tokenData.total_cache_creation_tokens || 0) / M * p.cacheWrite + + (tokenData.total_cache_read_tokens || 0) / M * p.cacheRead) * 100000 + ) / 100000; // 5 decimal precision + } catch { return undefined; } +} + +// --- Incremental token extraction from transcript --- +// Reads only new bytes since last offset, accumulates totals in timing files. +// Used by the `stop` hook for intermediate snapshots — O(new bytes) not O(transcript size). +function extractTokensIncremental(transcriptPath, sessionId) { + try { + const offsetKey = 'token-offset-' + sessionId; + const totalsKey = 'token-totals-' + sessionId; + const prev = readTimingFile(offsetKey, false) || { offset: 0 }; + const accum = readTimingFile(totalsKey, false) || { + input: 0, output: 0, cacheCreate: 0, cacheRead: 0, + turns: 0, tools: 0, model: undefined, + }; + + const stat = fs.statSync(transcriptPath); + if (stat.size <= prev.offset) { + return formatTokenResult(accum); + } + + const fd = fs.openSync(transcriptPath, 'r'); + const buf = Buffer.alloc(stat.size - prev.offset); + fs.readSync(fd, buf, 0, buf.length, prev.offset); + fs.closeSync(fd); + + const chunk = buf.toString('utf8'); + const lines = chunk.split('\n').filter(Boolean); + + for (const line of lines) { + try { + const obj = JSON.parse(line); + if (obj.type === 'assistant' && obj.message && obj.message.usage) { + const u = obj.message.usage; + accum.input += u.input_tokens || 0; + accum.output += u.output_tokens || 0; + accum.cacheCreate += u.cache_creation_input_tokens || 0; + accum.cacheRead += u.cache_read_input_tokens || 0; + accum.turns++; + if (!accum.model && obj.message.model) accum.model = obj.message.model; + } + if (obj.type === 'tool_use' || (obj.type === 'progress' && obj.data && obj.data.type === 'tool_use')) { + accum.tools++; + } + } catch {} + } + + writeTimingFile(offsetKey, { offset: stat.size }); + writeTimingFile(totalsKey, accum); + + return formatTokenResult(accum); + } catch { return null; } +} + +function formatTokenResult(accum) { + return { + total_input_tokens: accum.input, + total_output_tokens: accum.output, + total_cache_creation_tokens: accum.cacheCreate, + total_cache_read_tokens: accum.cacheRead, + total_tokens: accum.input + accum.output, + assistant_turns: accum.turns, + tool_use_count: accum.tools, + model: accum.model || undefined, + }; +} + +function cleanupTokenFiles(sessionId) { + try { fs.unlinkSync(path.join(TIMING_DIR, 'token-offset-' + sessionId + '.json')); } catch {} + try { fs.unlinkSync(path.join(TIMING_DIR, 'token-totals-' + sessionId + '.json')); } catch {} +} + +// --- Full session token extraction (session-end only) --- +// Single file read; includes effort detection. Authoritative — used for the permanent record. function extractSessionTokens(transcriptPath) { try { const text = fs.readFileSync(transcriptPath, 'utf8'); @@ -212,18 +321,67 @@ function extractSessionTokens(transcriptPath) { } catch {} } + // Detect effort (reuses already-read text — no second file read) + let effort = 'standard'; + if (/"type"\s*:\s*"thinking"/.test(text)) effort = 'extended_thinking'; + else if (model && model.toLowerCase().includes('fast')) effort = 'fast'; + return { total_input_tokens: totalInput, total_output_tokens: totalOutput, total_cache_creation_tokens: totalCacheCreate, total_cache_read_tokens: totalCacheRead, + total_tokens: totalInput + totalOutput, assistant_turns: assistantTurns, tool_use_count: toolUseCalls, model: model || undefined, + effort, }; } catch { return null; } } +// --- Push token usage to Loki --- +// isFinal=false → claude-dev-logging (component=tokens), intermediate snapshot, no cost/effort +// isFinal=true → claude-token-metrics, one per session, includes cost_usd + labeled by effort +function pushTokenUsage(sessionId, project, tokenData, isFinal) { + if (isFinal) { + const logLine = scrubSecrets(JSON.stringify({ + event: 'claude_session_token_summary', + session_id: sessionId, + project, + machine, + env: envLabel, + is_final: true, + timestamp: new Date().toISOString(), + ...tokenData, + })); + pushToLoki( + { + app: 'claude-token-metrics', + env: envLabel, + model: tokenData.model || 'unknown', + project: project || 'unknown', + effort: tokenData.effort || 'standard', + }, + logLine + ); + } else { + const logLine = scrubSecrets(JSON.stringify({ + event: 'claude_token_usage', + session_id: sessionId, + project, + machine, + env: envLabel, + is_final: false, + ...tokenData, + })); + pushToLoki( + { app: 'claude-dev-logging', env: envLabel, component: 'tokens', level: 'INFO' }, + logLine + ); + } +} + // --- Build enriched log line --- function buildEnrichedLogLine(hp, hType, enrichments, base) { return JSON.stringify({ @@ -260,7 +418,8 @@ function pushToLoki(stream, logLine) { req.end(); } -// --- Write session metrics sidecar --- +// --- Write session metrics sidecar (on-disk backup, not ingested into Loki) --- +// Includes session_duration_ms + compaction_count for local debugging context. function writeSessionMetricsSidecar(hp, sessionId, project, sessionEnrichments, tokenData) { try { const metricsDir = path.join(hp.cwd || process.cwd(), 'logs'); @@ -336,7 +495,6 @@ process.stdin.on('end', () => { else if (hookType === 'subagent-start') { const agentId = hp.agent_id || toolUseId || 'unknown'; writeTimingFile('agent-' + agentId, { start: Date.now(), session_id: sessionId }); - // Count open agent files for depth try { const agentFiles = fs.readdirSync(TIMING_DIR).filter(f => f.startsWith('agent-') && f.endsWith('.json')); @@ -382,28 +540,38 @@ process.stdin.on('end', () => { enrichments.compaction_count = newCount; } - // --- Build and push --- + // --- Main push to claude-dev-logging (all hook types) --- const stream = { app: 'claude-dev-logging', env: envLabel, component, level }; const logLine = scrubSecrets(buildEnrichedLogLine(hp, hookType, enrichments, base)); pushToLoki(stream, logLine); - // --- Session token sidecar (SessionEnd only) --- + // --- Stop hook: intermediate token snapshot → claude-dev-logging component=tokens --- + if (hookType === 'stop' && hp.transcript_path) { + const tokenData = extractTokensIncremental(hp.transcript_path, sessionId); + if (tokenData) pushTokenUsage(sessionId, project, tokenData, false); + } + + // --- Session-end: full token summary → claude-token-metrics + on-disk sidecar --- if (hookType === 'session-end' && hp.transcript_path) { const tokenData = extractSessionTokens(hp.transcript_path); if (tokenData) { + tokenData.cost_usd = computeCostUsd(tokenData); + pushTokenUsage(sessionId, project, tokenData, true); writeSessionMetricsSidecar(hp, sessionId, project, enrichments, tokenData); } else { - // Log extraction failure to Loki - const errLine = JSON.stringify({ - event: 'claude_session_metrics_error', - session_id: sessionId, - project, - machine, - env: envLabel, - error: 'transcript_parse_failed', - transcript_path: compress(hp.transcript_path), - }); - pushToLoki({ app: 'claude-dev-logging', env: envLabel, component: 'lifecycle', level: 'WARN' }, scrubSecrets(errLine)); + pushToLoki( + { app: 'claude-dev-logging', env: envLabel, component: 'lifecycle', level: 'WARN' }, + scrubSecrets(JSON.stringify({ + event: 'claude_session_metrics_error', + session_id: sessionId, + project, + machine, + env: envLabel, + error: 'transcript_parse_failed', + transcript_path: compress(hp.transcript_path), + })) + ); } + cleanupTokenFiles(sessionId); } }); diff --git a/src/SimSteward.Plugin/LokiPushClient.cs b/src/SimSteward.Plugin/LokiPushClient.cs new file mode 100644 index 0000000..67e44b3 --- /dev/null +++ b/src/SimSteward.Plugin/LokiPushClient.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SimSteward.Plugin +{ + /// + /// Lightweight fire-and-forget Loki push client used by . + /// Replaces Alloy file-tailing for the simsteward stream. + /// Only active when SIMSTEWARD_LOKI_URL is set; silently no-ops otherwise. + /// + public static class LokiPushClient + { + private static readonly HttpClient _client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(3), + }; + + /// + /// Pushes a batch of log entries to Loki, grouped by stream labels. + /// Fire-and-forget — never throws. Returns immediately. + /// + public static void Push(string lokiUrl, string appLabel, string envLabel, IEnumerable entries) + { + if (string.IsNullOrEmpty(lokiUrl)) return; + var list = entries?.ToList(); + if (list == null || list.Count == 0) return; + + // Capture for the async task — do not capture locals that may change + Task.Run(() => PushInternalAsync(lokiUrl, appLabel, envLabel, list)); + } + + private static async Task PushInternalAsync( + string lokiUrl, string appLabel, string envLabel, List entries) + { + try + { + // Group entries by their label combination to minimise stream count per push. + // Labels match what Alloy previously extracted: level, component, event, domain. + var groups = entries + .GroupBy(e => ( + level: e.Level ?? "INFO", + component: e.Component ?? "", + evt: e.Event ?? "", + domain: e.Domain ?? "" + )) + .ToList(); + + var streams = new JArray(); + + foreach (var g in groups) + { + var streamLabels = new JObject + { + ["app"] = appLabel, + ["env"] = envLabel, + ["level"] = g.Key.level, + }; + if (!string.IsNullOrEmpty(g.Key.component)) streamLabels["component"] = g.Key.component; + if (!string.IsNullOrEmpty(g.Key.evt)) streamLabels["event"] = g.Key.evt; + if (!string.IsNullOrEmpty(g.Key.domain)) streamLabels["domain"] = g.Key.domain; + + var values = new JArray(); + foreach (var entry in g) + { + // Loki timestamp in nanoseconds + long tsNs; + if (!string.IsNullOrEmpty(entry.Timestamp) && + DateTimeOffset.TryParse(entry.Timestamp, out var dto)) + tsNs = dto.ToUnixTimeMilliseconds() * 1_000_000L; + else + tsNs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L; + + values.Add(new JArray(tsNs.ToString(), JsonConvert.SerializeObject(entry))); + } + + streams.Add(new JObject + { + ["stream"] = streamLabels, + ["values"] = values, + }); + } + + var body = new JObject { ["streams"] = streams }.ToString(Formatting.None); + var url = lokiUrl.TrimEnd('/') + "/loki/api/v1/push"; + + using (var content = new StringContent(body, Encoding.UTF8, "application/json")) + { + await _client.PostAsync(url, content).ConfigureAwait(false); + } + } + catch + { + // Fire-and-forget: swallow all errors — plugin must never fail due to observability + } + } + } +} diff --git a/src/SimSteward.Plugin/PluginLogger.cs b/src/SimSteward.Plugin/PluginLogger.cs index b15b411..a7fbad4 100644 --- a/src/SimSteward.Plugin/PluginLogger.cs +++ b/src/SimSteward.Plugin/PluginLogger.cs @@ -59,9 +59,12 @@ public class PluginLogger private readonly string _logPath; private readonly string _jsonLogPath; + private readonly string _lokiUrl; + private readonly string _lokiEnv; private readonly object _lock = new object(); private readonly Queue _ring = new Queue(); private readonly Queue<(string json, string text)> _writeBuffer = new Queue<(string, string)>(); + private readonly Queue _lokiBuffer = new Queue(); private System.Threading.Timer _flushTimer; private Func<(string sessionId, string sessionSeq, int replayFrame)> _getSpine; @@ -73,6 +76,8 @@ public PluginLogger(string basePath, bool isDebugMode = false) { _logPath = string.IsNullOrEmpty(basePath) ? null : Path.Combine(basePath, "plugin.log"); _jsonLogPath = string.IsNullOrEmpty(basePath) ? null : Path.Combine(basePath, "plugin-structured.jsonl"); + _lokiUrl = Environment.GetEnvironmentVariable("SIMSTEWARD_LOKI_URL"); + _lokiEnv = Environment.GetEnvironmentVariable("SIMSTEWARD_LOG_ENV") ?? "local"; IsDebugMode = isDebugMode; if (!string.IsNullOrEmpty(basePath)) _flushTimer = new System.Threading.Timer(_ => Flush(), null, FlushIntervalMs, FlushIntervalMs); @@ -159,6 +164,7 @@ private void Write(LogEntry entry) JsonConvert.SerializeObject(entry) + "\n", $"{entry.Timestamp} [{entry.Level}] {entry.Message}{Environment.NewLine}" )); + _lokiBuffer.Enqueue(entry); } try { LogWritten?.Invoke(entry); } catch { } @@ -167,11 +173,14 @@ private void Write(LogEntry entry) private void Flush() { (string json, string text)[] batch; + LogEntry[] lokiBatch; lock (_lock) { if (_writeBuffer.Count == 0) return; batch = _writeBuffer.ToArray(); _writeBuffer.Clear(); + lokiBatch = _lokiBuffer.ToArray(); + _lokiBuffer.Clear(); } var jsonSb = new System.Text.StringBuilder(); @@ -189,6 +198,10 @@ private void Flush() try { AppendToFile(_logPath, textSb.ToString(), () => RotateLogs()); } catch (Exception ex) { try { WriteError?.Invoke("log_write_error", ex); } catch { } } } + + // Fire-and-forget push to Loki — replaces Alloy file-tailing + if (!string.IsNullOrEmpty(_lokiUrl) && lokiBatch.Length > 0) + LokiPushClient.Push(_lokiUrl, "simsteward", _lokiEnv, lokiBatch); } private void AppendToFile(string path, string content, Action rotate) From 34cf1ef6e3e76700f2f077ae6d84584ea17069f5 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Thu, 26 Mar 2026 00:42:30 -0400 Subject: [PATCH 4/7] feat: per-turn token logging at every stop hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractTokensIncremental now returns { turn, total } — the delta for the current response and the running session total separately. stop hook pushes claude_turn_tokens to claude-dev-logging/tokens with: - turn_input/output/cache_creation/cache_read/total_tokens (this call) - turn_tool_use_count (tools called this turn) - total_* running totals for trend lines - session_id for correlation with lifecycle events session-end continues to push the authoritative summary to app="claude-token-metrics" with cost_usd + effort. Co-Authored-By: Claude Sonnet 4.6 --- scripts/hooks/loki-log.js | 80 +++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/scripts/hooks/loki-log.js b/scripts/hooks/loki-log.js index 5adc66e..369bc41 100644 --- a/scripts/hooks/loki-log.js +++ b/scripts/hooks/loki-log.js @@ -227,7 +227,8 @@ function computeCostUsd(tokenData) { // --- Incremental token extraction from transcript --- // Reads only new bytes since last offset, accumulates totals in timing files. -// Used by the `stop` hook for intermediate snapshots — O(new bytes) not O(transcript size). +// Returns { turn, total } where `turn` is the delta for THIS stop event and +// `total` is the running session total. Used by the `stop` hook for per-call logging. function extractTokensIncremental(transcriptPath, sessionId) { try { const offsetKey = 'token-offset-' + sessionId; @@ -240,7 +241,7 @@ function extractTokensIncremental(transcriptPath, sessionId) { const stat = fs.statSync(transcriptPath); if (stat.size <= prev.offset) { - return formatTokenResult(accum); + return { turn: null, total: formatTokenResult(accum) }; } const fd = fs.openSync(transcriptPath, 'r'); @@ -251,19 +252,28 @@ function extractTokensIncremental(transcriptPath, sessionId) { const chunk = buf.toString('utf8'); const lines = chunk.split('\n').filter(Boolean); + // Track this turn's delta separately from the running total + const delta = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, turns: 0, tools: 0 }; + for (const line of lines) { try { const obj = JSON.parse(line); if (obj.type === 'assistant' && obj.message && obj.message.usage) { const u = obj.message.usage; - accum.input += u.input_tokens || 0; - accum.output += u.output_tokens || 0; + delta.input += u.input_tokens || 0; + delta.output += u.output_tokens || 0; + delta.cacheCreate += u.cache_creation_input_tokens || 0; + delta.cacheRead += u.cache_read_input_tokens || 0; + delta.turns++; + accum.input += u.input_tokens || 0; + accum.output += u.output_tokens || 0; accum.cacheCreate += u.cache_creation_input_tokens || 0; - accum.cacheRead += u.cache_read_input_tokens || 0; + accum.cacheRead += u.cache_read_input_tokens || 0; accum.turns++; if (!accum.model && obj.message.model) accum.model = obj.message.model; } if (obj.type === 'tool_use' || (obj.type === 'progress' && obj.data && obj.data.type === 'tool_use')) { + delta.tools++; accum.tools++; } } catch {} @@ -272,20 +282,31 @@ function extractTokensIncremental(transcriptPath, sessionId) { writeTimingFile(offsetKey, { offset: stat.size }); writeTimingFile(totalsKey, accum); - return formatTokenResult(accum); + return { + turn: { + input_tokens: delta.input, + output_tokens: delta.output, + cache_creation_tokens: delta.cacheCreate, + cache_read_tokens: delta.cacheRead, + total_tokens: delta.input + delta.output, + assistant_turns: delta.turns, + tool_use_count: delta.tools, + }, + total: formatTokenResult(accum), + }; } catch { return null; } } function formatTokenResult(accum) { return { - total_input_tokens: accum.input, - total_output_tokens: accum.output, + total_input_tokens: accum.input, + total_output_tokens: accum.output, total_cache_creation_tokens: accum.cacheCreate, - total_cache_read_tokens: accum.cacheRead, - total_tokens: accum.input + accum.output, - assistant_turns: accum.turns, - tool_use_count: accum.tools, - model: accum.model || undefined, + total_cache_read_tokens: accum.cacheRead, + total_tokens: accum.input + accum.output, + assistant_turns: accum.turns, + tool_use_count: accum.tools, + model: accum.model || undefined, }; } @@ -545,10 +566,37 @@ process.stdin.on('end', () => { const logLine = scrubSecrets(buildEnrichedLogLine(hp, hookType, enrichments, base)); pushToLoki(stream, logLine); - // --- Stop hook: intermediate token snapshot → claude-dev-logging component=tokens --- + // --- Stop hook: per-turn token delta → claude-dev-logging component=tokens --- + // Fires after every Claude response. Pushes this turn's token burn + running total. if (hookType === 'stop' && hp.transcript_path) { - const tokenData = extractTokensIncremental(hp.transcript_path, sessionId); - if (tokenData) pushTokenUsage(sessionId, project, tokenData, false); + const result = extractTokensIncremental(hp.transcript_path, sessionId); + if (result) { + pushToLoki( + { app: 'claude-dev-logging', env: envLabel, component: 'tokens', level: 'INFO' }, + scrubSecrets(JSON.stringify({ + event: 'claude_turn_tokens', + session_id: sessionId, + project, + machine, + env: envLabel, + model: result.total.model || undefined, + // This turn's delta — what was just burned + turn_input_tokens: result.turn ? result.turn.input_tokens : 0, + turn_output_tokens: result.turn ? result.turn.output_tokens : 0, + turn_cache_creation_tokens: result.turn ? result.turn.cache_creation_tokens : 0, + turn_cache_read_tokens: result.turn ? result.turn.cache_read_tokens : 0, + turn_total_tokens: result.turn ? result.turn.total_tokens : 0, + turn_tool_use_count: result.turn ? result.turn.tool_use_count : 0, + // Running session totals (for trend lines) + total_input_tokens: result.total.total_input_tokens, + total_output_tokens: result.total.total_output_tokens, + total_cache_creation_tokens: result.total.total_cache_creation_tokens, + total_cache_read_tokens: result.total.total_cache_read_tokens, + total_tokens: result.total.total_tokens, + assistant_turns: result.total.assistant_turns, + })) + ); + } } // --- Session-end: full token summary → claude-token-metrics + on-disk sidecar --- From 986f7328ab78fadb2fbaba058684ef3ceaffd872 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Thu, 26 Mar 2026 00:45:59 -0400 Subject: [PATCH 5/7] feat: effort levels low/med/high/max, thinking as separate field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - effort: low | med | high | max (maps Claude Code effortLevel) Detection order: transcript metadata → ~/.claude/settings.json fallback Default: high (Claude Code default) - thinking: boolean — true when transcript contains thinking blocks Separate from effort so both dimensions are independently queryable Co-Authored-By: Claude Sonnet 4.6 --- scripts/hooks/loki-log.js | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/loki-log.js b/scripts/hooks/loki-log.js index 369bc41..7bebfff 100644 --- a/scripts/hooks/loki-log.js +++ b/scripts/hooks/loki-log.js @@ -342,10 +342,30 @@ function extractSessionTokens(transcriptPath) { } catch {} } - // Detect effort (reuses already-read text — no second file read) - let effort = 'standard'; - if (/"type"\s*:\s*"thinking"/.test(text)) effort = 'extended_thinking'; - else if (model && model.toLowerCase().includes('fast')) effort = 'fast'; + // Detect thinking (separate from effort — presence of thinking blocks in transcript) + const thinking = /"type"\s*:\s*"thinking"/.test(text); + + // Detect effort level: check transcript metadata first, fall back to settings.json + const EFFORT_MAP = { low: 'low', medium: 'med', med: 'med', high: 'high', max: 'max' }; + let effort = 'high'; // Claude Code default + for (const line of lines) { + try { + const obj = JSON.parse(line); + if (obj.effort) { + const mapped = EFFORT_MAP[obj.effort.toLowerCase()]; + if (mapped) { effort = mapped; break; } + } + } catch {} + } + if (effort === 'high') { + // Fall back to settings.json effortLevel + try { + const settings = JSON.parse(fs.readFileSync( + path.join(os.homedir(), '.claude', 'settings.json'), 'utf8')); + const mapped = EFFORT_MAP[(settings.effortLevel || '').toLowerCase()]; + if (mapped) effort = mapped; + } catch {} + } return { total_input_tokens: totalInput, @@ -357,6 +377,7 @@ function extractSessionTokens(transcriptPath) { tool_use_count: toolUseCalls, model: model || undefined, effort, + thinking, }; } catch { return null; } } From f253ea6f6f0b66433d02f119bbd3241ef10b5543 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Thu, 26 Mar 2026 01:07:56 -0400 Subject: [PATCH 6/7] Add session_id dropdown to Claude token usage dashboard Adds a Session variable (single-select, All = no filter) that filters every panel in the dashboard by session_id, enabling drill-down from the cost summary into a specific session's metrics. Co-Authored-By: Claude Sonnet 4.6 --- .../dashboards/claude-token-usage.json | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/observability/local/grafana/provisioning/dashboards/claude-token-usage.json b/observability/local/grafana/provisioning/dashboards/claude-token-usage.json index 585227d..fe8f1ad 100644 --- a/observability/local/grafana/provisioning/dashboards/claude-token-usage.json +++ b/observability/local/grafana/provisioning/dashboards/claude-token-usage.json @@ -56,6 +56,20 @@ "allValue": ".*", "current": { "text": "All", "value": "$__all" }, "sort": 1 + }, + { + "name": "session_id", + "label": "Session", + "type": "query", + "datasource": { "type": "loki", "uid": "loki_local" }, + "query": "{app=\"claude-token-metrics\"} | json", + "regex": "\"session_id\":\"([^\"]+)\"", + "refresh": 2, + "includeAll": true, + "multi": false, + "allValue": ".*", + "current": { "text": "All", "value": "$__all" }, + "sort": 0 } ] }, @@ -78,7 +92,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_output_tokens [$__range]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_output_tokens [$__range]))", "queryType": "range" } ], @@ -114,7 +128,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [$__range]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap cost_usd [$__range]))", "queryType": "range" } ], @@ -158,21 +172,21 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_read_tokens [$__range]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_cache_read_tokens [$__range]))", "queryType": "range", "hide": true }, { "refId": "B", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_input_tokens [$__range]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_input_tokens [$__range]))", "queryType": "range", "hide": true }, { "refId": "C", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_creation_tokens [$__range]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_cache_creation_tokens [$__range]))", "queryType": "range", "hide": true }, @@ -225,7 +239,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(count_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} [$__range]))", + "expr": "sum(count_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" [$__range]))", "queryType": "range" } ], @@ -261,7 +275,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "avg_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [$__range])", + "expr": "avg_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap cost_usd [$__range])", "queryType": "range" } ], @@ -312,28 +326,28 @@ { "refId": "Cache Read", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_read_tokens [$__interval]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_cache_read_tokens [$__interval]))", "legendFormat": "Cache Read", "queryType": "range" }, { "refId": "Output", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_output_tokens [$__interval]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_output_tokens [$__interval]))", "legendFormat": "Output", "queryType": "range" }, { "refId": "Cache Create", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_creation_tokens [$__interval]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_cache_creation_tokens [$__interval]))", "legendFormat": "Cache Create", "queryType": "range" }, { "refId": "Input", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_input_tokens [$__interval]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_input_tokens [$__interval]))", "legendFormat": "Input", "queryType": "range" } @@ -404,7 +418,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (model) (sum_over_time({app=\"claude-token-metrics\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_output_tokens [$__interval]))", + "expr": "sum by (model) (sum_over_time({app=\"claude-token-metrics\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_output_tokens [$__interval]))", "legendFormat": "{{model}}", "queryType": "range" } @@ -461,7 +475,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (model) (sum_over_time({app=\"claude-token-metrics\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [$__range]))", + "expr": "sum by (model) (sum_over_time({app=\"claude-token-metrics\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap cost_usd [$__range]))", "legendFormat": "{{model}}", "queryType": "range" } @@ -514,7 +528,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (effort) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\"} | json | unwrap cost_usd [$__range]))", + "expr": "sum by (effort) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\"} | json | session_id=~\"$session_id\" | unwrap cost_usd [$__range]))", "legendFormat": "{{effort}}", "queryType": "range" } @@ -568,7 +582,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (effort) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\"} | json | unwrap total_output_tokens [$__range]))", + "expr": "sum by (effort) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\"} | json | session_id=~\"$session_id\" | unwrap total_output_tokens [$__range]))", "legendFormat": "{{effort}}", "queryType": "range" } @@ -622,21 +636,21 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_read_tokens [$__interval]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_cache_read_tokens [$__interval]))", "queryType": "range", "hide": true }, { "refId": "B", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_input_tokens [$__interval]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_input_tokens [$__interval]))", "queryType": "range", "hide": true }, { "refId": "C", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap total_cache_creation_tokens [$__interval]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap total_cache_creation_tokens [$__interval]))", "queryType": "range", "hide": true }, @@ -701,7 +715,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "topk(15, sum by (session_id) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json session_id=\"session_id\" | unwrap cost_usd [$__range])))", + "expr": "topk(15, sum by (session_id) (sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json session_id=\"session_id\" | session_id=~\"$session_id\" | unwrap cost_usd [$__range])))", "legendFormat": "{{session_id}}", "queryType": "range" } @@ -744,7 +758,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "{app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | line_format \"{{.model}} | {{.effort}} | ${{.cost_usd}} | out={{.total_output_tokens}} | turns={{.assistant_turns}} | cache={{.total_cache_read_tokens}} | {{.session_id}}\"", + "expr": "{app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | line_format \"{{.model}} | {{.effort}} | ${{.cost_usd}} | out={{.total_output_tokens}} | turns={{.assistant_turns}} | cache={{.total_cache_read_tokens}} | {{.session_id}}\"", "queryType": "range" } ], @@ -778,7 +792,7 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap cost_usd [1d]))", + "expr": "sum(sum_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap cost_usd [1d]))", "legendFormat": "Daily Cost", "queryType": "range" } @@ -826,14 +840,14 @@ { "refId": "A", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "avg_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap assistant_turns [$__interval])", + "expr": "avg_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap assistant_turns [$__interval])", "legendFormat": "Avg Turns", "queryType": "range" }, { "refId": "B", "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "max_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | unwrap assistant_turns [$__interval])", + "expr": "max_over_time({app=\"claude-token-metrics\",model=~\"$model\",project=~\"$project\",effort=~\"$effort\"} | json | session_id=~\"$session_id\" | unwrap assistant_turns [$__interval])", "legendFormat": "Max Turns", "queryType": "range" } From 8a62599b3ad8aea55dfbb737eb5c60effc843c92 Mon Sep 17 00:00:00 2001 From: win gutmann Date: Thu, 26 Mar 2026 01:26:53 -0400 Subject: [PATCH 7/7] feat: redesign claude-code-overview dashboard for short time ranges Complete rewrite of the Grafana dashboard to fix visual rendering at 5m/15m/30m time ranges. Key changes: - Switch timeseries panels from count_over_time to rate()*60 so values normalize per-minute regardless of bucket size - Remove interval floor so Grafana uses natural fine-grained steps (avoids cliff edges from coarse 1m buckets at short ranges) - Switch drawStyle from bars to smooth area lines (spanNulls: true + lineInterpolation: smooth) so bursty activity reads as curves - Fix all bar/table panels (Hook Type, Top Tools, Agent Events, Cross-Session) to use table type with options.sortBy for reliable descending sort in Grafana 11 - Add percentage display on Hook Type and Top Tools via binary division against total count - Replace Agent Lifecycle Timeline (broken state-timeline) with Agent Events sorted table - Remove Log Stream row entirely (visuals-only dashboard) - Fix JSON label explosion by using selective field extraction: | json hook_type, tool_name, session_id instead of | json Co-Authored-By: Claude Sonnet 4.6 --- .../dashboards/claude-code-overview.json | 1272 ++++++++++++++--- 1 file changed, 1037 insertions(+), 235 deletions(-) diff --git a/observability/local/grafana/provisioning/dashboards/claude-code-overview.json b/observability/local/grafana/provisioning/dashboards/claude-code-overview.json index b1bcc40..de6c222 100644 --- a/observability/local/grafana/provisioning/dashboards/claude-code-overview.json +++ b/observability/local/grafana/provisioning/dashboards/claude-code-overview.json @@ -3,11 +3,17 @@ "uid": "claude-code-overview", "title": "Claude Code — Session Overview", "description": "Full observability for Claude Code sessions: tool calls, agents, errors, and cross-session trends.", - "tags": ["claude-code", "observability"], + "tags": [ + "claude-code", + "observability" + ], "timezone": "browser", "editable": true, "graphTooltip": 1, - "time": { "from": "now-6h", "to": "now" }, + "time": { + "from": "now-6h", + "to": "now" + }, "refresh": "30s", "schemaVersion": 39, "fiscalYearStartMonth": 0, @@ -19,26 +25,38 @@ "name": "session_id", "label": "Session", "type": "query", - "datasource": { "type": "loki", "uid": "loki_local" }, - "query": "{app=\"claude-dev-logging\", component=\"lifecycle\"} | json | hook_type=\"session-start\"", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "query": "{app=\"claude-dev-logging\", component=\"lifecycle\"} | json hook_type, session_id | hook_type=\"session-start\"", "regex": "session_id\":\"([^\"]+)", "refresh": 2, "includeAll": true, "allValue": ".*", - "current": { "text": "All", "value": "$__all" }, + "current": { + "text": "All", + "value": "$__all" + }, "sort": 2 }, { "name": "project", "label": "Project", "type": "query", - "datasource": { "type": "loki", "uid": "loki_local" }, - "query": "{app=\"claude-dev-logging\", component=\"lifecycle\"} | json | hook_type=\"session-start\"", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "query": "{app=\"claude-dev-logging\", component=\"lifecycle\"} | json hook_type, session_id | hook_type=\"session-start\"", "regex": "project\":\"([^\"]+)", "refresh": 2, "includeAll": true, "allValue": ".*", - "current": { "text": "All", "value": "$__all" }, + "current": { + "text": "All", + "value": "$__all" + }, "sort": 1 } ] @@ -48,37 +66,74 @@ "type": "row", "title": "Session At-a-Glance", "collapsed": false, - "gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 } + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 1 + } }, { "id": 1, "title": "Tool Calls", "type": "stat", "transparent": true, - "gridPos": { "x": 0, "y": 1, "w": 5, "h": 5 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 0, + "y": 1, + "w": 5, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" [$__range])", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum(count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" [$__range]))", + "legendFormat": "Tool Calls", "queryType": "range" } ], "options": { "colorMode": "background-gradient", - "graphMode": "area", - "textMode": "auto", - "wideLayout": true, - "justifyMode": "auto", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center", "orientation": "auto", - "text": { "titleSize": 12, "valueSize": 32 }, - "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + "text": { + "titleSize": 12, + "valueSize": 36 + }, + "reduceOptions": { + "values": false, + "calcs": [ + "sum" + ], + "fields": "" + } }, "fieldConfig": { "defaults": { - "color": { "mode": "fixed", "fixedColor": "#5794F2" }, - "thresholds": { "mode": "absolute", "steps": [{ "value": null, "color": "#5794F2" }] } + "noValue": "0", + "color": { + "mode": "fixed", + "fixedColor": "#5794F2" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + } }, "overrides": [] } @@ -88,36 +143,67 @@ "title": "Errors", "type": "stat", "transparent": true, - "gridPos": { "x": 5, "y": 1, "w": 5, "h": 5 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 5, + "y": 1, + "w": 5, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "count_over_time({app=\"claude-dev-logging\", level=\"ERROR\"} | json | session_id=~\"$session_id\" [$__range])", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum(count_over_time({app=\"claude-dev-logging\", level=\"ERROR\"} | json session_id | session_id=~\"$session_id\" [$__range]))", + "legendFormat": "Errors", "queryType": "range" } ], "options": { "colorMode": "background-gradient", - "graphMode": "area", - "textMode": "auto", - "wideLayout": true, - "justifyMode": "auto", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center", "orientation": "auto", - "text": { "titleSize": 12, "valueSize": 32 }, - "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + "text": { + "titleSize": 12, + "valueSize": 36 + }, + "reduceOptions": { + "values": false, + "calcs": [ + "sum" + ], + "fields": "" + } }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, "noValue": "0", + "color": { + "mode": "thresholds" + }, "thresholds": { "mode": "absolute", "steps": [ - { "value": null, "color": "#73BF69" }, - { "value": 1, "color": "#FF9830" }, - { "value": 5, "color": "#F2495C" } + { + "value": null, + "color": "#73BF69" + }, + { + "value": 1, + "color": "#FF9830" + }, + { + "value": 5, + "color": "#F2495C" + } ] } }, @@ -129,31 +215,62 @@ "title": "Agents Spawned", "type": "stat", "transparent": true, - "gridPos": { "x": 10, "y": 1, "w": 5, "h": 5 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 10, + "y": 1, + "w": 5, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "count_over_time({app=\"claude-dev-logging\", component=\"agent\"} | json | session_id=~\"$session_id\" | hook_type=\"subagent-start\" [$__range])", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum(count_over_time({app=\"claude-dev-logging\", component=\"agent\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type=\"subagent-start\" [$__range]))", + "legendFormat": "Agents", "queryType": "range" } ], "options": { "colorMode": "background-gradient", - "graphMode": "area", - "textMode": "auto", - "wideLayout": true, - "justifyMode": "auto", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center", "orientation": "auto", - "text": { "titleSize": 12, "valueSize": 32 }, - "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + "text": { + "titleSize": 12, + "valueSize": 36 + }, + "reduceOptions": { + "values": false, + "calcs": [ + "sum" + ], + "fields": "" + } }, "fieldConfig": { "defaults": { - "color": { "mode": "fixed", "fixedColor": "#B877D9" }, "noValue": "0", - "thresholds": { "mode": "absolute", "steps": [{ "value": null, "color": "#B877D9" }] } + "color": { + "mode": "fixed", + "fixedColor": "#B877D9" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#B877D9" + } + ] + } }, "overrides": [] } @@ -163,31 +280,62 @@ "title": "User Prompts", "type": "stat", "transparent": true, - "gridPos": { "x": 15, "y": 1, "w": 5, "h": 5 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 15, + "y": 1, + "w": 5, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "count_over_time({app=\"claude-dev-logging\", component=\"user\"} | json | session_id=~\"$session_id\" | hook_type=\"user-prompt-submit\" [$__range])", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum(count_over_time({app=\"claude-dev-logging\", component=\"user\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type=\"user-prompt-submit\" [$__range]))", + "legendFormat": "Prompts", "queryType": "range" } ], "options": { "colorMode": "background-gradient", - "graphMode": "area", - "textMode": "auto", - "wideLayout": true, - "justifyMode": "auto", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center", "orientation": "auto", - "text": { "titleSize": 12, "valueSize": 32 }, - "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + "text": { + "titleSize": 12, + "valueSize": 36 + }, + "reduceOptions": { + "values": false, + "calcs": [ + "sum" + ], + "fields": "" + } }, "fieldConfig": { "defaults": { - "color": { "mode": "fixed", "fixedColor": "#8AB8FF" }, "noValue": "0", - "thresholds": { "mode": "absolute", "steps": [{ "value": null, "color": "#8AB8FF" }] } + "color": { + "mode": "fixed", + "fixedColor": "#8AB8FF" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#8AB8FF" + } + ] + } }, "overrides": [] } @@ -197,31 +345,62 @@ "title": "Permission Requests", "type": "stat", "transparent": true, - "gridPos": { "x": 20, "y": 1, "w": 4, "h": 5 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 20, + "y": 1, + "w": 4, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "count_over_time({app=\"claude-dev-logging\", component=\"user\"} | json | session_id=~\"$session_id\" | hook_type=\"permission-request\" [$__range])", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum(count_over_time({app=\"claude-dev-logging\", component=\"user\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type=\"permission-request\" [$__range]))", + "legendFormat": "Permissions", "queryType": "range" } ], "options": { "colorMode": "background-gradient", - "graphMode": "area", - "textMode": "auto", - "wideLayout": true, - "justifyMode": "auto", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center", "orientation": "auto", - "text": { "titleSize": 12, "valueSize": 32 }, - "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + "text": { + "titleSize": 12, + "valueSize": 36 + }, + "reduceOptions": { + "values": false, + "calcs": [ + "sum" + ], + "fields": "" + } }, "fieldConfig": { "defaults": { - "color": { "mode": "fixed", "fixedColor": "#FF9830" }, "noValue": "0", - "thresholds": { "mode": "absolute", "steps": [{ "value": null, "color": "#FF9830" }] } + "color": { + "mode": "fixed", + "fixedColor": "#FF9830" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#FF9830" + } + ] + } }, "overrides": [] } @@ -230,309 +409,932 @@ "type": "row", "title": "Activity Over Time", "collapsed": false, - "gridPos": { "x": 0, "y": 7, "w": 24, "h": 1 } + "gridPos": { + "x": 0, + "y": 6, + "w": 24, + "h": 1 + } }, { "id": 6, - "title": "Tool Call Rate by Component", - "description": "Stacked view of tool usage across all component types over time.", + "title": "Tool Call Rate", + "description": "Stacked bar rate of individual tool usage over time.", "type": "timeseries", "transparent": true, - "gridPos": { "x": 0, "y": 8, "w": 24, "h": 10 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 0, + "y": 7, + "w": 24, + "h": 10 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (component) (count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" [$__interval]))", - "legendFormat": "{{component}}", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum by (tool_name) (rate({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json hook_type, tool_name, session_id | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" | tool_name != \"\" [$__interval]) * 60)", + "legendFormat": "{{tool_name}}", "queryType": "range" } ], "options": { - "legend": { "displayMode": "table", "placement": "right", "calcs": ["sum", "mean"] }, - "tooltip": { "mode": "multi", "sort": "desc" } + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "sum" + ] + }, + "tooltip": { + "mode": "single", + "sort": "desc" + } }, "fieldConfig": { "defaults": { - "color": { "mode": "palette-classic" }, - "unit": "short", + "color": { + "mode": "palette-classic" + }, + "unit": "calls/min", "custom": { "drawStyle": "line", - "lineInterpolation": "smooth", - "lineWidth": 2, - "fillOpacity": 15, + "lineWidth": 1, + "fillOpacity": 10, "gradientMode": "opacity", "showPoints": "never", "barAlignment": 0, - "spanNulls": false, + "spanNulls": true, "axisBorderShow": false, - "stacking": { "mode": "none", "group": "A" } + "stacking": { + "mode": "normal", + "group": "A" + }, + "lineInterpolation": "smooth" } }, - "overrides": [ - { "matcher": { "id": "byName", "options": "tool" }, "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "mcp-contextstream" }, "properties": [{ "id": "color", "value": { "fixedColor": "#B877D9", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "mcp-sentry" }, "properties": [{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "mcp-ollama" }, "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] } - ] + "overrides": [] } }, { "type": "row", - "title": "Breakdown", + "title": "Composition", "collapsed": false, - "gridPos": { "x": 0, "y": 19, "w": 24, "h": 1 } + "gridPos": { + "x": 0, + "y": 18, + "w": 24, + "h": 1 + } }, { "id": 7, - "title": "Component Distribution", + "title": "Component Share", "type": "piechart", "transparent": true, - "gridPos": { "x": 0, "y": 20, "w": 8, "h": 9 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 0, + "y": 19, + "w": 8, + "h": 10 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (component) (count_over_time({app=\"claude-dev-logging\"} | json | session_id=~\"$session_id\" [$__range]))", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum by (component) (count_over_time({app=\"claude-dev-logging\"} | json session_id | session_id=~\"$session_id\" [$__range]))", + "legendFormat": "{{component}}", "queryType": "range" } ], "options": { "pieType": "donut", - "displayLabels": ["percent"], - "legend": { "displayMode": "table", "placement": "right", "values": ["value", "percent"] }, - "tooltip": { "mode": "multi" }, - "reduceOptions": { "values": false, "calcs": ["sum"], "fields": "" } + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "values": false, + "calcs": [ + "sum" + ], + "fields": "" + } }, "fieldConfig": { - "defaults": { "color": { "mode": "palette-classic" } }, + "defaults": { + "color": { + "mode": "palette-classic" + } + }, "overrides": [ - { "matcher": { "id": "byName", "options": "tool" }, "properties": [{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "mcp-contextstream" }, "properties": [{ "id": "color", "value": { "fixedColor": "#B877D9", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "user" }, "properties": [{ "id": "color", "value": { "fixedColor": "#8AB8FF", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "lifecycle" }, "properties": [{ "id": "color", "value": { "fixedColor": "#73BF69", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "agent" }, "properties": [{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "transcript" }, "properties": [{ "id": "color", "value": { "fixedColor": "#FADE2A", "mode": "fixed" } }] } + { + "matcher": { + "id": "byName", + "options": "tool" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#5794F2", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "mcp-contextstream" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#B877D9", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "user" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#8AB8FF", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "lifecycle" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#73BF69", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "agent" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#FF9830", + "mode": "fixed" + } + } + ] + } ] } }, { "id": 8, "title": "Hook Type Breakdown", - "type": "barchart", + "description": "% share of each hook type across all events.", + "type": "table", "transparent": true, - "gridPos": { "x": 8, "y": 20, "w": 8, "h": 9 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 8, + "y": 19, + "w": 8, + "h": 10 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (hook_type) (count_over_time({app=\"claude-dev-logging\"} | json | session_id=~\"$session_id\" | hook_type!=\"\" [$__range]))", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum by (hook_type) (count_over_time({app=\"claude-dev-logging\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type != \"\" [$__range])) / ignoring(hook_type) group_left() sum(count_over_time({app=\"claude-dev-logging\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type != \"\" [$__range])) * 100", + "legendFormat": "{{hook_type}}", "queryType": "range" } ], + "transformations": [ + { + "id": "reduce", + "options": { + "reducers": [ + "lastNotNull" + ], + "mode": "seriesToRows" + } + }, + { + "id": "sortBy", + "options": { + "fields": [ + { + "displayName": "Last *", + "desc": true + } + ] + } + } + ], "options": { - "orientation": "horizontal", - "barWidth": 0.7, - "groupWidth": 0.7, - "showValue": "auto", - "stacking": "none", - "legend": { "displayMode": "hidden" }, - "tooltip": { "mode": "multi" } + "frameIndex": 0, + "showHeader": false, + "cellHeight": "sm", + "footer": { + "show": false, + "reducer": [ + "sum" + ], + "fields": "" + }, + "sortBy": [ + { + "desc": true, + "displayName": "Last *" + } + ] }, "fieldConfig": { "defaults": { - "color": { "mode": "palette-classic" }, - "custom": { "fillOpacity": 80, "gradientMode": "hue" } + "unit": "percent", + "decimals": 1, + "custom": { + "inspect": false, + "width": 0 + }, + "color": { + "mode": "palette-classic" + } }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "custom.width", + "value": 200 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "min", + "value": 0 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "gauge", + "mode": "basic", + "valueDisplayMode": "color" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Field" + }, + "properties": [ + { + "id": "custom.width", + "value": 170 + } + ] + } + ] } }, { "id": 9, "title": "Top Tools Used", - "type": "barchart", + "description": "% share of each tool across all post-tool-use events.", + "type": "table", "transparent": true, - "gridPos": { "x": 16, "y": 20, "w": 8, "h": 9 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 16, + "y": 19, + "w": 8, + "h": 10 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (tool_name) (count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" [$__range]))", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum by (tool_name) (count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json hook_type, tool_name, session_id | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" | tool_name != \"\" [$__range])) / ignoring(tool_name) group_left() sum(count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json hook_type, tool_name, session_id | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" | tool_name != \"\" [$__range])) * 100", + "legendFormat": "{{tool_name}}", "queryType": "range" } ], + "transformations": [ + { + "id": "reduce", + "options": { + "reducers": [ + "lastNotNull" + ], + "mode": "seriesToRows" + } + }, + { + "id": "sortBy", + "options": { + "fields": [ + { + "displayName": "Last *", + "desc": true + } + ] + } + } + ], "options": { - "orientation": "horizontal", - "barWidth": 0.7, - "groupWidth": 0.7, - "showValue": "auto", - "stacking": "none", - "legend": { "displayMode": "hidden" }, - "tooltip": { "mode": "multi" } + "frameIndex": 0, + "showHeader": false, + "cellHeight": "sm", + "footer": { + "show": false, + "reducer": [ + "sum" + ], + "fields": "" + }, + "sortBy": [ + { + "desc": true, + "displayName": "Last *" + } + ] }, "fieldConfig": { "defaults": { - "color": { "mode": "fixed", "fixedColor": "#5794F2" }, - "custom": { "fillOpacity": 80, "gradientMode": "hue" } + "unit": "percent", + "decimals": 1, + "custom": { + "inspect": false, + "width": 0 + }, + "color": { + "mode": "palette-classic" + } }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "custom.width", + "value": 200 + }, + { + "id": "max", + "value": 100 + }, + { + "id": "min", + "value": 0 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "gauge", + "mode": "basic", + "valueDisplayMode": "color" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Field" + }, + "properties": [ + { + "id": "custom.width", + "value": 170 + } + ] + } + ] } }, { "type": "row", - "title": "Agent Activity", + "title": "Agent Insights", "collapsed": false, - "gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 } + "gridPos": { + "x": 0, + "y": 30, + "w": 24, + "h": 1 + } }, { "id": 10, - "title": "Agent Lifecycle Timeline", - "description": "Subagent start/stop events over time.", - "type": "state-timeline", + "title": "Agent Events", + "description": "Count of agent lifecycle events by type.", + "type": "table", "transparent": true, - "gridPos": { "x": 0, "y": 31, "w": 24, "h": 6 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 0, + "y": 31, + "w": 8, + "h": 8 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "{app=\"claude-dev-logging\", component=\"agent\"} | json | session_id=~\"$session_id\"", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum by (hook_type) (count_over_time({app=\"claude-dev-logging\", component=\"agent\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type != \"\" [$__range]))", + "legendFormat": "{{hook_type}}", "queryType": "range" } ], + "transformations": [ + { + "id": "reduce", + "options": { + "reducers": [ + "lastNotNull" + ], + "mode": "seriesToRows" + } + }, + { + "id": "sortBy", + "options": { + "fields": [ + { + "displayName": "Last *", + "desc": true + } + ] + } + } + ], "options": { - "showValue": "auto", - "mergeValues": true, - "alignValue": "left", - "legend": { "displayMode": "list", "placement": "bottom" }, - "tooltip": { "mode": "multi" } + "frameIndex": 0, + "showHeader": false, + "cellHeight": "sm", + "footer": { + "show": false, + "reducer": [ + "sum" + ], + "fields": "" + }, + "sortBy": [ + { + "desc": true, + "displayName": "Last *" + } + ] }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "custom": { "fillOpacity": 70 }, - "thresholds": { "mode": "absolute", "steps": [{ "value": null, "color": "#B877D9" }] } + "unit": "short", + "decimals": 0, + "custom": { + "inspect": false, + "width": 0 + }, + "color": { + "mode": "palette-classic" + } }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "custom.width", + "value": 200 + }, + { + "id": "min", + "value": 0 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "gauge", + "mode": "basic", + "valueDisplayMode": "color" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Field" + }, + "properties": [ + { + "id": "custom.width", + "value": 170 + } + ] + } + ] + } + }, + { + "id": 14, + "title": "Tool Calls vs Errors Over Time", + "description": "Session health — productive tool call rate alongside error rate.", + "type": "timeseries", + "transparent": true, + "gridPos": { + "x": 8, + "y": 31, + "w": 16, + "h": 8 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum(rate({app=\"claude-dev-logging\"} | json hook_type, session_id | session_id=~\"$session_id\" | hook_type=\"post-tool-use\" [$__interval]) * 60)", + "legendFormat": "Tool Calls", + "queryType": "range" + }, + { + "refId": "B", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum(rate({app=\"claude-dev-logging\", level=\"ERROR\"} | json session_id | session_id=~\"$session_id\" [$__interval]) * 60)", + "legendFormat": "Errors", + "queryType": "range" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 10, + "gradientMode": "opacity", + "showPoints": "never", + "spanNulls": true, + "axisBorderShow": false, + "lineInterpolation": "smooth" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Tool Calls" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#5794F2", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Errors" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F2495C", + "mode": "fixed" + } + }, + { + "id": "custom.fillOpacity", + "value": 60 + } + ] + } + ] } }, { "type": "row", "title": "Cross-Session Trends", "collapsed": false, - "gridPos": { "x": 0, "y": 38, "w": 24, "h": 1 } + "gridPos": { + "x": 0, + "y": 40, + "w": 24, + "h": 1 + } }, { "id": 11, "title": "Tool Calls per Session", - "type": "barchart", + "type": "table", "transparent": true, - "gridPos": { "x": 0, "y": 39, "w": 12, "h": 8 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 0, + "y": 41, + "w": 12, + "h": 8 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (session_id) (count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json | hook_type=\"post-tool-use\" [$__range]))", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum by (session_id) (count_over_time({app=\"claude-dev-logging\", component=~\"tool|mcp-.*\"} | json hook_type, session_id | hook_type=\"post-tool-use\" [$__range]))", + "legendFormat": "{{session_id}}", "queryType": "range" } ], + "transformations": [ + { + "id": "reduce", + "options": { + "reducers": [ + "lastNotNull" + ], + "mode": "seriesToRows" + } + } + ], "options": { - "orientation": "vertical", - "barWidth": 0.6, - "showValue": "auto", - "stacking": "none", - "xTickLabelRotation": -45, - "xTickLabelMaxLength": 8, - "legend": { "displayMode": "hidden" }, - "tooltip": { "mode": "multi" } + "frameIndex": 0, + "showHeader": false, + "cellHeight": "sm", + "sortBy": [ + { + "desc": true, + "displayName": "Last *" + } + ], + "footer": { + "show": false + } }, "fieldConfig": { "defaults": { - "color": { "mode": "fixed", "fixedColor": "#5794F2" }, - "custom": { "fillOpacity": 80, "gradientMode": "hue" } + "unit": "short", + "decimals": 0 }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "custom.width", + "value": 140 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "gauge", + "mode": "basic", + "valueDisplayMode": "color" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#5794F2" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Field" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "displayName", + "value": "Session" + } + ] + } + ] } }, { "id": 12, "title": "Errors per Session", - "type": "barchart", + "type": "table", "transparent": true, - "gridPos": { "x": 12, "y": 39, "w": 12, "h": 8 }, - "datasource": { "type": "loki", "uid": "loki_local" }, + "gridPos": { + "x": 12, + "y": 41, + "w": 12, + "h": 8 + }, + "datasource": { + "type": "loki", + "uid": "loki_local" + }, "targets": [ { "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "sum by (session_id) (count_over_time({app=\"claude-dev-logging\", level=\"ERROR\"} | json [$__range]))", + "datasource": { + "type": "loki", + "uid": "loki_local" + }, + "expr": "sum by (session_id) (count_over_time({app=\"claude-dev-logging\", level=\"ERROR\"} | json session_id [$__range]))", + "legendFormat": "{{session_id}}", "queryType": "range" } ], + "transformations": [ + { + "id": "reduce", + "options": { + "reducers": [ + "lastNotNull" + ], + "mode": "seriesToRows" + } + } + ], "options": { - "orientation": "vertical", - "barWidth": 0.6, - "showValue": "auto", - "stacking": "none", - "xTickLabelRotation": -45, - "xTickLabelMaxLength": 8, - "legend": { "displayMode": "hidden" }, - "tooltip": { "mode": "multi" } + "frameIndex": 0, + "showHeader": false, + "cellHeight": "sm", + "sortBy": [ + { + "desc": true, + "displayName": "Last *" + } + ], + "footer": { + "show": false + } }, "fieldConfig": { "defaults": { - "color": { "mode": "thresholds" }, - "custom": { "fillOpacity": 80, "gradientMode": "hue" }, - "thresholds": { - "mode": "absolute", - "steps": [ - { "value": null, "color": "#73BF69" }, - { "value": 1, "color": "#FF9830" }, - { "value": 5, "color": "#F2495C" } + "unit": "short", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "custom.width", + "value": 140 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "gauge", + "mode": "basic", + "valueDisplayMode": "color" + } + }, + { + "id": "min", + "value": 0 + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#F2495C" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Field" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "displayName", + "value": "Session" + } ] } - }, - "overrides": [] + ] } - }, - { - "type": "row", - "title": "Log Stream", - "collapsed": true, - "gridPos": { "x": 0, "y": 48, "w": 24, "h": 1 }, - "panels": [ - { - "id": 13, - "title": "Full Session Logs", - "type": "logs", - "transparent": true, - "gridPos": { "x": 0, "y": 49, "w": 24, "h": 14 }, - "datasource": { "type": "loki", "uid": "loki_local" }, - "targets": [ - { - "refId": "A", - "datasource": { "type": "loki", "uid": "loki_local" }, - "expr": "{app=\"claude-dev-logging\"} | json | session_id=~\"$session_id\" | line_format \"{{.hook_type}} | {{.component}} | {{.tool_name}}\"", - "queryType": "range" - } - ], - "options": { - "showTime": true, - "showLabels": false, - "showCommonLabels": false, - "wrapLogMessage": true, - "prettifyLogMessage": false, - "enableLogDetails": true, - "sortOrder": "Descending", - "dedupStrategy": "none" - } - } - ] } ] -} +} \ No newline at end of file