Skip to content

Commit 95fce66

Browse files
authored
feat: move metadata labels to client level with per-instance merge (#164)
## Summary Comprehensive metadata labels support across the UI, building on the backend label infrastructure. ### Label Filtering - Replace single-value label filters with multi-value chip UI (OR within key, AND across keys) on the runs table, suite detail page, and suites page — consistent Grafana-style filter chips everywhere - URL format: `key1:val1|val2,key2:val3` ### Label Visibility - **Run detail page**: show metadata labels as blue chips between stats cards and GitHub section - **Runs table**: expandable row via tag icon (rightmost column) with label chips and instance ID; hover popover on the tag icon; suite hash hover popover showing suite name, hash, filter, test count, and labels - **Heatmap tooltip**: show instance ID and labels when hovering over run squares ### Suite Detail — Group By - Add group-by selector (label key or instance ID) to the suite detail Runs tab - **Heatmap**: renders grouped sections with headers, each containing per-client rows with independent stats - **Charts**: separate series per client+group combo via client name suffixing - **Chart filters**: client toggle badges and label chip filters for the Run Charts section, using base client names so they work correctly with group-by active - Fix ECharts `notMerge` so filtered-out series actually disappear ### Suite Detail — Compare Buttons - Per-group compare button in heatmap headers (compare latest successful run per client within a group) - Cross-group compare button per client badge (compare a client's latest successful run across all groups) - Cross-group compare defaults the compare page label mode to the group-by key ### Compare Page - Dynamic label mode options built from runs' metadata (in addition to None and Instance ID) - Label mode stored in URL as `label:{key}` for metadata labels ### Infrastructure - Centralize client chart colors in `client-colors.ts` with `getBaseClient()` extraction so grouped names like "geth / mainnet" resolve to correct colors in badges, charts, and logos ## Test plan - [x] Verify label filter chips work on /runs, suite detail runs tab, and /suites pages - [x] Verify labels show on run detail page, runs table (tag icon + popover), and heatmap tooltip - [x] Test group-by on suite detail: heatmap sections, chart series, chart filters - [x] Test per-group and cross-group compare buttons navigate correctly - [x] Verify compare page shows dynamic label options and defaults from group-by - [x] Verify client colors are correct across all views when group-by is active
1 parent e38c5c8 commit 95fce66

29 files changed

Lines changed: 1547 additions & 365 deletions

cmd/benchmarkoor/run.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ func runBenchmark(cmd *cobra.Command, args []string) error {
6464
return fmt.Errorf("invalid metadata label %q: must be key=value", entry)
6565
}
6666

67-
if cfg.Runner.Metadata.Labels == nil {
68-
cfg.Runner.Metadata.Labels = make(map[string]string, len(metadataLabels))
67+
if cfg.Runner.Client.Config.Metadata.Labels == nil {
68+
cfg.Runner.Client.Config.Metadata.Labels = make(map[string]string, len(metadataLabels))
6969
}
7070

71-
cfg.Runner.Metadata.Labels[k] = v
71+
cfg.Runner.Client.Config.Metadata.Labels[k] = v
7272
}
7373

7474
// Parse results owner configuration.

config.example.yaml

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,6 @@ runner:
2020
# Uses Go duration format (e.g., "1h", "30m", "2h30m").
2121
# Can also be set via BENCHMARKOOR_RUNNER_RUN_TIMEOUT environment variable.
2222
# run_timeout: 4h
23-
# Optional: Arbitrary key-value labels attached to this benchmark run.
24-
# Labels appear in the run's config.json and can be used for filtering/organization.
25-
# Can also be set via CLI: --metadata.label=env=production --metadata.label=team=platform
26-
# metadata:
27-
# labels:
28-
# env: production
29-
# team: platform
3023
# Optional directory configurations.
3124
# directories:
3225
# # Directory for temporary datadir copies (defaults to system temp).
@@ -382,6 +375,14 @@ runner:
382375
# # CPU frequency governor (common: performance, powersave, schedutil)
383376
# # Defaults to "performance" when cpu_freq is set
384377
# cpu_freq_governor: performance
378+
# Optional: Default metadata labels for all instances.
379+
# Labels appear in each run's config.json and can be used for filtering/organization.
380+
# Can also be set via CLI: --metadata.label=env=production --metadata.label=team=platform
381+
# Instance-level labels (see instances below) are merged and take precedence.
382+
# metadata:
383+
# labels:
384+
# env: production
385+
# team: platform
385386
# Genesis file URLs per client type
386387
genesis:
387388
besu: https://github.com/nethermindeth/gas-benchmarks/raw/refs/heads/main/scripts/genesisfiles/besu/zkevmgenesis.json
@@ -470,6 +471,9 @@ runner:
470471
# enabled: true
471472
# filename: trace
472473
# bootstrap_fcu: true # Instance-level override (optional)
474+
# metadata: # Instance-level labels (optional, merged with client defaults, instance wins)
475+
# labels:
476+
# variant: snap-sync
473477

474478
# Example: running multiple clients
475479
# - id: nethermind-latest

docs/configuration.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,30 @@ sudo systemctl start podman.socket
141141

142142
#### Metadata Labels
143143

144-
The `runner.metadata.labels` field attaches arbitrary key-value pairs to a benchmark run. Labels are included in the output `config.json` and can be used for filtering and organization (e.g., in the UI or CI pipelines).
144+
The `runner.client.config.metadata.labels` field attaches arbitrary key-value pairs to benchmark runs. Labels are included in each run's output `config.json` and can be used for filtering and organization (e.g., in the UI or CI pipelines).
145+
146+
Labels can be set at the client level (defaults for all instances) and overridden per instance. Instance-level labels are merged with client-level labels, with instance values taking precedence on conflict.
145147

146148
```yaml
147149
runner:
148-
metadata:
149-
labels:
150-
env: production
151-
team: platform
150+
client:
151+
config:
152+
metadata:
153+
labels:
154+
env: production
155+
team: platform
156+
instances:
157+
- id: geth-latest
158+
client: geth
159+
metadata:
160+
labels:
161+
env: staging # overrides client-level "env"
162+
variant: snap-sync # additional instance-specific label
152163
```
153164

154-
Labels can also be set (or overridden) via the CLI flag `--metadata.label`:
165+
In this example, `geth-latest` runs will have labels `env=staging`, `team=platform`, and `variant=snap-sync`.
166+
167+
Labels can also be set (or overridden) at the client level via the CLI flag `--metadata.label`:
155168

156169
```bash
157170
benchmarkoor run --config config.yaml \

pkg/api/handlers_index.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
6969
entry.Tests.Steps = &executor.IndexStepsStats{}
7070
}
7171

72+
if run.MetadataJSON != "" {
73+
var m map[string]string
74+
if json.Unmarshal([]byte(run.MetadataJSON), &m) == nil {
75+
entry.Metadata = m
76+
}
77+
}
78+
7279
entries = append(entries, indexEntryWithDP{
7380
DiscoveryPath: run.DiscoveryPath,
7481
IndexEntry: entry,

pkg/api/indexer/indexer.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,15 @@ func (idx *indexer) indexRun(
430430
}
431431
}
432432

433+
// Serialize metadata labels to JSON.
434+
metadataJSON := ""
435+
if len(entry.Metadata) > 0 {
436+
b, mErr := json.Marshal(entry.Metadata)
437+
if mErr == nil {
438+
metadataJSON = string(b)
439+
}
440+
}
441+
433442
now := time.Now().UTC()
434443

435444
run := &indexstore.Run{
@@ -449,6 +458,7 @@ func (idx *indexer) indexRun(
449458
TestsPassed: entry.Tests.TestsPassed,
450459
TestsFailed: entry.Tests.TestsFailed,
451460
StepsJSON: stepsJSON,
461+
MetadataJSON: metadataJSON,
452462
IndexedAt: now,
453463
}
454464

pkg/api/indexstore/run.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ type Run struct {
2828
// Per-step stats serialized as JSON.
2929
StepsJSON string `gorm:"type:text"`
3030

31+
// Run metadata labels serialized as JSON.
32+
MetadataJSON string `gorm:"type:text"`
33+
3134
IndexedAt time.Time
3235
ReindexedAt *time.Time
3336
}

pkg/api/openapi.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,6 +1474,11 @@ components:
14741474
type: string
14751475
termination_reason:
14761476
type: string
1477+
metadata:
1478+
type: object
1479+
additionalProperties:
1480+
type: string
1481+
description: Run metadata labels (key-value pairs)
14771482
instance:
14781483
type: object
14791484
properties:

pkg/config/config.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ type RunnerConfig struct {
7979
DropCachesPath string `yaml:"drop_caches_path,omitempty" mapstructure:"drop_caches_path"`
8080
CPUSysfsPath string `yaml:"cpu_sysfs_path,omitempty" mapstructure:"cpu_sysfs_path"`
8181
GitHubToken string `yaml:"github_token,omitempty" mapstructure:"github_token"`
82-
Metadata MetadataConfig `yaml:"metadata,omitempty" mapstructure:"metadata"`
8382
Benchmark BenchmarkConfig `yaml:"benchmark" mapstructure:"benchmark"`
8483
Client ClientConfig `yaml:"client" mapstructure:"client"`
8584
Instances []ClientInstance `yaml:"instances" mapstructure:"instances"`
@@ -599,6 +598,7 @@ type ClientDefaults struct {
599598
PostTestSleepDuration string `yaml:"post_test_sleep_duration,omitempty" mapstructure:"post_test_sleep_duration"`
600599
BootstrapFCU *BootstrapFCUConfig `yaml:"bootstrap_fcu,omitempty" mapstructure:"bootstrap_fcu"`
601600
CheckpointRestoreStrategyOptions *CheckpointRestoreStrategyOptions `yaml:"checkpoint_restore_strategy_options,omitempty" mapstructure:"checkpoint_restore_strategy_options"`
601+
Metadata MetadataConfig `yaml:"metadata,omitempty" mapstructure:"metadata"`
602602
}
603603

604604
// ClientInstance defines a single client instance to benchmark.
@@ -624,6 +624,7 @@ type ClientInstance struct {
624624
PostTestSleepDuration string `yaml:"post_test_sleep_duration,omitempty" mapstructure:"post_test_sleep_duration"`
625625
BootstrapFCU *BootstrapFCUConfig `yaml:"bootstrap_fcu,omitempty" mapstructure:"bootstrap_fcu"`
626626
CheckpointRestoreStrategyOptions *CheckpointRestoreStrategyOptions `yaml:"checkpoint_restore_strategy_options,omitempty" mapstructure:"checkpoint_restore_strategy_options"`
627+
Metadata MetadataConfig `yaml:"metadata,omitempty" mapstructure:"metadata"`
627628
}
628629

629630
// expandEnvWithDefaults is a mapping function for os.Expand that supports
@@ -1458,6 +1459,29 @@ func (c *Config) GetCheckpointRestartContainer(instance *ClientInstance) bool {
14581459
return opts.RestartContainer
14591460
}
14601461

1462+
// GetMetadataLabels returns the merged metadata labels for an instance.
1463+
// Client-level metadata labels serve as defaults; instance-level labels
1464+
// override specific keys.
1465+
func (c *Config) GetMetadataLabels(instance *ClientInstance) map[string]string {
1466+
defaults := c.Runner.Client.Config.Metadata.Labels
1467+
overrides := instance.Metadata.Labels
1468+
1469+
if len(defaults) == 0 && len(overrides) == 0 {
1470+
return nil
1471+
}
1472+
1473+
merged := make(map[string]string, len(defaults)+len(overrides))
1474+
for k, v := range defaults {
1475+
merged[k] = v
1476+
}
1477+
1478+
for k, v := range overrides {
1479+
merged[k] = v
1480+
}
1481+
1482+
return merged
1483+
}
1484+
14611485
// ParseByteSize parses a human-readable byte size string into bytes.
14621486
// Uses the same format as resource_limits.memory (Docker go-units):
14631487
// e.g. "32g", "512m", "1024k", "1073741824".

pkg/config/config_test.go

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,14 +1195,14 @@ func TestLoad_MetadataLabels(t *testing.T) {
11951195
t.Run("parses labels from yaml", func(t *testing.T) {
11961196
configContent := `
11971197
runner:
1198-
metadata:
1199-
labels:
1200-
env: production
1201-
team: platform
12021198
client:
12031199
config:
12041200
genesis:
12051201
geth: http://example.com/genesis.json
1202+
metadata:
1203+
labels:
1204+
env: production
1205+
team: platform
12061206
instances:
12071207
- id: test-instance
12081208
client: geth
@@ -1214,9 +1214,9 @@ runner:
12141214
cfg, err := Load(configPath)
12151215
require.NoError(t, err)
12161216

1217-
require.Len(t, cfg.Runner.Metadata.Labels, 2)
1218-
assert.Equal(t, "production", cfg.Runner.Metadata.Labels["env"])
1219-
assert.Equal(t, "platform", cfg.Runner.Metadata.Labels["team"])
1217+
require.Len(t, cfg.Runner.Client.Config.Metadata.Labels, 2)
1218+
assert.Equal(t, "production", cfg.Runner.Client.Config.Metadata.Labels["env"])
1219+
assert.Equal(t, "platform", cfg.Runner.Client.Config.Metadata.Labels["team"])
12201220
})
12211221

