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 +}