Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8d061ea
feat: add apps observability helpers
qingniaotonghua Jun 23, 2026
e9fde3e
feat: add apps log observability shortcuts
qingniaotonghua Jun 23, 2026
fdcd9f6
feat: add apps trace observability shortcuts
qingniaotonghua Jun 23, 2026
9b9ac87
feat: add apps metric analytics shortcuts
qingniaotonghua Jun 23, 2026
736db1c
feat: add apps envvar shortcuts
qingniaotonghua Jun 23, 2026
8939bff
docs: document apps observability envvar shortcuts
qingniaotonghua Jun 23, 2026
46c99cb
fix: add apps observability env hint
qingniaotonghua Jun 23, 2026
6ff02ea
test: cover apps envvar delete dry-run
qingniaotonghua Jun 23, 2026
2cfe090
fix: align apps observability OpenAPI schema
qingniaotonghua Jun 23, 2026
0f88409
fix: map apps observability named series
qingniaotonghua Jun 23, 2026
0552c5c
fix: apps observability api upgrade
qingniaotonghua Jun 24, 2026
d2452b7
fix: refine apps observability output
qingniaotonghua Jun 24, 2026
f334cc9
feat(apps): integrate miaoda db/file CLI commands into apps-spark int…
chenxingyang1019 Jun 25, 2026
6cbb9d6
feat(apps): add openapi-key shortcuts for open API key management (#1…
raistlin042 Jun 25, 2026
81c3736
refactor(apps): rename db --env to --environment (hard rename)
chenxingyang1019 Jun 25, 2026
9efa8b3
fix: upgrade observability and env
qingniaotonghua Jun 25, 2026
3e430dd
chore: merge apps spark capabilities base
qingniaotonghua Jun 25, 2026
431160a
Merge pull request #1584 from larksuite/feat/apps-observability
qingniaotonghua Jun 25, 2026
7121ff1
feat: rename app observability commands to list
qingniaotonghua Jun 26, 2026
8f0d072
feat(apps): default db --environment to dev across all db commands
chenxingyang1019 Jun 26, 2026
eb3ace1
Merge pull request #1595 from larksuite/feat/metric-list
qingniaotonghua Jun 26, 2026
6764949
fix: remove unsed files
qingniaotonghua Jun 26, 2026
bca7f7d
Merge pull request #1597 from larksuite/fix/delete-e2e
qingniaotonghua Jun 26, 2026
9fa28be
file_common.go 的 3 处裸 fmt.Errorf 已改为 typed errs.NewValidationError(er…
chenxingyang1019 Jun 26, 2026
3544683
Merge branch 'feat/apps-spark-capibilities' of github.com:larksuite/c…
chenxingyang1019 Jun 26, 2026
33458e6
fix(apps): resolve openapi-key CI gate failures (#1604)
raistlin042 Jun 26, 2026
72c61cc
style(apps): gofmt openapi-key common test after fixture rename
raistlin042 Jun 26, 2026
4229ea7
Merge remote-tracking branch 'origin/main' into feat/apps-spark-capib…
raistlin042 Jun 26, 2026
8a5c1dc
test(apps): align db dry-run e2e with --environment rename and dev de…
chenxingyang1019 Jun 26, 2026
2362437
fix: improve env-pull dev database hint (#1614)
qingniaotonghua Jun 26, 2026
7d1164d
feat(plugin): add plugin package management commands (#1609)
anngo-nk Jun 29, 2026
9f2fe50
feat(apps): add release polling interval time and release time costs
raistlin042 Jun 29, 2026
ff65e61
fix(plugin): rename files to apps_ prefix and handle Close() errors (…
anngo-nk Jun 30, 2026
9a85ffb
style: gofmt apps plugin files (#1664)
zhangli-268 Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions internal/output/spinner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package output

import (
"fmt"
"io"
"sync"
"time"
)

// spinnerFrames are braille spinner glyphs cycled to animate progress.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}

const (
spinnerInterval = 80 * time.Millisecond
spinnerHideCursor = "\x1b[?25l"
spinnerShowCursor = "\x1b[?25h"
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
)

// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
// until the returned stop() is called, e.g.:
//
// ⠹ Publishing dev → main... 3s
//
// It is meant for slow operations (long polls, first-time provisioning) so the
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
// animation never pollutes stdout — the JSON/pretty result stays clean.
//
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
//
// stop() clears the spinner line, restores the cursor, and blocks until the
// render goroutine has finished — so callers can safely write the result to
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
// it is safe to call more than once (e.g. an explicit call plus a defer).
func StartSpinner(w io.Writer, enabled bool, label string) func() {
if !enabled || w == nil {
return func() {}
}

done := make(chan struct{})
finished := make(chan struct{})
start := time.Now()

go func() {
defer close(finished)
frame := 0
fmt.Fprint(w, spinnerHideCursor)
render := func() {
elapsed := int(time.Since(start).Seconds())
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
frame = (frame + 1) % len(spinnerFrames)
}
render()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
return
case <-ticker.C:
render()
}
}
}()

var once sync.Once
return func() {
once.Do(func() {
close(done)
<-finished // wait for the line to be cleared before returning
})
}
}
54 changes: 54 additions & 0 deletions internal/output/spinner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package output

import (
"bytes"
"strings"
"testing"
)

// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, false, "working")
stop()
stop() // idempotent
if buf.Len() != 0 {
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
}
}

// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
stop := StartSpinner(nil, true, "working")
stop() // must not panic
}

// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, true, "Publishing")
// The goroutine renders the first frame synchronously before selecting on
// the stop channel, so even an immediate stop() yields one full cycle.
stop()
stop() // idempotent, must not panic or double-write after finished

out := buf.String()
if !strings.Contains(out, spinnerHideCursor) {
t.Errorf("missing hide-cursor escape:\n%q", out)
}
if !strings.Contains(out, spinnerFrames[0]) {
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
}
if !strings.Contains(out, "Publishing...") {
t.Errorf("missing label:\n%q", out)
}
if !strings.Contains(out, spinnerClearLine) {
t.Errorf("missing clear-line escape:\n%q", out)
}
if !strings.HasSuffix(out, spinnerShowCursor) {
t.Errorf("must end by restoring the cursor:\n%q", out)
}
}
207 changes: 207 additions & 0 deletions shortcuts/apps/apps_analytics.go
Original file line number Diff line number Diff line change
@@ -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}
}
Loading
Loading