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
4 changes: 2 additions & 2 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ func (f *fakeAnalytics) Query(context.Context, analytics.QueryRequest, agent.Tok
return analytics.QueryResponse{}, nil
}

func (f *fakeAnalytics) Schema(context.Context, agent.Token) ([]analytics.QueryTable, error) {
return nil, nil
func (f *fakeAnalytics) Schema(context.Context, agent.Token) (analytics.SchemaResponse, error) {
return analytics.SchemaResponse{}, nil
}

func TestBuildConsentStrategyAgentConfirmsIcons(t *testing.T) {
Expand Down
38 changes: 34 additions & 4 deletions internal/cli/analytics.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package cli

import (
"errors"
"fmt"
"strings"

"github.com/spf13/cobra"
"github.com/tollbit/cli/internal/app"
analyticsclient "github.com/tollbit/cli/internal/client/analytics"
"github.com/tollbit/cli/internal/credentials/agenttoken"
"github.com/tollbit/cli/internal/errorsx/problemjson"
)

const analyticsLongHelp = `Query TollBit analytics for your organization's sites.
Expand All @@ -31,13 +33,19 @@ unfiltered queries are rejected even with LIMIT. The server also caps the
number of rows returned, so add ORDER BY with LIMIT and OFFSET when paging
through large results.

Output is a JSON object with "columns" (name and type) and "rows" (arrays in
column order, null for missing values).`
Output is a JSON object with "columns" (name and type), "rows" (arrays in
column order, null for missing values), and "meta" (row_count, truncated,
bytes_scanned, duration_ms). When meta.truncated is true the result was cut
at the server's row limit and a warning is printed to stderr.`

const analyticsSchemaLongHelp = `List the analytics tables and columns available to your organization.

Output is a JSON array of tables, each with its name and columns (name and
type). Run this before "analytics query" to discover table and column names.`
Output is a JSON object with "dialect", "tables" and "limits". Each table has
its name, a description, its columns (name, type, description and, where the
column has a fixed set, values), and "clustering": the columns the table is
ordered by, most significant first. Filtering and grouping in that order,
after a timestamp filter, scans the least data. "limits" maps each server
limit to its value, unit and description. Run this before "analytics query".`

const analyticsQueryExample = ` # Discover tables and columns first
tollbit analytics schema
Expand Down Expand Up @@ -116,14 +124,36 @@ func runAnalyticsQuery(cmd *cobra.Command, factory app.Factory, sql string) erro
}
result, err := analyticsClient.Query(cmd.Context(), analyticsclient.QueryRequest{SQL: sql}, token)
if err != nil {
if hint := analyticsErrorHint(err); hint != "" {
printLeadingCommand(cmd.ErrOrStderr(), hint)
}
return RuntimeError(fmt.Errorf("error querying analytics: %w", err))
}
if err := writeJSON(cmd.OutOrStdout(), result); err != nil {
return RuntimeError(fmt.Errorf("error writing analytics response: %w", err))
}
if result.Meta != nil && result.Meta.Truncated {
printLeadingCommand(cmd.ErrOrStderr(), fmt.Sprintf("warning: result truncated at %d rows (server limit). Add ORDER BY with LIMIT and OFFSET to page, or narrow the query.", result.Meta.RowCount))
}
return nil
}

// analyticsErrorHint maps a server error code to a next step. Unknown or
// absent codes give no hint.
func analyticsErrorHint(err error) string {
var problem problemjson.Problem
if !errors.As(err, &problem) || problem.Code == nil {
return ""
}
switch string(*problem.Code) {
case "analytics_unknown_table", "analytics_statement_not_allowed":
return "Run \"tollbit analytics schema\" to list the available tables."
case "analytics_scan_limit_exceeded", "analytics_query_timeout":
return "Run \"tollbit analytics schema\" to see the query limits, then filter on timestamp or select fewer columns."
}
return ""
}

