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{