firmweaver: Usage metrics support

- Shared metrics utils
- Added to zephyr_dtsi and coreboot_gpio
- Add unit tests
Uploads metrics to bigquery, viewable with plx dashboard

BUG=b:475292981
TEST=go test -v -race ./...

Change-Id: I43a41f14ea4524969077254844e1a6956febf94b
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/firmweaver/+/8318031
Reviewed-by: Karthik Ramasubramanian <kramasub@google.com>
Commit-Queue: Kshitij Shah <tij@google.com>
Tested-by: Kshitij Shah <tij@google.com>
diff --git a/coreboot_gpio/gpio_tool.go b/coreboot_gpio/gpio_tool.go
index f1c53cd..6e58e6e 100644
--- a/coreboot_gpio/gpio_tool.go
+++ b/coreboot_gpio/gpio_tool.go
@@ -16,7 +16,9 @@
 	"path/filepath"
 	"strconv"
 	"strings"
+	"time"
 
+	"firmweaver/shared/metrics"
 	"firmweaver/shared/sheets"
 )
 
@@ -81,7 +83,28 @@
 }
 
 // Run executes the coreboot_gpio subcommand with the provided arguments.
-func Run(args []string) error {
+func Run(args []string) (runErr error) {
+	start := time.Now()
+	ev := metrics.NewEvent("coreboot_gpio", "generate")
+	ev.Flags = args
+	var isHelp bool
+	defer func() {
+		if isHelp {
+			return
+		}
+		if r := recover(); r != nil {
+			runErr = fmt.Errorf("panic: %v", r)
+			defer panic(r)
+		}
+		ev.DurationMs = time.Since(start).Milliseconds()
+		if runErr != nil {
+			ev.ErrorMsg = runErr.Error()
+		} else {
+			ev.Success = true
+		}
+		metrics.Send(ev)
+	}()
+
 	fs := flag.NewFlagSet("coreboot_gpio", flag.ContinueOnError)
 	fs.Usage = func() {
 		fmt.Fprintf(fs.Output(), "Usage: firmweaver coreboot_gpio --soc=<soc> (--csv=<path> | --sheet=<url>) [options]\n\n")
@@ -97,6 +120,7 @@
 
 	if err := fs.Parse(args); err != nil {
 		if errors.Is(err, flag.ErrHelp) {
+			isHelp = true
 			return nil
 		}
 		return err
@@ -106,6 +130,15 @@
 		return fmt.Errorf("unexpected arguments: %v", fs.Args())
 	}
 
+	if *soc != "" {
+		ev.TargetSoC = *soc
+	}
+	if *sheetFlag != "" {
+		ev.InputMode = "sheet"
+	} else if *csvFile != "" {
+		ev.InputMode = "csv"
+	}
+
 	if *soc == "" {
 		return fmt.Errorf("--soc flag is required")
 	}
diff --git a/coreboot_gpio/gpio_tool_test.go b/coreboot_gpio/gpio_tool_test.go
index 210e3c6..e3ed504 100644
--- a/coreboot_gpio/gpio_tool_test.go
+++ b/coreboot_gpio/gpio_tool_test.go
@@ -16,15 +16,17 @@
 	"time"
 
 	"firmweaver/shared/auth"
+	"firmweaver/shared/metrics"
 	"firmweaver/shared/sheets"
 )
 
 func TestGpioTool_RunValidation(t *testing.T) {
+	t.Setenv(metrics.DisableMetricsEnv, "1")
 	if err := Run([]string{"-h"}); err != nil {
 		t.Errorf("Run('-h') unexpected error: %v", err)
 	}
 	if err := Run([]string{"--help"}); err != nil {
-		t.Errorf("Run(--help) expected nil, got %v", err)
+		t.Errorf("Run('--help') unexpected error: %v", err)
 	}
 	if err := Run([]string{"--soc=intel", "--csv=test/ExampleGpio.csv", "--sheet=go/rex-pins"}); err == nil {
 		t.Errorf("expected error when both --csv and --sheet specified")
@@ -44,6 +46,7 @@
 }
 
 func TestGpioTool_RunSuccess(t *testing.T) {
+	t.Setenv(metrics.DisableMetricsEnv, "1")
 	out := filepath.Join(t.TempDir(), "gpio.c")
 	err := Run([]string{
 		"--soc=intel",
@@ -69,6 +72,7 @@
 )
 
 func TestGpioTool_RunSheetIntegration(t *testing.T) {
+	t.Setenv(metrics.DisableMetricsEnv, "1")
 	t.Setenv(auth.ConfigDirEnv, t.TempDir())
 
 	_ = auth.SaveToken(&auth.Token{
diff --git a/main_test.go b/main_test.go
index e84189c..56862c2 100644
--- a/main_test.go
+++ b/main_test.go
@@ -9,9 +9,11 @@
 	"time"
 
 	"firmweaver/shared/auth"
+	"firmweaver/shared/metrics"
 )
 
 func TestRun_SubcommandRouting(t *testing.T) {
+	t.Setenv(metrics.DisableMetricsEnv, "1")
 	if err := run([]string{}); err == nil {
 		t.Error("run([]) expected error for missing subcommand, got nil")
 	}
diff --git a/shared/metrics/metrics.go b/shared/metrics/metrics.go
new file mode 100644
index 0000000..6d87bca
--- /dev/null
+++ b/shared/metrics/metrics.go
@@ -0,0 +1,138 @@
+// Copyright 2026 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package metrics
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math/rand/v2"
+	"os"
+	"time"
+
+	"firmweaver/shared/auth"
+	"golang.org/x/oauth2"
+	bigquery "google.golang.org/api/bigquery/v2"
+	"google.golang.org/api/option"
+)
+
+// DefaultEndpoint is the BigQuery service metrics rows are streamed to.
+const DefaultEndpoint = "https://bigquery.googleapis.com"
+
+// Endpoint allows overriding the destination service in unit tests.
+var Endpoint = DefaultEndpoint
+
+// Destination table for usage metrics. The client escapes the project ID,
+// so the "google.com:" prefix is written literally here.
+const (
+	projectID = "google.com:chromeos-arbitrage"
+	datasetID = "firmweaver"
+	tableID   = "user_logs"
+)
+
+const (
+	DisableMetricsEnv = "FIRMWEAVER_DISABLE_METRICS"
+	// DoNotTrackEnv adheres to the Console Do Not Track standard (https://consoledonottrack.com).
+	DoNotTrackEnv = "DO_NOT_TRACK"
+)
+
+// Event captures an execution log row matching the BigQuery metrics schema.
+type Event struct {
+	EventID       string   `json:"event_id"`
+	TimestampUTC  string   `json:"timestamp_utc"`
+	UserLDAP      string   `json:"user_ldap"`
+	ToolName      string   `json:"tool_name"`
+	Subcommand    string   `json:"subcommand"`
+	TargetChip    string   `json:"target_chip,omitempty"`
+	TargetSoC     string   `json:"target_soc,omitempty"`
+	VariantHeader string   `json:"variant_header,omitempty"`
+	InputMode     string   `json:"input_mode,omitempty"`
+	Flags         []string `json:"flags,omitempty"`
+	DurationMs    int64    `json:"duration_ms"`
+	Success       bool     `json:"success"`
+	ErrorMsg      string   `json:"error_msg,omitempty"`
+}
+
+// IsDisabled checks if metrics collection is explicitly opted out.
+func IsDisabled() bool {
+	return os.Getenv(DoNotTrackEnv) == "1" || os.Getenv(DisableMetricsEnv) == "1"
+}
+
+// NewEvent initializes a new Event with timestamp and user.
+func NewEvent(toolName, subcommand string) *Event {
+	ldap := os.Getenv("USER")
+	if ldap == "" {
+		ldap = os.Getenv("LOGNAME")
+	}
+	if ldap == "" {
+		ldap = "unknown"
+	}
+
+	return &Event{
+		EventID:      fmt.Sprintf("%s-%d-%08x", toolName, time.Now().UnixNano(), rand.Uint32()),
+		TimestampUTC: time.Now().UTC().Format(time.RFC3339),
+		UserLDAP:     ldap,
+		ToolName:     toolName,
+		Subcommand:   subcommand,
+	}
+}
+
+// eventRow renders the event using its json tags so the row keys line up with
+// the BigQuery table schema.
+func eventRow(ev *Event) (map[string]bigquery.JsonValue, error) {
+	raw, err := json.Marshal(ev)
+	if err != nil {
+		return nil, err
+	}
+	var row map[string]bigquery.JsonValue
+	if err := json.Unmarshal(raw, &row); err != nil {
+		return nil, err
+	}
+	return row, nil
+}
+
+// Send silently flushes the event to BigQuery with a bounded 1-second timeout.
+func Send(ev *Event) {
+	if ev == nil || IsDisabled() {
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+	defer cancel()
+
+	token, _, err := auth.GetToken(ctx)
+	if err != nil || token == "" {
+		return
+	}
+
+	row, err := eventRow(ev)
+	if err != nil {
+		return
+	}
+
+	opts := []option.ClientOption{
+		option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{
+			AccessToken: token,
+			TokenType:   "Bearer",
+		})),
+	}
+	if Endpoint != DefaultEndpoint {
+		opts = append(opts, option.WithEndpoint(Endpoint))
+	}
+
+	srv, err := bigquery.NewService(ctx, opts...)
+	if err != nil {
+		return
+	}
+
+	_, _ = srv.Tabledata.InsertAll(projectID, datasetID, tableID, &bigquery.TableDataInsertAllRequest{
+		Kind:                "bigquery#tableDataInsertAllRequest",
+		IgnoreUnknownValues: true,
+		Rows: []*bigquery.TableDataInsertAllRequestRows{{
+			InsertId: ev.EventID,
+			Json:     row,
+		}},
+	}).Context(ctx).Do()
+}
diff --git a/shared/metrics/metrics_test.go b/shared/metrics/metrics_test.go
new file mode 100644
index 0000000..8436e37
--- /dev/null
+++ b/shared/metrics/metrics_test.go
@@ -0,0 +1,140 @@
+// Copyright 2026 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package metrics
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"firmweaver/shared/auth"
+)
+
+// wireRequest mirrors the BigQuery insertAll payload so the test can assert on
+// exactly what goes over the wire.
+type wireRequest struct {
+	Kind                string    `json:"kind"`
+	IgnoreUnknownValues bool      `json:"ignoreUnknownValues"`
+	Rows                []wireRow `json:"rows"`
+}
+
+type wireRow struct {
+	InsertID string `json:"insertId"`
+	JSON     *Event `json:"json"`
+}
+
+func TestMetrics_SendAndPayload(t *testing.T) {
+	t.Setenv(auth.ConfigDirEnv, t.TempDir())
+	t.Setenv(DisableMetricsEnv, "")
+	t.Setenv(DoNotTrackEnv, "")
+
+	_ = auth.SaveToken(&auth.Token{
+		AccessToken: "mock-token",
+		Expiry:      time.Now().Add(time.Hour),
+		Email:       "engineer@google.com",
+	})
+
+	var receivedReq wireRequest
+	var authHeader string
+	var requestPath string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		authHeader = r.Header.Get("Authorization")
+		requestPath = r.URL.Path
+		body, _ := io.ReadAll(r.Body)
+		_ = json.Unmarshal(body, &receivedReq)
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte(`{}`))
+	}))
+	defer server.Close()
+
+	origEndpoint := Endpoint
+	Endpoint = server.URL
+	t.Cleanup(func() { Endpoint = origEndpoint })
+
+	ev := NewEvent("zephyr_dtsi", "generate")
+	ev.TargetChip = "npcx99"
+	ev.Flags = []string{"--chip=npcx99", "--csv=pins.csv"}
+	ev.DurationMs = 120
+	ev.Success = true
+
+	Send(ev)
+
+	if authHeader != "Bearer mock-token" {
+		t.Errorf("expected Authorization header 'Bearer mock-token', got %q", authHeader)
+	}
+	wantPath := "/projects/google.com:chromeos-arbitrage/datasets/firmweaver/tables/user_logs/insertAll"
+	if requestPath != wantPath {
+		t.Errorf("request path = %q, want %q", requestPath, wantPath)
+	}
+	if len(receivedReq.Rows) != 1 {
+		t.Fatalf("expected 1 row, got %d", len(receivedReq.Rows))
+	}
+	row := receivedReq.Rows[0]
+	if row.InsertID != ev.EventID {
+		t.Errorf("insertId = %q, want %q", row.InsertID, ev.EventID)
+	}
+	if row.JSON.ToolName != "zephyr_dtsi" || row.JSON.TargetChip != "npcx99" || !row.JSON.Success {
+		t.Errorf("unexpected event payload: %+v", row.JSON)
+	}
+}
+
+func TestMetrics_UnauthenticatedShortCircuit(t *testing.T) {
+	t.Setenv(auth.ConfigDirEnv, t.TempDir()) // empty dir, no token
+	t.Setenv(DisableMetricsEnv, "")
+	t.Setenv(DoNotTrackEnv, "")
+
+	called := false
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		called = true
+	}))
+	defer server.Close()
+
+	origEndpoint := Endpoint
+	Endpoint = server.URL
+	t.Cleanup(func() { Endpoint = origEndpoint })
+
+	ev := NewEvent("coreboot_gpio", "generate")
+	Send(ev)
+
+	if called {
+		t.Errorf("Send() should not dial backend when user is unauthenticated")
+	}
+}
+
+func TestMetrics_OptOut(t *testing.T) {
+	t.Setenv(auth.ConfigDirEnv, t.TempDir())
+
+	tests := []struct {
+		envVar string
+		want   bool
+	}{
+		{DisableMetricsEnv, true},
+		{DoNotTrackEnv, true},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.envVar, func(t *testing.T) {
+			t.Setenv(DisableMetricsEnv, "")
+			t.Setenv(DoNotTrackEnv, "")
+
+			t.Setenv(tc.envVar, "1")
+			if !IsDisabled() {
+				t.Errorf("IsDisabled() with %s=1 should be true", tc.envVar)
+			}
+		})
+	}
+
+	t.Run("EnabledWhenUnset", func(t *testing.T) {
+		t.Setenv(DisableMetricsEnv, "")
+		t.Setenv(DoNotTrackEnv, "")
+
+		if IsDisabled() {
+			t.Errorf("IsDisabled() when env vars unset should be false")
+		}
+	})
+}
diff --git a/zephyr_dtsi/run.go b/zephyr_dtsi/run.go
index 43468fe..b5757a6 100644
--- a/zephyr_dtsi/run.go
+++ b/zephyr_dtsi/run.go
@@ -12,12 +12,35 @@
 	"io"
 	"os"
 	"path/filepath"