func NewAnalyticsSchemaCommand(factory app.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "schema",
Expand Down
157 changes: 144 additions & 13 deletions internal/cli/analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func TestAnalyticsQueryUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
if request.SQL != "SELECT * FROM logs" {
t.Fatalf("unexpected SQL: %q", request.SQL)
}
_, _ = w.Write([]byte(`{"columns":[{"name":"requests","type":"INTEGER"},{"name":"optional","type":"STRING"}],"rows":[[42,null]]}`))
_, _ = w.Write([]byte(`{"columns":[{"name":"requests","type":"INTEGER"},{"name":"optional","type":"STRING"}],"rows":[[42,null]],"meta":{"row_count":1,"truncated":false,"bytes_scanned":10,"duration_ms":5}}`))
}))
defer analyticsSrv.Close()

Expand All @@ -68,6 +68,10 @@ func TestAnalyticsQueryUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
Type string `json:"type"`
} `json:"columns"`
Rows [][]any `json:"rows"`
Meta *struct {
RowCount int `json:"row_count"`
Truncated bool `json:"truncated"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("invalid JSON output %q: %v", stdout.String(), err)
Expand All @@ -78,6 +82,87 @@ func TestAnalyticsQueryUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
if len(output.Rows) != 1 || output.Rows[0][0] != float64(42) || output.Rows[0][1] != nil {
t.Fatalf("unexpected rows: %#v", output.Rows)
}
if output.Meta == nil || output.Meta.RowCount != 1 {
t.Fatalf("expected meta in output, got %q", stdout.String())
}
if strings.Contains(stderr.String(), "truncated") {
t.Fatalf("no truncation warning expected, got %q", stderr.String())
}
}

func TestAnalyticsQueryWarnsOnTruncation(t *testing.T) {
token := testAgentJWTWithOBO(t)
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "agent-token.jwt"), []byte(token), 0o600); err != nil {
t.Fatal(err)
}
analyticsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"columns":[{"name":"n","type":"INT64"}],"rows":[[1]],"meta":{"row_count":10000,"truncated":true,"bytes_scanned":10,"duration_ms":5}}`))
}))
defer analyticsSrv.Close()

config := testConfig()
config.Analytics.Enabled = true
config.Analytics.BaseURL = analyticsSrv.URL
config.Credentials.StorageDir = storageDir
config.Runtime.StateDir = storageDir

var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, []string{"analytics", "query", "SELECT 1"}, nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("expected success, got %d (stderr=%q)", code, stderr.String())
}
if !strings.Contains(stderr.String(), "warning: result truncated at 10000 rows") {
t.Fatalf("expected truncation warning on stderr, got %q", stderr.String())
}
if strings.Contains(stdout.String(), "warning") {
t.Fatalf("stdout must stay data-only, got %q", stdout.String())
}
}

func TestAnalyticsQueryPrintsHintForKnownErrorCodes(t *testing.T) {
token := testAgentJWTWithOBO(t)
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "agent-token.jwt"), []byte(token), 0o600); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
body string
wantHint string
}{
{`{"title":"Bad Request","status":400,"detail":"Unknown table. Available tables: a, b.","code":"analytics_unknown_table"}`, "tollbit analytics schema"},
{`{"title":"Unprocessable","status":422,"detail":"Query would scan about 150 GiB, more than the limit of 100 GiB.","code":"analytics_scan_limit_exceeded"}`, "filter on timestamp"},
{`{"title":"Bad Request","status":400,"detail":"Syntax error","code":"analytics_invalid_query"}`, ""},
{`{"title":"Bad Request","status":400,"detail":"no code at all"}`, ""},
} {
body := tc.body
analyticsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(body))
}))
config := testConfig()
config.Analytics.Enabled = true
config.Analytics.BaseURL = analyticsSrv.URL
config.Credentials.StorageDir = storageDir
config.Runtime.StateDir = storageDir

var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, []string{"analytics", "query", "SELECT 1"}, nil, &stdout, &stderr)
analyticsSrv.Close()
if code == 0 {
t.Fatalf("expected failure for %s", body)
}
if tc.wantHint == "" {
if strings.Contains(stderr.String(), "tollbit analytics schema") {
t.Fatalf("unexpected hint for %s: %q", body, stderr.String())
}
continue
}
if !strings.Contains(stderr.String(), tc.wantHint) {
t.Fatalf("expected hint %q for %s, got %q", tc.wantHint, body, stderr.String())
}
}
}

func TestAnalyticsSchemaUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
Expand All @@ -93,6 +178,57 @@ func TestAnalyticsSchemaUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
if r.Header.Get("Authorization") != "Bearer "+token {
t.Fatal("unexpected authorization header")
}
_, _ = w.Write([]byte(`{"dialect":"bigquery","tables":[{"name":"agent_logs_by_page","clustering":["host","user_agent","path"],"columns":[{"name":"host","type":"STRING"}]}],"limits":{"max_rows":{"value":10000,"unit":"rows"}}}`))
}))
defer analyticsSrv.Close()

config := testConfig()
config.Analytics.Enabled = true
config.Analytics.BaseURL = analyticsSrv.URL
config.Credentials.StorageDir = storageDir
config.Runtime.StateDir = storageDir

var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, []string{"analytics", "schema"}, nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("expected success, got %d (stderr=%q)", code, stderr.String())
}
var output struct {
Dialect string `json:"dialect"`
Tables []struct {
Name string `json:"name"`
Clustering []string `json:"clustering"`
Columns []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
} `json:"tables"`
Limits map[string]struct {
Value float64 `json:"value"`
Unit string `json:"unit"`
} `json:"limits"`
}
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("invalid JSON output %q: %v", stdout.String(), err)
}
if output.Dialect != "bigquery" || len(output.Tables) != 1 || output.Tables[0].Name != "agent_logs_by_page" {
t.Fatalf("unexpected schema: %#v", output)
}
if len(output.Tables[0].Clustering) != 3 || len(output.Tables[0].Columns) != 1 || output.Tables[0].Columns[0].Name != "host" {
t.Fatalf("unexpected table: %#v", output.Tables[0])
}
if output.Limits["max_rows"].Value != 10000 || output.Limits["max_rows"].Unit != "rows" {
t.Fatalf("unexpected limits: %#v", output.Limits)
}
}

func TestAnalyticsSchemaAcceptsLegacyArrayAndPrintsObject(t *testing.T) {
token := testAgentJWTWithOBO(t)
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "agent-token.jwt"), []byte(token), 0o600); err != nil {
t.Fatal(err)
}
analyticsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`[{"name":"agent_logs_by_page","columns":[{"name":"host","type":"STRING"}]}]`))
}))
defer analyticsSrv.Close()
Expand All @@ -108,21 +244,16 @@ func TestAnalyticsSchemaUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
if code != 0 {
t.Fatalf("expected success, got %d (stderr=%q)", code, stderr.String())
}
var output []struct {
Name string `json:"name"`
Columns []struct {
var output struct {
Tables []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
} `json:"tables"`
}
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("invalid JSON output %q: %v", stdout.String(), err)
}
if len(output) != 1 || output[0].Name != "agent_logs_by_page" {
t.Fatalf("unexpected tables: %#v", output)
}
if len(output[0].Columns) != 1 || output[0].Columns[0].Name != "host" {
t.Fatalf("unexpected columns: %#v", output[0].Columns)
if len(output.Tables) != 1 || output.Tables[0].Name != "agent_logs_by_page" {
t.Fatalf("unexpected tables: %#v", output.Tables)
}
}

Expand Down Expand Up @@ -173,8 +304,8 @@ func TestAnalyticsHelpDocumentsQueryContract(t *testing.T) {
want []string
}{
{[]string{"analytics", "--help"}, []string{"analytics schema", "analytics query"}},
{[]string{"analytics", "query", "--help"}, []string{"BigQuery Standard SQL", "single SELECT", "timestamp", "user_agent_aggregate", "Examples:"}},
{[]string{"analytics", "schema", "--help"}, []string{"JSON array of tables", "analytics query"}},
{[]string{"analytics", "query", "--help"}, []string{"BigQuery Standard SQL", "single SELECT", "timestamp", "user_agent_aggregate", "Examples:", "meta"}},
{[]string{"analytics", "schema", "--help"}, []string{"JSON object", "limits", "clustering", "analytics query"}},
} {
var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, tc.args, nil, &stdout, &stderr)
Expand Down
Loading