Files
larksuite__cli/shortcuts/apps/apps_analytics.go
木杉 1e87f67244 fix(apps): friendly-ize "Container not exists" for observability commands (#2302)
* fix(apps): friendly-ize "Container not exists" for observability commands

+metric-list and +analytics-list passed the upstream business code 400002655
("Container not exists") through verbatim. The message reads like an
infrastructure fault and misleads callers (including AI agents) into retrying a
non-retryable, expected business state: an app with no running container simply
has no metrics/analytics to query yet.

Rewrite it at a scoped observability helper (withObservabilityHint) into a
user-facing explanation plus a deploy-then-retry next step, mirroring the
existing isAppNoDatabaseError override. Detection is code-OR-message so a server
renumber alone does not silently drop the rewrite. Classification, code, and the
wrapped cause are preserved; unrelated failures still fall through to the shared
app-id recovery hint (and its own no-database override).

* test(apps): add execution-path regression tests for observability container hint

common_test.go proves withObservabilityHint in isolation but stays green if a
call site reverts to withAppsHint. Drive +metric-list and +analytics-list
Execute with a mocked 400002655 "Container not exists" envelope and assert the
container-specific message/hint/code, so a revert fails the build. Also closes
the two uncovered call-site lines flagged by coverage.

* fix(errclass): classify no-container code as validation/failed_precondition

Register 400002655 in sparkCodeMeta mirroring its no-database twin
(400002465) so both "expected precondition not met" business states expose
the same validation/failed_precondition classification to machine consumers,
instead of falling back to api/unknown. The shortcut-layer message rewrite
already keyed off the raw code, so this only aligns the typed envelope's
category/subtype; update the execution-path tests to pin the new
classification.

* fix(apps): gate the no-container hint's release behind user authorization

The no-container hint told a harness to deploy via +release-create, a "write"
that takes the whole app live and can affect existing production traffic —
without the user-confirmation gate its no-database twin deliberately carries.
Since the hint's audience is an AI agent that acts on it, a failed metrics read
could trigger an unconfirmed go-live. Lead with a read-only +release-list
status check and gate +release-create behind an explicit user confirmation,
mirroring appNoDatabaseHint.
2026-08-12 15:01:10 +08:00

208 lines
7.6 KiB
Go

// 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 withObservabilityHint(err)
}
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}
}