Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion pkg/cli/audit_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ type MCPToolCall struct {

// MCPServerStats contains server-level statistics
type MCPServerStats struct {
ServerName string `json:"server_name" console:"header:Server"`
ServerName string `json:"server_name" console:"header:Server"`
// RequestCount is kept for backward-compatible report schemas that label per-server
// request volume; in MCP usage summaries this currently mirrors ToolCallCount.
RequestCount int `json:"request_count" console:"header:Requests"`
ToolCallCount int `json:"tool_call_count" console:"header:Tool Calls"`
TotalInputSize int `json:"total_input_size" console:"header:Total Input,format:number"`
Expand Down
25 changes: 16 additions & 9 deletions pkg/cli/logs_run_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, out
result.Run.AvgTimeBetweenTurns = metrics.AvgTimeBetweenTurns
result.Run.LogsPath = runOutputDir

// Load precomputed activity aggregates from the usage artifact when available.
// These aggregates are generated by the conclusion job and allow lightweight
// usage-only downloads to include firewall/session summaries.
usageActivitySummary, usageActivityErr := loadUsageActivitySummary(runOutputDir)
if usageActivityErr != nil && verbose {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] Non-ENOENT errors from loadUsageActivitySummary (e.g., permission denied, storage corruption) are only surfaced when --verbose is active. A systematic read failure affecting every run would be completely invisible in normal operation — not counted in any metric, not printed, not aggregated.

💡 Suggestion

Consider emitting this warning unconditionally (or at least incrementing a skipped-run counter), since a non-ENOENT IO error is genuinely unexpected and should not require --verbose to diagnose. The warning is already low-noise (one line per run) and distinguishes the missing-file case (which loadUsageActivitySummary returns nil, nil for silently) from the real-error case.

fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to read usage activity summary for run %d: %v", run.DatabaseID, usageActivityErr)))
}

// If the GitHub API returned an empty workflow path (which can happen for
// scheduled or agentic workflow runs), infer it from aw_info.json so that
// the cached RunSummary and downstream consumers have a usable identifier.
Expand Down Expand Up @@ -307,15 +315,11 @@ func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, out
result.MCPToolUsage = mcpToolUsage

// Analyze token usage from firewall proxy logs.
// Gated on hasFirewallArtifact since token-usage.jsonl lives in the agent artifact.
var tokenUsage *TokenUsageSummary
if hasFirewallArtifact {
var tokenErr error
tokenUsage, tokenErr = analyzeTokenUsage(runOutputDir, verbose)
if tokenErr != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze token usage for run %d: %v", run.DatabaseID, tokenErr)))
}
// token-usage.jsonl is also available in the compact usage artifact.
tokenUsage, tokenErr := analyzeTokenUsage(runOutputDir, verbose)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] Removing the hasFirewallArtifact gate means analyzeTokenUsage is now attempted for every run. For usage-only downloads that lack token-usage.jsonl, the error is silently swallowed unless --verbose is active. This means a systematic issue (e.g., a format change or missing file in every run's usage artifact) produces no observable signal in normal operation.

💡 Options
  • Emit the warning unconditionally (not gated on verbose) but at a lower-noise level (e.g., only once if the error repeats across many runs).
  • Alternatively, accept the current behaviour and add a comment here explaining that token-usage parsing is best-effort for usage-only downloads and a missing file is expected rather than exceptional.

if tokenErr != nil {
if verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to analyze token usage for run %d: %v", run.DatabaseID, tokenErr)))
}
}
result.TokenUsage = tokenUsage
Expand All @@ -333,6 +337,9 @@ func downloadRunArtifactsConcurrent(ctx context.Context, runs []WorkflowRun, out
}
}
result.GitHubRateLimitUsage = rateLimitUsage
// Fill missing activity summaries from usage artifact precomputes.
// This call is unconditional but only backfills fields that are still empty.
applyUsageActivitySummaryToResult(usageActivitySummary, &result)

// Count safe output items created in GitHub (from manifest artifact)
result.Run.SafeItemsCount = len(extractCreatedItemsFromManifest(runOutputDir))
Expand Down
108 changes: 108 additions & 0 deletions pkg/cli/logs_usage_activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)

