Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added a structured `Tilebox-Client` header with automatically detected SDK, runtime, OS, execution environment, invoker, and cloud metadata. SDK wrappers can replace the metadata with `WithClientMetadata` and `client.NewMetadata`.

### Removed

- Removed the legacy client metadata from dataset list requests.

### Changed

- `workflows`: Change `Client.NewPollingTaskRunner` to accept a resolved `*Cluster`, executor, and logger directly; it no longer fetches a cluster or accepts task-runner options.
Expand Down
13 changes: 12 additions & 1 deletion accounts/v1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"connectrpc.com/connect"
"github.com/tilebox/tilebox-go/client"
"github.com/tilebox/tilebox-go/internal/grpc"
"github.com/tilebox/tilebox-go/protogen/accounts/v1alpha1/accountsv1alpha1connect"
"go.opentelemetry.io/otel"
Expand Down Expand Up @@ -56,6 +57,7 @@ type clientConfig struct {
httpClient connect.HTTPClient
url string
apiKey string
clientMetadata client.Metadata
connectOptions []connect.ClientOption

tracerProvider trace.TracerProvider
Expand Down Expand Up @@ -91,6 +93,14 @@ func WithAPIKey(apiKey string) ClientOption {
}
}

// WithClientMetadata replaces the automatically detected metadata sent with each request.
// Wrappers such as the Tilebox CLI can use this to identify themselves as the client.
func WithClientMetadata(metadata client.Metadata) ClientOption {
return func(cfg *clientConfig) {
cfg.clientMetadata = metadata
}
}

