Merge branch 'feat/apps-spark-capibilities' of github.com:larksuite/cli into feat/apps-spark-capibilities

This commit is contained in:
陈兴炀
2026-06-26 14:22:46 +08:00
14 changed files with 586 additions and 1181 deletions

View File

@@ -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 <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}
}

View File

@@ -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)
}

View File

@@ -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") {

View File

@@ -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 <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 <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 <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])

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -11,7 +11,7 @@ import (
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
// 6 基础 + 1 init + 3 publish + 1 env-pull
// - 6 observabilitylog-list/log-get/trace-list/trace-get/metric-query/analytics-query
// - 6 observabilitylog-list/log-get/trace-list/trace-get/metric-list/analytics-list
// - 3 envlist/set/delete
// - 16 dbtable-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,

View File

@@ -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) |
| 逐条执行 SQLSELECT / 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 <app_id> --metric requests --since <range> --as user`(不传 `--series` 会同时返回 total/error延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99。如果用户给了具体接口再加 `--api <path-or-name>`;不要臆造 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 <app_id> --metric requests --since <range> --as user`(不传 `--series` 会同时返回 total/error延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99。如果用户给了具体接口再加 `--api <path-or-name>`;不要臆造 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`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。

View File

@@ -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 <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 <app_id> --level error --keyword timeout --sinc
lark-cli apps +log-get --app-id <app_id> --log-id <log_id>
lark-cli apps +trace-list --app-id <app_id> --trace-id <trace_id>
lark-cli apps +trace-get --app-id <app_id> --trace-id <trace_id>
lark-cli apps +metric-query --app-id <app_id> --metric requests --series total --since 1d
lark-cli apps +metric-query --app-id <app_id> --metric requests --since 1h
lark-cli apps +metric-query --app-id <app_id> --metric latency --since 1h
lark-cli apps +metric-query --app-id <app_id> --metric latency --series p99 --since 1d
lark-cli apps +metric-query --app-id <app_id> --metric cpu --since 1h
lark-cli apps +metric-query --app-id <app_id> --metric memory --since 1h
lark-cli apps +analytics-query --app-id <app_id> --analytics users --series active-users --granularity day
lark-cli apps +analytics-query --app-id <app_id> --analytics page-view --granularity day
lark-cli apps +metric-list --app-id <app_id> --metric requests --series total --since 1d
lark-cli apps +metric-list --app-id <app_id> --metric requests --since 1h
lark-cli apps +metric-list --app-id <app_id> --metric latency --since 1h
lark-cli apps +metric-list --app-id <app_id> --metric latency --series p99 --since 1d
lark-cli apps +metric-list --app-id <app_id> --metric cpu --since 1h
lark-cli apps +metric-list --app-id <app_id> --metric memory --since 1h
lark-cli apps +analytics-list --app-id <app_id> --analytics users --series active-users --granularity day
lark-cli apps +analytics-list --app-id <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 <path-or-name>`;当前没有 `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。

View File

@@ -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
}