mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
208 lines
7.6 KiB
Go
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 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}
|
|
}
|