// WithConnectClientOptions sets additional options for the connect.HTTPClient.
func WithConnectClientOptions(options ...connect.ClientOption) ClientOption {
return func(cfg *clientConfig) {
Expand All @@ -109,6 +119,7 @@ func newClientConfig(options []ClientOption) *clientConfig {
cfg := &clientConfig{
url: "https://api.tilebox.com",
apiKey: os.Getenv("TILEBOX_API_KEY"),
clientMetadata: client.DefaultMetadata(),
tracerProvider: otel.GetTracerProvider(),
}
for _, option := range options {
Expand All @@ -134,7 +145,7 @@ func newClientConfig(options []ClientOption) *clientConfig {
}

func newConnectClient[T any](newClientFunc func(httpClient connect.HTTPClient, baseURL string, options ...connect.ClientOption) T, cfg *clientConfig) T {
interceptors := make([]connect.Interceptor, 0)
interceptors := []connect.Interceptor{grpc.NewAddClientMetadataInterceptor(cfg.clientMetadata.HeaderValue())}
if cfg.apiKey != "" {
interceptors = append(interceptors, grpc.NewAddAuthTokenInterceptor(func() string {
return cfg.apiKey
Expand Down
24 changes: 20 additions & 4 deletions accounts/v1/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tilebox/tilebox-go/client"
accountsv1alpha1 "github.com/tilebox/tilebox-go/protogen/accounts/v1alpha1"
"github.com/tilebox/tilebox-go/protogen/accounts/v1alpha1/accountsv1alpha1connect"
)
Expand All @@ -23,6 +24,18 @@ func TestClient_GetAccountDetails(t *testing.T) {
assert.Equal(t, "Test User", details.GetUserName())
assert.Equal(t, "test-organization", details.GetOrganizationSlug())
assert.Equal(t, "Bearer test-api-key", service.authorization)
assert.Contains(t, service.clientMetadata, `name="go"`)
assert.Contains(t, service.clientMetadata, `runtime="go"`)
}

func TestClientMetadataCanBeOverridden(t *testing.T) {
service := &fakeAccountsService{}
client := newTestClient(t, service, WithClientMetadata(client.Metadata{Name: "cli", Version: "v1.2.3"}))

_, err := client.Account.GetAccountDetails(t.Context())
require.NoError(t, err)

assert.Equal(t, `name="cli", version="v1.2.3"`, service.clientMetadata)
}

func TestClient_GetActivePlan(t *testing.T) {
Expand Down Expand Up @@ -68,7 +81,7 @@ func TestClient_GetUsageReport(t *testing.T) {
}
}

func newTestClient(t *testing.T, service *fakeAccountsService) *Client {
func newTestClient(t *testing.T, service *fakeAccountsService, options ...ClientOption) *Client {
t.Helper()

mux := http.NewServeMux()
Expand All @@ -80,21 +93,24 @@ func newTestClient(t *testing.T, service *fakeAccountsService) *Client {
server := httptest.NewServer(mux)
t.Cleanup(server.Close)

return NewClient(
options = append(options,
WithURL(server.URL),
WithHTTPClient(server.Client()),
WithAPIKey("test-api-key"),
WithDisableTracing(),
)
return NewClient(options...)
}

type fakeAccountsService struct {
authorization string
historyDays uint64
authorization string
clientMetadata string
historyDays uint64
}

func (s *fakeAccountsService) GetAccountDetails(_ context.Context, request *connect.Request[accountsv1alpha1.GetAccountDetailsRequest]) (*connect.Response[accountsv1alpha1.AccountDetails], error) {
s.authorization = request.Header().Get("Authorization")
s.clientMetadata = request.Header().Get("Tilebox-Client")
return connect.NewResponse(accountsv1alpha1.AccountDetails_builder{
UserName: "Test User",
OrganizationSlug: "test-organization",
Expand Down
248 changes: 248 additions & 0 deletions client/client_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// Package client provides metadata describing clients that call Tilebox APIs.
package client

import (
"os"
"runtime"
"runtime/debug"
"strings"
)

const (
clientHeaderMaxSize = 2 * 1024
clientFieldMaxSize = 256
modulePath = "github.com/tilebox/tilebox-go"
)

// Metadata describes the client and environment making a Tilebox API request.
// The metadata is sent for analytics only and must not contain secrets.
type Metadata struct {
Name string
Version string
Runtime string
RuntimeVersion string
OS string
OSVersion string
Arch string
ExecutionEnvironment string
Invoker string
InvokerVersion string
CloudProvider string
CloudPlatform string
CloudRegion string
}

// DefaultMetadata detects metadata for the Tilebox Go SDK from local process information.
// Detection does not make network requests or start subprocesses.
func DefaultMetadata() Metadata {
metadata := NewMetadata("go", clientVersion())
metadata.Runtime = "go"
metadata.RuntimeVersion = strings.TrimPrefix(runtime.Version(), "go")
return metadata
}

// NewMetadata detects environment metadata for a client with the supplied identity.
// It is useful for wrappers such as the Tilebox CLI, which should identify themselves while
// retaining detected OS, execution environment, invoker, and cloud information.
func NewMetadata(name, version string) Metadata {
metadata := Metadata{
Name: name,
Version: version,
OS: runtime.GOOS,
OSVersion: osVersion(),
Arch: runtime.GOARCH,
}
metadata.ExecutionEnvironment = executionEnvironment()
metadata.Invoker, metadata.InvokerVersion = invoker()
metadata.CloudProvider, metadata.CloudPlatform, metadata.CloudRegion = cloudEnvironment()
return metadata
}

// HeaderValue serializes metadata as an RFC 9651 Structured Fields dictionary.
// Empty and invalid values are omitted.
func (m Metadata) HeaderValue() string {
fields := [...]struct {
name string
value string
}{
{"name", m.Name},
{"version", m.Version},
{"runtime", m.Runtime},
{"runtime-version", m.RuntimeVersion},
{"os", m.OS},
{"os-version", m.OSVersion},
{"arch", m.Arch},
{"execution-environment", m.ExecutionEnvironment},
{"invoker", m.Invoker},
{"invoker-version", m.InvokerVersion},
{"cloud-provider", m.CloudProvider},
{"cloud-platform", m.CloudPlatform},
{"cloud-region", m.CloudRegion},
}

var header strings.Builder
header.Grow(256)
for _, field := range fields {
value, ok := structuredString(field.value)
if !ok {
continue
}
additionalSize := len(field.name) + len(value) + 3
if header.Len() != 0 {
additionalSize += 2
}
if header.Len()+additionalSize > clientHeaderMaxSize {
continue
}
if header.Len() != 0 {
header.WriteString(", ")
}
header.WriteString(field.name)
header.WriteString("=\"")
header.WriteString(value)
header.WriteByte('"')
}
return header.String()
}

func structuredString(value string) (string, bool) {
if value == "" || len(value) > clientFieldMaxSize {
return "", false
}
var escaped strings.Builder
for i := range len(value) {
character := value[i]
if character < 0x20 || character > 0x7e {
return "", false
}
if character == '"' || character == '\\' {
escaped.WriteByte('\\')
}
escaped.WriteByte(character)
}
return escaped.String(), true
}

func clientVersion() string {
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
return "dev"
}
if buildInfo.Main.Path == modulePath && buildInfo.Main.Version != "(devel)" {
return buildInfo.Main.Version
}
for _, dependency := range buildInfo.Deps {
if dependency.Path == modulePath {
return dependency.Version
}
}
return "dev"
}

func executionEnvironment() string {
switch {
case os.Getenv("GITHUB_ACTIONS") == "true":
return "github-actions"
case os.Getenv("GITLAB_CI") == "true":
return "gitlab-ci"
case envSet("BUILDKITE"):
return "buildkite"
case envSet("CIRCLECI"):
return "circleci"
case envSet("JENKINS_URL"):
return "jenkins"
case envSet("TEAMCITY_VERSION"):
return "teamcity"
case envSet("TF_BUILD"):
return "azure-pipelines"
case envSet("K_SERVICE", "CLOUD_RUN_JOB"):
return "google-cloud-run"
case strings.HasPrefix(os.Getenv("AWS_EXECUTION_ENV"), "AWS_Lambda_"):
return "aws-lambda"
case envSet("FUNCTIONS_WORKER_RUNTIME"):
return "azure-functions"
case envSet("KUBERNETES_SERVICE_HOST"):
return "kubernetes"
case isTerminal():
return "terminal"
default:
return ""
}
}

func invoker() (string, string) {
switch {
case os.Getenv("AGENT") == "amp":
return "amp", ""
case envSet("COPILOT_AGENT_SESSION_ID"):
return "github-copilot", ""
case os.Getenv("OPENCODE") == "1":
return "opencode", ""
case envSet("CLAUDECODE"):
return "claude-code", ""
case envSet("CURSOR_AGENT"):
return "cursor", ""
case envSet("CODEX_SESSION_ID", "CODEX_THREAD_ID"):
return "codex", os.Getenv("CODEX_VERSION")
case envSet("GEMINI_CLI"):
return "gemini-cli", ""
default:
return "", ""
}
}

func cloudEnvironment() (string, string, string) {
var provider, platform, region string
switch {
case envSet("AWS_EXECUTION_ENV", "AWS_REGION", "AWS_DEFAULT_REGION"):
provider = "aws"
region = firstEnvironmentValue("AWS_REGION", "AWS_DEFAULT_REGION")
executionEnvironment := os.Getenv("AWS_EXECUTION_ENV")
switch {
case strings.HasPrefix(executionEnvironment, "AWS_Lambda_"):
platform = "aws_lambda"
case strings.HasPrefix(executionEnvironment, "AWS_ECS_"):
platform = "aws_ecs"
}
case envSet("K_SERVICE", "CLOUD_RUN_JOB", "GAE_ENV"):
provider = "gcp"
region = firstEnvironmentValue("GOOGLE_CLOUD_REGION", "CLOUD_RUN_REGION", "FUNCTION_REGION")
if envSet("K_SERVICE", "CLOUD_RUN_JOB") {
platform = "gcp_cloud_run"
} else if envSet("GAE_ENV") {
platform = "gcp_app_engine"
}
case envSet("WEBSITE_INSTANCE_ID", "FUNCTIONS_WORKER_RUNTIME", "REGION_NAME"):
provider = "azure"
region = os.Getenv("REGION_NAME")
if envSet("FUNCTIONS_WORKER_RUNTIME") {
platform = "azure_functions"
} else if envSet("WEBSITE_INSTANCE_ID") {
platform = "azure_app_service"
}
}
return provider, platform, region
}

func isTerminal() bool {
info, err := os.Stdin.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}

func envSet(names ...string) bool {
for _, name := range names {
if os.Getenv(name) != "" {
return true
}
}
return false
}

func firstEnvironmentValue(names ...string) string {
for _, name := range names {
if value := os.Getenv(name); value != "" {
return value
}
}
return ""
}
7 changes: 7 additions & 0 deletions client/client_metadata_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !darwin && !linux

package client

func osVersion() string {
return ""
}
Loading