type usageActivitySummary struct {
Schema string `json:"schema,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The Schema field is unmarshalled but never inspected. If the producer emits a usage-activity-summary/v2 with different field semantics, this consumer will silently accept and misinterpret it.

💡 Suggested guard

Add a schema version check after parsing:

const usageActivitySummarySchema = "usage-activity-summary/v1"

// in loadUsageActivitySummary, after json.Unmarshal:
if summary.Schema != "" && summary.Schema != usageActivitySummarySchema {
    return nil, fmt.Errorf("unsupported usage activity summary schema %q (expected %q)", summary.Schema, usageActivitySummarySchema)
}

Since the schema is producer-controlled by this repo's conclusion job, a hard error is safe here — if the constant falls out of sync it will surface immediately in tests rather than silently producing wrong activity counts.

Firewall *usageActivityFirewall `json:"firewall,omitempty"`
Session *usageActivitySession `json:"session,omitempty"`
Gateway *usageActivityGateway `json:"gateway,omitempty"`
}

type usageActivityFirewall struct {
TotalRequests int `json:"total_requests"`
AllowedRequests int `json:"allowed_requests"`
BlockedRequests int `json:"blocked_requests"`
}

type usageActivitySession struct {
TotalEvents int `json:"total_events"`
SessionStarts int `json:"session_starts"`
SessionShutdowns int `json:"session_shutdowns"`
Turns int `json:"turns"`
AssistantMessages int `json:"assistant_messages"`
ReasoningEvents int `json:"reasoning_events"`
ToolExecutionStarts int `json:"tool_execution_starts"`
ToolExecutionCompletes int `json:"tool_execution_completes"`
FailedToolExecutions int `json:"failed_tool_executions"`
}

type usageActivityGateway struct {
TotalCalls int `json:"total_calls"`
FailedCalls int `json:"failed_calls"`
Servers []usageActivityGatewayServer `json:"servers,omitempty"`
}

type usageActivityGatewayServer struct {
ServerName string `json:"server_name"`
ToolCallCount int `json:"tool_call_count"`
FailedCalls int `json:"failed_calls"`
}

func loadUsageActivitySummary(runDir string) (*usageActivitySummary, error) {
candidates := []string{
filepath.Join(runDir, "usage", "activity", "summary.json"),
filepath.Join(runDir, "activity", "summary.json"),
}
for _, candidate := range candidates {
cleanPath := filepath.Clean(candidate)
raw, err := os.ReadFile(cleanPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("read usage activity summary %s: %w", cleanPath, err)
}
var summary usageActivitySummary
if err := json.Unmarshal(raw, &summary); err != nil {
return nil, fmt.Errorf("parse usage activity summary %s: %w", cleanPath, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON parse failure on the primary candidate short-circuits the fallback path: if usage/activity/summary.json exists but contains malformed JSON (e.g. partially written during artifact upload), the function immediately returns an error and never tries the activity/summary.json fallback — so applyUsageActivitySummaryToResult no-ops silently.

💡 Suggested fix

Fall through on unmarshal failure the same way os.IsNotExist falls through:

var summary usageActivitySummary
if err := json.Unmarshal(raw, &summary); err != nil {
    continue  // try next candidate
}
return &summary, nil

Or collect the error and return it only after exhausting all candidates:

var lastErr error
for _, candidate := range candidates {
    raw, err := os.ReadFile(cleanPath)
    if err != nil {
        if os.IsNotExist(err) { continue }
        return nil, err
    }
    var s usageActivitySummary
    if err := json.Unmarshal(raw, &s); err != nil {
        lastErr = err
        continue
    }
    return &s, nil
}
return nil, lastErr

A partially-written JSON file from an interrupted artifact upload should behave the same as a missing file for the purposes of the fallback chain.

}
return &summary, nil
Comment on lines +64 to +73
}
return nil, nil
}

func applyUsageActivitySummaryToResult(summary *usageActivitySummary, result *DownloadResult) {
if summary == nil || result == nil {
return
}

// Preserve previously parsed turn counts (from full session artifacts/events.jsonl)
// and only backfill when they are missing.
if summary.Session != nil && result.Run.Turns == 0 && summary.Session.Turns > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The condition result.Run.Turns == 0 cannot distinguish between "full artifacts downloaded, zero turns genuinely observed" and "turns field not yet populated". A run that crashes before the first user message would have Turns == 0 from full-artifact parsing, and the backfill would silently overwrite that with the precomputed value from the usage summary.

💡 Options to consider

The simplest fix is to use a sentinel that means "no full-artifact data available" instead of relying on the zero value. For example:

  • Set result.Run.Turns = -1 (or use a separate bool turnsPopulated flag) during full-artifact processing to signal "we parsed the artifacts and found zero turns". Then backfill only when the field is still at its zero-value default.
  • Alternatively, document explicitly in a comment on this line that a zero-turn run IS safe to backfill (e.g., "if full-artifact parsing produced 0, the precomputed value is equally authoritative"), so the behaviour is a conscious choice rather than an accidental side effect.

result.Run.Turns = summary.Session.Turns
}

if summary.Firewall != nil && result.FirewallAnalysis == nil {
result.FirewallAnalysis = &FirewallAnalysis{
TotalRequests: summary.Firewall.TotalRequests,
AllowedRequests: summary.Firewall.AllowedRequests,
BlockedRequests: summary.Firewall.BlockedRequests,
RequestsByDomain: map[string]DomainRequestStats{},
}
}

if summary.Gateway != nil && result.MCPToolUsage == nil {
servers := make([]MCPServerStats, 0, len(summary.Gateway.Servers))
for _, server := range summary.Gateway.Servers {
servers = append(servers, MCPServerStats{
ServerName: server.ServerName,
// Keep both RequestCount and ToolCallCount aligned because MCPServerStats
// distinguishes overall request volume (RequestCount) from tool-invocation
// volume (ToolCallCount). In usage-aggregate mode we only have per-server
// tool-call counts, so both fields are populated from that single source.
RequestCount: server.ToolCallCount,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/improve-codebase-architecture] RequestCount and ToolCallCount are semantically distinct fields in MCPServerStats (request volume vs. tool-invocation volume), but both are set to the same server.ToolCallCount value here. Downstream code or reports that compute a requests_per_tool_call ratio — or display RequestCount separately from ToolCallCount — will always show 1.0 / identical values for any backfilled row, masking real usage patterns.

💡 Suggestion

Either:

  • Leave RequestCount at zero (its zero value) and set only ToolCallCount, making it clear that request-level data is unavailable from usage aggregates.
  • Or rename the comment to explicitly state "usage aggregates only track tool invocations, not HTTP request volume; RequestCount is an approximation" so readers of downstream reports understand the limitation.

The existing comment explains the intent, but it's easy to miss. A zero value with a named sentinel (or a bool requestCountAvailable flag on MCPServerStats) would make the absence of data self-documenting.

ToolCallCount: server.ToolCallCount,
ErrorCount: server.FailedCalls,
})
}
result.MCPToolUsage = &MCPToolUsageData{
Servers: servers,
}
}
Comment on lines +112 to +117
}
68 changes: 68 additions & 0 deletions pkg/cli/logs_usage_activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cli

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLoadUsageActivitySummary(t *testing.T) {
t.Parallel()

runDir := t.TempDir()
summaryPath := filepath.Join(runDir, "usage", "activity", "summary.json")
require.NoError(t, os.MkdirAll(filepath.Dir(summaryPath), 0o755))
require.NoError(t, os.WriteFile(summaryPath, []byte(`{
"schema":"usage-activity-summary/v1",
"firewall":{"total_requests":10,"allowed_requests":8,"blocked_requests":2},
"session":{"turns":7},
"gateway":{"total_calls":5,"failed_calls":1}
}`), 0o644))

summary, err := loadUsageActivitySummary(runDir)
require.NoError(t, err)
require.NotNil(t, summary)
require.NotNil(t, summary.Firewall)
assert.Equal(t, 10, summary.Firewall.TotalRequests)
require.NotNil(t, summary.Session)
assert.Equal(t, 7, summary.Session.Turns)
require.NotNil(t, summary.Gateway)
assert.Equal(t, 5, summary.Gateway.TotalCalls)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestLoadUsageActivitySummary only tests the primary usage/activity/summary.json path. Three more cases need coverage: (1) the activity/summary.json fallback, (2) neither path exists → nil, nil, (3) a file exists but contains invalid JSON → non-nil error.

💡 Suggested additions
func TestLoadUsageActivitySummary_FallbackPath(t *testing.T) {
    t.Parallel()
    runDir := t.TempDir()
    // Write to the legacy fallback location.
    fallbackPath := filepath.Join(runDir, "activity", "summary.json")
    require.NoError(t, os.MkdirAll(filepath.Dir(fallbackPath), 0o755))
    require.NoError(t, os.WriteFile(fallbackPath, []byte(`{"session":{"turns":3}}`), 0o644))
    summary, err := loadUsageActivitySummary(runDir)
    require.NoError(t, err)
    require.NotNil(t, summary)
    assert.Equal(t, 3, summary.Session.Turns)
}

func TestLoadUsageActivitySummary_NoFile(t *testing.T) {
    t.Parallel()
    summary, err := loadUsageActivitySummary(t.TempDir())
    require.NoError(t, err)
    assert.Nil(t, summary)
}

func TestLoadUsageActivitySummary_MalformedJSON(t *testing.T) {
    t.Parallel()
    runDir := t.TempDir()
    p := filepath.Join(runDir, "usage", "activity", "summary.json")
    require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
    require.NoError(t, os.WriteFile(p, []byte(`{not valid json`), 0o644))
    _, err := loadUsageActivitySummary(runDir)
    require.Error(t, err)
}


func TestApplyUsageActivitySummaryToResult(t *testing.T) {
t.Parallel()

result := DownloadResult{}
summary := &usageActivitySummary{
Session: &usageActivitySession{Turns: 4},
Firewall: &usageActivityFirewall{
TotalRequests: 12,
AllowedRequests: 9,
BlockedRequests: 3,
},
Gateway: &usageActivityGateway{
TotalCalls: 6,
FailedCalls: 2,
Servers: []usageActivityGatewayServer{
{ServerName: "github", ToolCallCount: 5, FailedCalls: 2},
{ServerName: "playwright", ToolCallCount: 1, FailedCalls: 0},
},
},
}

applyUsageActivitySummaryToResult(summary, &result)

assert.Equal(t, 4, result.Run.Turns)
require.NotNil(t, result.FirewallAnalysis)
assert.Equal(t, 12, result.FirewallAnalysis.TotalRequests)
assert.Equal(t, 3, result.FirewallAnalysis.BlockedRequests)
require.NotNil(t, result.MCPToolUsage)
require.Len(t, result.MCPToolUsage.Servers, 2)
assert.Equal(t, "github", result.MCPToolUsage.Servers[0].ServerName)
assert.Equal(t, 5, result.MCPToolUsage.Servers[0].ToolCallCount)
assert.Equal(t, 2, result.MCPToolUsage.Servers[0].ErrorCount)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The three backfill-precedence branches in applyUsageActivitySummaryToResult are only tested in the happy path (empty result). There are no tests verifying the "don't overwrite" invariant for any of the three guarded fields.

💡 Suggested test cases
func TestApplyUsageActivitySummary_DoesNotOverwriteExistingData(t *testing.T) {
    t.Parallel()

    // Turns: a non-zero value must not be replaced.
    result := DownloadResult{}
    result.Run.Turns = 9
    applyUsageActivitySummaryToResult(
        &usageActivitySummary{Session: &usageActivitySession{Turns: 4}},
        &result,
    )
    assert.Equal(t, 9, result.Run.Turns, "existing turns must not be overwritten")

    // FirewallAnalysis: a non-nil pointer must not be replaced.
    existing := &FirewallAnalysis{TotalRequests: 100}
    result2 := DownloadResult{FirewallAnalysis: existing}
    applyUsageActivitySummaryToResult(
        &usageActivitySummary{Firewall: &usageActivityFirewall{TotalRequests: 5}},
        &result2,
    )
    assert.Same(t, existing, result2.FirewallAnalysis)

    // MCPToolUsage: a non-nil pointer must not be replaced.
    existingMCP := &MCPToolUsageData{}
    result3 := DownloadResult{MCPToolUsage: existingMCP}
    applyUsageActivitySummaryToResult(
        &usageActivitySummary{Gateway: &usageActivityGateway{TotalCalls: 3}},
        &result3,
    )
    assert.Same(t, existingMCP, result3.MCPToolUsage)
}

This precedence contract is the core invariant of the backfill design: if a future change reorders full-artifact parsing and the backfill call, these tests will catch the regression immediately.

4 changes: 2 additions & 2 deletions pkg/workflow/maintenance_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,8 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) {
if !strings.Contains(yaml, "--start-date -1w") {
t.Errorf("Job activity_report gh aw logs command should include --start-date -1w in:\n%s", yaml)
}
if !strings.Contains(yaml, "--count 100") {
t.Errorf("Job activity_report gh aw logs command should include --count 100 in:\n%s", yaml)
if !strings.Contains(yaml, "--count 500") {
t.Errorf("Job activity_report gh aw logs command should include --count 500 in:\n%s", yaml)
}
if !strings.Contains(yaml, "--format markdown") {
t.Errorf("Job activity_report gh aw logs command should include --format markdown in:\n%s", yaml)
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/maintenance_workflow_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ jobs:
${GH_AW_CMD_PREFIX} logs \
--repo "${{ github.repository }}" \
--start-date -1w \
--count 100 \
--count 500 \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The 5× increase from --count 100 to --count 500 is well-motivated (lighter artifact downloads), but there is no documentation of what ceiling was considered or what constraint the number 500 is tracking against. If the window needs to grow again, a future developer has no guidance on the limiting factor.

💡 Suggestion

Add a brief inline comment explaining the constraint, for example:

// 500 runs: fits within the GitHub Actions usage download budget for usage-only artifacts;
// raise further if the artifact footprint is reduced again.
"            --count 500 \\\n",

Alternatively, note in the PR description or a code comment what the expected download time / API call budget is at 500 vs. 100.

--output ./.cache/gh-aw/activity-report-logs \
--format markdown \
--report-file ./.cache/gh-aw/activity-report-logs/report.md
Expand Down
Loading
Loading