diff --git a/shortcuts/apps/apps_analytics.go b/shortcuts/apps/apps_analytics.go new file mode 100644 index 000000000..1f7afd1fd --- /dev/null +++ b/shortcuts/apps/apps_analytics.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsAnalyticsEnv = "online" + defaultAppsAnalyticsGranular = "day" + analyticsListEndpoint = "query_analytics_data" +) + +// AppsAnalyticsList lists online app product analytics. +var AppsAnalyticsList = common.Shortcut{ + Service: appsService, + Command: "+analytics-list", + Description: "List online app user and page-view analytics", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +analytics-list --app-id --analytics users --granularity week", + "Tip: analytics timestamps use nanoseconds; use +metric-list for request/runtime metrics.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online analytics should be listed", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, + {Name: "analytics", Desc: "analytics family to list", Required: true, Enum: []string{"users", "page-view"}}, + {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "page", Desc: "frontend page or route filter"}, + {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, + {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, _, _, err := buildAnalyticsListBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _, _, _ := buildAnalyticsListBody(rctx) + return common.NewDryRunAPI(). + POST(analyticsListPath(rctx.Str("app-id"))). + Desc("List online app analytics"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, types, labels, err := buildAnalyticsListBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", analyticsListPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := observabilitySeriesOutput{ + Items: normalizeAnalyticsSeries(data, types, labels), + HasMore: false, + } + rctx.OutFormat(out, nil, func(w io.Writer) { + rows := observabilitySeriesRows(out.Items) + sortObservabilityRowsDesc(rows, "timestamp_ns") + rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") + appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels)) + }) + return nil + }, +} + +func analyticsListPath(appID string) string { + return appScopedPath(appID, analyticsListEndpoint) +} + +func buildAnalyticsListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + env = defaultAppsAnalyticsEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, nil, nil, err + } + types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type")) + if err != nil { + return nil, nil, nil, err + } + since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) + if err != nil { + return nil, nil, nil, err + } + aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity")) + if err != nil { + return nil, nil, nil, err + } + if page := strings.TrimSpace(rctx.Str("page")); page != "" { + filter["page"] = page + } + body := map[string]interface{}{ + "metric_types": types, + "start_timestamp_ns": nsNumber(since), + "end_timestamp_ns": nsNumber(until), + "time_aggregation_unit": aggregation, + "need_pack_lack_point": false, + } + if len(filter) > 0 { + body["filter"] = filter + } + return body, types, labels, nil +} + +func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) { + name = strings.TrimSpace(strings.ToLower(name)) + series = strings.TrimSpace(strings.ToLower(series)) + deviceType = strings.TrimSpace(strings.ToLower(deviceType)) + filter := make(map[string]interface{}) + if deviceType != "" { + switch deviceType { + case "desktop", "mobile": + filter["device_types"] = []string{deviceType} + default: + return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile") + } + } + + switch name { + case "users": + switch series { + case "": + return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil + case "active", "active-users": + return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil + case "new", "new-users": + return []string{"NEW_USER"}, []string{"new-users"}, filter, nil + case "total", "total-users": + return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total") + } + case "page-view": + switch series { + case "", "all": + return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil + case "desktop", "desktop-view": + if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil + case "mobile", "mobile-view": + if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile") + } + default: + return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view") + } +} + +func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error { + if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType { + return appsValidationParamError("--device-type", "--device-type conflicts with --series") + } + filter["device_types"] = []string{deviceType} + return nil +} + +func analyticsGranularityForCLI(granularity string) (string, error) { + switch strings.TrimSpace(strings.ToLower(granularity)) { + case "", "day": + return "DAY", nil + case "week": + return "WEEK", nil + case "month": + return "MONTH", nil + default: + return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month") + } +} + +func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} { + items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") + fillObservabilityZeroesWhenPartiallyPresent(items, labels) + return items +} + +func analyticsSeriesSchema(labels []string) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")}, + } + for _, label := range labels { + columns = append(columns, appsOutputColumn{Key: label}) + } + return appsOutputSchema{Columns: columns, Strict: true} +} diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_analytics_test.go similarity index 52% rename from shortcuts/apps/apps_observability_metrics_test.go rename to shortcuts/apps/apps_analytics_test.go index 3209ebe96..3e8eeb5de 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_analytics_test.go @@ -12,295 +12,10 @@ import ( "github.com/larksuite/cli/internal/httpmock" ) -func TestMetricNamesMapping(t *testing.T) { - got, labels, err := metricNamesForCLI("requests", "") - if err != nil { - t.Fatal(err) - } - if strings.Join(got, ",") != "client_api_request_count,client_api_request_error_count" { - t.Fatalf("names = %#v", got) - } - if strings.Join(labels, ",") != "total,error" { - t.Fatalf("labels = %#v", labels) - } - if _, _, err := metricNamesForCLI("cpu", "p99"); err == nil { - t.Fatalf("cpu with p99 should fail") - } -} - -func TestAppsMetricQuery_DryRunUsesSeconds(t *testing.T) { +func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", - "--series", "total", "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T10:01:00Z", "--down-sample", "1m", - "--dry-run", "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("dry-run err=%v", err) - } - var env struct { - API []struct { - Method string `json:"method"` - URL string `json:"url"` - Body map[string]interface{} `json:"body"` - } `json:"api"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) - } - if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_metrics_data" { - t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) - } - body := env.API[0].Body - if _, ok := body["start_timestamp"]; !ok { - t.Fatalf("metric dry-run missing start_timestamp: %#v", body) - } - if _, ok := body["start_timestamp_ns"]; ok { - t.Fatalf("metric should not use start_timestamp_ns: %#v", body) - } - if _, ok := body["app_env"]; ok { - t.Fatalf("metric OpenAPI body should not include app_env: %#v", body) - } - if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { - t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) - } - if body["down_sample"] != "1m" { - t.Fatalf("down_sample = %v", body["down_sample"]) - } -} - -func TestAppsMetricQuery_AutoDownSampleByRange(t *testing.T) { - for _, tc := range []struct { - name string - since string - until string - want string - }{ - {name: "short", since: "2026-06-23T10:00:00Z", until: "2026-06-23T12:00:00Z", want: "1m"}, - {name: "medium", since: "2026-06-21T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1h"}, - {name: "long", since: "2026-06-01T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1d"}, - } { - t.Run(tc.name, func(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", - "--since", tc.since, "--until", tc.until, "--dry-run", "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("dry-run err=%v", err) - } - var env struct { - API []struct { - Body map[string]interface{} `json:"body"` - } `json:"api"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) - } - if got := env.API[0].Body["down_sample"]; got != tc.want { - t.Fatalf("down_sample = %#v, want %q; stdout:\n%s", got, tc.want, stdout.String()) - } - }) - } -} - -func TestAppsMetricQuery_RejectsDevEnv(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user", - }, factory, stdout) - requireAppsValidationParam(t, err, "--environment") -} - -func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "points": []interface{}{ - map[string]interface{}{ - "timestamp": float64(1782208800), - "dimensions": map[string]interface{}{"page": "/home"}, - "values": []interface{}{ - map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, - }, - }, - map[string]interface{}{ - "timestamp": float64(1782208860), - "dimensions": map[string]interface{}{"page": "/settings"}, - "values": []interface{}{ - map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(8)}, - map[string]interface{}{"metric_name": "client_api_request_error_count", "value": nil}, - }, - }, - }, - }, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - - var env struct { - Data struct { - Items []struct { - Values map[string]interface{} `json:"values"` - } `json:"items"` - HasMore bool `json:"has_more"` - } `json:"data"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode output: %v\n%s", err, stdout.String()) - } - if env.Data.HasMore { - t.Fatalf("has_more = true, want false") - } - if len(env.Data.Items) != 2 { - t.Fatalf("items len = %d", len(env.Data.Items)) - } - for i, item := range env.Data.Items { - if item.Values["error"] != float64(0) { - t.Fatalf("item %d error = %#v, want 0; values=%#v", i, item.Values["error"], item.Values) - } - } -} - -func TestAppsMetricQuery_PrettyFormatsTimeFirst(t *testing.T) { - const rawSec = int64(1782208800) - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "points": []interface{}{ - map[string]interface{}{ - "timestamp": float64(rawSec), - "values": []interface{}{ - map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, - map[string]interface{}{"metric_name": "client_api_request_error_count", "value": float64(1)}, - }, - }, - }, - }, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--format", "pretty", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - got := stdout.String() - wantTime := time.Unix(rawSec, 0).Local().Format("2006-01-02 15:04:05") - if !strings.HasPrefix(got, "time") { - t.Fatalf("pretty output should start with time column, got:\n%s", got) - } - if !strings.Contains(got, wantTime) { - t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) - } - if strings.Contains(got, "timestamp") || strings.Contains(got, "1782208800") { - t.Fatalf("pretty output should hide raw timestamp, got:\n%s", got) - } -} - -func TestAppsMetricQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "series": []interface{}{ - map[string]interface{}{ - "name": "client_api_request_error_count", - "points": []interface{}{ - map[string]interface{}{"timestamp": float64(1782208800), "value": float64(2)}, - }, - }, - map[string]interface{}{ - "name": "client_api_request_count", - "points": []interface{}{ - map[string]interface{}{"timestamp": float64(1782208800), "value": float64(10)}, - }, - }, - }, - }, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - - var env struct { - Data struct { - Items []struct { - Values map[string]interface{} `json:"values"` - } `json:"items"` - } `json:"data"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode output: %v\n%s", err, stdout.String()) - } - if len(env.Data.Items) != 1 { - t.Fatalf("items len = %d", len(env.Data.Items)) - } - values := env.Data.Items[0].Values - if values["total"] != float64(10) || values["error"] != float64(2) { - t.Fatalf("values = %#v, want total=10 error=2", values) - } -} - -func TestAppsMetricQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{}, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "latency", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - - var env struct { - Data struct { - Items []map[string]interface{} `json:"items"` - HasMore bool `json:"has_more"` - } `json:"data"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode output: %v\n%s", err, stdout.String()) - } - if env.Data.Items == nil { - t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) - } - if len(env.Data.Items) != 0 || env.Data.HasMore { - t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) - } -} - -func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", + err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", "--granularity", "week", "--dry-run", "--as", "user", }, factory, stdout) @@ -351,7 +66,7 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { } } -func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { +func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { for _, tc := range []struct { name string args []string @@ -359,21 +74,21 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { name: "series", args: []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", "--series", "desktop", "--page", "/home", "--dry-run", "--as", "user", }, }, { name: "device-type", args: []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", "--device-type", "desktop", "--dry-run", "--as", "user", }, }, } { t.Run(tc.name, func(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAnalyticsQuery, tc.args, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsAnalyticsList, tc.args, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } var env struct { @@ -396,7 +111,7 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) } } -func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { +func TestAppsAnalyticsList_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -419,8 +134,8 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", "--series", "desktop", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) @@ -447,7 +162,7 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { } } -func TestAppsAnalyticsQuery_PrettyFormatsTimeFirst(t *testing.T) { +func TestAppsAnalyticsList_PrettyFormatsTimeFirst(t *testing.T) { const rawNS = int64(1782208800000000000) factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -468,8 +183,8 @@ func TestAppsAnalyticsQuery_PrettyFormatsTimeFirst(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -486,7 +201,7 @@ func TestAppsAnalyticsQuery_PrettyFormatsTimeFirst(t *testing.T) { } } -func TestAppsAnalyticsQuery_PrettySkipsRowsWithoutTime(t *testing.T) { +func TestAppsAnalyticsList_PrettySkipsRowsWithoutTime(t *testing.T) { const rawNS = int64(1782208800000000000) rows := []map[string]interface{}{ {"timestamp_ns": rawNS, "active-users": float64(7)}, @@ -502,7 +217,7 @@ func TestAppsAnalyticsQuery_PrettySkipsRowsWithoutTime(t *testing.T) { } } -func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { +func TestAppsAnalyticsList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -534,8 +249,8 @@ func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -559,7 +274,7 @@ func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) } } -func TestAppsAnalyticsQuery_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) { +func TestAppsAnalyticsList_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -580,8 +295,8 @@ func TestAppsAnalyticsQuery_FillsMissingAndNullValuesWhenAnyValuePresent(t *test }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -602,7 +317,7 @@ func TestAppsAnalyticsQuery_FillsMissingAndNullValuesWhenAnyValuePresent(t *test } } -func TestAppsAnalyticsQuery_DoesNotFillAllNullValues(t *testing.T) { +func TestAppsAnalyticsList_DoesNotFillAllNullValues(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -623,8 +338,8 @@ func TestAppsAnalyticsQuery_DoesNotFillAllNullValues(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -648,7 +363,7 @@ func TestAppsAnalyticsQuery_DoesNotFillAllNullValues(t *testing.T) { } } -func TestAppsAnalyticsQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { +func TestAppsAnalyticsList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -659,8 +374,8 @@ func TestAppsAnalyticsQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go index 75576b6c7..2439d83ee 100644 --- a/shortcuts/apps/apps_examples_test.go +++ b/shortcuts/apps/apps_examples_test.go @@ -72,8 +72,8 @@ func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) { "+log-get", "+trace-list", "+trace-get", - "+metric-query", - "+analytics-query", + "+metric-list", + "+analytics-list", } { shortcut := requireShortcutForExamples(t, cmd) if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") { diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_logs.go similarity index 100% rename from shortcuts/apps/apps_observability_logs.go rename to shortcuts/apps/apps_logs.go diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_logs_test.go similarity index 100% rename from shortcuts/apps/apps_observability_logs_test.go rename to shortcuts/apps/apps_logs_test.go diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_metrics.go similarity index 66% rename from shortcuts/apps/apps_observability_metrics.go rename to shortcuts/apps/apps_metrics.go index be32d9ad7..1f68e48a1 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_metrics.go @@ -18,30 +18,27 @@ import ( const ( defaultAppsMetricEnv = "online" defaultAppsMetricDownSample = "1m" - defaultAppsAnalyticsEnv = "online" - defaultAppsAnalyticsGranular = "day" - metricQueryEndpoint = "query_metrics_data" - analyticsQueryEndpoint = "query_analytics_data" + metricListEndpoint = "query_metrics_data" defaultObservabilityRangeDays = 30 ) -// AppsMetricQuery queries online app observability metrics. -var AppsMetricQuery = common.Shortcut{ +// AppsMetricList lists online app observability metrics. +var AppsMetricList = common.Shortcut{ Service: appsService, - Command: "+metric-query", - Description: "Query online app request, latency, CPU, and memory metrics", + Command: "+metric-list", + Description: "List online app request, latency, CPU, and memory metrics", Risk: "read", Tips: []string{ - "Example: lark-cli apps +metric-query --app-id --metric requests --series total --since 1d", - "Tip: metric timestamps use seconds; use +analytics-query for PV/UV-style analytics.", + "Example: lark-cli apps +metric-list --app-id --metric requests --series total --since 1d", + "Tip: metric timestamps use seconds; use +analytics-list for PV/UV-style analytics.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "app-id", Desc: "app ID whose online metrics should be queried", Required: true}, + {Name: "app-id", Desc: "app ID whose online metrics should be listed", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, - {Name: "metric", Desc: "metric family to query", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, + {Name: "metric", Desc: "metric family to list", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, @@ -53,23 +50,23 @@ var AppsMetricQuery = common.Shortcut{ if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } - _, _, _, _, err := buildMetricQueryBody(rctx) + _, _, _, _, err := buildMetricListBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - body, _, _, _, _ := buildMetricQueryBody(rctx) + body, _, _, _, _ := buildMetricListBody(rctx) return common.NewDryRunAPI(). - POST(metricQueryPath(rctx.Str("app-id"))). - Desc("Query online app metrics"). + POST(metricListPath(rctx.Str("app-id"))). + Desc("List online app metrics"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) - body, names, labels, fillZero, err := buildMetricQueryBody(rctx) + body, names, labels, fillZero, err := buildMetricListBody(rctx) if err != nil { return err } - data, err := rctx.CallAPITyped("POST", metricQueryPath(appID), nil, body) + data, err := rctx.CallAPITyped("POST", metricListPath(appID), nil, body) if err != nil { return withAppsHint(err, appIDListHint) } @@ -87,82 +84,16 @@ var AppsMetricQuery = common.Shortcut{ }, } -// AppsAnalyticsQuery queries online app product analytics. -var AppsAnalyticsQuery = common.Shortcut{ - Service: appsService, - Command: "+analytics-query", - Description: "Query online app user and page-view analytics", - Risk: "read", - Tips: []string{ - "Example: lark-cli apps +analytics-query --app-id --analytics users --granularity week", - "Tip: analytics timestamps use nanoseconds; use +metric-query for request/runtime metrics.", - }, - Scopes: []string{"spark:app:read"}, - AuthTypes: []string{"user"}, - HasFormat: true, - Flags: []common.Flag{ - {Name: "app-id", Desc: "app ID whose online analytics should be queried", Required: true}, - {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, - {Name: "analytics", Desc: "analytics family to query", Required: true, Enum: []string{"users", "page-view"}}, - {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, - {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, - {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, - {Name: "page", Desc: "frontend page or route filter"}, - {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, - {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if _, err := requireAppID(rctx.Str("app-id")); err != nil { - return err - } - _, _, _, err := buildAnalyticsQueryBody(rctx) - return err - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - body, _, _, _ := buildAnalyticsQueryBody(rctx) - return common.NewDryRunAPI(). - POST(analyticsQueryPath(rctx.Str("app-id"))). - Desc("Query online app analytics"). - Body(body) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - appID, _ := requireAppID(rctx.Str("app-id")) - body, types, labels, err := buildAnalyticsQueryBody(rctx) - if err != nil { - return err - } - data, err := rctx.CallAPITyped("POST", analyticsQueryPath(appID), nil, body) - if err != nil { - return withAppsHint(err, appIDListHint) - } - out := observabilitySeriesOutput{ - Items: normalizeAnalyticsSeries(data, types, labels), - HasMore: false, - } - rctx.OutFormat(out, nil, func(w io.Writer) { - rows := observabilitySeriesRows(out.Items) - sortObservabilityRowsDesc(rows, "timestamp_ns") - rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") - appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels)) - }) - return nil - }, -} - type observabilitySeriesOutput struct { Items []map[string]interface{} `json:"items"` HasMore bool `json:"has_more"` } -func metricQueryPath(appID string) string { - return appScopedPath(appID, metricQueryEndpoint) +func metricListPath(appID string) string { + return appScopedPath(appID, metricListEndpoint) } -func analyticsQueryPath(appID string) string { - return appScopedPath(appID, analyticsQueryEndpoint) -} - -func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { +func buildMetricListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsMetricEnv @@ -191,7 +122,7 @@ func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, "down_sample": downSample, "need_pack_lack_point": false, } - if filter := buildMetricQueryFilter(rctx); len(filter) > 0 { + if filter := buildMetricListFilter(rctx); len(filter) > 0 { body["filter"] = filter } return body, names, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil @@ -209,7 +140,7 @@ func appsMetricDownSampleForRange(since, until time.Time) string { } } -func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} { +func buildMetricListFilter(rctx *common.RuntimeContext) map[string]interface{} { filter := make(map[string]interface{}) if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 { filter["pages"] = pages @@ -220,42 +151,6 @@ func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} return filter } -func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { - env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) - if env == "" { - env = defaultAppsAnalyticsEnv - } - if err := validateObservabilityEnv(env); err != nil { - return nil, nil, nil, err - } - types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type")) - if err != nil { - return nil, nil, nil, err - } - since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) - if err != nil { - return nil, nil, nil, err - } - aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity")) - if err != nil { - return nil, nil, nil, err - } - if page := strings.TrimSpace(rctx.Str("page")); page != "" { - filter["page"] = page - } - body := map[string]interface{}{ - "metric_types": types, - "start_timestamp_ns": nsNumber(since), - "end_timestamp_ns": nsNumber(until), - "time_aggregation_unit": aggregation, - "need_pack_lack_point": false, - } - if len(filter) > 0 { - body["filter"] = filter - } - return body, types, labels, nil -} - func defaultedObservabilityTimeRange(sinceRaw, untilRaw string) (time.Time, time.Time, error) { since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", sinceRaw, "--until", untilRaw) if err != nil { @@ -314,87 +209,10 @@ func metricNamesForCLI(metric, series string) ([]string, []string, error) { } } -func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) { - name = strings.TrimSpace(strings.ToLower(name)) - series = strings.TrimSpace(strings.ToLower(series)) - deviceType = strings.TrimSpace(strings.ToLower(deviceType)) - filter := make(map[string]interface{}) - if deviceType != "" { - switch deviceType { - case "desktop", "mobile": - filter["device_types"] = []string{deviceType} - default: - return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile") - } - } - - switch name { - case "users": - switch series { - case "": - return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil - case "active", "active-users": - return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil - case "new", "new-users": - return []string{"NEW_USER"}, []string{"new-users"}, filter, nil - case "total", "total-users": - return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil - default: - return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total") - } - case "page-view": - switch series { - case "", "all": - return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil - case "desktop", "desktop-view": - if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil { - return nil, nil, nil, err - } - return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil - case "mobile", "mobile-view": - if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil { - return nil, nil, nil, err - } - return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil - default: - return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile") - } - default: - return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view") - } -} - -func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error { - if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType { - return appsValidationParamError("--device-type", "--device-type conflicts with --series") - } - filter["device_types"] = []string{deviceType} - return nil -} - -func analyticsGranularityForCLI(granularity string) (string, error) { - switch strings.TrimSpace(strings.ToLower(granularity)) { - case "", "day": - return "DAY", nil - case "week": - return "WEEK", nil - case "month": - return "MONTH", nil - default: - return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month") - } -} - func normalizeMetricSeries(data map[string]interface{}, names, labels []string, fillZero bool) []map[string]interface{} { return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), fillZero, "timestamp") } -func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} { - items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") - fillObservabilityZeroesWhenPartiallyPresent(items, labels) - return items -} - func normalizeObservabilitySeries(data map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { if series := observabilityMapSlice(data["series"]); len(series) > 0 { return mergeObservabilitySeries(series, labels, nameLabels, fillZero, timeField) @@ -747,16 +565,6 @@ func metricSeriesSchema(labels []string, durationValues bool) appsOutputSchema { return appsOutputSchema{Columns: columns, Strict: true} } -func analyticsSeriesSchema(labels []string) appsOutputSchema { - columns := []appsOutputColumn{ - {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")}, - } - for _, label := range labels { - columns = append(columns, appsOutputColumn{Key: label}) - } - return appsOutputSchema{Columns: columns, Strict: true} -} - func sortObservabilityRowsDesc(rows []map[string]interface{}, key string) { sort.SliceStable(rows, func(i, j int) bool { left, leftOK := appsInt64Value(rows[i][key]) diff --git a/shortcuts/apps/apps_metrics_test.go b/shortcuts/apps/apps_metrics_test.go new file mode 100644 index 000000000..3fa032491 --- /dev/null +++ b/shortcuts/apps/apps_metrics_test.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestMetricNamesMapping(t *testing.T) { + got, labels, err := metricNamesForCLI("requests", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(got, ",") != "client_api_request_count,client_api_request_error_count" { + t.Fatalf("names = %#v", got) + } + if strings.Join(labels, ",") != "total,error" { + t.Fatalf("labels = %#v", labels) + } + if _, _, err := metricNamesForCLI("cpu", "p99"); err == nil { + t.Fatalf("cpu with p99 should fail") + } +} + +func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", + "--series", "total", "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T10:01:00Z", "--down-sample", "1m", + "--dry-run", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_metrics_data" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + body := env.API[0].Body + if _, ok := body["start_timestamp"]; !ok { + t.Fatalf("metric dry-run missing start_timestamp: %#v", body) + } + if _, ok := body["start_timestamp_ns"]; ok { + t.Fatalf("metric should not use start_timestamp_ns: %#v", body) + } + if _, ok := body["app_env"]; ok { + t.Fatalf("metric OpenAPI body should not include app_env: %#v", body) + } + if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { + t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) + } + if body["down_sample"] != "1m" { + t.Fatalf("down_sample = %v", body["down_sample"]) + } +} + +func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) { + for _, tc := range []struct { + name string + since string + until string + want string + }{ + {name: "short", since: "2026-06-23T10:00:00Z", until: "2026-06-23T12:00:00Z", want: "1m"}, + {name: "medium", since: "2026-06-21T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1h"}, + {name: "long", since: "2026-06-01T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1d"}, + } { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", + "--since", tc.since, "--until", tc.until, "--dry-run", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if got := env.API[0].Body["down_sample"]; got != tc.want { + t.Fatalf("down_sample = %#v, want %q; stdout:\n%s", got, tc.want, stdout.String()) + } + }) + } +} + +func TestAppsMetricList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user", + }, factory, stdout) + requireAppsValidationParam(t, err, "--environment") +} + +func TestAppsMetricList_FillsMissingRequestValuesWithZero(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "points": []interface{}{ + map[string]interface{}{ + "timestamp": float64(1782208800), + "dimensions": map[string]interface{}{"page": "/home"}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + }, + }, + map[string]interface{}{ + "timestamp": float64(1782208860), + "dimensions": map[string]interface{}{"page": "/settings"}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(8)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": nil}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(env.Data.Items) != 2 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + for i, item := range env.Data.Items { + if item.Values["error"] != float64(0) { + t.Fatalf("item %d error = %#v, want 0; values=%#v", i, item.Values["error"], item.Values) + } + } +} + +func TestAppsMetricList_PrettyFormatsTimeFirst(t *testing.T) { + const rawSec = int64(1782208800) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "points": []interface{}{ + map[string]interface{}{ + "timestamp": float64(rawSec), + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": float64(1)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(rawSec, 0).Local().Format("2006-01-02 15:04:05") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp") || strings.Contains(got, "1782208800") { + t.Fatalf("pretty output should hide raw timestamp, got:\n%s", got) + } +} + +func TestAppsMetricList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "name": "client_api_request_error_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(2)}, + }, + }, + map[string]interface{}{ + "name": "client_api_request_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(10)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + values := env.Data.Items[0].Values + if values["total"] != float64(10) || values["error"] != float64(2) { + t.Fatalf("values = %#v, want total=10 error=2", values) + } +} + +func TestAppsMetricList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "latency", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.Items == nil { + t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) + } + if len(env.Data.Items) != 0 || env.Data.HasMore { + t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) + } +} diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_traces.go similarity index 100% rename from shortcuts/apps/apps_observability_traces.go rename to shortcuts/apps/apps_traces.go diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_traces_test.go similarity index 100% rename from shortcuts/apps/apps_observability_traces_test.go rename to shortcuts/apps/apps_traces_test.go diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 178e7a618..cf05afdfa 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -26,8 +26,8 @@ func Shortcuts() []common.Shortcut { withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --environment omitted or set --environment online."), withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --environment omitted or set --environment online."), withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --environment omitted or set --environment online."), - withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --environment omitted or set --environment online."), - withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsMetricList, "Tip: metrics are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsAnalyticsList, "Tip: analytics are online-only; keep --environment omitted or set --environment online."), AppsEnvVarList, envSet, envDelete, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 147963641..69b5b9654 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -11,7 +11,7 @@ import ( // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 // 6 基础 + 1 init + 3 publish + 1 env-pull -// - 6 observability(log-list/log-get/trace-list/trace-get/metric-query/analytics-query) +// - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list) // - 3 env(list/set/delete) // - 16 db(table-list/table-schema/sql/dev-init/data-import/data-export/changelog-list/ // audit-status/audit-enable/audit-disable/audit-list/ @@ -36,6 +36,15 @@ func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) { } } +func TestAppsShortcuts_DoesNotIncludeMetricQueryAliases(t *testing.T) { + for _, sc := range Shortcuts() { + switch sc.Command { + case "+metric-query", "+analytics-query": + t.Fatalf("Shortcuts() must not register %s", sc.Command) + } + } +} + func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) { want := map[string]bool{ "+env-list": false, diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index cf0ac1385..b93d82382 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -25,7 +25,7 @@ metadata: | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | | 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | -| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-query`, `+analytics-query` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | +| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | | 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) | | 逐条执行 SQL(SELECT / DML / DDL) | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md) | | 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) | @@ -37,9 +37,9 @@ metadata: ## 高频路径 -- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-query`。 -- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-query --app-id --metric requests --since --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api `;不要臆造 group-by 参数。 -- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-query`,不要误用 `+metric-query`。 +- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-list`。 +- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-list --app-id --metric requests --since --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api `;不要臆造 group-by 参数。 +- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-list`,不要误用 `+metric-list`。 - **设置环境变量**:如果用户只给应用名,仍先 `+list --keyword` 解析 app_id;设置 online 环境且用户已经明确说“确认/直接执行”时,调用 `+env-set --environment online ... --yes`,不要再次要求确认。回复和日志摘要里只提 key / env / app,不回显真实 value;需要传复杂值时优先用 `@file` 或 stdin。 - **删除环境变量**:`+env-delete` 是破坏性操作。除非用户在同一轮已经明确确认删除这个 app/env/key,否则先向用户确认应用、环境、key 和删除后果;确认后再加 `--yes`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。 diff --git a/skills/lark-apps/references/lark-apps-observability.md b/skills/lark-apps/references/lark-apps-observability.md index b0375b7ad..e40a9a865 100644 --- a/skills/lark-apps/references/lark-apps-observability.md +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -6,7 +6,7 @@ 日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。dry-run 输出会展示这个后端参数。 -metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-query` 和 `+analytics-query` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。 +metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-list` 和 `+analytics-list` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。 时间过滤支持相对时间(如 `30s`、`5m`、`0.5h`、`2h`、`3d`、`1w`)、本地日期 / 时间和 RFC3339。 @@ -16,10 +16,10 @@ metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+met - `+log-list` 不再支持 `--log-id`;已有 log ID 时直接用 `+log-get --log-id `。 - 前端 ERROR 日志详情:`+log-get` 可能补充 `source_stack`;没有独立的 source-stack 命令。 - Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。 -- 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-query`。 -- 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-query`,不要放到 runtime metric 里混查。 -- `+analytics-query` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 -- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-query`。 +- 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-list`。 +- 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-list`,不要放到 runtime metric 里混查。 +- `+analytics-list` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 +- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-list`。 ## 示例 @@ -28,21 +28,21 @@ lark-cli apps +log-list --app-id --level error --keyword timeout --sinc lark-cli apps +log-get --app-id --log-id lark-cli apps +trace-list --app-id --trace-id lark-cli apps +trace-get --app-id --trace-id -lark-cli apps +metric-query --app-id --metric requests --series total --since 1d -lark-cli apps +metric-query --app-id --metric requests --since 1h -lark-cli apps +metric-query --app-id --metric latency --since 1h -lark-cli apps +metric-query --app-id --metric latency --series p99 --since 1d -lark-cli apps +metric-query --app-id --metric cpu --since 1h -lark-cli apps +metric-query --app-id --metric memory --since 1h -lark-cli apps +analytics-query --app-id --analytics users --series active-users --granularity day -lark-cli apps +analytics-query --app-id --analytics page-view --granularity day +lark-cli apps +metric-list --app-id --metric requests --series total --since 1d +lark-cli apps +metric-list --app-id --metric requests --since 1h +lark-cli apps +metric-list --app-id --metric latency --since 1h +lark-cli apps +metric-list --app-id --metric latency --series p99 --since 1d +lark-cli apps +metric-list --app-id --metric cpu --since 1h +lark-cli apps +metric-list --app-id --metric memory --since 1h +lark-cli apps +analytics-list --app-id --analytics users --series active-users --granularity day +lark-cli apps +analytics-list --app-id --analytics page-view --granularity day ``` ## 使用边界 -- 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-query`。 -- `+metric-query --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 +- 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-list`。 +- `+metric-list --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 - 按接口收窄范围时使用 `--api `;当前没有 `group-by` 参数,不要臆造。 -- `+metric-query` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 -- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-query`。 +- `+metric-list` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 +- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-list`。 - 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。 diff --git a/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go b/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go deleted file mode 100644 index d171b3284..000000000 --- a/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go +++ /dev/null @@ -1,632 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -const ( - appsE2EAppID = "app_x" - appsSecretValue = "super-secret-value-for-e2e" -) - -func TestAppsObservabilityDryRunContract(t *testing.T) { - cases := []struct { - name string - args []string - method string - url string - assertBody func(*testing.T, string) - }{ - { - name: "log_list_request_shape", - args: []string{ - "apps", "+log-list", - "--app-id", appsE2EAppID, - "--env", "online", - "--level", "error", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--log-id", "LOG1", - "--log-id", "LOG2", - "--trace-id", "trace-1", - "--keyword", "timeout", - "--min-duration", "200", - "--page-size", "50", - "--page-token", "next-token", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/search_logs", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(50), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) - assert.Equal(t, "next-token", gjson.Get(stdout, "api.0.body.page_token").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.levels", []string{"ERROR"}) - requireStringArray(t, stdout, "api.0.body.filter.log_ids", []string{"LOG1", "LOG2"}) - requireStringArray(t, stdout, "api.0.body.filter.trace_ids", []string{"trace-1"}) - assert.Equal(t, "timeout", gjson.Get(stdout, "api.0.body.filter.keyword").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(200), gjson.Get(stdout, "api.0.body.filter.min_duration_ms").Int(), "stdout:\n%s", stdout) - }, - }, - { - name: "log_get_uses_search_logs_with_limit_one", - args: []string{ - "apps", "+log-get", - "--app-id", appsE2EAppID, - "--env", "online", - "--log-id", "LOG763372528845174288", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/search_logs", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(1), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.log_ids", []string{"LOG763372528845174288"}) - }, - }, - { - name: "trace_list_request_shape", - args: []string{ - "apps", "+trace-list", - "--app-id", appsE2EAppID, - "--env", "online", - "--trace-id", "trace-1", - "--root-span", "api-gateway", - "--user-id", "ou_user", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--page-size", "25", - "--page-token", "next-token", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/search_traces", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(25), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) - assert.Equal(t, "next-token", gjson.Get(stdout, "api.0.body.page_token").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.trace_ids", []string{"trace-1"}) - assert.Equal(t, "api-gateway", gjson.Get(stdout, "api.0.body.filter.keyword").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.user_ids", []string{"ou_user"}) - }, - }, - { - name: "trace_get_request_shape", - args: []string{ - "apps", "+trace-get", - "--app-id", appsE2EAppID, - "--env", "online", - "--trace-id", "359d7ab1d9e222b43ee56619a55f937a", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/trace", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, "359d7ab1d9e222b43ee56619a55f937a", gjson.Get(stdout, "api.0.body.trace_id").String(), "stdout:\n%s", stdout) - }, - }, - { - name: "metric_query_request_shape", - args: []string{ - "apps", "+metric-query", - "--app-id", appsE2EAppID, - "--env", "online", - "--metric", "requests", - "--series", "total", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--page", "/home", - "--api", "/api/orders", - "--down-sample", "1m", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - assertBody: func(t *testing.T, stdout string) { - assert.False(t, gjson.Get(stdout, "api.0.body.app_env").Exists(), "metric OpenAPI body should not include app_env, stdout:\n%s", stdout) - assert.Equal(t, "1782208800", gjson.Get(stdout, "api.0.body.start_timestamp").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400", gjson.Get(stdout, "api.0.body.end_timestamp").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.metric_names", []string{"client_api_request_count"}) - assert.Equal(t, "/home", gjson.Get(stdout, "api.0.body.filter.pages.0").String(), "stdout:\n%s", stdout) - assert.Equal(t, "/api/orders", gjson.Get(stdout, "api.0.body.filter.apis.0").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1m", gjson.Get(stdout, "api.0.body.down_sample").String(), "stdout:\n%s", stdout) - assert.True(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Exists(), "stdout:\n%s", stdout) - assert.False(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Bool(), "stdout:\n%s", stdout) - }, - }, - { - name: "analytics_query_request_shape", - args: []string{ - "apps", "+analytics-query", - "--app-id", appsE2EAppID, - "--env", "online", - "--analytics", "users", - "--series", "active-users", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--page", "/home", - "--device-type", "desktop", - "--granularity", "week", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/query_analytics_data", - assertBody: func(t *testing.T, stdout string) { - assert.False(t, gjson.Get(stdout, "api.0.body.app_env").Exists(), "analytics OpenAPI body should not include app_env, stdout:\n%s", stdout) - assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.metric_types", []string{"ACTIVE_USER"}) - assert.Equal(t, "WEEK", gjson.Get(stdout, "api.0.body.time_aggregation_unit").String(), "stdout:\n%s", stdout) - assert.Equal(t, "/home", gjson.Get(stdout, "api.0.body.filter.page").String(), "stdout:\n%s", stdout) - assert.Equal(t, "desktop", gjson.Get(stdout, "api.0.body.filter.device_types.0").String(), "stdout:\n%s", stdout) - assert.True(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Exists(), "stdout:\n%s", stdout) - assert.False(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Bool(), "stdout:\n%s", stdout) - assert.False(t, gjson.Get(stdout, "api.0.body.group_by").Exists(), "group_by is intentionally unsupported for now, stdout:\n%s", stdout) - }, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, tc.args, false) - result.AssertExitCode(t, 0) - assert.Equal(t, tc.method, gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, tc.url, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - tc.assertBody(t, result.Stdout) - }) - } -} - -func TestAppsObservabilityRejectsNonOnlineEnv(t *testing.T) { - cases := []struct { - name string - args []string - }{ - { - name: "log_list", - args: []string{"apps", "+log-list", "--app-id", appsE2EAppID, "--env", "dev"}, - }, - { - name: "log_get", - args: []string{"apps", "+log-get", "--app-id", appsE2EAppID, "--env", "dev", "--log-id", "LOG763372528845174288"}, - }, - { - name: "trace_list", - args: []string{"apps", "+trace-list", "--app-id", appsE2EAppID, "--env", "dev"}, - }, - { - name: "trace_get", - args: []string{"apps", "+trace-get", "--app-id", appsE2EAppID, "--env", "dev", "--trace-id", "359d7ab1d9e222b43ee56619a55f937a"}, - }, - { - name: "metric_query", - args: []string{"apps", "+metric-query", "--app-id", appsE2EAppID, "--env", "dev", "--metric", "requests"}, - }, - { - name: "analytics_query", - args: []string{"apps", "+analytics-query", "--app-id", appsE2EAppID, "--env", "dev", "--analytics", "users"}, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, tc.args, false) - result.AssertExitCode(t, 2) - raw := errorEnvelope(t, result) - assert.Equal(t, "validation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) - assert.Equal(t, "invalid_argument", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) - assert.Equal(t, "--env", gjson.Get(raw, "error.param").String(), "error envelope:\n%s", raw) - assert.Contains(t, gjson.Get(raw, "error.message").String(), "observability commands only support online", "error envelope:\n%s", raw) - }) - } -} - -func TestAppsEnvVarDryRunAndSafety(t *testing.T) { - t.Run("env_pull_uses_dev_post_body_contract", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - projectDir := filepath.Join(t.TempDir(), "demo") - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+env-pull", - "--app-id", appsE2EAppID, - "--project-path", projectDir, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_values").Exists(), "stdout:\n%s", result.Stdout) - assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists(), "env-pull dry-run must not expose key list, stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, appsSecretValue, "env-pull dry-run must not leak env values, stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_list_defaults_to_dev_without_values", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-list", - "--app-id", appsE2EAppID, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_values").Exists(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "api.0.body.value").Exists(), "list dry-run must not send values, stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_set_dev_post_redacts_value", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appsE2EAppID, - "--env", "dev", - "--key", "API_HOST", - "--value", appsSecretValue, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "API_HOST", gjson.Get(result.Stdout, "api.0.body.key").String(), "stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, appsSecretValue, "envvar-set dry-run must not leak raw value in stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stderr, appsSecretValue, "envvar-set dry-run must not leak raw value in stderr:\n%s", result.Stderr) - }) - - t.Run("envvar_set_online_dry_run_does_not_require_yes", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appsE2EAppID, - "--env", "online", - "--key", "API_HOST", - "--value", appsSecretValue, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "API_HOST", gjson.Get(result.Stdout, "api.0.body.key").String(), "stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, appsSecretValue, "online dry-run must not leak raw value in stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_set_online_requires_yes_without_dry_run", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appsE2EAppID, - "--env", "online", - "--key", "API_HOST", - "--value", appsSecretValue, - }, false) - result.AssertExitCode(t, 10) - raw := errorEnvelope(t, result) - assert.Equal(t, "confirmation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) - assert.Equal(t, "confirmation_required", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) - assert.Contains(t, gjson.Get(raw, "error.hint").String(), "add --yes to confirm", "error envelope:\n%s", raw) - assert.NotContains(t, raw, appsSecretValue, "confirmation error must not leak raw value:\n%s", raw) - }) - - t.Run("envvar_delete_dry_run_body", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-delete", - "--app-id", appsE2EAppID, - "--env", "dev", - "--key", "API_HOST", - "--key", "API_TOKEN", - }, true) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/delete_env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - requireStringArray(t, result.Stdout, "api.0.body.keys", []string{"API_HOST", "API_TOKEN"}) - assert.False(t, gjson.Get(result.Stdout, "api.0.body.value").Exists(), "delete body must not contain values, stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_delete_requires_yes_without_dry_run", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsCommand(t, ctx, []string{ - "apps", "+envvar-delete", - "--app-id", appsE2EAppID, - "--env", "dev", - "--key", "API_HOST", - }, false) - result.AssertExitCode(t, 10) - raw := errorEnvelope(t, result) - assert.Equal(t, "confirmation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) - assert.Equal(t, "confirmation_required", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) - }) -} - -func TestAppsObservabilityLiveFixtureOutputs(t *testing.T) { - appID := os.Getenv("LARK_CLI_E2E_APPS_OBSERVABILITY_APP_ID") - logID := os.Getenv("LARK_CLI_E2E_APPS_LOG_ID") - traceID := os.Getenv("LARK_CLI_E2E_APPS_TRACE_ID") - if appID == "" || logID == "" || traceID == "" { - t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_OBSERVABILITY_APP_ID, LARK_CLI_E2E_APPS_LOG_ID, and LARK_CLI_E2E_APPS_TRACE_ID to an online app with visible log, trace, metric, and analytics data") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - t.Run("log_get_returns_fixture_log", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+log-get", - "--app-id", appID, - "--env", "online", - "--log-id", logID, - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - assert.Equal(t, logID, gjson.Get(result.Stdout, "data.log_id").String(), "stdout:\n%s", result.Stdout) - }) - - t.Run("trace_get_returns_fixture_trace", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+trace-get", - "--app-id", appID, - "--env", "online", - "--trace-id", traceID, - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - assert.Equal(t, traceID, gjson.Get(result.Stdout, "data.trace_id").String(), "stdout:\n%s", result.Stdout) - require.NotEmpty(t, gjson.Get(result.Stdout, "data.spans").Array(), "trace should include spans, stdout:\n%s", result.Stdout) - }) - - t.Run("metric_query_returns_request_series", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+metric-query", - "--app-id", appID, - "--env", "online", - "--metric", "requests", - "--series", "total", - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - items := gjson.Get(result.Stdout, "data.items").Array() - require.NotEmpty(t, items, "fixture app should have request metric points, stdout:\n%s", result.Stdout) - assert.True(t, items[0].Get("values.total").Exists(), "request metric should expose total values, stdout:\n%s", result.Stdout) - }) - - t.Run("analytics_query_returns_active_users", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+analytics-query", - "--app-id", appID, - "--env", "online", - "--analytics", "users", - "--series", "active-users", - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - items := gjson.Get(result.Stdout, "data.items").Array() - require.NotEmpty(t, items, "fixture app should have analytics points, stdout:\n%s", result.Stdout) - assert.True(t, items[0].Get("values.active-users").Exists(), "analytics should expose active-users values, stdout:\n%s", result.Stdout) - }) -} - -func TestAppsEnvVarLiveWorkflow(t *testing.T) { - appID := os.Getenv("LARK_CLI_E2E_APPS_ENVVAR_APP_ID") - if appID == "" { - t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ENVVAR_APP_ID to an app where the user identity may create, list, and delete online env vars") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - suffix := strings.NewReplacer("-", "_").Replace(clie2e.GenerateSuffix()) - key := "LARK_CLI_E2E_" + suffix - value := "secret-value-" + suffix - created := false - - t.Cleanup(func() { - if !created { - return - } - cleanupCtx, cleanupCancel := clie2e.CleanupContext() - defer cleanupCancel() - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{ - "apps", "+envvar-delete", - "--app-id", appID, - "--env", "online", - "--key", key, - }, - DefaultAs: "user", - Env: appsNoNoticeEnv(), - Yes: true, - }) - clie2e.ReportCleanupFailure(t, "delete apps envvar "+key, deleteResult, deleteErr) - }) - - t.Run("set_online_redacts_value", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appID, - "--env", "online", - "--key", key, - "--value", value, - }, true) - if result.ExitCode == 0 { - created = true - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - assert.Equal(t, key, gjson.Get(result.Stdout, "data.key").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "online", gjson.Get(result.Stdout, "data.env").String(), "stdout:\n%s", result.Stdout) - assert.Contains(t, []string{"set", "created", "updated"}, gjson.Get(result.Stdout, "data.action").String(), "stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, value, "set output must not leak raw value, stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stderr, value, "set output must not leak raw value, stderr:\n%s", result.Stderr) - }) - - t.Run("list_include_values_observes_created_key", func(t *testing.T) { - result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ - Args: []string{ - "apps", "+envvar-list", - "--app-id", appID, - "--env", "online", - "--include-values", - }, - DefaultAs: "user", - Env: appsNoNoticeEnv(), - }, clie2e.RetryOptions{ - ShouldRetry: func(result *clie2e.Result) bool { - return result == nil || result.ExitCode != 0 || !envVarKeyExists(result.Stdout, key) - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - item, found := envVarItem(result.Stdout, key) - require.True(t, found, "list should include created key %q, stdout:\n%s", key, result.Stdout) - assert.Equal(t, "online", item.Get("env").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, value, item.Get("value").String(), "include-values should expose the explicitly requested test value, stdout:\n%s", result.Stdout) - }) - - t.Run("delete_removes_key", func(t *testing.T) { - deleteResult := runAppsLiveCommand(t, ctx, []string{ - "apps", "+envvar-delete", - "--app-id", appID, - "--env", "online", - "--key", key, - }, true) - if deleteResult.ExitCode == 0 { - created = false - } - deleteResult.AssertExitCode(t, 0) - deleteResult.AssertStdoutStatus(t, true) - requireStringArray(t, deleteResult.Stdout, "data.deleted_keys", []string{key}) - assert.Equal(t, "online", gjson.Get(deleteResult.Stdout, "data.env").String(), "stdout:\n%s", deleteResult.Stdout) - - listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ - Args: []string{ - "apps", "+envvar-list", - "--app-id", appID, - "--env", "online", - "--include-values", - }, - DefaultAs: "user", - Env: appsNoNoticeEnv(), - }, clie2e.RetryOptions{ - ShouldRetry: func(result *clie2e.Result) bool { - return result == nil || result.ExitCode != 0 || envVarKeyExists(result.Stdout, key) - }, - }) - require.NoError(t, err) - listResult.AssertExitCode(t, 0) - listResult.AssertStdoutStatus(t, true) - assert.False(t, envVarKeyExists(listResult.Stdout, key), "deleted key should be absent, stdout:\n%s", listResult.Stdout) - }) -} - -func runAppsDryRunCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { - t.Helper() - dryRunArgs := append([]string{}, args...) - dryRunArgs = append(dryRunArgs, "--dry-run") - return runAppsCommandWithEnv(t, ctx, dryRunArgs, yes, appsDryRunEnv()) -} - -func runAppsCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { - t.Helper() - return runAppsCommandWithEnv(t, ctx, args, yes, appsDryRunEnv()) -} - -func runAppsLiveCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { - t.Helper() - return runAppsCommandWithEnv(t, ctx, args, yes, appsNoNoticeEnv()) -} - -func runAppsCommandWithEnv(t *testing.T, ctx context.Context, args []string, yes bool, env map[string]string) *clie2e.Result { - t.Helper() - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: args, - DefaultAs: "user", - Env: env, - Yes: yes, - }) - require.NoError(t, err) - return result -} - -func appsDryRunEnv() map[string]string { - env := appsNoNoticeEnv() - env["LARKSUITE_CLI_APP_ID"] = "cli-e2e-app-id" - env["LARKSUITE_CLI_APP_SECRET"] = "cli-e2e-app-secret" - env["LARKSUITE_CLI_BRAND"] = "feishu" - return env -} - -func appsNoNoticeEnv() map[string]string { - return map[string]string{ - "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", - "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", - } -} - -func requireStringArray(t *testing.T, stdout string, path string, want []string) { - t.Helper() - got := gjson.Get(stdout, path).Array() - require.Len(t, got, len(want), "path %s should contain %d items, stdout:\n%s", path, len(want), stdout) - for i, value := range want { - assert.Equal(t, value, got[i].String(), "path %s[%d], stdout:\n%s", path, i, stdout) - } -} - -func errorEnvelope(t *testing.T, result *clie2e.Result) string { - t.Helper() - raw := strings.TrimSpace(result.Stdout) - if raw == "" { - raw = strings.TrimSpace(result.Stderr) - } - require.NotEmpty(t, raw, "expected structured error output, stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) - return raw -} - -func envVarKeyExists(stdout string, key string) bool { - _, found := envVarItem(stdout, key) - return found -} - -func envVarItem(stdout string, key string) (gjson.Result, bool) { - for _, item := range gjson.Get(stdout, "data.items").Array() { - if item.Get("key").String() == key { - return item, true - } - } - return gjson.Result{}, false -}