12221222
t.Run("empty metadata produces no errors", func(t *testing.T) {
@@ -1237,18 +1237,18 @@ runner:
12371237
cfg, err := Load(configPath)
12381238
require.NoError(t, err)
12391239

1240-
assert.Nil(t, cfg.Runner.Metadata.Labels)
1240+
assert.Nil(t, cfg.Runner.Client.Config.Metadata.Labels)
12411241
})
12421242

12431243
t.Run("empty labels map produces no errors", func(t *testing.T) {
12441244
configContent := `
12451245
runner:
1246-
metadata:
1247-
labels: {}
12481246
client:
12491247
config:
12501248
genesis:
12511249
geth: http://example.com/genesis.json
1250+
metadata:
1251+
labels: {}
12521252
instances:
12531253
- id: test-instance
12541254
client: geth
@@ -1260,10 +1260,66 @@ runner:
12601260
cfg, err := Load(configPath)
12611261
require.NoError(t, err)
12621262

1263-
assert.Empty(t, cfg.Runner.Metadata.Labels)
1263+
assert.Empty(t, cfg.Runner.Client.Config.Metadata.Labels)
12641264
})
12651265
}
12661266

1267+
func TestGetMetadataLabels(t *testing.T) {
1268+
tests := []struct {
1269+
name string
1270+
clientLabels map[string]string
1271+
instanceLabels map[string]string
1272+
expected map[string]string
1273+
}{
1274+
{
1275+
name: "no labels at either level",
1276+
expected: nil,
1277+
},
1278+
{
1279+
name: "only client-level labels",
1280+
clientLabels: map[string]string{"env": "production", "team": "platform"},
1281+
expected: map[string]string{"env": "production", "team": "platform"},
1282+
},
1283+
{
1284+
name: "only instance-level labels",
1285+
instanceLabels: map[string]string{"variant": "snap-sync"},
1286+
expected: map[string]string{"variant": "snap-sync"},
1287+
},
1288+
{
1289+
name: "both levels no overlap",
1290+
clientLabels: map[string]string{"env": "production"},
1291+
instanceLabels: map[string]string{"variant": "snap-sync"},
1292+
expected: map[string]string{"env": "production", "variant": "snap-sync"},
1293+
},
1294+
{
1295+
name: "instance overrides client on conflict",
1296+
clientLabels: map[string]string{"env": "production", "team": "platform"},
1297+
instanceLabels: map[string]string{"env": "staging"},
1298+
expected: map[string]string{"env": "staging", "team": "platform"},
1299+
},
1300+
}
1301+
1302+
for _, tt := range tests {
1303+
t.Run(tt.name, func(t *testing.T) {
1304+
cfg := &Config{
1305+
Runner: RunnerConfig{
1306+
Client: ClientConfig{
1307+
Config: ClientDefaults{
1308+
Metadata: MetadataConfig{Labels: tt.clientLabels},
1309+
},
1310+
},
1311+
},
1312+
}
1313+
instance := &ClientInstance{
1314+
Metadata: MetadataConfig{Labels: tt.instanceLabels},
1315+
}
1316+
1317+
result := cfg.GetMetadataLabels(instance)
1318+
assert.Equal(t, tt.expected, result)
1319+
})
1320+
}
1321+
}
1322+
12671323
func TestValidateAPIStorage(t *testing.T) {
12681324
// Helper to build a Config with API storage and minimal valid fields.
12691325
makeConfig := func(s3Cfg *APIS3Config) Config {

pkg/executor/index.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ type Index struct {
2323

2424
// IndexEntry contains summary information for a single benchmark run.
2525
type IndexEntry struct {
26-
RunID string `json:"run_id"`
27-
Timestamp int64 `json:"timestamp"`
28-
TimestampEnd int64 `json:"timestamp_end,omitempty"`
29-
SuiteHash string `json:"suite_hash,omitempty"`
30-
Instance *IndexInstance `json:"instance"`
31-
Tests *IndexTestStats `json:"tests"`
32-
Status string `json:"status,omitempty"`
33-
TerminationReason string `json:"termination_reason,omitempty"`
26+
RunID string `json:"run_id"`
27+
Timestamp int64 `json:"timestamp"`
28+
TimestampEnd int64 `json:"timestamp_end,omitempty"`
29+
SuiteHash string `json:"suite_hash,omitempty"`
30+
Instance *IndexInstance `json:"instance"`
31+
Tests *IndexTestStats `json:"tests"`
32+
Status string `json:"status,omitempty"`
33+
TerminationReason string `json:"termination_reason,omitempty"`
34+
Metadata map[string]string `json:"metadata,omitempty"`
3435
}
3536

3637
// IndexInstance contains the client instance information for the index.
@@ -84,6 +85,9 @@ type runConfigJSON struct {
8485
Passed int `json:"passed"`
8586
Failed int `json:"failed"`
8687
} `json:"test_counts,omitempty"`
88+
Metadata *struct {
89+
Labels map[string]string `json:"labels"`
90+
} `json:"metadata,omitempty"`
8791
}
8892

8993
// GenerateIndex scans the results directory and builds an index from all runs.
@@ -368,7 +372,7 @@ func BuildIndexEntryFromData(
368372
testStats.TestsFailed = runConfig.TestCounts.Failed
369373
}
370374

371-
return &IndexEntry{
375+
entry := &IndexEntry{
372376
RunID: runID,
373377
Timestamp: runConfig.Timestamp,
374378
TimestampEnd: runConfig.TimestampEnd,
@@ -382,7 +386,13 @@ func BuildIndexEntryFromData(
382386
RollbackStrategy: runConfig.Instance.RollbackStrategy,
383387
},
384388
Tests: testStats,
385-
}, nil
389+
}
390+
391+
if runConfig.Metadata != nil && len(runConfig.Metadata.Labels) > 0 {
392+
entry.Metadata = runConfig.Metadata.Labels
393+
}
394+
395+
return entry, nil
386396
}
387397

388398
// WriteIndex writes the index to index.json in the runs subdirectory.

0 commit comments

Comments
 (0)