[TreeStatus] Add proto + set up BigQuery Client

I also moved the Client creation function to LUCI Common and updated LUCI Analysis, ResultDB and Bisection accordingly.

Change-Id: I4345fcb756c3df4e2186b589b6374555199fe84c
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/5767753
Commit-Queue: Tuan Nguyen <nqmtuan@google.com>
Reviewed-by: Patrick Meiring <meiring@google.com>
diff --git a/analysis/internal/analysis/client.go b/analysis/internal/analysis/client.go
index bd479f8..81b3fe0 100644
--- a/analysis/internal/analysis/client.go
+++ b/analysis/internal/analysis/client.go
@@ -22,6 +22,7 @@
 	"cloud.google.com/go/bigquery"
 	"google.golang.org/api/iterator"
 
+	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/errors"
 
 	"go.chromium.org/luci/analysis/internal/bqutil"
@@ -34,7 +35,7 @@
 // NewClient creates a new client for reading clusters. Close() MUST
 // be called after you have finished using this client.
 func NewClient(ctx context.Context, gcpProject string) (*Client, error) {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return nil, err
 	}
diff --git a/analysis/internal/analysis/clusteredfailures/client.go b/analysis/internal/analysis/clusteredfailures/client.go
index 7a16781..50c5031 100644
--- a/analysis/internal/analysis/clusteredfailures/client.go
+++ b/analysis/internal/analysis/clusteredfailures/client.go
@@ -36,7 +36,7 @@
 		return nil, errors.New("GCP Project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/analysis/internal/bqutil/insert.go b/analysis/internal/bqutil/insert.go
index 4f66ef3..ff9e2e0 100644
--- a/analysis/internal/bqutil/insert.go
+++ b/analysis/internal/bqutil/insert.go
@@ -20,32 +20,13 @@
 
 	"cloud.google.com/go/bigquery"
 	"google.golang.org/api/googleapi"
-	"google.golang.org/api/option"
 
 	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/errors"
 	"go.chromium.org/luci/common/retry"
 	"go.chromium.org/luci/common/retry/transient"
-	"go.chromium.org/luci/server/auth"
 )
 
-// Client returns a new BigQuery client for use with the given GCP project,
-// that authenticates as LUCI Analysis itself. Only use this method if the
-// specification of the BigQuery dataset to access is not under the
-// control of the project (e.g. via configuration).
-func Client(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
-	if gcpProject == "" {
-		return nil, errors.New("GCP Project must be specified")
-	}
-	tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(bigquery.Scope))
-	if err != nil {
-		return nil, err
-	}
-	return bigquery.NewClient(ctx, gcpProject, option.WithHTTPClient(&http.Client{
-		Transport: tr,
-	}))
-}
-
 // Inserter provides methods to insert rows into a BigQuery table.
 type Inserter struct {
 	table     *bigquery.Table
diff --git a/analysis/internal/changepoints/bqclient.go b/analysis/internal/changepoints/bqclient.go
index 5e40190..a17d402 100644
--- a/analysis/internal/changepoints/bqclient.go
+++ b/analysis/internal/changepoints/bqclient.go
@@ -22,15 +22,15 @@
 	"go.opentelemetry.io/otel/attribute"
 	"google.golang.org/api/iterator"
 
+	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/errors"
 
-	"go.chromium.org/luci/analysis/internal/bqutil"
 	"go.chromium.org/luci/analysis/internal/tracing"
 )
 
 // NewClient creates a new client for reading changepints.
 func NewClient(ctx context.Context, gcpProject string) (*Client, error) {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return nil, err
 	}
diff --git a/analysis/internal/changepoints/bqexporter/client.go b/analysis/internal/changepoints/bqexporter/client.go
index ada468f..5d0f0bb 100644
--- a/analysis/internal/changepoints/bqexporter/client.go
+++ b/analysis/internal/changepoints/bqexporter/client.go
@@ -37,7 +37,7 @@
 		return nil, errors.New("GCP Project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/analysis/internal/changepoints/bqexporter/merge_table.go b/analysis/internal/changepoints/bqexporter/merge_table.go
index 51bcc41..24b610e 100644
--- a/analysis/internal/changepoints/bqexporter/merge_table.go
+++ b/analysis/internal/changepoints/bqexporter/merge_table.go
@@ -41,7 +41,7 @@
 		return nil
 	}
 
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return errors.Annotate(err, "create bq client").Err()
 	}
