diff --git a/README.md b/README.md
index 9c0df982..4b891459 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,22 @@ If neither sudo nor root access is available, an administrator must apply the fo
Once the configuration changes are applied, use the `--noroot` flag on the command line, for example, `perfspect metrics --noroot`.
+##### Refining Metrics to a Specific Time Range
+After collecting metrics, you can generate new summary reports for a specific time interval using the `metrics trim` subcommand. This is useful when you've collected metrics for an entire workload but want to analyze only a specific portion, excluding setup, teardown, or other unwanted phases.
+
+The time range can be specified using either absolute timestamps (seconds since epoch) or relative offsets from the beginning/end of the data. At least one time parameter must be specified.
+
+The trimmed CSV and HTML summary files will be placed in a new output directory. The output directory can be specified using the `--output` flag.
+
+**Examples:**
+
+# Skip the first 10 seconds and last 5 seconds
+$ ./perfspect metrics trim --input perfspect_2025-11-28_09-21-56 --start-offset 10 --end-offset 5
+
+# Use absolute timestamps (seconds since epoch)
+$ ./perfspect metrics trim --input perfspect_2025-11-28_09-21-56 --start-time 1764174327 --end-time 1764174351
+
+
##### Prometheus Endpoint
The `metrics` command can expose metrics via a Prometheus compatible `metrics` endpoint. This allows integration with Prometheus monitoring systems. To enable the Prometheus endpoint, use the `--prometheus-server` flag. By default, the endpoint listens on port 9090. The port can be changed using the `--prometheus-server-addr` flag. Run `perfspect metrics --prometheus-server`. See the [example daemonset](docs/perfspect-daemonset.md) for deploying in Kubernetes.
@@ -149,7 +165,7 @@ $ ./perfspect metrics --syslog
##### Report Files
-By default, PerfSpect creates a unique directory in the user's current working directory to store output files. Users can specify a custom output directory, but the directory provided must exist; PerfSpect will not create it.
+By default, PerfSpect creates a unique directory in the user's current working directory to store output files. Users can specify a custom output directory with the --output flag.
$./perfspect telemetry --output /home/elaine/perfspect/telemetry
diff --git a/cmd/metrics/metadata.go b/cmd/metrics/metadata.go
index 806ebf15..c7e94901 100644
--- a/cmd/metrics/metadata.go
+++ b/cmd/metrics/metadata.go
@@ -539,12 +539,19 @@ func (md Metadata) String() string {
return string(jsonData)
}
+func (md Metadata) Initialized() bool {
+ return md.SocketCount != 0 && md.CoresPerSocket != 0
+}
+
// JSON converts the Metadata struct to a JSON-encoded byte slice.
//
// Returns:
// - out: JSON-encoded byte slice representation of the Metadata.
// - err: error encountered during the marshaling process, if any.
func (md Metadata) JSON() (out []byte, err error) {
+ if !md.Initialized() {
+ return []byte("null"), nil
+ }
if out, err = json.Marshal(md); err != nil {
slog.Error("failed to marshal metadata structure", slog.String("error", err.Error()))
return
diff --git a/cmd/metrics/metrics.go b/cmd/metrics/metrics.go
index 1cdcbc74..f17f1d63 100644
--- a/cmd/metrics/metrics.go
+++ b/cmd/metrics/metrics.go
@@ -265,6 +265,10 @@ func usageFunc(cmd *cobra.Command) error {
cmd.Printf(" --%-20s %s%s\n", flag.Name, flag.Help, flagDefault)
}
}
+ cmd.Printf("\nSubcommands:\n")
+ for _, subCmd := range cmd.Commands() {
+ cmd.Printf(" %s: %s\n", subCmd.Name(), subCmd.Short)
+ }
cmd.Println("\nGlobal Flags:")
cmd.Parent().PersistentFlags().VisitAll(func(pf *pflag.Flag) {
flagDefault := ""
diff --git a/cmd/metrics/resources/base.html b/cmd/metrics/resources/base.html
index e5cafb3f..879646c2 100644
--- a/cmd/metrics/resources/base.html
+++ b/cmd/metrics/resources/base.html
@@ -967,7 +967,7 @@
- {system_info.map(([key, value]) => (
+ {system_info && system_info.map(([key, value]) => (
{JSON.stringify(key)}
@@ -994,7 +994,7 @@
- {Object.entries(metadata).sort(([key1], [key2]) => key1.localeCompare(key2)).map(([key, value]) => (
+ {metadata && Object.entries(metadata).sort(([key1], [key2]) => key1.localeCompare(key2)).map(([key, value]) => (
{JSON.stringify(key)}
diff --git a/cmd/metrics/summary.go b/cmd/metrics/summary.go
index 20a9dd84..2b27b867 100644
--- a/cmd/metrics/summary.go
+++ b/cmd/metrics/summary.go
@@ -25,17 +25,29 @@ import (
"github.com/casbin/govaluate"
)
+// summarizeMetrics reads the metrics CSV from localOutputDir for targetName,
+// generates summary files (CSV and HTML) using the provided metadata and metric definitions,
+// and returns a list of created summary file paths.
func summarizeMetrics(localOutputDir string, targetName string, metadata Metadata, metricDefinitions []MetricDefinition) ([]string, error) {
+ return summarizeMetricsWithTrim(localOutputDir, localOutputDir, targetName, metadata, metricDefinitions, 0, 0)
+}
+func summarizeMetricsWithTrim(localInputDir, localOutputDir, targetName string, metadata Metadata, metricDefinitions []MetricDefinition, startTimestamp, endTimestamp int) ([]string, error) {
filesCreated := []string{}
// read the metrics from CSV
- csvMetricsFile := filepath.Join(localOutputDir, targetName+"_metrics.csv")
+ csvMetricsFile := filepath.Join(localInputDir, targetName+"_metrics.csv")
metrics, err := newMetricCollection(csvMetricsFile)
if err != nil {
return filesCreated, fmt.Errorf("failed to read metrics from %s: %w", csvMetricsFile, err)
}
- // exclude the final sample if metrics were collected with a workload
- if metadata.WithWorkload {
- metrics.excludeFinalSample()
+ if startTimestamp != 0 || endTimestamp != 0 {
+ // trim the metrics to the specified time range
+ metrics.filterByTimeRange(startTimestamp, endTimestamp)
+ } else {
+ // trim time range not specified,
+ // exclude the final sample if metrics were collected with a workload
+ if metadata.WithWorkload {
+ metrics.excludeFinalSample()
+ }
}
// csv summary
out, err := metrics.getCSV()
@@ -74,7 +86,7 @@ type metricStats struct {
}
type row struct {
- timestamp float64
+ timestamp int
socket string
cpu string
cgroup string
@@ -87,8 +99,8 @@ func newRow(fields []string, names []string) (r row, err error) {
for fIdx, field := range fields {
switch fIdx {
case idxTimestamp:
- var ts float64
- if ts, err = strconv.ParseFloat(field, 64); err != nil {
+ var ts int
+ if ts, err = strconv.Atoi(field); err != nil {
return
}
r.timestamp = ts
@@ -336,8 +348,8 @@ func (mc MetricCollection) aggregate() (m *MetricGroup, err error) {
groupByValue: "",
}
// aggregate the rows by timestamp
- timestampMap := make(map[float64][]map[string]float64) // map of timestamp to list of metric maps
- var timestamps []float64 // list of timestamps in order
+ timestampMap := make(map[int][]map[string]float64) // map of timestamp to list of metric maps
+ var timestamps []int // list of timestamps in order
for _, metrics := range mc {
for _, row := range metrics.rows {
if _, ok := timestampMap[row.timestamp]; !ok {
@@ -816,3 +828,21 @@ func (mc MetricCollection) getCSV() (out string, err error) {
}
return
}
+
+// filterByTimeRange filters all metric groups to only include rows within the specified time range
+func (mc MetricCollection) filterByTimeRange(startTime, endTime int) {
+ for i := range mc {
+ mc[i].filterByTimeRange(startTime, endTime)
+ }
+}
+
+// filterByTimeRange filters the metric group to only include rows within the specified time range
+func (mg *MetricGroup) filterByTimeRange(startTime, endTime int) {
+ var filteredRows []row
+ for _, row := range mg.rows {
+ if row.timestamp >= startTime && row.timestamp <= endTime {
+ filteredRows = append(filteredRows, row)
+ }
+ }
+ mg.rows = filteredRows
+}
diff --git a/cmd/metrics/summary_test.go b/cmd/metrics/summary_test.go
index 0f15fd0f..c4aaa218 100644
--- a/cmd/metrics/summary_test.go
+++ b/cmd/metrics/summary_test.go
@@ -14,46 +14,46 @@ func TestExcludeFinalSample(t *testing.T) {
name string
inputRows []row
expectedCount int
- expectedMaxTS float64
+ expectedMaxTS int
}{
{
name: "exclude single final timestamp",
inputRows: []row{
- {timestamp: 5.0, metrics: map[string]float64{"metric1": 100.0}},
- {timestamp: 10.0, metrics: map[string]float64{"metric1": 200.0}},
- {timestamp: 15.0, metrics: map[string]float64{"metric1": 150.0}},
- {timestamp: 20.0, metrics: map[string]float64{"metric1": 50.0}}, // this should be excluded
+ {timestamp: 5, metrics: map[string]float64{"metric1": 100.0}},
+ {timestamp: 10, metrics: map[string]float64{"metric1": 200.0}},
+ {timestamp: 15, metrics: map[string]float64{"metric1": 150.0}},
+ {timestamp: 20, metrics: map[string]float64{"metric1": 50.0}}, // this should be excluded
},
expectedCount: 3,
- expectedMaxTS: 15.0,
+ expectedMaxTS: 15,
},
{
name: "exclude multiple rows with same final timestamp",
inputRows: []row{
- {timestamp: 5.0, socket: "0", metrics: map[string]float64{"metric1": 100.0}},
- {timestamp: 10.0, socket: "0", metrics: map[string]float64{"metric1": 200.0}},
- {timestamp: 15.0, socket: "0", metrics: map[string]float64{"metric1": 150.0}},
- {timestamp: 15.0, socket: "1", metrics: map[string]float64{"metric1": 160.0}}, // same timestamp, different socket
+ {timestamp: 5, socket: "0", metrics: map[string]float64{"metric1": 100.0}},
+ {timestamp: 10, socket: "0", metrics: map[string]float64{"metric1": 200.0}},
+ {timestamp: 15, socket: "0", metrics: map[string]float64{"metric1": 150.0}},
+ {timestamp: 15, socket: "1", metrics: map[string]float64{"metric1": 160.0}}, // same timestamp, different socket
},
expectedCount: 2,
- expectedMaxTS: 10.0,
+ expectedMaxTS: 10,
},
{
name: "single sample - should not exclude",
inputRows: []row{
- {timestamp: 5.0, metrics: map[string]float64{"metric1": 100.0}},
+ {timestamp: 5, metrics: map[string]float64{"metric1": 100.0}},
},
expectedCount: 1,
- expectedMaxTS: 5.0,
+ expectedMaxTS: 5,
},
{
name: "two samples - exclude last one",
inputRows: []row{
- {timestamp: 5.0, metrics: map[string]float64{"metric1": 100.0}},
- {timestamp: 10.0, metrics: map[string]float64{"metric1": 50.0}},
+ {timestamp: 5, metrics: map[string]float64{"metric1": 100.0}},
+ {timestamp: 10, metrics: map[string]float64{"metric1": 50.0}},
},
expectedCount: 1,
- expectedMaxTS: 5.0,
+ expectedMaxTS: 5,
},
}
@@ -93,9 +93,9 @@ func TestExcludeFinalSampleMultipleGroups(t *testing.T) {
groupByField: "SKT",
groupByValue: "0",
rows: []row{
- {timestamp: 5.0, socket: "0", metrics: map[string]float64{"metric1": 100.0}},
- {timestamp: 10.0, socket: "0", metrics: map[string]float64{"metric1": 200.0}},
- {timestamp: 15.0, socket: "0", metrics: map[string]float64{"metric1": 50.0}}, // should be excluded
+ {timestamp: 5, socket: "0", metrics: map[string]float64{"metric1": 100.0}},
+ {timestamp: 10, socket: "0", metrics: map[string]float64{"metric1": 200.0}},
+ {timestamp: 15, socket: "0", metrics: map[string]float64{"metric1": 50.0}}, // should be excluded
},
},
MetricGroup{
@@ -103,9 +103,9 @@ func TestExcludeFinalSampleMultipleGroups(t *testing.T) {
groupByField: "SKT",
groupByValue: "1",
rows: []row{
- {timestamp: 5.0, socket: "1", metrics: map[string]float64{"metric1": 110.0}},
- {timestamp: 10.0, socket: "1", metrics: map[string]float64{"metric1": 210.0}},
- {timestamp: 15.0, socket: "1", metrics: map[string]float64{"metric1": 60.0}}, // should be excluded
+ {timestamp: 5, socket: "1", metrics: map[string]float64{"metric1": 110.0}},
+ {timestamp: 10, socket: "1", metrics: map[string]float64{"metric1": 210.0}},
+ {timestamp: 15, socket: "1", metrics: map[string]float64{"metric1": 60.0}}, // should be excluded
},
},
}
@@ -117,8 +117,8 @@ func TestExcludeFinalSampleMultipleGroups(t *testing.T) {
assert.Equal(t, 2, len(mc[1].rows), "socket 1 should have 2 rows")
// Verify max timestamps
- assert.Equal(t, 10.0, mc[0].rows[1].timestamp, "socket 0 max timestamp should be 10.0")
- assert.Equal(t, 10.0, mc[1].rows[1].timestamp, "socket 1 max timestamp should be 10.0")
+ assert.Equal(t, 10, mc[0].rows[1].timestamp, "socket 0 max timestamp should be 10")
+ assert.Equal(t, 10, mc[1].rows[1].timestamp, "socket 1 max timestamp should be 10")
}
func TestExcludeFinalSampleEmptyCollection(t *testing.T) {
diff --git a/cmd/metrics/trim.go b/cmd/metrics/trim.go
new file mode 100644
index 00000000..df3251fa
--- /dev/null
+++ b/cmd/metrics/trim.go
@@ -0,0 +1,399 @@
+package metrics
+
+// Copyright (C) 2021-2025 Intel Corporation
+// SPDX-License-Identifier: BSD-3-Clause
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "perfspect/internal/common"
+ "perfspect/internal/util"
+
+ "github.com/spf13/cobra"
+)
+
+const trimCmdName = "trim"
+
+// trim command flags
+var (
+ flagTrimInput string
+ flagTrimStartTime int
+ flagTrimEndTime int
+ flagTrimStartOffset int
+ flagTrimEndOffset int
+)
+
+const (
+ flagTrimInputName = "input"
+ flagTrimStartTimeName = "start-time"
+ flagTrimEndTimeName = "end-time"
+ flagTrimStartOffsetName = "start-offset"
+ flagTrimEndOffsetName = "end-offset"
+)
+
+var trimExamples = []string{
+ " Skip first 30 seconds: $ perfspect metrics trim --input perfspect_2025-11-28_09-21-56 --start-offset 30",
+ " Skip first 10 seconds and last 5 seconds: $ perfspect metrics trim --input perfspect_2025-11-28_09-21-56 --start-offset 10 --end-offset 5",
+ " Use absolute timestamps and specific CSV: $ perfspect metrics trim --input perfspect_2025-11-28_09-21-56/myhost_metrics.csv --start-time 1764174327 --end-time 1764174351",
+}
+
+var trimCmd = &cobra.Command{
+ Use: trimCmdName,
+ Short: "Generate new summary reports from existing metrics collection by filtering to a specific time range",
+ Long: "",
+ Example: strings.Join(trimExamples, "\n"),
+ RunE: runTrimCmd,
+ PreRunE: validateTrimFlags,
+ SilenceErrors: true,
+}
+
+func init() {
+ Cmd.AddCommand(trimCmd)
+
+ trimCmd.Flags().StringVar(&flagTrimInput, flagTrimInputName, "", "path to the directory or specific metrics CSV file to trim (required)")
+ trimCmd.Flags().IntVar(&flagTrimStartTime, flagTrimStartTimeName, 0, "absolute start timestamp (seconds since epoch)")
+ trimCmd.Flags().IntVar(&flagTrimEndTime, flagTrimEndTimeName, 0, "absolute end timestamp (seconds since epoch)")
+ trimCmd.Flags().IntVar(&flagTrimStartOffset, flagTrimStartOffsetName, 0, "seconds to skip from the beginning of the data")
+ trimCmd.Flags().IntVar(&flagTrimEndOffset, flagTrimEndOffsetName, 0, "seconds to exclude from the end of the data")
+
+ _ = trimCmd.MarkFlagRequired(flagTrimInputName) // error only occurs if flag doesn't exist
+
+ // Set custom usage function to avoid parent's usage function issues
+ trimCmd.SetUsageFunc(func(cmd *cobra.Command) error {
+ fmt.Fprintf(cmd.OutOrStdout(), "Usage:\n %s\n\n", cmd.UseLine())
+ if cmd.HasExample() {
+ fmt.Fprintf(cmd.OutOrStdout(), "Examples:\n%s\n\n", cmd.Example)
+ }
+ if cmd.HasAvailableLocalFlags() {
+ fmt.Fprintf(cmd.OutOrStdout(), "Flags:\n%s\n", cmd.LocalFlags().FlagUsages())
+ }
+ if cmd.HasAvailableInheritedFlags() {
+ fmt.Fprintf(cmd.OutOrStdout(), "Global Flags:\n%s\n", cmd.InheritedFlags().FlagUsages())
+ }
+ return nil
+ })
+}
+
+// validateTrimFlags checks that the trim command flags are valid and consistent
+func validateTrimFlags(cmd *cobra.Command, args []string) error {
+ // Check input file or directory exists
+ if _, err := os.Stat(flagTrimInput); err != nil {
+ if os.IsNotExist(err) {
+ return common.FlagValidationError(cmd, fmt.Sprintf("input file or directory does not exist: %s", flagTrimInput))
+ }
+ return common.FlagValidationError(cmd, fmt.Sprintf("failed to access input file or directory: %v", err))
+ }
+
+ // Check that at least one time parameter is provided
+ if flagTrimStartTime == 0 && flagTrimEndTime == 0 && flagTrimStartOffset == 0 && flagTrimEndOffset == 0 {
+ return common.FlagValidationError(cmd, "at least one time parameter must be specified (--start-time, --end-time, --start-offset, or --end-offset)")
+ }
+
+ // Check that both absolute time and offset are not specified for start
+ if flagTrimStartTime != 0 && flagTrimStartOffset != 0 {
+ return common.FlagValidationError(cmd, "cannot specify both --start-time and --start-offset")
+ }
+
+ // Check that both absolute time and offset are not specified for end
+ if flagTrimEndTime != 0 && flagTrimEndOffset != 0 {
+ return common.FlagValidationError(cmd, "cannot specify both --end-time and --end-offset")
+ }
+
+ // Check for negative values
+ if flagTrimStartTime < 0 {
+ return common.FlagValidationError(cmd, "--start-time cannot be negative")
+ }
+ if flagTrimEndTime < 0 {
+ return common.FlagValidationError(cmd, "--end-time cannot be negative")
+ }
+ if flagTrimStartOffset < 0 {
+ return common.FlagValidationError(cmd, "--start-offset cannot be negative")
+ }
+ if flagTrimEndOffset < 0 {
+ return common.FlagValidationError(cmd, "--end-offset cannot be negative")
+ }
+
+ // Check that absolute times are in order if both specified
+ if flagTrimStartTime != 0 && flagTrimEndTime != 0 && flagTrimStartTime >= flagTrimEndTime {
+ return common.FlagValidationError(cmd, "--start-time must be less than --end-time")
+ }
+
+ return nil
+}
+
+// runTrimCmd executes the trim command
+func runTrimCmd(cmd *cobra.Command, args []string) error {
+ // appContext is the application context that holds common data and resources.
+ appContext := cmd.Parent().Context().Value(common.AppContext{}).(common.AppContext)
+ outputDir := appContext.OutputDir
+
+ // flagTrimInput can be a file or directory
+ var sourceDir string
+ fileInfo, err := os.Stat(flagTrimInput)
+ if err != nil {
+ err = fmt.Errorf("failed to access input path: %w", err)
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ slog.Error(err.Error())
+ cmd.SilenceUsage = true
+ return err
+ }
+ if fileInfo.IsDir() {
+ sourceDir = flagTrimInput
+ } else {
+ sourceDir = filepath.Dir(flagTrimInput)
+ }
+
+ // Determine source files to process
+ sourceInfos, err := getTrimmedSourceInfos(flagTrimInput)
+ if err != nil {
+ err = fmt.Errorf("failed to determine source files: %w", err)
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ slog.Error(err.Error())
+ cmd.SilenceUsage = true
+ return err
+ }
+ if len(sourceInfos) == 0 {
+ err = fmt.Errorf("no valid metrics CSV files found to trim")
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ slog.Error(err.Error())
+ cmd.SilenceUsage = true
+ return err
+ }
+
+ // create output directory if it doesn't exist
+ err = util.CreateDirectoryIfNotExists(outputDir, 0755) // #nosec G301
+ if err != nil {
+ err = fmt.Errorf("failed to create output directory: %w", err)
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ slog.Error(err.Error())
+ cmd.SilenceUsage = true
+ return err
+ }
+
+ // Process each source file
+ var filesCreated []string
+ for _, sourceInfo := range sourceInfos {
+ filesCreated, err = summarizeMetricsWithTrim(sourceDir, outputDir, sourceInfo.targetName, sourceInfo.metadata, sourceInfo.metricDefinitions, sourceInfo.startTime, sourceInfo.endTime)
+ if err != nil {
+ err = fmt.Errorf("failed to generate trimmed summaries for %s: %w", sourceInfo.allCSVPath, err)
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ slog.Error(err.Error())
+ cmd.SilenceUsage = true
+ return err
+ }
+ }
+
+ // Report success
+ fmt.Println("\nTrimmed metrics successfully created:")
+ for _, filePath := range filesCreated {
+ fmt.Printf(" %s\n", filePath)
+ }
+
+ return nil
+}
+
+type trimSourceInfo struct {
+ allCSVPath string
+ summaryCSVPath string
+ summaryHTMLPath string
+ targetName string
+ metadata Metadata
+ metricDefinitions []MetricDefinition
+ startTime int
+ endTime int
+}
+
+func getTrimmedSourceInfos(sourceDirOrFilename string) ([]trimSourceInfo, error) {
+ var sourceInfos []trimSourceInfo
+
+ // If a specific file is provided, use that
+ if sourceDirOrFilename != "" && strings.HasSuffix(strings.ToLower(sourceDirOrFilename), ".csv") {
+ baseName := strings.TrimSuffix(filepath.Base(sourceDirOrFilename), filepath.Ext(sourceDirOrFilename))
+ summaryCSV := filepath.Join(filepath.Dir(sourceDirOrFilename), baseName+"_summary.csv")
+ summaryHTML := filepath.Join(filepath.Dir(sourceDirOrFilename), baseName+"_summary.html")
+ sourceInfos = append(sourceInfos, trimSourceInfo{
+ allCSVPath: sourceDirOrFilename,
+ summaryCSVPath: summaryCSV,
+ summaryHTMLPath: summaryHTML,
+ })
+ } else {
+
+ // Otherwise, scan the directory for all *_metrics.csv files
+ files, err := os.ReadDir(sourceDirOrFilename)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read directory: %w", err)
+ }
+
+ for _, file := range files {
+ if file.IsDir() {
+ continue
+ }
+ if strings.HasSuffix(strings.ToLower(file.Name()), "_metrics.csv") {
+ baseName := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))
+ allCSVPath := filepath.Join(sourceDirOrFilename, file.Name())
+ summaryCSV := filepath.Join(sourceDirOrFilename, baseName+"_summary.csv")
+ summaryHTML := filepath.Join(sourceDirOrFilename, baseName+"_summary.html")
+ sourceInfos = append(sourceInfos, trimSourceInfo{
+ allCSVPath: allCSVPath,
+ summaryCSVPath: summaryCSV,
+ summaryHTMLPath: summaryHTML,
+ })
+ }
+ }
+ }
+
+ for i, sourceInfo := range sourceInfos {
+ // Determine target name from filename
+ inputBase := filepath.Base(sourceInfo.allCSVPath)
+ inputName := strings.TrimSuffix(inputBase, filepath.Ext(inputBase))
+ targetName := strings.TrimSuffix(inputName, "_metrics")
+ sourceInfos[i].targetName = targetName
+ // Load all metrics to determine time range
+ metrics, err := newMetricCollection(sourceInfo.allCSVPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load metrics from CSV: %w", err)
+ }
+ if len(metrics) == 0 {
+ return nil, fmt.Errorf("no metrics found in CSV file")
+ }
+ // Calculate the time range
+ startTime, endTime, err := calculateTimeRange(metrics, flagTrimStartTime, flagTrimEndTime, flagTrimStartOffset, flagTrimEndOffset)
+ if err != nil {
+ return nil, fmt.Errorf("failed to calculate time range: %w", err)
+ }
+ sourceInfos[i].startTime = startTime
+ sourceInfos[i].endTime = endTime
+ // Retrieve the metadata from the HTML summary
+ metadata, err := loadMetadataFromHTMLSummary(sourceInfo.summaryHTMLPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load metadata from HTML summary: %w", err)
+ }
+ sourceInfos[i].metadata = metadata
+ // Load metric definitions using the metadata
+ metricDefinitions, err := loadMetricDefinitions(metadata)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get metric definitions: %w", err)
+ }
+ sourceInfos[i].metricDefinitions = metricDefinitions
+ }
+
+ return sourceInfos, nil
+}
+
+func loadMetricDefinitions(metadata Metadata) ([]MetricDefinition, error) {
+ loader, err := NewLoader(metadata.Microarchitecture)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create metric definition loader: %w", err)
+ }
+ metricDefinitions, _, err := loader.Load(getLoaderConfig(loader, []string{}, metadata, "", ""))
+ if err != nil {
+ return nil, fmt.Errorf("failed to load metric definitions: %w", err)
+ }
+ return metricDefinitions, nil
+}
+
+func loadMetadataFromHTMLSummary(summaryHTMLPath string) (Metadata, error) {
+ var metadata Metadata
+ // Check if the summary HTML file exists
+ _, err := os.Stat(summaryHTMLPath)
+ if err != nil {
+ return metadata, fmt.Errorf("summary HTML file does not exist: %s", summaryHTMLPath)
+ }
+
+ // find "const metadata = " and "const system_info = " in HTML file.
+ // The JSON string follows the equals sign.
+ // e.g., const metadata = {"NumGeneralPurposeCounters":8,"SocketCount":2, ... }
+ content, err := os.ReadFile(summaryHTMLPath)
+ if err != nil {
+ return metadata, fmt.Errorf("failed to read summary HTML file: %w", err)
+ }
+
+ // assumes system_info comes after metadata in the file
+ const metadataPrefix = "const metadata = "
+ const systemInfoPrefix = "const system_info = "
+ for line := range strings.SplitSeq(string(content), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, metadataPrefix) {
+ jsonStart := len(metadataPrefix)
+ // to end of line
+ jsonString := strings.TrimSpace(line[jsonStart:])
+ // parse JSON string into Metadata struct
+ err = json.Unmarshal([]byte(jsonString), &metadata)
+ if err != nil {
+ return metadata, fmt.Errorf("failed to parse metadata JSON: %w", err)
+ }
+ } else if strings.HasPrefix(line, systemInfoPrefix) {
+ // system info
+ var systemInfo [][]string
+ jsonStart := len(systemInfoPrefix)
+ jsonString := strings.TrimSpace(line[jsonStart:])
+ err = json.Unmarshal([]byte(jsonString), &systemInfo)
+ if err != nil {
+ return metadata, fmt.Errorf("failed to parse system info JSON: %w", err)
+ }
+ metadata.SystemSummaryFields = systemInfo
+ return metadata, nil
+ }
+ }
+
+ return metadata, fmt.Errorf("metadata not found in summary HTML file: %s", summaryHTMLPath)
+}
+
+// calculateTimeRange determines the actual start and end times based on the flags and data
+// Returns startTime, endTime, error
+func calculateTimeRange(metrics MetricCollection, startTime, endTime, startOffset, endOffset int) (int, int, error) {
+ if len(metrics) == 0 || len(metrics[0].rows) == 0 {
+ return 0, 0, fmt.Errorf("no data available to calculate time range")
+ }
+
+ // Find min and max timestamps in the data
+ minTimestamp := metrics[0].rows[0].timestamp
+ maxTimestamp := metrics[0].rows[0].timestamp
+
+ for _, mg := range metrics {
+ for _, row := range mg.rows {
+ if row.timestamp < minTimestamp {
+ minTimestamp = row.timestamp
+ }
+ if row.timestamp > maxTimestamp {
+ maxTimestamp = row.timestamp
+ }
+ }
+ }
+
+ // Calculate start time
+ calcStartTime := minTimestamp
+ if startTime != 0 {
+ calcStartTime = startTime
+ } else if startOffset != 0 {
+ calcStartTime = minTimestamp + startOffset
+ }
+
+ // Calculate end time
+ calcEndTime := maxTimestamp
+ if endTime != 0 {
+ calcEndTime = endTime
+ } else if endOffset != 0 {
+ calcEndTime = maxTimestamp - endOffset
+ }
+
+ // Validate the calculated range
+ if calcStartTime >= calcEndTime {
+ return 0, 0, fmt.Errorf("invalid time range: start (%d) >= end (%d)", calcStartTime, calcEndTime)
+ }
+
+ if calcStartTime > maxTimestamp {
+ return 0, 0, fmt.Errorf("start time (%d) is beyond the end of available data (%d)", calcStartTime, maxTimestamp)
+ }
+
+ if calcEndTime < minTimestamp {
+ return 0, 0, fmt.Errorf("end time (%d) is before the beginning of available data (%d)", calcEndTime, minTimestamp)
+ }
+
+ return calcStartTime, calcEndTime, nil
+}