mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
Merge branch 'feat/apps-spark-capibilities' of github.com:larksuite/cli into feat/apps-spark-capibilities
This commit is contained in:
207
shortcuts/apps/apps_analytics.go
Normal file
207
shortcuts/apps/apps_analytics.go
Normal 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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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])
|
||||
298
shortcuts/apps/apps_metrics_test.go
Normal file
298
shortcuts/apps/apps_metrics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <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`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user