diff --git a/analysis/internal/changepoints/bqupdater/update_changepoints_table.go b/analysis/internal/changepoints/bqupdater/update_changepoints_table.go
index 2673c2e..af2b99b 100644
--- a/analysis/internal/changepoints/bqupdater/update_changepoints_table.go
+++ b/analysis/internal/changepoints/bqupdater/update_changepoints_table.go
@@ -30,7 +30,7 @@
 // UpdateChangepointTable is the entry point of the update-changepoint-table cron job.
 // It runs DDL to create or replace the test_variant_changepoints table.
 func UpdateChangepointTable(ctx context.Context, gcpProject string) (retErr error) {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return errors.Annotate(err, "create bq client").Err()
 	}
diff --git a/analysis/internal/clustering/rules/exporter/client.go b/analysis/internal/clustering/rules/exporter/client.go
index a42499f..6f47807 100644
--- a/analysis/internal/clustering/rules/exporter/client.go
+++ b/analysis/internal/clustering/rules/exporter/client.go
@@ -46,7 +46,7 @@
 	if projectID == "" {
 		return nil, errors.New("GCP Project must be specified")
 	}
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/analysis/internal/failureattributes/client.go b/analysis/internal/failureattributes/client.go
index 5b204ea..7e60978 100644
--- a/analysis/internal/failureattributes/client.go
+++ b/analysis/internal/failureattributes/client.go
@@ -19,6 +19,7 @@
 
 	"cloud.google.com/go/bigquery"
 
+	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/errors"
 
 	"go.chromium.org/luci/analysis/internal/bqutil"
@@ -31,7 +32,7 @@
 		return nil, errors.New("GCP Project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/analysis/internal/services/backfill/backfill.go b/analysis/internal/services/backfill/backfill.go
index 5a0c6ab..de83529 100644
--- a/analysis/internal/services/backfill/backfill.go
+++ b/analysis/internal/services/backfill/backfill.go
@@ -34,7 +34,6 @@
 	"go.chromium.org/luci/server"
 	"go.chromium.org/luci/server/tq"
 
-	"go.chromium.org/luci/analysis/internal/bqutil"
 	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
 )
 
