Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
59 changes: 56 additions & 3 deletions cmd/agentsview/export_reporting.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func defaultExportReportingDeps() exportReportingDeps {

func newExportHourCommand(deps exportReportingDeps) *cobra.Command {
var schemaVersion *int
var projectKeys *[]string
var bucket *string
command := &cobra.Command{
Use: "hour YYYY-MM-DD-HH",
Short: "Export one closed UTC reporting hour",
Expand All @@ -36,6 +38,12 @@ func newExportHourCommand(deps exportReportingDeps) *cobra.Command {
if err := validateReportingSchemaVersion(*schemaVersion); err != nil {
return err
}
if err := export.ValidateReportingProjectScope(*schemaVersion, *projectKeys); err != nil {
return err
}
if _, err := export.ParseReportingBucket(*schemaVersion, *bucket); err != nil {
return err
}
now := deps.now()
hourStart, err := export.ParseReportingHourKey(args[0], now)
if err != nil {
Expand All @@ -52,6 +60,8 @@ func newExportHourCommand(deps exportReportingDeps) *cobra.Command {
Date: hourStart.Truncate(24 * time.Hour),
Now: now,
SchemaVersion: *schemaVersion,
ProjectKeys: *projectKeys,
Bucket: *bucket,
},
)
if err != nil {
Expand All @@ -68,11 +78,15 @@ func newExportHourCommand(deps exportReportingDeps) *cobra.Command {
},
}
schemaVersion = bindReportingSchemaVersion(command)
projectKeys = bindReportingProjectKeys(command)
bucket = bindReportingBucket(command)
return command
}

func newExportDayCommand(deps exportReportingDeps) *cobra.Command {
var schemaVersion *int
var projectKeys *[]string
var bucket *string
command := &cobra.Command{
Use: "day YYYY-MM-DD",
Short: "Export all closed UTC reporting hours for a date",
Expand All @@ -82,6 +96,12 @@ func newExportDayCommand(deps exportReportingDeps) *cobra.Command {
if err := validateReportingSchemaVersion(*schemaVersion); err != nil {
return err
}
if err := export.ValidateReportingProjectScope(*schemaVersion, *projectKeys); err != nil {
return err
}
if _, err := export.ParseReportingBucket(*schemaVersion, *bucket); err != nil {
return err
}
date, err := export.ParseReportingDate(args[0])
if err != nil {
return err
Expand All @@ -95,6 +115,7 @@ func newExportDayCommand(deps exportReportingDeps) *cobra.Command {
cmd.Context(),
db.ReportingExportOptions{
Date: date, Now: deps.now(), SchemaVersion: *schemaVersion,
ProjectKeys: *projectKeys, Bucket: *bucket,
},
)
if err != nil {
Expand All @@ -104,13 +125,17 @@ func newExportDayCommand(deps exportReportingDeps) *cobra.Command {
},
}
schemaVersion = bindReportingSchemaVersion(command)
projectKeys = bindReportingProjectKeys(command)
bucket = bindReportingBucket(command)
return command
}

func newExportDigestCommand(deps exportReportingDeps) *cobra.Command {
var fromValue string
var toValue string
var schemaVersion *int
var projectKeys *[]string
var bucket *string
command := &cobra.Command{
Use: "digest --from YYYY-MM-DD --to YYYY-MM-DD",
Short: "Export reporting digests for a UTC date range",
Expand All @@ -120,6 +145,13 @@ func newExportDigestCommand(deps exportReportingDeps) *cobra.Command {
if err := validateReportingSchemaVersion(*schemaVersion); err != nil {
return err
}
if err := export.ValidateReportingProjectScope(*schemaVersion, *projectKeys); err != nil {
return err
}
duration, err := export.ParseReportingBucket(*schemaVersion, *bucket)
if err != nil {
return err
}
if fromValue == "" || toValue == "" {
return fmt.Errorf("--from and --to are required")
}
Expand Down Expand Up @@ -155,6 +187,7 @@ func newExportDigestCommand(deps exportReportingDeps) *cobra.Command {
cmd.Context(),
db.ReportingExportOptions{
Date: date, Now: now, SchemaVersion: *schemaVersion,
ProjectKeys: *projectKeys, Bucket: *bucket,
},
)
if err != nil {
Expand All @@ -172,15 +205,21 @@ func newExportDigestCommand(deps exportReportingDeps) *cobra.Command {
HourDigests: hourDigests,
})
}
return writeCanonicalReportingDocument(cmd, export.ReportingDigest{
digest := export.ReportingDigest{
SchemaVersion: *schemaVersion,
From: fromValue,
To: toValue,
Days: days,
})
}
if *schemaVersion == export.ReportingJointSchemaVersion {
digest.BucketSeconds = int(duration / time.Second)
}
return writeCanonicalReportingDocument(cmd, digest)
},
}
schemaVersion = bindReportingSchemaVersion(command)
projectKeys = bindReportingProjectKeys(command)
bucket = bindReportingBucket(command)
command.Flags().StringVar(
&fromValue, "from", "", "First UTC date (YYYY-MM-DD)",
)
Expand All @@ -196,11 +235,25 @@ func bindReportingSchemaVersion(command *cobra.Command) *int {
version,
"schema-version",
export.ReportingSchemaVersion,
fmt.Sprintf("Reporting export schema version (only %d is supported)", export.ReportingSchemaVersion),
fmt.Sprintf("Reporting export schema version (%d or %d)", export.ReportingSchemaVersion, export.ReportingJointSchemaVersion),
)
return version
}

func bindReportingProjectKeys(command *cobra.Command) *[]string {
keys := new([]string)
command.Flags().StringArrayVar(keys, "project-key", nil,
"Limit the complete export to an archive project key (repeatable; schema 4)")
return keys
}

func bindReportingBucket(command *cobra.Command) *string {
bucket := new(string)
command.Flags().StringVar(bucket, "bucket", "",
"Bucket duration: whole-minute divisor of one hour (schema 4; default 5m)")
return bucket
}

func validateReportingSchemaVersion(version int) error {
if !export.IsSupportedReportingSchemaVersion(version) {
return fmt.Errorf("unsupported reporting schema version %d", version)
Expand Down
190 changes: 190 additions & 0 deletions cmd/agentsview/export_reporting_joint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package main

import (
"encoding/json/v2"
"errors"
"testing"
"time"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/export"
)

func TestExportJointProjectScopeAgreesAcrossHourDayAndDigest(t *testing.T) {
seedExportReportingGoldenArchive(t)
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
out, stderr, err := executeExportSessionsCommand(newExportReportingTestRoot(now),
"export", "day", "--schema-version", "4", "2026-07-28")
require.NoError(t, err)
assert.Empty(t, stderr)
var all export.ReportingDay
require.NoError(t, json.Unmarshal([]byte(out), &all))
var key string
foundStandalone := false
for _, cell := range all.Hours[11].Joint.Cells {
if cell.Project == reportingGoldenProject {
key = cell.ProjectKey
}
if cell.Agent == "cursor" {
foundStandalone = true
assert.Empty(t, cell.ProjectKey)
assert.Equal(t, "unknown", cell.Automation)
assert.Equal(t, int64(4_000), cell.Pricing.ReportedCost.Microdollars)
assert.Zero(t, cell.AgentMinutes)
}
}
require.True(t, foundStandalone, "standalone cost must remain visible without invented attribution")
require.NotEmpty(t, key)
out, stderr, err = executeExportSessionsCommand(newExportReportingTestRoot(now),
"export", "day", "--schema-version", "4", "--project-key", key, "2026-07-28")
require.NoError(t, err)
assert.Empty(t, stderr)
var day export.ReportingDay
require.NoError(t, json.Unmarshal([]byte(out), &day))
assert.Equal(t, int64(200), day.Hours[11].Usage.Totals.OutputTokens)
assert.Equal(t, 3.0, day.Hours[11].Activity.Totals.AgentMinutes)
assert.NotContains(t, out, "fixture-cross")
assert.NotContains(t, out, `"content"`)
for _, cell := range day.Hours[11].Joint.Cells {
assert.Equal(t, key, cell.ProjectKey)
}

hourOut, _, err := executeExportSessionsCommand(newExportReportingTestRoot(now),
"export", "hour", "--schema-version", "4", "--project-key", key, "2026-07-28-11")
require.NoError(t, err)
var hour export.ReportingHour
require.NoError(t, json.Unmarshal([]byte(hourOut), &hour))
assert.Equal(t, day.Hours[11], hour)
digestOut, _, err := executeExportSessionsCommand(newExportReportingTestRoot(now),
"export", "digest", "--schema-version", "4", "--project-key", key,
"--from", "2026-07-28", "--to", "2026-07-28")
require.NoError(t, err)
var digest export.ReportingDigest
require.NoError(t, json.Unmarshal([]byte(digestOut), &digest))
require.Len(t, digest.Days, 1)
assert.Equal(t, day.Digest, digest.Days[0].DayDigest)
assert.Equal(t, hour.Digest, digest.Days[0].HourDigests[11])
}

func TestExportJointRejectsScopeOnOldVersionBeforeOpening(t *testing.T) {
for _, args := range [][]string{
{"export", "hour", "2026-07-28-12"},
{"export", "day", "2026-07-28"},
{"export", "digest", "--from", "2026-07-28", "--to", "2026-07-28"},
} {
t.Run(args[1], func(t *testing.T) {
opened := false
deps := exportReportingDeps{now: time.Now,
openDatabase: func(*cobra.Command) (*db.DB, func(), error) {
opened = true
return nil, nil, errors.New("archive must not be opened")
}}
out, _, err := executeExportSessionsCommand(newExportReportingTestRootWithDeps(deps),
append(args, "--schema-version", "3", "--project-key", "synthetic-key")...)
assert.ErrorContains(t, err, "project scope requires reporting schema 4")
assert.False(t, opened)
assert.Empty(t, out)
})
}
}

func TestExportJointBucketResolutionAcrossCommands(t *testing.T) {
seedExportReportingGoldenArchive(t)
now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
quietDigests := make(map[string]string)
for _, tc := range []struct {
bucket string
seconds, count int
usageBucket string
firstBucketMinutes float64
}{
{"1m", 60, 60, "2026-07-28T11:10:00Z", 1},
{"2m", 120, 30, "2026-07-28T11:10:00Z", 2},
{"5m", 300, 12, "2026-07-28T11:10:00Z", 3},
{"15m", 900, 4, "2026-07-28T11:00:00Z", 3},
{"1h", 3600, 1, "2026-07-28T11:00:00Z", 3},
} {
t.Run(tc.bucket, func(t *testing.T) {
flags := []string{"--schema-version", "4", "--bucket", tc.bucket}
out, _, err := executeExportSessionsCommand(newExportReportingTestRoot(now),
append([]string{"export", "day", "2026-07-28"}, flags...)...)
require.NoError(t, err)
var day export.ReportingDay
require.NoError(t, json.Unmarshal([]byte(out), &day))
require.Len(t, day.Hours, 24)
for _, hour := range day.Hours {
assert.Len(t, hour.Activity.Buckets, tc.count, "quiet hours use the same resolution")
}
assert.Equal(t, 3.0, day.Hours[11].Activity.Totals.AgentMinutes, "bucket size must not change the inactivity gap cap")
assert.Equal(t, tc.firstBucketMinutes, day.Hours[11].Activity.Buckets[0].AgentMinutes)
assert.Equal(t, int64(256), day.Hours[11].Usage.Totals.OutputTokens)
assert.Equal(t, int64(71_000), day.Hours[11].Usage.Totals.Cost.Microdollars)
found := false
for _, cell := range day.Hours[11].Joint.Cells {
if cell.Model == reportingGoldenLatestModel && cell.Usage.OutputTokens > 0 {
found = true
assert.Equal(t, tc.usageBucket, cell.BucketStart)
assert.Equal(t, int64(200), cell.Usage.OutputTokens)
}
}
require.True(t, found)
quietDigests[tc.bucket] = day.Hours[0].Digest
hourOut, _, err := executeExportSessionsCommand(newExportReportingTestRoot(now),
append([]string{"export", "hour", "2026-07-28-11"}, flags...)...)
require.NoError(t, err)
var hour export.ReportingHour
require.NoError(t, json.Unmarshal([]byte(hourOut), &hour))
assert.Equal(t, day.Hours[11], hour)
digestOut, _, err := executeExportSessionsCommand(newExportReportingTestRoot(now),
append([]string{"export", "digest", "--from", "2026-07-28", "--to", "2026-07-28"}, flags...)...)
require.NoError(t, err)
var digest export.ReportingDigest
require.NoError(t, json.Unmarshal([]byte(digestOut), &digest))
require.Len(t, digest.Days, 1)
assert.Equal(t, day.Digest, digest.Days[0].DayDigest)
assert.Equal(t, hour.Digest, digest.Days[0].HourDigests[11])
for _, document := range []string{out, hourOut, digestOut} {
var wire map[string]any
require.NoError(t, json.Unmarshal([]byte(document), &wire))
assert.Equal(t, float64(tc.seconds), wire["bucket_seconds"])
}
})
}
assert.NotEqual(t, quietDigests["1m"], quietDigests["5m"], "even empty replacements bind resolution")
}

func TestExportJointRejectsInvalidBucketBeforeOpening(t *testing.T) {
for _, args := range [][]string{
{"export", "hour", "2026-07-28-12"},
{"export", "day", "2026-07-28"},
{"export", "digest", "--from", "2026-07-28", "--to", "2026-07-28"},
} {
for _, tc := range []struct{ version, bucket, message string }{
{"4", "0m", "whole-minute divisor"},
{"4", "-1m", "whole-minute divisor"},
{"4", "30s", "whole-minute divisor"},
{"4", "7m", "whole-minute divisor"},
{"4", "2h", "whole-minute divisor"},
{"4", "invalid", "invalid reporting bucket"},
{"3", "1m", "bucket selection requires reporting schema 4"},
} {
t.Run(args[1]+"/v"+tc.version+"/"+tc.bucket, func(t *testing.T) {
opened := false
deps := exportReportingDeps{now: time.Now,
openDatabase: func(*cobra.Command) (*db.DB, func(), error) {
opened = true
return nil, nil, errors.New("archive must not be opened")
}}
out, _, err := executeExportSessionsCommand(newExportReportingTestRootWithDeps(deps),
append(append([]string(nil), args...), "--schema-version", tc.version, "--bucket", tc.bucket)...)
assert.ErrorContains(t, err, tc.message)
assert.False(t, opened)
assert.Empty(t, out)
})
}
}
}
2 changes: 1 addition & 1 deletion cmd/agentsview/export_reporting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func TestExportReportingSchemaVersionRejectsBeforeOpen(t *testing.T) {
{"export", "day", "2026-07-28"},
{"export", "digest", "--from", "2026-07-28", "--to", "2026-07-28"},
} {
for _, version := range []int{1, 2, 4} {
for _, version := range []int{1, 2, 99} {
t.Run(args[1]+"/"+strconv.Itoa(version), func(t *testing.T) {
opened := false
deps := exportReportingDeps{
Expand Down
11 changes: 11 additions & 0 deletions docs/internal/performance-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,17 @@ shape (call + late output, full inline signal/secret maintenance).

## Adding a benchmark to local comparisons

`BenchmarkReportingJointDay` compares reporting v3 and v4 at one- and
five-minute resolutions on the same synthetic SQLite archive: 200 sessions,
eight models, three agents, model switches and overlap, with four or 100
projects. It measures snapshot reads, aggregation, canonical digests and
serialization, and reports allocations, bytes and cell count. Compare resolution
independently of project cardinality; sparse cell counts and payload sizes do
not grow in direct proportion to bucket counts. Setup is outside the timer. It
does not measure CLI startup, full-history digest screening or a
production-sized archive. Run it directly with
`go test -tags fts5 ./internal/db -run '^$' -bench '^BenchmarkReportingJointDay$' -benchmem`.

The local comparison includes every benchmark in the selected packages. A
benchmark present in only one revision has no baseline and is reported without a
threshold check.
Expand Down
Loading
Loading