Add SoongExecutionMetrics to ninja run

This adds metrics for partial compilation to Soong.

- Soong_ui names the directory SOONG_METRICS_AGGREGATION_DIR, which it
  empties before each ninja run.
- Find_input_delta writes metrics there
- At the end of the build, Soong_ui aggregates the metrics that were
  written to generate the aggregated metrics.

Bug: b/376287012
Test: Manual, TH
Change-Id: I123df654f5b963fcd213b5c4d815173051f5d72e
diff --git a/ui/execution_metrics/Android.bp b/ui/execution_metrics/Android.bp
new file mode 100644
index 0000000..542e550
--- /dev/null
+++ b/ui/execution_metrics/Android.bp
@@ -0,0 +1,35 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+bootstrap_go_package {
+    name: "soong-ui-execution-metrics",
+    pkgPath: "android/soong/ui/execution_metrics",
+    deps: [
+        "golang-protobuf-proto",
+        "soong-shared",
+        "soong-ui-logger",
+        "soong-ui-execution_metrics_proto",
+        "soong-ui-metrics_proto",
+        "soong-cmd-find_input_delta-proto",
+    ],
+    srcs: [
+        "execution_metrics.go",
+    ],
+    testSrcs: [
+    ],
+}
diff --git a/ui/execution_metrics/execution_metrics.go b/ui/execution_metrics/execution_metrics.go
new file mode 100644
index 0000000..4ea251f
--- /dev/null
+++ b/ui/execution_metrics/execution_metrics.go
@@ -0,0 +1,273 @@
+// Copyright 2024 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package execution_metrics represents the metrics system for Android Platform Build Systems.
+package execution_metrics
+
+// This is the main heart of the metrics system for Android Platform Build Systems.
+// The starting of the soong_ui (cmd/soong_ui/main.go), the metrics system is
+// initialized by the invocation of New and is then stored in the context
+// (ui/build/context.go) to be used throughout the system. During the build
+// initialization phase, several functions in this file are invoked to store
+// information such as the environment, build configuration and build metadata.
+// There are several scoped code that has Begin() and defer End() functions
+// that captures the metrics and is them added as a perfInfo into the set
+// of the collected metrics. Finally, when soong_ui has finished the build,
+// the defer Dump function is invoked to store the collected metrics to the
+// raw protobuf file in the $OUT directory and this raw protobuf file will be
+// uploaded to the destination. See ui/build/upload.go for more details. The
+// filename of the raw protobuf file and the list of files to be uploaded is
+// defined in cmd/soong_ui/main.go. See ui/metrics/event.go for the explanation
+// of what an event is and how the metrics system is a stack based system.
+
+import (
+	"context"
+	"io/fs"
+	"maps"
+	"os"
+	"path/filepath"
+	"slices"
+	"sync"
+
+	"android/soong/ui/logger"
+
+	fid_proto "android/soong/cmd/find_input_delta/find_input_delta_proto"
+	"android/soong/ui/metrics"
+	soong_execution_proto "android/soong/ui/metrics/execution_metrics_proto"
+	soong_metrics_proto "android/soong/ui/metrics/metrics_proto"
+	"google.golang.org/protobuf/encoding/protowire"
+	"google.golang.org/protobuf/proto"
+)
+
+type ExecutionMetrics struct {
+	MetricsAggregationDir string
+	ctx                   context.Context
+	logger                logger.Logger
+	waitGroup             sync.WaitGroup
+	fileList              *fileList
+}
+
+type fileList struct {
+	totalChanges uint32
+	changes      fileChanges
+	seenFiles    map[string]bool
+}
+
+type fileChanges struct {
+	additions     changeInfo
+	deletions     changeInfo
+	modifications changeInfo
+}
+
+type fileChangeCounts struct {
+	additions     uint32
+	deletions     uint32
+	modifications uint32
+}
+
+type changeInfo struct {
+	total       uint32
+	list        []string
+	byExtension map[string]uint32
+}
+
+var MAXIMUM_FILES uint32 = 50
+
+// Setup the handler for SoongExecutionMetrics.
+func NewExecutionMetrics(log logger.Logger) *ExecutionMetrics {
+	return &ExecutionMetrics{
+		logger:   log,
+		fileList: &fileList{seenFiles: make(map[string]bool)},
+	}
+}
+
+// Save the path for ExecutionMetrics communications.
+func (c *ExecutionMetrics) SetDir(path string) {
+	c.MetricsAggregationDir = path
+}
+
+// Start collecting SoongExecutionMetrics.
+func (c *ExecutionMetrics) Start() {
+	if c.MetricsAggregationDir == "" {
+		return
+	}
+
+	tmpDir := c.MetricsAggregationDir + ".rm"
+	if _, err := fs.Stat(os.DirFS("."), c.MetricsAggregationDir); err == nil {
+		if err = os.RemoveAll(tmpDir); err != nil {
+			c.logger.Fatalf("Failed to remove %s: %v", tmpDir, err)
+		}
+		if err = os.Rename(c.MetricsAggregationDir, tmpDir); err != nil {
+			c.logger.Fatalf("Failed to rename %s to %s: %v", c.MetricsAggregationDir, tmpDir)
+		}
+	}
+	if err := os.MkdirAll(c.MetricsAggregationDir, 0777); err != nil {
+		c.logger.Fatalf("Failed to create %s: %v", c.MetricsAggregationDir)
+	}
+
+	c.waitGroup.Add(1)
+	go func(d string) {
+		defer c.waitGroup.Done()
+		os.RemoveAll(d)
+	}(tmpDir)
+
+	c.logger.Verbosef("ExecutionMetrics running\n")
+}
+
+type hasTrace interface {
+	BeginTrace(name, desc string)
+	EndTrace()
+}
+
+// Aggregate any execution metrics.
+func (c *ExecutionMetrics) Finish(ctx hasTrace) {
+	ctx.BeginTrace(metrics.RunSoong, "execution_metrics.Finish")
+	defer ctx.EndTrace()
+	if c.MetricsAggregationDir == "" {
+		return
+	}
+	c.waitGroup.Wait()
+
+	// Find and process all of the metrics files.
+	aggFs := os.DirFS(c.MetricsAggregationDir)
+	fs.WalkDir(aggFs, ".", func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			c.logger.Fatalf("ExecutionMetrics.Finish: Error walking %s: %v", c.MetricsAggregationDir, err)
+		}
+		if d.IsDir() {
+			return nil
+		}
+		path = filepath.Join(c.MetricsAggregationDir, path)
+		r, err := os.ReadFile(path)
+		if err != nil {
+			c.logger.Fatalf("ExecutionMetrics.Finish: Failed to read %s: %v", path, err)
+		}
+		msg := &soong_execution_proto.SoongExecutionMetrics{}
+		err = proto.Unmarshal(r, msg)
+		if err != nil {
+			c.logger.Verbosef("ExecutionMetrics.Finish: Error unmarshalling SoongExecutionMetrics message: %v\n", err)
+			return nil
+		}
+		switch {
+		case msg.GetFileList() != nil:
+			if err := c.fileList.aggregateFileList(msg.GetFileList()); err != nil {
+				c.logger.Verbosef("ExecutionMetrics.Finish: Error processing SoongExecutionMetrics message: %v\n", err)
+			}
+		// Status update for all others.
+		default:
+			tag, _ := protowire.ConsumeVarint(r)
+			id, _ := protowire.DecodeTag(tag)
+			c.logger.Verbosef("ExecutionMetrics.Finish: Unexpected SoongExecutionMetrics submessage id=%d\n", id)
+		}
+		return nil
+	})
+}
+
+func (fl *fileList) aggregateFileList(msg *fid_proto.FileList) error {
+	fl.updateChangeInfo(msg.GetAdditions(), &fl.changes.additions)
+	fl.updateChangeInfo(msg.GetDeletions(), &fl.changes.deletions)
+	fl.updateChangeInfo(msg.GetChanges(), &fl.changes.modifications)
+	return nil
+}
+
+func (fl *fileList) updateChangeInfo(list []string, info *changeInfo) {
+	for _, filename := range list {
+		if fl.seenFiles[filename] {
+			continue
+		}
+		fl.seenFiles[filename] = true
+		if info.total < MAXIMUM_FILES {
+			info.list = append(info.list, filename)
+		}
+		ext := filepath.Ext(filename)
+		if info.byExtension == nil {
+			info.byExtension = make(map[string]uint32)
+		}
+		info.byExtension[ext] += 1
+		info.total += 1
+		fl.totalChanges += 1
+	}
+}
+
+func (c *ExecutionMetrics) Dump(path string, args []string) error {
+	if c.MetricsAggregationDir == "" {
+		return nil
+	}
+	msg := c.GetMetrics()
+	msg.CommandArgs = args
+
+	if _, err := os.Stat(filepath.Dir(path)); err != nil {
+		if err = os.MkdirAll(filepath.Dir(path), 0775); err != nil {
+			return err
+		}
+	}
+	data, err := proto.Marshal(msg)
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0644)
+}
+
+func (c *ExecutionMetrics) GetMetrics() *soong_metrics_proto.AggregatedFileList {
+	fl := c.fileList
+	if fl == nil {
+		return nil
+	}
+	var count uint32
+	fileCounts := make(map[string]*soong_metrics_proto.FileCount)
+	ret := &soong_metrics_proto.AggregatedFileList{TotalDelta: proto.Uint32(c.fileList.totalChanges)}
+
+	// MAXIMUM_FILES is the upper bound on total file names reported.
+	if limit := min(MAXIMUM_FILES-min(MAXIMUM_FILES, count), fl.changes.additions.total); limit > 0 {
+		ret.Additions = fl.changes.additions.list[:limit]
+		count += limit
+	}
+	if limit := min(MAXIMUM_FILES-min(MAXIMUM_FILES, count), fl.changes.modifications.total); limit > 0 {
+		ret.Changes = fl.changes.modifications.list[:limit]
+		count += limit
+	}
+	if limit := min(MAXIMUM_FILES-min(MAXIMUM_FILES, count), fl.changes.deletions.total); limit > 0 {
+		ret.Deletions = fl.changes.deletions.list[:limit]
+		count += limit
+	}
+
+	addExt := func(key string) *soong_metrics_proto.FileCount {
+		// Create the fileCounts map entry if needed, and return the address to the caller.
+		if _, ok := fileCounts[key]; !ok {
+			fileCounts[key] = &soong_metrics_proto.FileCount{Extension: proto.String(key)}
+		}
+		return fileCounts[key]
+	}
+	addCount := func(loc **uint32, count uint32) {
+		if *loc == nil {
+			*loc = proto.Uint32(0)
+		}
+		**loc += count
+	}
+	for k, v := range fl.changes.additions.byExtension {
+		addCount(&addExt(k).Additions, v)
+	}
+	for k, v := range fl.changes.modifications.byExtension {
+		addCount(&addExt(k).Modifications, v)
+	}
+	for k, v := range fl.changes.deletions.byExtension {
+		addCount(&addExt(k).Deletions, v)
+	}
+
+	keys := slices.Sorted(maps.Keys(fileCounts))
+	for _, k := range keys {
+		ret.Counts = append(ret.Counts, fileCounts[k])
+	}
+	return ret
+}
diff --git a/ui/execution_metrics/execution_metrics_test.go b/ui/execution_metrics/execution_metrics_test.go
new file mode 100644
index 0000000..28fa973
--- /dev/null
+++ b/ui/execution_metrics/execution_metrics_test.go
@@ -0,0 +1,63 @@
+// Copyright 2024 Google Inc. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package execution_metrics represents the metrics system for Android Platform Build Systems.
+package execution_metrics
+
+import (
+	"reflect"
+	"testing"
+
+	fid_proto "android/soong/cmd/find_input_delta/find_input_delta_proto"
+)
+
+func TestUpdateChangeInfo(t *testing.T) {
+	testCases := []struct {
+		Name     string
+		Message  *fid_proto.FileList
+		FileList *fileList
+		Expected *fileList
+	}{
+		{
+			Name: "various",
+			Message: &fid_proto.FileList{
+				Additions: []string{"file1", "file2", "file3", "file2"},
+				Deletions: []string{"file5.go", "file6"},
+			},
+			FileList: &fileList{seenFiles: make(map[string]bool)},
+			Expected: &fileList{
+				seenFiles:    map[string]bool{"file1": true, "file2": true, "file3": true, "file5.go": true, "file6": true},
+				totalChanges: 5,
+				changes: fileChanges{
+					additions: changeInfo{
+						total:       3,
+						list:        []string{"file1", "file2", "file3"},
+						byExtension: map[string]uint32{"": 3},
+					},
+					deletions: changeInfo{
+						total:       2,
+						list:        []string{"file5.go", "file6"},
+						byExtension: map[string]uint32{"": 1, ".go": 1},
+					},
+				},
+			},
+		},
+	}
+	for _, tc := range testCases {
+		tc.FileList.aggregateFileList(tc.Message)
+		if !reflect.DeepEqual(tc.FileList, tc.Expected) {
+			t.Errorf("Expected: %v, Actual: %v", tc.Expected, tc.FileList)
+		}
+	}
+}