@@ -58,7 +57,7 @@
 
 // RegisterTaskHandler registers the handler for backfill tasks.
 func RegisterTaskHandler(srv *server.Server) error {
-	client, err := bqutil.Client(srv.Context, srv.Options.CloudProject)
+	client, err := bq.NewClient(srv.Context, srv.Options.CloudProject)
 	if err != nil {
 		return err
 	}
diff --git a/analysis/internal/testresults/exporter/client.go b/analysis/internal/testresults/exporter/client.go
index f536e47..79f0fe2 100644
--- a/analysis/internal/testresults/exporter/client.go
+++ b/analysis/internal/testresults/exporter/client.go
@@ -36,7 +36,7 @@
 		return nil, errors.New("GCP Project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/analysis/internal/testresults/read_client.go b/analysis/internal/testresults/read_client.go
index 14a5098..66e9631 100644
--- a/analysis/internal/testresults/read_client.go
+++ b/analysis/internal/testresults/read_client.go
@@ -21,15 +21,14 @@
 	"cloud.google.com/go/bigquery"
 	"google.golang.org/api/iterator"
 
+	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/errors"
 	"go.chromium.org/luci/server/auth/realms"
-
-	"go.chromium.org/luci/analysis/internal/bqutil"
 )
 
 // NewReadClient creates a new client for reading test results BigQuery table.
 func NewReadClient(ctx context.Context, gcpProject string) (*ReadClient, error) {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return nil, err
 	}
diff --git a/analysis/internal/testverdicts/client.go b/analysis/internal/testverdicts/client.go
index 71ec321..8bcae5c 100644
--- a/analysis/internal/testverdicts/client.go
+++ b/analysis/internal/testverdicts/client.go
@@ -36,7 +36,7 @@
 		return nil, errors.New("GCP Project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/analysis/internal/testverdicts/read_verdict_after_position.go b/analysis/internal/testverdicts/read_verdict_after_position.go
index ff944f4..5daa262 100644
--- a/analysis/internal/testverdicts/read_verdict_after_position.go
+++ b/analysis/internal/testverdicts/read_verdict_after_position.go
@@ -21,14 +21,13 @@
 	"cloud.google.com/go/bigquery"
 	"google.golang.org/api/iterator"
 
+	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/errors"
-
-	"go.chromium.org/luci/analysis/internal/bqutil"
 )
 
 // NewReadClient creates a new client for reading from test verdicts BigQuery table.
 func NewReadClient(ctx context.Context, gcpProject string) (*ReadClient, error) {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return nil, err
 	}
diff --git a/analysis/internal/views/ensure_views_cron.go b/analysis/internal/views/ensure_views_cron.go
index d36c0dc..2f4e847 100644
--- a/analysis/internal/views/ensure_views_cron.go
+++ b/analysis/internal/views/ensure_views_cron.go
@@ -199,7 +199,7 @@
 
 // CronHandler is then entry-point for the ensure views cron job.
 func CronHandler(ctx context.Context, gcpProject string) (retErr error) {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return errors.Annotate(err, "create bq client").Err()
 	}
diff --git a/bisection/bqexporter/client.go b/bisection/bqexporter/client.go
index 65b62e8..55e1161 100644
--- a/bisection/bqexporter/client.go
+++ b/bisection/bqexporter/client.go
@@ -39,7 +39,7 @@
 		return nil, errors.New("GCP Project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/bisection/bqexporter/ensure_views.go b/bisection/bqexporter/ensure_views.go
index 722939a..8bb91b2 100644
--- a/bisection/bqexporter/ensure_views.go
+++ b/bisection/bqexporter/ensure_views.go
@@ -46,7 +46,7 @@
 		logging.Warningf(ctx, "ensure view is not enabled")
 	}
 
-	client, err := bqutil.Client(ctx, info.AppID(ctx))
+	client, err := bq.NewClient(ctx, info.AppID(ctx))
 	if err != nil {
 		return errors.Annotate(err, "create bq client").Err()
 	}
diff --git a/bisection/util/bqutil/client.go b/common/bq/client.go
similarity index 79%
rename from bisection/util/bqutil/client.go
rename to common/bq/client.go
index 497b815..f221767 100644
--- a/bisection/util/bqutil/client.go
+++ b/common/bq/client.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The LUCI Authors.
+// Copyright 2024 The LUCI Authors.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,22 +12,21 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package bqutil
+package bq
 
 import (
 	"context"
 	"net/http"
 
 	"cloud.google.com/go/bigquery"
-	"google.golang.org/api/option"
-
 	"go.chromium.org/luci/common/errors"
 	"go.chromium.org/luci/server/auth"
+	"google.golang.org/api/option"
 )
 
-// Client returns a new BigQuery client for use with the given GCP project,
-// that authenticates as LUCI Bisection.
-func Client(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
+// NewClient returns a new BigQuery client for use with the given GCP project,
+// that authenticates as the LUCI service itself.
+func NewClient(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
 	if gcpProject == "" {
 		return nil, errors.New("GCP Project must be specified")
 	}
diff --git a/resultdb/bqutil/client.go b/resultdb/bqutil/client.go
deleted file mode 100644
index d166cf1..0000000
--- a/resultdb/bqutil/client.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright 2024 The LUCI Authors.
-//
-// 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 bqutil contains utility functions for BigQuery.
-package bqutil
-
-import (
-	"context"
-	"net/http"
-
-	"cloud.google.com/go/bigquery"
-	"google.golang.org/api/option"
-
-	"go.chromium.org/luci/common/errors"
-	"go.chromium.org/luci/server/auth"
-)
-
-// TODO (nqmtuan): Share the code with LUCI Analysis.
-// Client returns a new BigQuery client for use with the given GCP project,
-// that authenticates as ResultDB.
-func Client(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
-	if gcpProject == "" {
-		return nil, errors.New("GCP Project must be specified")
-	}
-	tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(bigquery.Scope))
-	if err != nil {
-		return nil, err
-	}
-	return bigquery.NewClient(ctx, gcpProject, option.WithHTTPClient(&http.Client{
-		Transport: tr,
-	}))
-}
diff --git a/resultdb/internal/artifacts/bqclient.go b/resultdb/internal/artifacts/bqclient.go
index f85fcb5..0881fd3 100644
--- a/resultdb/internal/artifacts/bqclient.go
+++ b/resultdb/internal/artifacts/bqclient.go
@@ -26,9 +26,9 @@
 	"cloud.google.com/go/bigquery"
 	"google.golang.org/api/iterator"
 
+	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/errors"
 
-	"go.chromium.org/luci/resultdb/bqutil"
 	"go.chromium.org/luci/resultdb/internal/pagination"
 	pb "go.chromium.org/luci/resultdb/proto/v1"
 )
@@ -40,7 +40,7 @@
 
 // NewClient creates a new client for reading text_artifacts table.
 func NewClient(ctx context.Context, gcpProject string) (*Client, error) {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return nil, err
 	}
diff --git a/resultdb/internal/ensureviews/ensure_views.go b/resultdb/internal/ensureviews/ensure_views.go
index 7dc8c5c..e5ddae6 100644
--- a/resultdb/internal/ensureviews/ensure_views.go
+++ b/resultdb/internal/ensureviews/ensure_views.go
@@ -53,7 +53,7 @@
 
 // CronHandler is then entry-point for the ensure views cron job.
 func CronHandler(ctx context.Context, gcpProject string) error {
-	client, err := bqutil.Client(ctx, gcpProject)
+	client, err := bq.NewClient(ctx, gcpProject)
 	if err != nil {
 		return errors.Annotate(err, "create bq client").Err()
 	}
diff --git a/resultdb/internal/services/artifactexporter/client.go b/resultdb/internal/services/artifactexporter/client.go
index ce89c62..ecc28c5 100644
--- a/resultdb/internal/services/artifactexporter/client.go
+++ b/resultdb/internal/services/artifactexporter/client.go
@@ -37,7 +37,7 @@
 		return nil, errors.New("GCP Project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/resultdb/internal/services/bqexporter/invocation_client.go b/resultdb/internal/services/bqexporter/invocation_client.go
index b8072d3..e767743 100644
--- a/resultdb/internal/services/bqexporter/invocation_client.go
+++ b/resultdb/internal/services/bqexporter/invocation_client.go
@@ -101,7 +101,7 @@
 		return nil, errors.New("GCP project must be specified")
 	}
 
-	bqClient, err := bqutil.Client(ctx, projectID)
+	bqClient, err := bq.NewClient(ctx, projectID)
 	if err != nil {
 		return nil, errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/resultdb/internal/services/resultdb/resultdb.go b/resultdb/internal/services/resultdb/resultdb.go
index f2e5024..7235c8f 100644
--- a/resultdb/internal/services/resultdb/resultdb.go
+++ b/resultdb/internal/services/resultdb/resultdb.go
@@ -23,6 +23,7 @@
 	sppb "cloud.google.com/go/spanner/apiv1/spannerpb"
 	"google.golang.org/genproto/googleapis/bytestream"
 
+	"go.chromium.org/luci/common/bq"
 	"go.chromium.org/luci/common/data/stringset"
 	"go.chromium.org/luci/common/errors"
 	"go.chromium.org/luci/grpc/prpc"
@@ -30,7 +31,6 @@
 	"go.chromium.org/luci/server/cron"
 	"go.chromium.org/luci/server/gerritauth"
 
-	"go.chromium.org/luci/resultdb/bqutil"
 	"go.chromium.org/luci/resultdb/internal"
 	"go.chromium.org/luci/resultdb/internal/artifactcontent"
 	"go.chromium.org/luci/resultdb/internal/artifacts"
@@ -84,7 +84,7 @@
 		contentServer.InstallHandlers(srv.VirtualHost(host))
 	}
 
-	bqClient, err := bqutil.Client(srv.Context, srv.Options.CloudProject)
+	bqClient, err := bq.NewClient(srv.Context, srv.Options.CloudProject)
 	if err != nil {
 		return errors.Annotate(err, "creating BQ client").Err()
 	}
diff --git a/tree_status/bqutil/dataset.go b/tree_status/bqutil/dataset.go
new file mode 100644
index 0000000..eed4209
--- /dev/null
+++ b/tree_status/bqutil/dataset.go
@@ -0,0 +1,20 @@
+// Copyright 2024 The LUCI Authors.
+//
+// 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 bqutil provides utility functions to interact with BigQuery.
+package bqutil
+
+// InternalDatasetID is the name of the BigQuery dataset which is intended
+// for internal service use only.
+const InternalDatasetID = "internal"
diff --git a/tree_status/internal/bqexporter/client.go b/tree_status/internal/bqexporter/client.go
new file mode 100644
index 0000000..86c3b6d
--- /dev/null
+++ b/tree_status/internal/bqexporter/client.go
@@ -0,0 +1,111 @@
+// Copyright 2024 The LUCI Authors.
+//
+// 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 bqexporter
+
+import (
+	"context"
+	"fmt"
+
+	"cloud.google.com/go/bigquery"
+	"cloud.google.com/go/bigquery/storage/managedwriter"
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/common/bq"
+	"go.chromium.org/luci/common/errors"
+
+	"go.chromium.org/luci/tree_status/bqutil"
+	bqpb "go.chromium.org/luci/tree_status/proto/bq"
+)
+
+// NewClient creates a new client for exporting statuses
+// via the BigQuery Write API.
+func NewClient(ctx context.Context, projectID string) (s *Client, reterr error) {
+	if projectID == "" {
+		return nil, errors.New("GCP Project must be specified")
+	}
+
+	bqClient, err := bq.NewClient(ctx, projectID)
+	if err != nil {
+		return nil, errors.Annotate(err, "creating BQ client").Err()
+	}
+	defer func() {
+		if reterr != nil {
+			// This method failed for some reason, clean up the
+			// BigQuery client. Swallow any error returned by the Close()
+			// call.
+			bqClient.Close()
+		}
+	}()
+
+	mwClient, err := bq.NewWriterClient(ctx, projectID)
+	if err != nil {
+		return nil, errors.Annotate(err, "creating managed writer client").Err()
+	}
+	return &Client{
+		projectID: projectID,
+		bqClient:  bqClient,
+		mwClient:  mwClient,
+	}, nil
+}
+
+// Close releases resources held by the client.
+func (c *Client) Close() (reterr error) {
+	// Ensure both bqClient and mwClient Close() methods
+	// are called, even if one panics or fails.
+	defer func() {
+		err := c.mwClient.Close()
+		if reterr == nil {
+			reterr = err
+		}
+	}()
+	return c.bqClient.Close()
+}
+
+// Client provides methods to export statuses to BigQuery
+// via the BigQuery Write API.
+type Client struct {
+	// projectID is the name of the GCP project that contains Tree Status
+	// BigQuery datasets.
+	projectID string
+	bqClient  *bigquery.Client
+	mwClient  *managedwriter.Client
+}
+
+// schemaApplier ensures BQ schema matches the row proto definitions.
+var schemaApplyer = bq.NewSchemaApplyer(bq.RegisterSchemaApplyerCache(1))
+
+func (c *Client) EnsureSchema(ctx context.Context) error {
+	table := c.bqClient.Dataset(bqutil.InternalDatasetID).Table(tableName)
+	if err := schemaApplyer.EnsureTable(ctx, table, tableMetadata, bq.UpdateMetadata()); err != nil {
+		return errors.Annotate(err, "ensuring statuses table").Err()
+	}
+	return nil
+}
+
+// InsertStatusRows inserts the given rows in BigQuery.
+func (c *Client) InsertStatusRows(ctx context.Context, rows []*bqpb.StatusRow) error {
+	if err := c.EnsureSchema(ctx); err != nil {
+		return errors.Annotate(err, "ensure schema").Err()
+	}
+	tableName := fmt.Sprintf("projects/%s/datasets/%s/tables/%s", c.projectID, bqutil.InternalDatasetID, tableName)
+	writer := bq.NewWriter(c.mwClient, tableName, tableSchemaDescriptor)
+	payload := make([]proto.Message, len(rows))
+	for i, r := range rows {
+		payload[i] = r
+	}
+	// TODO (nqmtuan): Consider using commit stream with offset if we really want
+	// exactly-once semantic.
+	return writer.AppendRowsWithDefaultStream(ctx, payload)
+}
diff --git a/tree_status/internal/bqexporter/schema.go b/tree_status/internal/bqexporter/schema.go
new file mode 100644
index 0000000..dc824d0
--- /dev/null
+++ b/tree_status/internal/bqexporter/schema.go
@@ -0,0 +1,79 @@
+// Copyright 2024 The LUCI Authors.
+//
+// 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 bqexporter
+
+import (
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	"cloud.google.com/go/bigquery/storage/managedwriter/adapt"
+	"github.com/golang/protobuf/descriptor"
+	desc "github.com/golang/protobuf/protoc-gen-go/descriptor"
+	"google.golang.org/protobuf/types/descriptorpb"
+
+	"go.chromium.org/luci/common/bq"
+
+	bqpb "go.chromium.org/luci/tree_status/proto/bq"
+)
+
+// The table containing tree statuses.
+const tableName = "statuses"
+
+const partitionExpirationTime = 140 * 24 * time.Hour // 140 days.
+
+const rowMessage = "luci.tree_status.bq.StatusRow"
+
+var tableMetadata *bigquery.TableMetadata
+
+// tableSchemaDescriptor is a self-contained DescriptorProto for describing
+// row protocol buffers sent to the BigQuery Write API.
+var tableSchemaDescriptor *descriptorpb.DescriptorProto
+
+func init() {
+	var err error
+	var schema bigquery.Schema
+	if schema, err = generateRowSchema(); err != nil {
+		panic(err)
+	}
+	if tableSchemaDescriptor, err = generateRowSchemaDescriptor(); err != nil {
+		panic(err)
+	}
+
+	tableMetadata = &bigquery.TableMetadata{
+		TimePartitioning: &bigquery.TimePartitioning{
+			Type:       bigquery.DayPartitioningType,
+			Expiration: partitionExpirationTime,
+			Field:      "create_time",
+		},
+		// Relax ensures no fields are marked "required".
+		Schema: schema.Relax(),
+		Labels: map[string]string{bq.MetadataVersionKey: "1"},
+	}
+}
+
+func generateRowSchema() (schema bigquery.Schema, err error) {
+	fd, _ := descriptor.MessageDescriptorProto(&bqpb.StatusRow{})
+	fdset := &desc.FileDescriptorSet{File: []*desc.FileDescriptorProto{fd}}
+	return bq.GenerateSchema(fdset, rowMessage)
+}
+
+func generateRowSchemaDescriptor() (*desc.DescriptorProto, error) {
+	m := &bqpb.StatusRow{}
+	descriptorProto, err := adapt.NormalizeDescriptor(m.ProtoReflect().Descriptor())
+	if err != nil {
+		return nil, err
+	}
+	return descriptorProto, nil
+}
diff --git a/tree_status/internal/bqexporter/schema_test.go b/tree_status/internal/bqexporter/schema_test.go
new file mode 100644
index 0000000..31ab910
--- /dev/null
+++ b/tree_status/internal/bqexporter/schema_test.go
@@ -0,0 +1,35 @@
+// Copyright 2024 The LUCI Authors.
+//
+// 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 bqexporter
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestSchema(t *testing.T) {
+	t.Parallel()
+	Convey(`With Schema`, t, func() {
+		var fieldNames []string
+		for _, field := range tableMetadata.Schema {
+			fieldNames = append(fieldNames, field.Name)
+		}
+		Convey(`Time partitioning field is defined`, func() {
+			partitioningField := tableMetadata.TimePartitioning.Field
+			So(partitioningField, ShouldBeIn, fieldNames)
+		})
+	})
+}
diff --git a/tree_status/proto/bq/gen.go b/tree_status/proto/bq/gen.go
new file mode 100644
index 0000000..b076409
--- /dev/null
+++ b/tree_status/proto/bq/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2024 The LUCI Authors.
+//
+// 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 bqpb
+
+//go:generate cproto
diff --git a/tree_status/proto/bq/status_row.pb.go b/tree_status/proto/bq/status_row.pb.go
new file mode 100644
index 0000000..11b6495
--- /dev/null
+++ b/tree_status/proto/bq/status_row.pb.go
@@ -0,0 +1,313 @@
+// Copyright 2024 The LUCI Authors.
+//
+// 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.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.33.0
+// 	protoc        v5.26.1
+// source: go.chromium.org/luci/tree_status/proto/bq/status_row.proto
+
+package bqpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Represents a row in the table `luci-tree-status.internal.statuses`.
+type StatusRow struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the tree, e.g. chromium.
+	TreeName string `protobuf:"bytes,1,opt,name=tree_name,json=treeName,proto3" json:"tree_name,omitempty"`
+	// Possible values: 'open', 'closed', 'throttled' or 'maintenance'.
+	Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
+	// The message provided with the status update.
+	Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
+	// If the status was created by a bot (service account), it will contain the service account.
+	// Otherwise, it will just contain "user", as we don't want BigQuery to contain PII.
+	CreateUser string `protobuf:"bytes,4,opt,name=create_user,json=createUser,proto3" json:"create_user,omitempty"`
+	// The timestamp when this status was posted.
+	CreateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
+	// The LUCI builder name that caused the tree to close.
+	// Only applicable if the status is 'closed'.
+	ClosingBuilder *Builder `protobuf:"bytes,6,opt,name=closing_builder,json=closingBuilder,proto3" json:"closing_builder,omitempty"`
+}
+
+func (x *StatusRow) Reset() {
+	*x = StatusRow{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *StatusRow) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StatusRow) ProtoMessage() {}
+
+func (x *StatusRow) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use StatusRow.ProtoReflect.Descriptor instead.
+func (*StatusRow) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *StatusRow) GetTreeName() string {
+	if x != nil {
+		return x.TreeName
+	}
+	return ""
+}
+
+func (x *StatusRow) GetStatus() string {
+	if x != nil {
+		return x.Status
+	}
+	return ""
+}
+
+func (x *StatusRow) GetMessage() string {
+	if x != nil {
+		return x.Message
+	}
+	return ""
+}
+
+func (x *StatusRow) GetCreateUser() string {
+	if x != nil {
+		return x.CreateUser
+	}
+	return ""
+}
+
+func (x *StatusRow) GetCreateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreateTime
+	}
+	return nil
+}
+
+func (x *StatusRow) GetClosingBuilder() *Builder {
+	if x != nil {
+		return x.ClosingBuilder
+	}
+	return nil
+}
+
+// Represents a LUCI builder.
+type Builder struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	Bucket  string `protobuf:"bytes,2,opt,name=bucket,proto3" json:"bucket,omitempty"`
+	Builder string `protobuf:"bytes,3,opt,name=builder,proto3" json:"builder,omitempty"`
+}
+
+func (x *Builder) Reset() {
+	*x = Builder{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Builder) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Builder) ProtoMessage() {}
+
+func (x *Builder) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Builder.ProtoReflect.Descriptor instead.
+func (*Builder) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Builder) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *Builder) GetBucket() string {
+	if x != nil {
+		return x.Bucket
+	}
+	return ""
+}
+
+func (x *Builder) GetBuilder() string {
+	if x != nil {
+		return x.Builder
+	}
+	return ""
+}
+
+var File_go_chromium_org_luci_tree_status_proto_bq_status_row_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc = []byte{
+	0x0a, 0x3a, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x62, 0x71, 0x2f, 0x73, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x5f, 0x72, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 0x6c, 0x75,
+	0x63, 0x69, 0x2e, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x62,
+	0x71, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x22, 0xff, 0x01, 0x0a, 0x09, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x6f, 0x77,
+	0x12, 0x1b, 0x0a, 0x09, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x72, 0x65, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a,
+	0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73,
+	0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12,
+	0x1f, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x04,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72,
+	0x12, 0x3b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
+	0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
+	0x70, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x45, 0x0a,
+	0x0f, 0x63, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72,
+	0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6c, 0x75, 0x63, 0x69, 0x2e, 0x74, 0x72,
+	0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x62, 0x71, 0x2e, 0x42, 0x75, 0x69,
+	0x6c, 0x64, 0x65, 0x72, 0x52, 0x0e, 0x63, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x42, 0x75, 0x69,
+	0x6c, 0x64, 0x65, 0x72, 0x22, 0x55, 0x0a, 0x07, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x12,
+	0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x62, 0x75, 0x63,
+	0x6b, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x62, 0x75, 0x63, 0x6b, 0x65,
+	0x74, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x42, 0x30, 0x5a, 0x2e, 0x67,
+	0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c,
+	0x75, 0x63, 0x69, 0x2f, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x62, 0x71, 0x3b, 0x62, 0x71, 0x70, 0x62, 0x62, 0x06, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData = file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_goTypes = []interface{}{
+	(*StatusRow)(nil),             // 0: luci.tree_status.bq.StatusRow
+	(*Builder)(nil),               // 1: luci.tree_status.bq.Builder
+	(*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp
+}
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_depIdxs = []int32{
+	2, // 0: luci.tree_status.bq.StatusRow.create_time:type_name -> google.protobuf.Timestamp
+	1, // 1: luci.tree_status.bq.StatusRow.closing_builder:type_name -> luci.tree_status.bq.Builder
+	2, // [2:2] is the sub-list for method output_type
+	2, // [2:2] is the sub-list for method input_type
+	2, // [2:2] is the sub-list for extension type_name
+	2, // [2:2] is the sub-list for extension extendee
+	0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_init() }
+func file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_init() {
+	if File_go_chromium_org_luci_tree_status_proto_bq_status_row_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*StatusRow); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Builder); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_depIdxs,
+		MessageInfos:      file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_tree_status_proto_bq_status_row_proto = out.File
+	file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc = nil
+	file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_goTypes = nil
+	file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_depIdxs = nil
+}
diff --git a/tree_status/proto/bq/status_row.proto b/tree_status/proto/bq/status_row.proto
new file mode 100644
index 0000000..a356aa2
--- /dev/null
+++ b/tree_status/proto/bq/status_row.proto
@@ -0,0 +1,51 @@
+// Copyright 2024 The LUCI Authors.
+//
+// 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.
+
+syntax = "proto3";
+
+package luci.tree_status.bq;
+
+import "google/protobuf/timestamp.proto";
+
+option go_package = "go.chromium.org/luci/tree_status/proto/bq;bqpb";
+
+// Represents a row in the table `luci-tree-status.internal.statuses`.
+message StatusRow {
+  // The name of the tree, e.g. chromium.
+  string tree_name = 1;
+
+  // Possible values: 'open', 'closed', 'throttled' or 'maintenance'.
+  string status = 2;
+
+  // The message provided with the status update.
+  string message = 3;
+
+  // If the status was created by a bot (service account), it will contain the service account.
+  // Otherwise, it will just contain "user", as we don't want BigQuery to contain PII.
+  string create_user = 4;
+
+  // The timestamp when this status was posted.
+  google.protobuf.Timestamp create_time = 5;
+
+  // The LUCI builder name that caused the tree to close.
+  // Only applicable if the status is 'closed'.
+  Builder closing_builder = 6;
+}
+
+// Represents a LUCI builder.
+message Builder {
+  string project = 1;
+  string bucket = 2;
+  string builder = 3;
+}