fix: align apps observability OpenAPI schema

This commit is contained in:
qingniaotonghua
2026-06-23 22:36:07 +08:00
parent 6ff02ea10c
commit 2cfe090c1d
8 changed files with 205 additions and 74 deletions

View File

@@ -158,10 +158,10 @@ func parseAppsRelativeDuration(s string) (time.Duration, bool) {
return time.Duration(n) * unitDuration, true
}
func nsString(t time.Time) string {
return strconv.FormatInt(t.UnixNano(), 10)
func nsNumber(t time.Time) int64 {
return t.UnixNano()
}
func secString(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
func secNumber(t time.Time) int64 {
return t.Unix()
}

View File

@@ -76,11 +76,11 @@ func TestAppsObservabilityCommonHelpers(t *testing.T) {
}
}
ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC)
if got := nsString(ts); got != "1782209472123456789" {
t.Fatalf("nsString = %q", got)
if got := nsNumber(ts); got != int64(1782209472123456789) {
t.Fatalf("nsNumber = %d", got)
}
if got := secString(ts); got != "1782209472" {
t.Fatalf("secString = %q", got)
if got := secNumber(ts); got != int64(1782209472) {
t.Fatalf("secNumber = %d", got)
}
}

View File

@@ -200,10 +200,10 @@ func addLogSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeCont
return err
}
if hasSince {
body["start_timestamp_ns"] = nsString(since)
body["start_timestamp_ns"] = nsNumber(since)
}
if hasUntil {
body["end_timestamp_ns"] = nsString(until)
body["end_timestamp_ns"] = nsNumber(until)
}
return nil
}
@@ -224,16 +224,22 @@ func buildLogSearchFilter(rctx *common.RuntimeContext) (map[string]interface{},
filter["trace_ids"] = traceIDs
}
addTrimmedLogFilterString(filter, "keyword", rctx.Str("keyword"))
addTrimmedLogFilterString(filter, "module", rctx.Str("module"))
addTrimmedLogFilterString(filter, "user_id", rctx.Str("user-id"))
addTrimmedLogFilterString(filter, "page", rctx.Str("page"))
addTrimmedLogFilterString(filter, "api", rctx.Str("api"))
addTrimmedLogFilterStrings(filter, "modules", rctx.Str("module"))
addTrimmedLogFilterStrings(filter, "user_ids", rctx.Str("user-id"))
addTrimmedLogFilterStrings(filter, "pages", rctx.Str("page"))
addTrimmedLogFilterStrings(filter, "apis", rctx.Str("api"))
if err := addDurationFilters(filter, rctx); err != nil {
return nil, err
}
return filter, nil
}
func addTrimmedLogFilterStrings(filter map[string]interface{}, key, value string) {
if value = strings.TrimSpace(value); value != "" {
filter[key] = []string{value}
}
}
func addTrimmedLogFilterString(filter map[string]interface{}, key, value string) {
if value = strings.TrimSpace(value); value != "" {
filter[key] = value

View File

@@ -16,7 +16,9 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) {
err := runAppsShortcut(t, AppsLogList, []string{
"+log-list", "--app-id", "app_x", "--level", "error",
"--log-id", "LOG1", "--log-id", "LOG2", "--trace-id", "trace-1",
"--keyword", "timeout", "--min-duration", "200",
"--keyword", "timeout", "--module", "frontend", "--user-id", "ou_1",
"--page", "/home", "--api", "/api/orders", "--min-duration", "200",
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
"--page-size", "20", "--dry-run", "--as", "user",
}, factory, stdout)
if err != nil {
@@ -42,6 +44,21 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) {
if got := filter["keyword"]; got != "timeout" {
t.Fatalf("filter.keyword = %v", got)
}
for key, want := range map[string]string{
"modules": "frontend",
"user_ids": "ou_1",
"pages": "/home",
"apis": "/api/orders",
} {
values, ok := filter[key].([]interface{})
if !ok || len(values) != 1 || values[0] != want {
t.Fatalf("filter.%s = %#v, want [%q]", key, filter[key], want)
}
}
if env.API[0].Body["start_timestamp_ns"] != float64(1782208800000000000) ||
env.API[0].Body["end_timestamp_ns"] != float64(1782208860000000000) {
t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"])
}
}
func TestAppsLogList_RejectsDevEnv(t *testing.T) {

View File

@@ -33,7 +33,7 @@ var AppsMetricQuery = common.Shortcut{
Risk: "read",
Tips: []string{
"Example: lark-cli apps +metric-query --app-id <app_id> --metric requests --series total --since 1d",
"Tip: metric timestamps use second strings; use +analytics-query for PV/UV-style analytics.",
"Tip: metric timestamps use seconds; use +analytics-query for PV/UV-style analytics.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
@@ -53,11 +53,11 @@ var AppsMetricQuery = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, _, _, err := buildMetricQueryBody(rctx)
_, _, _, _, err := buildMetricQueryBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
body, _, _, _ := buildMetricQueryBody(rctx)
body, _, _, _, _ := buildMetricQueryBody(rctx)
return common.NewDryRunAPI().
POST(metricQueryPath(rctx.Str("app-id"))).
Desc("Query online app metrics").
@@ -65,7 +65,7 @@ var AppsMetricQuery = common.Shortcut{
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
body, labels, fillZero, err := buildMetricQueryBody(rctx)
body, names, labels, fillZero, err := buildMetricQueryBody(rctx)
if err != nil {
return err
}
@@ -74,7 +74,7 @@ var AppsMetricQuery = common.Shortcut{
return withAppsHint(err, appIDListHint)
}
out := observabilitySeriesOutput{
Items: normalizeMetricSeries(data, labels, fillZero),
Items: normalizeMetricSeries(data, names, labels, fillZero),
HasMore: false,
}
rctx.OutFormat(out, nil, func(w io.Writer) {
@@ -92,7 +92,7 @@ var AppsAnalyticsQuery = common.Shortcut{
Risk: "read",
Tips: []string{
"Example: lark-cli apps +analytics-query --app-id <app_id> --analytics users --granularity week",
"Tip: analytics timestamps use nanosecond strings; use +metric-query for request/runtime metrics.",
"Tip: analytics timestamps use nanoseconds; use +metric-query for request/runtime metrics.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
@@ -104,7 +104,7 @@ var AppsAnalyticsQuery = common.Shortcut{
{Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"},
{Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"},
{Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"},
{Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"},
{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"}},
},
@@ -156,38 +156,37 @@ func analyticsQueryPath(appID string) string {
return appScopedPath(appID, analyticsQueryEndpoint)
}
func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, bool, error) {
func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) {
env := strings.TrimSpace(rctx.Str("env"))
if env == "" {
env = defaultAppsMetricEnv
}
if err := validateObservabilityEnv(env); err != nil {
return nil, nil, false, err
return nil, nil, nil, false, err
}
names, labels, err := metricNamesForCLI(rctx.Str("metric"), rctx.Str("series"))
if err != nil {
return nil, nil, false, err
return nil, nil, nil, false, err
}
since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until"))
if err != nil {
return nil, nil, false, err
return nil, nil, nil, false, err
}
downSample := strings.TrimSpace(rctx.Str("down-sample"))
if downSample == "" {
downSample = defaultAppsMetricDownSample
}
body := map[string]interface{}{
"app_env": env,
"metric_names": names,
"start_timestamp": secString(since),
"end_timestamp": secString(until),
"start_timestamp": secNumber(since),
"end_timestamp": secNumber(until),
"down_sample": downSample,
"need_pack_lack_point": false,
}
if filter := buildMetricQueryFilter(rctx); len(filter) > 0 {
body["filter"] = filter
}
return body, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil
return body, names, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil
}
func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} {
@@ -221,14 +220,13 @@ func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{
if err != nil {
return nil, nil, err
}
if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 {
filter["pages"] = pages
if page := strings.TrimSpace(rctx.Str("page")); page != "" {
filter["page"] = page
}
body := map[string]interface{}{
"app_env": env,
"analytics_types": types,
"start_timestamp_ns": nsString(since),
"end_timestamp_ns": nsString(until),
"metric_types": types,
"start_timestamp_ns": nsNumber(since),
"end_timestamp_ns": nsNumber(until),
"time_aggregation_unit": aggregation,
}
if len(filter) > 0 {
@@ -366,27 +364,27 @@ func analyticsGranularityForCLI(granularity string) (string, error) {
}
}
func normalizeMetricSeries(data map[string]interface{}, labels []string, fillZero bool) []map[string]interface{} {
return normalizeObservabilitySeries(data, labels, fillZero, "timestamp")
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{}, labels []string) []map[string]interface{} {
return normalizeObservabilitySeries(data, labels, false, "timestamp_ns")
return normalizeObservabilitySeries(data, labels, nil, false, "timestamp_ns")
}
func normalizeObservabilitySeries(data map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} {
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, fillZero, timeField)
return mergeObservabilitySeries(series, labels, nameLabels, fillZero, timeField)
}
if items := observabilityMapSlice(data["items"]); len(items) > 0 {
if observabilityHasNestedPoints(items) {
return mergeObservabilitySeries(items, labels, fillZero, timeField)
return mergeObservabilitySeries(items, labels, nameLabels, fillZero, timeField)
}
return normalizeObservabilityPoints(items, labels, fillZero, timeField)
return normalizeObservabilityPoints(items, labels, nameLabels, fillZero, timeField)
}
for _, key := range []string{"data_points", "dataPoints"} {
for _, key := range []string{"points", "data_points", "dataPoints"} {
if points := observabilityMapSlice(data[key]); len(points) > 0 {
return normalizeObservabilityPoints(points, labels, fillZero, timeField)
return normalizeObservabilityPoints(points, labels, nameLabels, fillZero, timeField)
}
}
return []map[string]interface{}{}
@@ -401,7 +399,7 @@ func observabilityHasNestedPoints(items []map[string]interface{}) bool {
return false
}
func mergeObservabilitySeries(series []map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} {
func mergeObservabilitySeries(series []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} {
index := make(map[string]int)
items := make([]map[string]interface{}, 0)
for i, serie := range series {
@@ -428,7 +426,7 @@ func mergeObservabilitySeries(series []map[string]interface{}, labels []string,
})
}
values := items[pos]["values"].(map[string]interface{})
values[label] = observabilityPointValue(point, label)
values[label] = observabilityPointValue(point, label, nameLabels)
}
}
if fillZero {
@@ -437,10 +435,10 @@ func mergeObservabilitySeries(series []map[string]interface{}, labels []string,
return items
}
func normalizeObservabilityPoints(points []map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} {
func normalizeObservabilityPoints(points []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} {
items := make([]map[string]interface{}, 0, len(points))
for _, point := range points {
values := observabilityPointValues(point, labels, fillZero)
values := observabilityPointValues(point, labels, nameLabels, fillZero)
items = append(items, map[string]interface{}{
timeField: observabilityTimestamp(point, timeField),
"dimensions": observabilityDimensions(point),
@@ -465,7 +463,7 @@ func fillObservabilityZeroes(items []map[string]interface{}, labels []string) {
}
}
func observabilityPointValues(point map[string]interface{}, labels []string, fillZero bool) map[string]interface{} {
func observabilityPointValues(point map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool) map[string]interface{} {
values := make(map[string]interface{}, len(labels))
switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); v := raw.(type) {
case map[string]interface{}:
@@ -474,10 +472,26 @@ func observabilityPointValues(point map[string]interface{}, labels []string, fil
values[label] = value
}
}
for name, label := range nameLabels {
if value, ok := v[name]; ok {
values[label] = value
}
}
case []interface{}:
for i, label := range labels {
if i < len(v) {
values[label] = v[i]
for i, rawItem := range v {
if item, ok := rawItem.(map[string]interface{}); ok {
name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name")))
label := nameLabels[name]
if label == "" && i < len(labels) {
label = labels[i]
}
if label != "" {
values[label] = firstObservabilityValue(item, "value")
}
continue
}
if i < len(labels) {
values[labels[i]] = rawItem
}
}
}
@@ -501,16 +515,35 @@ func observabilityPointValues(point map[string]interface{}, labels []string, fil
return values
}
func observabilityPointValue(point map[string]interface{}, label string) interface{} {
func observabilityPointValue(point map[string]interface{}, label string, nameLabels map[string]string) interface{} {
if value, ok := point["value"]; ok {
return value
}
switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); values := raw.(type) {
case map[string]interface{}:
for name, mappedLabel := range nameLabels {
if mappedLabel == label {
if value, ok := values[name]; ok {
return value
}
}
}
return values[label]
case []interface{}:
if len(values) > 0 {
return values[0]
for _, rawItem := range values {
item, ok := rawItem.(map[string]interface{})
if !ok {
continue
}
name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name")))
if nameLabels[name] == label {
return firstObservabilityValue(item, "value")
}
}
for _, rawItem := range values {
if _, ok := rawItem.(map[string]interface{}); !ok {
return rawItem
}
}
}
return nil
@@ -543,7 +576,7 @@ func observabilityMapSlice(raw interface{}) []map[string]interface{} {
}
func observabilitySeriesLabel(serie map[string]interface{}, labels []string, index int) string {
for _, key := range []string{"label", "series", "name"} {
for _, key := range []string{"label", "series", "name", "metric_type", "metricType"} {
if value, ok := serie[key].(string); ok {
value = strings.TrimSpace(value)
for _, label := range labels {
@@ -574,10 +607,39 @@ func observabilityDimensions(point map[string]interface{}) map[string]interface{
if dimensions, ok := point[key].(map[string]interface{}); ok {
return cloneMap(dimensions)
}
if dimensions := observabilityKVList(point[key]); len(dimensions) > 0 {
return dimensions
}
}
return map[string]interface{}{}
}
func observabilityNameLabels(names, labels []string) map[string]string {
out := make(map[string]string, len(names))
for i, name := range names {
if i < len(labels) {
out[name] = labels[i]
}
}
return out
}
func observabilityKVList(raw interface{}) map[string]interface{} {
items := observabilityMapSlice(raw)
if len(items) == 0 {
return nil
}
out := make(map[string]interface{}, len(items))
for _, item := range items {
key := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name")))
if key == "" {
continue
}
out[key] = firstObservabilityValue(item, "value")
}
return out
}
func firstObservabilityValue(m map[string]interface{}, keys ...string) interface{} {
for _, key := range keys {
if value, ok := m[key]; ok {

View File

@@ -58,7 +58,10 @@ func TestAppsMetricQuery_DryRunUsesSeconds(t *testing.T) {
if _, ok := body["start_timestamp_ns"]; ok {
t.Fatalf("metric should not use start_timestamp_ns: %#v", body)
}
if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" {
if _, ok := body["app_env"]; ok {
t.Fatalf("metric OpenAPI body should not include app_env: %#v", body)
}
if body["start_timestamp"] != float64(1782208800) || body["end_timestamp"] != float64(1782208860) {
t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"])
}
if body["down_sample"] != "1m" {
@@ -82,16 +85,21 @@ func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
"points": []interface{}{
map[string]interface{}{
"timestamp": "1782208800",
"timestamp": float64(1782208800),
"dimensions": map[string]interface{}{"page": "/home"},
"values": map[string]interface{}{"total": float64(12)},
"values": []interface{}{
map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)},
},
},
map[string]interface{}{
"timestamp": "1782208860",
"timestamp": float64(1782208860),
"dimensions": map[string]interface{}{"page": "/settings"},
"values": map[string]interface{}{"total": float64(8), "error": nil},
"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},
},
},
},
},
@@ -166,6 +174,7 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAnalyticsQuery, []string{
"+analytics-query", "--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)
if err != nil {
@@ -194,6 +203,19 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) {
if body["time_aggregation_unit"] != "WEEK" {
t.Fatalf("time_aggregation_unit = %v", body["time_aggregation_unit"])
}
if _, ok := body["app_env"]; ok {
t.Fatalf("analytics OpenAPI body should not include app_env: %#v", body)
}
if _, ok := body["analytics_types"]; ok {
t.Fatalf("analytics OpenAPI body should use metric_types, not analytics_types: %#v", body)
}
if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 {
t.Fatalf("metric_types = %#v", body["metric_types"])
}
if body["start_timestamp_ns"] != float64(1782208800000000000) ||
body["end_timestamp_ns"] != float64(1782208860000000000) {
t.Fatalf("analytics timestamps = %#v %#v", body["start_timestamp_ns"], body["end_timestamp_ns"])
}
}
func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
@@ -205,7 +227,7 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T)
name: "series",
args: []string{
"+analytics-query", "--app-id", "app_x", "--analytics", "page-view",
"--series", "desktop", "--dry-run", "--as", "user",
"--series", "desktop", "--page", "/home", "--dry-run", "--as", "user",
},
},
{
@@ -234,6 +256,9 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T)
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
t.Fatalf("device_types = %#v", deviceTypes)
}
if tc.name == "series" && filter["page"] != "/home" {
t.Fatalf("filter.page = %#v, want /home", filter["page"])
}
})
}
}
@@ -246,10 +271,15 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"data_points": []interface{}{
"series": []interface{}{
map[string]interface{}{
"timestamp_ns": "1782208800000000000",
"value": float64(21),
"metric_type": "PAGE_VIEW",
"points": []interface{}{
map[string]interface{}{
"timestamp_ns": float64(1782208800000000000),
"value": float64(21),
},
},
},
},
},

View File

@@ -17,7 +17,7 @@ import (
const (
defaultAppsTraceEnv = "online"
traceSearchEndpoint = "search_traces"
traceGetEndpoint = "get_trace"
traceGetEndpoint = "trace"
)
// AppsTraceList searches online app traces with observability filters.
@@ -181,10 +181,10 @@ func addTraceSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeCo
return err
}
if hasSince {
body["start_timestamp_ns"] = nsString(since)
body["start_timestamp_ns"] = nsNumber(since)
}
if hasUntil {
body["end_timestamp_ns"] = nsString(until)
body["end_timestamp_ns"] = nsNumber(until)
}
return nil
}
@@ -195,7 +195,7 @@ func buildTraceSearchFilter(rctx *common.RuntimeContext) map[string]interface{}
filter["trace_ids"] = traceIDs
}
addTrimmedTraceFilterString(filter, "keyword", rctx.Str("root-span"))
addTrimmedTraceFilterString(filter, "user_id", rctx.Str("user-id"))
addTrimmedTraceFilterStrings(filter, "user_ids", rctx.Str("user-id"))
return filter
}
@@ -205,6 +205,12 @@ func addTrimmedTraceFilterString(filter map[string]interface{}, key, value strin
}
}
func addTrimmedTraceFilterStrings(filter map[string]interface{}, key, value string) {
if value = strings.TrimSpace(value); value != "" {
filter[key] = []string{value}
}
}
func normalizeTraceSearchResponse(data map[string]interface{}) traceSearchOutput {
items, sourceKey := firstTraceMapSliceWithKey(data, "items", "trace_items", "traceItems", "spans", "span_items", "spanItems")
normalized := normalizeTraceSummaries(items)

View File

@@ -14,7 +14,9 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsTraceList, []string{
"+trace-list", "--app-id", "app_x", "--trace-id", "trace-1",
"--root-span", "gateway", "--page-size", "10", "--dry-run", "--as", "user",
"--root-span", "gateway", "--user-id", "ou_1",
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
"--page-size", "10", "--dry-run", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("dry-run err=%v", err)
@@ -44,6 +46,14 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
if got := filter["keyword"]; got != "gateway" {
t.Fatalf("filter.keyword = %v", got)
}
userIDs := filter["user_ids"].([]interface{})
if len(userIDs) != 1 || userIDs[0] != "ou_1" {
t.Fatalf("filter.user_ids = %#v", userIDs)
}
if env.API[0].Body["start_timestamp_ns"] != float64(1782208800000000000) ||
env.API[0].Body["end_timestamp_ns"] != float64(1782208860000000000) {
t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"])
}
}
func TestAppsTraceList_RejectsDevEnv(t *testing.T) {
@@ -71,7 +81,7 @@ func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
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/get_trace" {
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/trace" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
if env.API[0].Body["app_env"] != "online" || env.API[0].Body["trace_id"] != "trace-1" {
@@ -249,7 +259,7 @@ func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/get_trace",
URL: "/open-apis/spark/v1/apps/app_x/trace",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{