blob: 6d87bcae5bec617835905c5efe697ce5e9bb607a [file] [edit]
// 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()
}