+	"time"
 
+	"firmweaver/shared/metrics"
 	"firmweaver/shared/sheets"
 )
 
 // Run executes the EC firmware pinmux DTSI generation logic.
-func Run(args []string) error {
+func Run(args []string) (runErr error) {
+	start := time.Now()
+	ev := metrics.NewEvent("zephyr_dtsi", "generate")
+	ev.Flags = args
+	var isHelp bool
+	defer func() {
+		if isHelp {
+			return
+		}
+		if r := recover(); r != nil {
+			runErr = fmt.Errorf("panic: %v", r)
+			defer panic(r)
+		}
+		ev.DurationMs = time.Since(start).Milliseconds()
+		if runErr != nil {
+			ev.ErrorMsg = runErr.Error()
+		} else {
+			ev.Success = true
+		}
+		metrics.Send(ev)
+	}()
+
 	fs := flag.NewFlagSet("zephyr_dtsi", flag.ContinueOnError)
 	chipFlag := fs.String("chip", "", "Supported EC chip name or alias (e.g. npcx99, it82002)")
 	csvFlag := fs.String("csv", "", "Path to the local EC pinout CSV file")
@@ -29,6 +52,7 @@
 
 	if err := fs.Parse(args); err != nil {
 		if errors.Is(err, flag.ErrHelp) {
+			isHelp = true
 			return nil
 		}
 		return err
@@ -37,6 +61,14 @@
 		return fmt.Errorf("unexpected arguments: %v", fs.Args())
 	}
 
+	ev.TargetChip = *chipFlag
+	ev.VariantHeader = *pinColFlag
+	if *sheetFlag != "" {
+		ev.InputMode = "sheet"
+	} else if *csvFlag != "" {
+		ev.InputMode = "csv"
+	}
+
 	if *chipDataDirFlag != "" {
 		if err := LoadExternalChipDir(*chipDataDirFlag); err != nil {
 			return fmt.Errorf("loading chip data dir %s: %w", *chipDataDirFlag, err)
diff --git a/zephyr_dtsi/run_test.go b/zephyr_dtsi/run_test.go
index bd05ba1..1482b81 100644
--- a/zephyr_dtsi/run_test.go
+++ b/zephyr_dtsi/run_test.go
@@ -16,15 +16,17 @@
 	"time"
 
 	"firmweaver/shared/auth"
+	"firmweaver/shared/metrics"
 	"firmweaver/shared/sheets"
 )
 
 func TestDtsiTool_RunFlagValidation(t *testing.T) {
+	t.Setenv(metrics.DisableMetricsEnv, "1")
 	if err := Run([]string{"-h"}); err != nil {
 		t.Errorf("Run('-h') unexpected error: %v", err)
 	}
 	if err := Run([]string{"--help"}); err != nil {
-		t.Errorf("Run(--help) expected nil, got %v", err)
+		t.Errorf("Run('--help') unexpected error: %v", err)
 	}
 	if err := Run([]string{"--chip=npcx99", "--csv=test/ExampleEcPinout.csv", "--sheet=go/rex-pins"}); err == nil {
 		t.Errorf("expected error when both --csv and --sheet specified")
@@ -44,6 +46,7 @@
 }
 
 func TestDtsiTool_RunSuccess(t *testing.T) {
+	t.Setenv(metrics.DisableMetricsEnv, "1")
 	out := filepath.Join(t.TempDir(), "out.dtsi")
 	err := Run([]string{
 		"--chip=npcx99",
@@ -70,6 +73,7 @@
 )
 
 func TestDtsiTool_RunSheetIntegration(t *testing.T) {
+	t.Setenv(metrics.DisableMetricsEnv, "1")
 	t.Setenv(auth.ConfigDirEnv, t.TempDir())
 
 	_ = auth.SaveToken(&auth.Token{