diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go index 0b1e587f4..68c0f1bda 100644 --- a/shortcuts/apps/apps_observability_common.go +++ b/shortcuts/apps/apps_observability_common.go @@ -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() } diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go index b2ede09e0..8ba8b9e1e 100644 --- a/shortcuts/apps/apps_observability_common_test.go +++ b/shortcuts/apps/apps_observability_common_test.go @@ -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) } } diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_observability_logs.go index a7a99714b..52ef3fdc0 100644 --- a/shortcuts/apps/apps_observability_logs.go +++ b/shortcuts/apps/apps_observability_logs.go @@ -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 diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_observability_logs_test.go index 437fd7d83..7f0ddcaef 100644 --- a/shortcuts/apps/apps_observability_logs_test.go +++ b/shortcuts/apps/apps_observability_logs_test.go @@ -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) { diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go index e8049f5f8..618a24255 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_observability_metrics.go @@ -33,7 +33,7 @@ var AppsMetricQuery = common.Shortcut{ Risk: "read", Tips: []string{ "Example: lark-cli apps +metric-query --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 --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 { diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go index a6c808ec8..c3c8414c0 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -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), + }, + }, }, }, }, diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_observability_traces.go index b8b0a28dc..6e6b5d825 100644 --- a/shortcuts/apps/apps_observability_traces.go +++ b/shortcuts/apps/apps_observability_traces.go @@ -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) diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_observability_traces_test.go index 538c0ad90..08a51bc49 100644 --- a/shortcuts/apps/apps_observability_traces_test.go +++ b/shortcuts/apps/apps_observability_traces_test.go @@ -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{}{