mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
fix: align apps observability OpenAPI schema
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
Reference in New Issue
Block a user