From ba806fbac7b61235fedb55f2fb5de8d7a545f869 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Thu, 29 Oct 2026 22:59:31 +0000 Subject: [PATCH 1/2] feat: add observability commands to query logs, traces, aggregations, and keys Add the hand-written "ldcli observability" command group (alias "o11y"): "logs search", "traces search", "aggregate", and "keys". An agent can now query observability data with the access token that ldcli already uses. The commands take the LaunchDarkly project key. They get the project _id from the REST API and send it to the observability GraphQL API, the same API and operations that the hosted LaunchDarkly MCP server uses. The CLI derives the API URL from --base-uri, and --observability-url overrides it. The default output is compact text, also when stdout is not a terminal, because full log records are large. Use --json for full records. "aggregate" shows the sample factor when the API sampled the data. Co-Authored-By: Claude Opus 5.5 Generated-by: termag --- README.md | 13 + cmd/observability/aggregate.go | 264 ++++++++++++++++ cmd/observability/keys.go | 146 +++++++++ cmd/observability/logs.go | 38 +++ cmd/observability/observability.go | 335 ++++++++++++++++++++ cmd/observability/observability_test.go | 388 ++++++++++++++++++++++++ cmd/observability/queries.go | 159 ++++++++++ cmd/observability/search.go | 221 ++++++++++++++ cmd/observability/time_test.go | 37 +++ cmd/observability/traces.go | 78 +++++ cmd/root.go | 21 +- cmd/templates.go | 1 + cmd/templates_test.go | 1 + internal/observability/client.go | 161 ++++++++++ internal/observability/client_test.go | 88 ++++++ 15 files changed, 1947 insertions(+), 4 deletions(-) create mode 100644 cmd/observability/aggregate.go create mode 100644 cmd/observability/keys.go create mode 100644 cmd/observability/logs.go create mode 100644 cmd/observability/observability.go create mode 100644 cmd/observability/observability_test.go create mode 100644 cmd/observability/queries.go create mode 100644 cmd/observability/search.go create mode 100644 cmd/observability/time_test.go create mode 100644 cmd/observability/traces.go create mode 100644 internal/observability/client.go create mode 100644 internal/observability/client_test.go diff --git a/README.md b/README.md index 621da7a12..f542db98a 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,19 @@ LaunchDarkly CLI commands: - `setup` guides you through creating your first flag, connecting an SDK, and evaluating your flag in your Test environment - `dev-server` lets you start a local server and retrieve flag values from a LaunchDarkly source environment so you can test your code locally. For assistance starting with or running dev-server, refer to the [reference docs](https://launchdarkly.com/docs/guides/flags/ldcli-dev-server). +### Observability Commands + +The `observability` commands (alias `o11y`) query logs, traces, aggregations, and attribute keys from LaunchDarkly observability. They take the LaunchDarkly project key and your access token. You do not need the observability project ID. + +```sh-session +ldcli observability logs search --project default --query 'level=error' --start 15m +ldcli observability traces search --project default --query 'service_name=api' --sort-column duration +ldcli observability aggregate --project default --product-type logs --query 'level=error' --group-by service_name --bucket-count 1 +ldcli observability keys --project default --product-type logs --key service_name +``` + +These commands print compact text by default, also when stdout is not a terminal. To get the full records as JSON, use `--json`. If `aggregate` shows a sample factor, the values are estimates. For exact counts, use a shorter time range. + ### Resource Commands Resource commands mirror the LaunchDarkly API and make requests for a given resource. To see a full list of resources supported by the CLI, enter `ldcli --help` into your terminal. diff --git a/cmd/observability/aggregate.go b/cmd/observability/aggregate.go new file mode 100644 index 000000000..12cd14196 --- /dev/null +++ b/cmd/observability/aggregate.go @@ -0,0 +1,264 @@ +package observability + +import ( + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/observability" +) + +const ( + bucketCountFlag = "bucket-count" + columnFlag = "column" + functionFlag = "function" + groupByFlag = "group-by" + groupLimitFlag = "group-limit" + productTypeFlag = "product-type" + + maxBucketCount = 300 + maxGroupLimit = 50 +) + +// functions are the aggregate functions of the API. Only Count works without a column. +var functions = []string{"Count", "CountDistinct", "CountDistinctKey", "Min", "Avg", "P50", "P75", "P90", "P95", "P99", "Max", "Sum"} + +type metricsResult struct { + Buckets []struct { + BucketID float64 `json:"bucket_id"` + BucketMin float64 `json:"bucket_min"` + BucketMax float64 `json:"bucket_max"` + MetricValue *float64 `json:"metric_value"` + MetricType string `json:"metric_type"` + Column string `json:"column"` + Group []string `json:"group"` + } `json:"buckets"` + BucketCount int `json:"bucket_count"` + SampleFactor float64 `json:"sample_factor"` +} + +func newAggregateCmd(clients Clients) *cobra.Command { + cmd := &cobra.Command{ + Use: "aggregate", + Short: "Count or aggregate records in time buckets", + Long: `Count or aggregate logs, traces, errors, sessions, metrics, or events in time buckets. + +Use this command for counts, trends, and group comparisons, and for time ranges longer than 24 hours. + +If the API read only a sample of the data, the output shows the sample factor. The values are then estimates. A sample factor of 90 can mean that one sampled record became a count of 90. For an exact count of rare events, use a shorter time range. + +To get one total for each group over the full time range, use --bucket-count 1.`, + Example: ` ldcli observability aggregate --project default --product-type logs --query 'level=error' --group-by service_name --bucket-count 1 + ldcli observability aggregate --project default --product-type traces --function P99 --column duration --group-by span_name --start 6h + ldcli observability aggregate --project default --product-type logs --query 'request_panic=true' --group-by error.message --start 7d --bucket-count 7`, + Args: validators.Validate(), + RunE: func(cmd *cobra.Command, args []string) error { + return runAggregate(cmd, clients) + }, + } + + addTimeRangeFlags(cmd) + cmd.Flags().String(productTypeFlag, "", "Data to aggregate: "+productTypeNames) + _ = cmd.MarkFlagRequired(productTypeFlag) + _ = cmd.Flags().SetAnnotation(productTypeFlag, "required", []string{"true"}) + cmd.Flags().String(queryFlag, "", "Search query that selects the records. Empty selects all records") + cmd.Flags().String(functionFlag, "Count", "Aggregate function: "+strings.Join(functions, ", ")) + cmd.Flags().String(columnFlag, "", "Column for the function, for example duration for traces or value for metrics. Required for each function except Count") + cmd.Flags().StringSlice(groupByFlag, nil, "Comma-separated keys to group by, for example service_name,level") + cmd.Flags().Int(groupLimitFlag, 5, fmt.Sprintf("Maximum number of groups, at most %d", maxGroupLimit)) + cmd.Flags().Int(bucketCountFlag, 10, fmt.Sprintf("Number of time buckets, at most %d. Use 1 for one total for each group", maxBucketCount)) + + return cmd +} + +func runAggregate(cmd *cobra.Command, clients Clients) error { + productTypeValue, _ := cmd.Flags().GetString(productTypeFlag) + productType, ok := productTypes[strings.ToLower(productTypeValue)] + if !ok { + return fmt.Errorf("--%s must be one of: %s", productTypeFlag, productTypeNames) + } + functionValue, _ := cmd.Flags().GetString(functionFlag) + function := "" + for _, f := range functions { + if strings.EqualFold(f, functionValue) { + function = f + } + } + if function == "" { + return fmt.Errorf("--%s must be one of: %s", functionFlag, strings.Join(functions, ", ")) + } + column, _ := cmd.Flags().GetString(columnFlag) + column = strings.TrimSpace(column) + if function != "Count" && column == "" { + return fmt.Errorf("--%s %s needs --%s. Only Count works without a column", functionFlag, function, columnFlag) + } + if function == "Count" && column != "" { + return fmt.Errorf("--%s is not used by Count. Set --%s, for example Avg or CountDistinct", columnFlag, functionFlag) + } + bucketCount, _ := cmd.Flags().GetInt(bucketCountFlag) + if bucketCount < 1 || bucketCount > maxBucketCount { + return fmt.Errorf("--%s must be from 1 to %d", bucketCountFlag, maxBucketCount) + } + groupLimit, _ := cmd.Flags().GetInt(groupLimitFlag) + if groupLimit < 1 || groupLimit > maxGroupLimit { + return fmt.Errorf("--%s must be from 1 to %d", groupLimitFlag, maxGroupLimit) + } + groupBy, _ := cmd.Flags().GetStringSlice(groupByFlag) + groups := make([]string, 0, len(groupBy)) + for _, g := range groupBy { + if g = strings.TrimSpace(g); g != "" { + groups = append(groups, g) + } + } + dr, err := dateRange(cmd, time.Now()) + if err != nil { + return err + } + + t, err := resolveTarget(cmd, clients.Resources) + if err != nil { + return err + } + + query, _ := cmd.Flags().GetString(queryFlag) + variables := map[string]any{ + "product_type": productType, + "project_id": t.projectID, + "params": map[string]any{"query": query, "date_range": dr}, + "bucket_by": "Timestamp", + "bucket_count": bucketCount, + "group_by": groups, + "expressions": []any{map[string]any{"aggregator": function, "column": column}}, + "limit": groupLimit, + } + // The API ranks groups by limit_aggregator. Rank by the function, so that the top + // groups for P99 are the slowest. Count and CountDistinctKey rank by record count. + if function == "Count" || function == "CountDistinctKey" { + variables["limit_aggregator"] = "Count" + } else { + variables["limit_aggregator"] = function + variables["limit_column"] = column + } + + data, err := clients.Observability.Query(cmd.Context(), observability.Request{ + AccessToken: t.accessToken, + Endpoint: t.endpoint, + ProjectID: t.projectID, + Query: aggregationsQuery, + Variables: variables, + }) + if err != nil { + return err + } + var result struct { + Metrics *metricsResult `json:"metrics"` + } + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("decoding aggregation: %w", err) + } + metrics := result.Metrics + if metrics == nil { + metrics = &metricsResult{} + } + sampled := metrics.SampleFactor > 1 + + if wantsJSON(cmd) { + var raw struct { + Metrics map[string]any `json:"metrics"` + } + _ = json.Unmarshal(data, &raw) + if raw.Metrics == nil { + raw.Metrics = map[string]any{} + } + raw.Metrics["sampled"] = sampled + return writeJSON(cmd, raw.Metrics) + } + + out := cmd.OutOrStdout() + description := fmt.Sprintf("%s %s", productType, function) + if column != "" { + description += "(" + column + ")" + } + if query != "" { + description += fmt.Sprintf(", query %q", query) + } + if len(groups) > 0 { + description += ", grouped by " + strings.Join(groups, ",") + } + fmt.Fprintf(out, "%s, %s to %s, %d buckets\n", description, dr["start_date"], dr["end_date"], bucketCount) + if sampled { + fmt.Fprintf(out, "SAMPLED: sample factor %.2f. The API read a sample of the records, so the values are estimates. For exact counts, use a shorter time range.\n", metrics.SampleFactor) + } + + empty := true + for _, b := range metrics.Buckets { + if b.MetricValue != nil && *b.MetricValue != 0 { + empty = false + } + } + if empty { + printEmpty(cmd, strings.ToLower(productType), queryProblems(cmd, clients.Observability, t, query)) + return nil + } + + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + header := "BUCKET START\t" + if len(groups) > 0 { + header += "GROUP\t" + } + fmt.Fprintln(w, header+"VALUE") + totals := map[string]float64{} + var groupOrder []string + for _, b := range metrics.Buckets { + if b.MetricValue == nil || *b.MetricValue == 0 { + continue + } + row := bucketTime(b.BucketMin) + "\t" + group := strings.Join(b.Group, ", ") + if len(groups) > 0 { + row += orDash(group) + "\t" + } + if _, seen := totals[group]; !seen { + groupOrder = append(groupOrder, group) + } + totals[group] += *b.MetricValue + fmt.Fprintln(w, row+formatNumber(*b.MetricValue)) + } + _ = w.Flush() + + // A total is a sum of the buckets, so it has a meaning only for Count and Sum. + if bucketCount > 1 && (function == "Count" || function == "Sum") { + if sampled { + fmt.Fprintln(out, "Totals (estimates):") + } else { + fmt.Fprintln(out, "Totals:") + } + for _, g := range groupOrder { + label := "all" + if len(groups) > 0 { + label = orDash(g) + } + fmt.Fprintf(out, " %s: %s\n", label, formatNumber(totals[g])) + } + } + + return nil +} + +// bucketTime prints the start of a time bucket. The API gives it in Unix seconds. +func bucketTime(seconds float64) string { + return time.Unix(int64(seconds), 0).UTC().Format("2006-01-02T15:04:05Z") +} + +func formatNumber(v float64) string { + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + + return fmt.Sprintf("%.2f", v) +} diff --git a/cmd/observability/keys.go b/cmd/observability/keys.go new file mode 100644 index 000000000..8d98fe5ef --- /dev/null +++ b/cmd/observability/keys.go @@ -0,0 +1,146 @@ +package observability + +import ( + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/observability" +) + +const ( + keyFlag = "key" + searchFlag = "search" +) + +func newKeysCmd(clients Clients) *cobra.Command { + cmd := &cobra.Command{ + Use: "keys", + Short: "List the attribute keys of a product type, or the values of one key", + Long: `List the attribute keys that the records of a product type have in a time range, with the number of records for each key. + +Use the keys to write the --query, --group-by, and --column values of the other commands. Compare the counts to find the correct key, for example service_name and not serviceName. + +To list the values of one key, most frequent first, use --key. Service names often have an environment prefix, so list the values before you filter on them.`, + Example: ` ldcli observability keys --project default --product-type logs + ldcli observability keys --project default --product-type logs --search service + ldcli observability keys --project default --product-type logs --key service_name --search gonfalon`, + Args: validators.Validate(), + RunE: func(cmd *cobra.Command, args []string) error { + return runKeys(cmd, clients) + }, + } + + addTimeRangeFlags(cmd) + cmd.Flags().String(productTypeFlag, "", "Product type: "+productTypeNames) + _ = cmd.MarkFlagRequired(productTypeFlag) + _ = cmd.Flags().SetAnnotation(productTypeFlag, "required", []string{"true"}) + cmd.Flags().String(keyFlag, "", "List the values of this key, for example service_name, instead of the keys") + cmd.Flags().String(searchFlag, "", "Only list keys with this text in the name. With --key, only list values with this text") + cmd.Flags().Int(limitFlag, 25, fmt.Sprintf("Number of keys or values to return, at most %d", maxLimit)) + + return cmd +} + +func runKeys(cmd *cobra.Command, clients Clients) error { + productTypeValue, _ := cmd.Flags().GetString(productTypeFlag) + productType, ok := productTypes[strings.ToLower(productTypeValue)] + if !ok { + return fmt.Errorf("--%s must be one of: %s", productTypeFlag, productTypeNames) + } + limit, _ := cmd.Flags().GetInt(limitFlag) + if limit < 1 || limit > maxLimit { + return fmt.Errorf("--%s must be from 1 to %d", limitFlag, maxLimit) + } + key, _ := cmd.Flags().GetString(keyFlag) + key = strings.TrimSpace(key) + search, _ := cmd.Flags().GetString(searchFlag) + dr, err := dateRange(cmd, time.Now()) + if err != nil { + return err + } + + t, err := resolveTarget(cmd, clients.Resources) + if err != nil { + return err + } + + variables := map[string]any{ + "project_id": t.projectID, + "date_range": dr, + "count": limit, + } + if search != "" { + variables["query"] = search + } + var query, field string + if key != "" { + query, field = valueSuggestionsQuery, "value_suggestions" + variables["product_type"] = productType + variables["key_name"] = key + } else { + k := keysQueries[productType] + query, field = k.query, k.field + if field == "keys" { + variables["product_type"] = productType + } + } + + data, err := clients.Observability.Query(cmd.Context(), observability.Request{ + AccessToken: t.accessToken, + Endpoint: t.endpoint, + ProjectID: t.projectID, + Query: query, + Variables: variables, + }) + if err != nil { + return err + } + var result map[string][]struct { + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Value string `json:"value,omitempty"` + Count float64 `json:"count"` + } + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("decoding keys: %w", err) + } + items := result[field] + + if wantsJSON(cmd) { + if key != "" { + return writeJSON(cmd, map[string]any{"key": key, "values": items}) + } + return writeJSON(cmd, map[string]any{"keys": items}) + } + + out := cmd.OutOrStdout() + if len(items) == 0 { + if key != "" { + fmt.Fprintf(out, "No values found for key %q. The key name can be wrong, or the records in this time range do not have the key. Use 'ldcli observability keys --%s %s --%s ' to find the key.\n", key, productTypeFlag, productTypeValue, searchFlag) + } else { + fmt.Fprintln(out, "No keys found. Try a longer time range or another --search value.") + } + return nil + } + + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + if key != "" { + fmt.Fprintln(w, "VALUE\tCOUNT") + for _, item := range items { + fmt.Fprintf(w, "%s\t%s\n", oneLine(item.Value), formatNumber(item.Count)) + } + } else { + fmt.Fprintln(w, "KEY\tTYPE\tCOUNT") + for _, item := range items { + fmt.Fprintf(w, "%s\t%s\t%s\n", item.Name, item.Type, formatNumber(item.Count)) + } + } + + return w.Flush() +} diff --git a/cmd/observability/logs.go b/cmd/observability/logs.go new file mode 100644 index 000000000..2036fe812 --- /dev/null +++ b/cmd/observability/logs.go @@ -0,0 +1,38 @@ +package observability + +import ( + "strings" + + "github.com/spf13/cobra" +) + +func newLogsSearchCmd(clients Clients) *cobra.Command { + return newSearchCmd( + clients, + "search", + "Search log records", + `Search log records in a time range, newest first. + +The compact output prints one line for each log: timestamp, level, service, and message. To add attributes, use --attributes. To get the full records with all attributes, use --json. + +For counts and trends, use 'ldcli observability aggregate'. It is faster than a search over a long time range.`, + ` ldcli observability logs search --project default --query 'level=error' --start 15m + ldcli observability logs search --project default --query 'service_name=api "timeout"' --start 2026-01-15T10:00:00Z --end 2026-01-15T11:00:00Z + ldcli observability logs search --project default --query 'level=error' --attributes error.message,code.function`, + searchSpec{ + what: "logs", + query: logsQuery, + field: "logs", + attributesField: "logAttributes", + compact: func(node map[string]any) []string { + return []string{ + shortTime(stringField(node, "timestamp")), + orDash(strings.ToUpper(stringField(node, "level"))), + orDash(stringField(node, "serviceName")), + stringField(node, "message"), + } + }, + }, + nil, + ) +} diff --git a/cmd/observability/observability.go b/cmd/observability/observability.go new file mode 100644 index 000000000..6eaed1633 --- /dev/null +++ b/cmd/observability/observability.go @@ -0,0 +1,335 @@ +// Package observability has the hand-written "ldcli observability" commands. They +// query logs, traces, aggregations, and keys from LaunchDarkly observability. +package observability + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/observability" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" +) + +const ( + directionFlag = "direction" + endFlag = "end" + limitFlag = "limit" + observabilityURLFlag = "observability-url" + queryFlag = "query" + startFlag = "start" + + defaultStart = "1h" + timeFormat = "2006-01-02T15:04:05.000Z" +) + +// Clients holds the clients that the observability commands use. +type Clients struct { + // Resources looks up the project _id from the project key. + Resources resources.Client + // Observability sends the GraphQL operations. + Observability observability.Client +} + +func NewObservabilityCmd(clients Clients, analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Use: "observability", + Aliases: []string{"o11y"}, + Short: "Query logs, traces, and aggregations from LaunchDarkly observability", + Long: `Query logs, traces, and aggregations from LaunchDarkly observability. + +The commands take the LaunchDarkly project key and use your access token. They find the observability project for you. + +By default, the commands print compact text, also when stdout is not a terminal. To get the full records as JSON, use --json or --output json. + +Time ranges take a relative duration before now, such as 15m, 6h, 7d, or 2w, or an ISO 8601 time, such as 2026-01-15T10:00:00Z. + +Queries use the observability search syntax, for example: level=error AND service_name=api "timeout".`, + Example: ` ldcli observability logs search --project default --query 'level=error' --start 15m + ldcli observability traces search --project default --query 'service_name=api' --sort-column duration + ldcli observability aggregate --project default --product-type logs --query 'level=error' --group-by service_name --bucket-count 1 + ldcli observability keys --project default --product-type logs --search service + ldcli observability keys --project default --product-type logs --key service_name`, + Args: cobra.NoArgs, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + tracker := analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ) + tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "observability", + map[string]interface{}{ + "action": strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" observability "), + })) + }, + } + + cmd.PersistentFlags().String(cliflags.ProjectFlag, "", "The LaunchDarkly project key. Required, unless the configuration file sets project") + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.PersistentFlags().Lookup(cliflags.ProjectFlag)) + cmd.PersistentFlags().String( + observabilityURLFlag, + "", + fmt.Sprintf("The observability API URL. Defaults to the observability API of the instance that --%s names (%s for the default)", cliflags.BaseURIFlag, observability.DefaultEndpoint), + ) + _ = viper.BindPFlag(observabilityURLFlag, cmd.PersistentFlags().Lookup(observabilityURLFlag)) + + logsCmd := &cobra.Command{ + Use: "logs", + Short: "Search logs", + Args: cobra.NoArgs, + } + logsCmd.AddCommand(newLogsSearchCmd(clients)) + logsCmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + tracesCmd := &cobra.Command{ + Use: "traces", + Short: "Search traces", + Args: cobra.NoArgs, + } + tracesCmd.AddCommand(newTracesSearchCmd(clients)) + tracesCmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + cmd.AddCommand(logsCmd) + cmd.AddCommand(tracesCmd) + cmd.AddCommand(newAggregateCmd(clients)) + cmd.AddCommand(newKeysCmd(clients)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} + +// target is the project and API endpoint that one command run queries. +type target struct { + accessToken string + endpoint string + projectID string + projectKey string +} + +// resolveTarget finds the observability project for the --project key and the +// observability API endpoint. +func resolveTarget(cmd *cobra.Command, client resources.Client) (target, error) { + projectKey := viper.GetString(cliflags.ProjectFlag) + if projectKey == "" { + return target{}, fmt.Errorf("required flag \"%s\" not set", cliflags.ProjectFlag) + } + + endpoint := viper.GetString(observabilityURLFlag) + if endpoint == "" { + var err error + endpoint, err = observability.EndpointFor(viper.GetString(cliflags.BaseURIFlag)) + if err != nil { + return target{}, err + } + } + + accessToken := viper.GetString(cliflags.AccessTokenFlag) + projectID, err := resolveProjectID(cmd, client, accessToken, projectKey) + if err != nil { + return target{}, err + } + + return target{ + accessToken: accessToken, + endpoint: endpoint, + projectID: projectID, + projectKey: projectKey, + }, nil +} + +// resolveProjectID gets the _id of the LaunchDarkly project. The observability API +// identifies a project by this ID. If the value is already an ID, it is used as it is. +func resolveProjectID(cmd *cobra.Command, client resources.Client, accessToken, projectKey string) (string, error) { + if isProjectID(projectKey) { + return projectKey, nil + } + + path, _ := url.JoinPath(viper.GetString(cliflags.BaseURIFlag), "api/v2/projects", projectKey) + res, err := client.MakeRequest(accessToken, "GET", path, "application/json", nil, nil, false) + if err != nil { + return "", output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + + var project struct { + ID string `json:"_id"` + } + if err := json.Unmarshal(res, &project); err != nil || project.ID == "" { + return "", fmt.Errorf("could not find the ID of project %q", projectKey) + } + + return project.ID, nil +} + +func isProjectID(value string) bool { + if len(value) != 24 { + return false + } + _, err := hex.DecodeString(value) + + return err == nil +} + +// wantsJSON tells if the user asked for JSON output. Other ldcli commands print JSON +// when stdout is not a terminal. These commands print compact text in that case, +// because full log records are large and agents read the output. +func wantsJSON(cmd *cobra.Command) bool { + if j, err := cmd.Root().PersistentFlags().GetBool(cliflags.JSONFlag); err == nil && j { + return true + } + if f := cmd.Root().PersistentFlags().Lookup(cliflags.OutputFlag); f != nil && f.Changed { + return f.Value.String() == "json" + } + if v, ok := os.LookupEnv("LD_OUTPUT"); ok { + return v == "json" + } + if viper.InConfig(cliflags.OutputFlag) { + return viper.GetString(cliflags.OutputFlag) == "json" + } + + return false +} + +func writeJSON(cmd *cobra.Command, value any) error { + b, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(b)) + + return nil +} + +// addTimeRangeFlags adds --start and --end. +func addTimeRangeFlags(cmd *cobra.Command) { + cmd.Flags().String(startFlag, defaultStart, "Start of the time range: a duration before now (15m, 6h, 7d, 2w) or an ISO 8601 time") + cmd.Flags().String(endFlag, "now", "End of the time range: now, a duration before now, or an ISO 8601 time") +} + +// dateRange reads --start and --end and returns the date_range GraphQL input. +func dateRange(cmd *cobra.Command, now time.Time) (map[string]any, error) { + startValue, _ := cmd.Flags().GetString(startFlag) + endValue, _ := cmd.Flags().GetString(endFlag) + + start, err := parseTime(startValue, now) + if err != nil { + return nil, fmt.Errorf("invalid --%s: %w", startFlag, err) + } + end, err := parseTime(endValue, now) + if err != nil { + return nil, fmt.Errorf("invalid --%s: %w", endFlag, err) + } + if !start.Before(end) { + return nil, fmt.Errorf("--%s must be before --%s", startFlag, endFlag) + } + + return map[string]any{ + "start_date": start.UTC().Format(timeFormat), + "end_date": end.UTC().Format(timeFormat), + }, nil +} + +// parseTime reads "now", a duration before now (90s, 15m, 6h, 7d, 2w), or an ISO 8601 +// time. +func parseTime(value string, now time.Time) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" || value == "now" { + return now, nil + } + if t, err := time.Parse(time.RFC3339Nano, value); err == nil { + return t, nil + } + if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil { + return t, nil + } + if t, err := time.Parse("2006-01-02", value); err == nil { + return t, nil + } + + duration := strings.TrimPrefix(value, "-") + if duration == "" { + return time.Time{}, fmt.Errorf("%q is not a duration or an ISO 8601 time", value) + } + units := map[byte]time.Duration{'d': 24 * time.Hour, 'w': 7 * 24 * time.Hour} + if unit, ok := units[duration[len(duration)-1]]; ok { + n, err := strconv.ParseFloat(duration[:len(duration)-1], 64) + if err != nil || n < 0 { + return time.Time{}, fmt.Errorf("%q is not a duration or an ISO 8601 time", value) + } + return now.Add(-time.Duration(n * float64(unit))), nil + } + d, err := time.ParseDuration(duration) + if err != nil || d < 0 { + return time.Time{}, fmt.Errorf("%q is not a duration or an ISO 8601 time", value) + } + + return now.Add(-d), nil +} + +// queryProblems asks the API why a query can match nothing, for example a value +// with "=" that needs quotes. It returns nil if the check fails. +func queryProblems(cmd *cobra.Command, client observability.Client, t target, query string) []string { + if strings.TrimSpace(query) == "" { + return nil + } + data, err := client.Query(cmd.Context(), observability.Request{ + AccessToken: t.accessToken, + Endpoint: t.endpoint, + ProjectID: t.projectID, + Query: validateSearchQueryQuery, + Variables: map[string]any{"project_id": t.projectID, "query": query}, + }) + if err != nil { + return nil + } + var result struct { + Problems []string `json:"validate_search_query"` + } + if json.Unmarshal(data, &result) != nil { + return nil + } + + return result.Problems +} + +// printEmpty explains an empty result and gives the next step. +func printEmpty(cmd *cobra.Command, what string, problems []string) { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "No %s matched.\n", what) + for _, p := range problems { + fmt.Fprintf(out, "Query problem: %s\n", p) + } + if len(problems) > 0 { + fmt.Fprintln(out, "Put quotes around values that contain '=', ':', spaces, or quotes.") + return + } + fmt.Fprintln(out, "Try a longer time range, or use 'ldcli observability keys' to find the keys and values that the data has.") +} + +// oneLine removes line breaks so that each record uses one line. +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func truncate(s string, n int) string { + r := []rune(s) + if n <= 0 || len(r) <= n { + return s + } + + return string(r[:n]) + "..." +} diff --git a/cmd/observability/observability_test.go b/cmd/observability/observability_test.go new file mode 100644 index 000000000..1df080804 --- /dev/null +++ b/cmd/observability/observability_test.go @@ -0,0 +1,388 @@ +package observability_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/observability" + "github.com/launchdarkly/ldcli/internal/resources" +) + +const projectID = "0123456789abcdef01234567" + +// graphQLRequest is one request that the fake observability API received. +type graphQLRequest struct { + Authorization string + ProjectHeader string + Query string + Variables map[string]any +} + +// fakeAPI is an observability API that returns the responses in order and records +// the requests. +type fakeAPI struct { + mu sync.Mutex + responses []string + requests []graphQLRequest +} + +func (f *fakeAPI) start(t *testing.T) string { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.Unmarshal(body, &payload)) + + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, graphQLRequest{ + Authorization: r.Header.Get("Gonfalon-Authorization"), + ProjectHeader: r.Header.Get("x-project-id"), + Query: payload.Query, + Variables: payload.Variables, + }) + response := `{"data":{}}` + if len(f.responses) > 0 { + response, f.responses = f.responses[0], f.responses[1:] + } + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(server.Close) + + return server.URL +} + +func run(t *testing.T, api *fakeAPI, args ...string) (string, error) { + t.Helper() + url := api.start(t) + resourcesClient := &resources.MockClient{Response: []byte(`{"_id":"` + projectID + `","key":"default"}`)} + base := []string{"--access-token", "api-test-token", "--observability-url", url, "--project", "default"} + out, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: resourcesClient, + ObservabilityClient: observability.NewClient("test"), + }, + analytics.NoopClientFn{}.Tracker(), + append(args, base...), + ) + + return string(out), err +} + +const logsResponse = `{"data":{"logs":{"edges":[ + {"node":{"timestamp":"2026-10-29T20:01:02.123Z","level":"error","message":"recovered panic\nin handler","serviceName":"production-gonfalon","traceID":"t1", + "logAttributes":{"error":{"message":"bad metricKey x/y"},"code.stacktrace":"goroutine 1 [running]: very long"}}}, + {"node":{"timestamp":"2026-10-29T20:00:00Z","level":"info","message":"ok","serviceName":"","logAttributes":{}}} +]}}}` + +func TestLogsSearch(t *testing.T) { + t.Run("prints one compact line for each log", func(t *testing.T) { + api := &fakeAPI{responses: []string{logsResponse}} + out, err := run(t, api, "observability", "logs", "search", "--query", "level=error", "--start", "15m", "--limit", "5") + + require.NoError(t, err) + assert.Equal(t, + "2026-10-29T20:01:02Z ERROR production-gonfalon recovered panic in handler\n"+ + "2026-10-29T20:00:00Z INFO - ok\n", + out) + require.Len(t, api.requests, 1) + req := api.requests[0] + assert.Equal(t, "api-test-token", req.Authorization) + assert.Equal(t, projectID, req.ProjectHeader) + assert.Contains(t, req.Query, "logs(") + assert.Equal(t, projectID, req.Variables["project_id"]) + assert.Equal(t, "DESC", req.Variables["direction"]) + assert.Equal(t, float64(5), req.Variables["limit"]) + params := req.Variables["params"].(map[string]any) + assert.Equal(t, "level=error", params["query"]) + dateRange := params["date_range"].(map[string]any) + assert.NotEmpty(t, dateRange["start_date"]) + assert.NotEmpty(t, dateRange["end_date"]) + }) + + t.Run("adds the attributes that --attributes names, nested or flat", func(t *testing.T) { + api := &fakeAPI{responses: []string{logsResponse}} + out, err := run(t, api, "o11y", "logs", "search", "--attributes", "error.message,code.stacktrace", "--truncate", "10") + + require.NoError(t, err) + assert.Contains(t, out, "recovered ... error.message=bad metric... code.stacktrace=goroutine ...\n") + }) + + t.Run("prints full records with --json", func(t *testing.T) { + api := &fakeAPI{responses: []string{logsResponse}} + out, err := run(t, api, "observability", "logs", "search", "--json") + + require.NoError(t, err) + var result struct { + Logs []map[string]any `json:"logs"` + } + require.NoError(t, json.Unmarshal([]byte(out), &result)) + require.Len(t, result.Logs, 2) + assert.Contains(t, result.Logs[0]["logAttributes"], "code.stacktrace") + }) + + t.Run("adds the --deployment-environment filter to the query", func(t *testing.T) { + api := &fakeAPI{responses: []string{logsResponse}} + _, err := run(t, api, "observability", "logs", "search", "--query", "level=error", "--deployment-environment", "production") + + require.NoError(t, err) + params := api.requests[0].Variables["params"].(map[string]any) + assert.Equal(t, `(level=error) AND environment="production"`, params["query"]) + }) + + t.Run("explains an empty result with the query problems", func(t *testing.T) { + api := &fakeAPI{responses: []string{ + `{"data":{"logs":{"edges":[]}}}`, + `{"data":{"validate_search_query":["unquoted '=' in value"]}}`, + }} + out, err := run(t, api, "observability", "logs", "search", "--query", "url=*a=b*") + + require.NoError(t, err) + assert.Contains(t, out, "No logs matched.\nQuery problem: unquoted '=' in value\n") + require.Len(t, api.requests, 2) + assert.Contains(t, api.requests[1].Query, "validate_search_query") + }) + + t.Run("says when more records can match", func(t *testing.T) { + api := &fakeAPI{responses: []string{logsResponse}} + out, err := run(t, api, "observability", "logs", "search", "--limit", "2") + + require.NoError(t, err) + assert.Contains(t, out, "Showed 2 logs, the --limit value. More records can match.\n") + }) + + t.Run("returns GraphQL errors", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"errors":[{"message":"401 - unauthorized"}],"data":null}`}} + _, err := run(t, api, "observability", "logs", "search") + + require.EqualError(t, err, "observability API error: 401 - unauthorized") + }) + + t.Run("rejects a limit out of range", func(t *testing.T) { + api := &fakeAPI{} + _, err := run(t, api, "observability", "logs", "search", "--limit", "500") + + require.EqualError(t, err, "--limit must be from 1 to 100") + assert.Empty(t, api.requests) + }) + + t.Run("rejects a start after the end", func(t *testing.T) { + api := &fakeAPI{} + _, err := run(t, api, "observability", "logs", "search", "--start", "1h", "--end", "2h") + + require.EqualError(t, err, "--start must be before --end") + }) +} + +func TestTracesSearch(t *testing.T) { + response := `{"data":{"traces":{"edges":[ + {"node":{"timestamp":"2026-10-29T20:01:02Z","duration":1500000000,"serviceName":"api","spanName":"GET /users","hasErrors":true,"traceID":"abc","statusMessage":"boom","traceAttributes":{"http.route":"/users"}}}, + {"node":{"timestamp":"2026-10-29T20:01:03Z","duration":2500000,"serviceName":"api","spanName":"db.query","hasErrors":false,"traceID":"abc","traceAttributes":{}}} + ]}}}` + + t.Run("prints one compact line for each span", func(t *testing.T) { + api := &fakeAPI{responses: []string{response}} + out, err := run(t, api, "observability", "traces", "search", "--attributes", "http.route") + + require.NoError(t, err) + assert.Equal(t, + "2026-10-29T20:01:02Z 1.50s api GET /users ERROR trace=abc boom http.route=/users\n"+ + "2026-10-29T20:01:03Z 2.5ms api db.query OK trace=abc\n", + out) + assert.Contains(t, api.requests[0].Query, "traces(") + }) + + t.Run("sends --sort-column as the sort", func(t *testing.T) { + api := &fakeAPI{responses: []string{response}} + _, err := run(t, api, "observability", "traces", "search", "--sort-column", "duration", "--direction", "asc") + + require.NoError(t, err) + params := api.requests[0].Variables["params"].(map[string]any) + assert.Equal(t, map[string]any{"column": "duration", "direction": "ASC"}, params["sort"]) + assert.Equal(t, "ASC", api.requests[0].Variables["direction"]) + }) +} + +func TestAggregate(t *testing.T) { + t.Run("prints the buckets and shows the sample factor", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"metrics":{"bucket_count":2,"sample_factor":93.58,"buckets":[ + {"bucket_id":0,"bucket_min":1761696000,"bucket_max":1761782400,"metric_value":94,"metric_type":"Count","column":"","group":["bad metricKey x/y"]}, + {"bucket_id":1,"bucket_min":1761782400,"bucket_max":1761868800,"metric_value":0,"metric_type":"Count","column":"","group":["bad metricKey x/y"]}, + {"bucket_id":1,"bucket_min":1761782400,"bucket_max":1761868800,"metric_value":12,"metric_type":"Count","column":"","group":["bad metricKey x/../y"]} + ]}}}`}} + out, err := run(t, api, + "observability", "aggregate", + "--product-type", "logs", + "--query", "request_panic=true", + "--group-by", "error.message", + "--bucket-count", "2", + "--start", "2025-10-29T00:00:00Z", + "--end", "2025-10-31T00:00:00Z", + ) + + require.NoError(t, err) + assert.Equal(t, `Logs Count, query "request_panic=true", grouped by error.message, 2025-10-29T00:00:00.000Z to 2025-10-31T00:00:00.000Z, 2 buckets +SAMPLED: sample factor 93.58. The API read a sample of the records, so the values are estimates. For exact counts, use a shorter time range. +BUCKET START GROUP VALUE +2025-10-29T00:00:00Z bad metricKey x/y 94 +2025-10-30T00:00:00Z bad metricKey x/../y 12 +Totals (estimates): + bad metricKey x/y: 94 + bad metricKey x/../y: 12 +`, out) + + vars := api.requests[0].Variables + assert.Equal(t, "Logs", vars["product_type"]) + assert.Equal(t, "Timestamp", vars["bucket_by"]) + assert.Equal(t, float64(2), vars["bucket_count"]) + assert.Equal(t, []any{"error.message"}, vars["group_by"]) + assert.Equal(t, []any{map[string]any{"aggregator": "Count", "column": ""}}, vars["expressions"]) + assert.Equal(t, "Count", vars["limit_aggregator"]) + assert.NotContains(t, vars, "limit_column") + }) + + t.Run("does not show a sample warning for exact results", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"metrics":{"bucket_count":1,"sample_factor":1,"buckets":[ + {"bucket_id":0,"bucket_min":1761696000,"bucket_max":1761782400,"metric_value":20,"group":[]} + ]}}}`}} + out, err := run(t, api, "observability", "aggregate", "--product-type", "logs", "--bucket-count", "1") + + require.NoError(t, err) + assert.NotContains(t, out, "SAMPLED") + assert.NotContains(t, out, "Totals") + assert.Contains(t, out, "20\n") + }) + + t.Run("ranks groups by the function and its column", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"metrics":{"buckets":[],"sample_factor":1}}}`}} + _, err := run(t, api, "observability", "aggregate", "--product-type", "traces", "--function", "p99", "--column", "duration") + + require.NoError(t, err) + vars := api.requests[0].Variables + assert.Equal(t, "Traces", vars["product_type"]) + assert.Equal(t, []any{map[string]any{"aggregator": "P99", "column": "duration"}}, vars["expressions"]) + assert.Equal(t, "P99", vars["limit_aggregator"]) + assert.Equal(t, "duration", vars["limit_column"]) + }) + + t.Run("adds sampled to the JSON output", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"metrics":{"buckets":[],"bucket_count":1,"sample_factor":10.4}}}`}} + out, err := run(t, api, "observability", "aggregate", "--product-type", "logs", "--json") + + require.NoError(t, err) + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, true, result["sampled"]) + assert.Equal(t, 10.4, result["sample_factor"]) + }) + + for name, tc := range map[string]struct { + args []string + err string + }{ + "unknown product type": {[]string{"--product-type", "spans"}, "--product-type must be one of: logs, traces, errors, sessions, metrics, paevents, events"}, + "function without column": {[]string{"--product-type", "traces", "--function", "Avg"}, "--function Avg needs --column. Only Count works without a column"}, + "column with Count": {[]string{"--product-type", "traces", "--column", "duration"}, "--column is not used by Count. Set --function, for example Avg or CountDistinct"}, + "too many buckets": {[]string{"--product-type", "logs", "--bucket-count", "301"}, "--bucket-count must be from 1 to 300"}, + } { + t.Run("rejects "+name, func(t *testing.T) { + api := &fakeAPI{} + _, err := run(t, api, append([]string{"observability", "aggregate"}, tc.args...)...) + + require.EqualError(t, err, tc.err) + assert.Empty(t, api.requests) + }) + } +} + +func TestKeys(t *testing.T) { + t.Run("lists keys", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"logs_keys":[{"name":"service_name","type":"String","count":1200},{"name":"serviceName","type":"String","count":3}]}}`}} + out, err := run(t, api, "observability", "keys", "--product-type", "logs", "--search", "service") + + require.NoError(t, err) + assert.Equal(t, "KEY TYPE COUNT\nservice_name String 1200\nserviceName String 3\n", out) + req := api.requests[0] + assert.Contains(t, req.Query, "logs_keys(") + assert.Equal(t, "service", req.Variables["query"]) + assert.Equal(t, float64(25), req.Variables["count"]) + assert.NotContains(t, req.Variables, "product_type") + }) + + t.Run("sends the product type for metrics keys", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"keys":[{"name":"http.server.duration","type":"Numeric","count":5}]}}`}} + _, err := run(t, api, "observability", "keys", "--product-type", "metrics") + + require.NoError(t, err) + assert.Contains(t, api.requests[0].Query, "keys(product_type: $product_type") + assert.Equal(t, "Metrics", api.requests[0].Variables["product_type"]) + }) + + t.Run("lists the values of one key", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"value_suggestions":[{"value":"production-gonfalon","count":900,"rank":1},{"value":"staging-gonfalon","count":40,"rank":2}]}}`}} + out, err := run(t, api, "observability", "keys", "--product-type", "logs", "--key", "service_name", "--search", "gonfalon") + + require.NoError(t, err) + assert.Equal(t, "VALUE COUNT\nproduction-gonfalon 900\nstaging-gonfalon 40\n", out) + req := api.requests[0] + assert.Contains(t, req.Query, "value_suggestions(") + assert.Equal(t, "service_name", req.Variables["key_name"]) + assert.Equal(t, "Logs", req.Variables["product_type"]) + }) + + t.Run("prints JSON with --json", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"logs_keys":[{"name":"level","type":"String","count":7}]}}`}} + out, err := run(t, api, "observability", "keys", "--product-type", "logs", "--json") + + require.NoError(t, err) + assert.JSONEq(t, `{"keys":[{"name":"level","type":"String","count":7}]}`, out) + }) +} + +func TestProjectResolution(t *testing.T) { + t.Run("returns an error if the project has no ID", func(t *testing.T) { + api := &fakeAPI{} + url := api.start(t) + _, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{Response: []byte(`{}`)}, + ObservabilityClient: observability.NewClient("test"), + }, + analytics.NoopClientFn{}.Tracker(), + []string{"observability", "logs", "search", "--access-token", "api-x", "--observability-url", url, "--project", "missing"}, + ) + + require.EqualError(t, err, `could not find the ID of project "missing"`) + assert.Empty(t, api.requests) + }) + + t.Run("uses a project ID as it is", func(t *testing.T) { + api := &fakeAPI{responses: []string{`{"data":{"logs":{"edges":[]}}}`}} + url := api.start(t) + _, err := cmd.CallCmd( + t, + cmd.APIClients{ + ResourcesClient: &resources.MockClient{Response: []byte(`{}`)}, + ObservabilityClient: observability.NewClient("test"), + }, + analytics.NoopClientFn{}.Tracker(), + []string{"observability", "logs", "search", "--access-token", "api-x", "--observability-url", url, "--project", projectID}, + ) + + require.NoError(t, err) + assert.Equal(t, projectID, api.requests[0].ProjectHeader) + }) +} diff --git a/cmd/observability/queries.go b/cmd/observability/queries.go new file mode 100644 index 000000000..24f2fa282 --- /dev/null +++ b/cmd/observability/queries.go @@ -0,0 +1,159 @@ +package observability + +// These GraphQL operations are the same ones that the hosted LaunchDarkly MCP server +// sends to the observability API. + +const logsQuery = ` +query GetProjectLogs($project_id: StringID!, $params: QueryInput!, $direction: SortDirection!, $limit: Int) { + logs(project_id: $project_id, params: $params, direction: $direction, limit: $limit) { + edges { + node { + timestamp + level + message + logAttributes + source + environment + traceID + spanID + secureSessionID + serviceName + serviceVersion + } + } + } +}` + +const tracesQuery = ` +query GetProjectTraces($project_id: StringID!, $params: QueryInput!, $direction: SortDirection!, $limit: Int) { + traces(project_id: $project_id, params: $params, direction: $direction, limit: $limit) { + edges { + node { + timestamp + traceID + spanID + parentSpanID + secureSessionID + spanName + spanKind + duration + serviceName + serviceVersion + environment + hasErrors + traceAttributes + statusCode + statusMessage + } + } + } +}` + +const aggregationsQuery = ` +query GetProjectMetricsBuckets( + $product_type: ProductType! + $project_id: StringID! + $params: QueryInput! + $bucket_by: String! + $bucket_count: Int + $group_by: [String!]! + $expressions: [MetricExpressionInput!]! + $limit: Int + $limit_aggregator: MetricAggregator + $limit_column: String +) { + metrics( + product_type: $product_type + project_id: $project_id + params: $params + bucket_by: $bucket_by + bucket_count: $bucket_count + group_by: $group_by + expressions: $expressions + limit: $limit + limit_aggregator: $limit_aggregator + limit_column: $limit_column + ) { + buckets { + bucket_id + bucket_min + bucket_max + metric_value + metric_type + column + group + } + bucket_count + sample_factor + } +}` + +// keysQueries are the key list operations for each product type. The data field of +// each response has the same name as the operation field. +var keysQueries = map[string]struct{ field, query string }{ + "Logs": {"logs_keys", keysOperation("GetLogKeys", "logs_keys", false)}, + "Traces": {"traces_keys", keysOperation("GetTracesKeys", "traces_keys", false)}, + "Sessions": {"sessions_keys", keysOperation("GetSessionsKeys", "sessions_keys", false)}, + "Errors": {"errors_keys", keysOperation("GetErrorGroupsKeys", "errors_keys", false)}, + "Metrics": {"keys", keysOperation("GetKeys", "keys", true)}, + "PAEvents": {"keys", keysOperation("GetKeys", "keys", true)}, + "Events": {"keys", keysOperation("GetKeys", "keys", true)}, +} + +func keysOperation(name, field string, withProductType bool) string { + productTypeVariable, productTypeArgument := "", "" + if withProductType { + productTypeVariable = "$product_type: ProductType, " + productTypeArgument = "product_type: $product_type, " + } + + return ` +query ` + name + `(` + productTypeVariable + `$project_id: StringID!, $date_range: DateRangeRequiredInput!, $query: String, $type: KeyType, $count: Int) { + ` + field + `(` + productTypeArgument + `project_id: $project_id, date_range: $date_range, query: $query, type: $type, count: $count) { + name + type + count + } +}` +} + +const valueSuggestionsQuery = ` +query GetValueSuggestions( + $product_type: ProductType + $project_id: StringID! + $key_name: String! + $date_range: DateRangeRequiredInput! + $query: String + $count: Int +) { + value_suggestions( + product_type: $product_type + project_id: $project_id + key_name: $key_name + date_range: $date_range + query: $query + count: $count + ) { + value + count + rank + } +}` + +const validateSearchQueryQuery = ` +query ValidateSearchQuery($project_id: StringID!, $query: String!) { + validate_search_query(project_id: $project_id, query: $query) +}` + +// productTypes maps the names that users type to the ProductType values of the API. +var productTypes = map[string]string{ + "errors": "Errors", "error": "Errors", + "traces": "Traces", "trace": "Traces", "requests": "Traces", + "logs": "Logs", "log": "Logs", + "sessions": "Sessions", "session": "Sessions", + "metrics": "Metrics", "metric": "Metrics", + "paevents": "PAEvents", "paevent": "PAEvents", "pa-events": "PAEvents", "pa_events": "PAEvents", + "events": "Events", "event": "Events", +} + +const productTypeNames = "logs, traces, errors, sessions, metrics, paevents, events" diff --git a/cmd/observability/search.go b/cmd/observability/search.go new file mode 100644 index 000000000..11eb3a98c --- /dev/null +++ b/cmd/observability/search.go @@ -0,0 +1,221 @@ +package observability + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/observability" +) + +const ( + attributesFlag = "attributes" + environmentFlag = "deployment-environment" + sortColumnFlag = "sort-column" + truncateFlag = "truncate" + + defaultLimit = 20 + maxLimit = 100 + defaultTruncate = 300 +) + +// searchSpec is what differs between "logs search" and "traces search". +type searchSpec struct { + what string + query string + field string + attributesField string + compact func(node map[string]any) []string +} + +func addSearchFlags(cmd *cobra.Command) { + addTimeRangeFlags(cmd) + cmd.Flags().String(queryFlag, "", "Search query, for example: level=error AND service_name=api. Empty matches all records") + cmd.Flags().Int(limitFlag, defaultLimit, fmt.Sprintf("Number of records to return, at most %d", maxLimit)) + cmd.Flags().String(directionFlag, "DESC", "Sort direction of the timestamp: DESC (newest first) or ASC") + cmd.Flags().String(environmentFlag, "", "Only return records with this environment attribute, for example production. This is the environment that the observability SDK reports, not a LaunchDarkly environment key") + cmd.Flags().StringSlice(attributesFlag, nil, "Comma-separated attribute keys to add to each line of the compact output, for example error.message,code.function") + cmd.Flags().Int(truncateFlag, defaultTruncate, "Maximum characters for each value in the compact output. 0 shows full values") +} + +// searchQuery adds the --environment filter to the --query value. +func searchQuery(cmd *cobra.Command) string { + query, _ := cmd.Flags().GetString(queryFlag) + env, _ := cmd.Flags().GetString(environmentFlag) + if env == "" { + return query + } + filter := fmt.Sprintf("environment=%q", env) + if strings.TrimSpace(query) == "" { + return filter + } + + return fmt.Sprintf("(%s) AND %s", query, filter) +} + +func newSearchCmd(clients Clients, use, short, long, example string, spec searchSpec, extraParams func(*cobra.Command, map[string]any)) *cobra.Command { + cmd := &cobra.Command{ + Use: use, + Short: short, + Long: long, + Example: example, + Args: validators.Validate(), + RunE: func(cmd *cobra.Command, args []string) error { + return runSearch(cmd, clients, spec, extraParams) + }, + } + addSearchFlags(cmd) + + return cmd +} + +func runSearch(cmd *cobra.Command, clients Clients, spec searchSpec, extraParams func(*cobra.Command, map[string]any)) error { + dr, err := dateRange(cmd, time.Now()) + if err != nil { + return err + } + limit, _ := cmd.Flags().GetInt(limitFlag) + if limit < 1 || limit > maxLimit { + return fmt.Errorf("--%s must be from 1 to %d", limitFlag, maxLimit) + } + direction, _ := cmd.Flags().GetString(directionFlag) + direction = strings.ToUpper(direction) + if direction != "ASC" && direction != "DESC" { + return fmt.Errorf("--%s must be ASC or DESC", directionFlag) + } + + t, err := resolveTarget(cmd, clients.Resources) + if err != nil { + return err + } + + query := searchQuery(cmd) + params := map[string]any{"query": query, "date_range": dr} + if extraParams != nil { + extraParams(cmd, params) + } + data, err := clients.Observability.Query(cmd.Context(), observability.Request{ + AccessToken: t.accessToken, + Endpoint: t.endpoint, + ProjectID: t.projectID, + Query: spec.query, + Variables: map[string]any{ + "project_id": t.projectID, + "params": params, + "direction": direction, + "limit": limit, + }, + }) + if err != nil { + return err + } + + var result map[string]struct { + Edges []struct { + Node map[string]any `json:"node"` + } `json:"edges"` + } + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("decoding %s: %w", spec.what, err) + } + nodes := make([]map[string]any, 0, len(result[spec.field].Edges)) + for _, e := range result[spec.field].Edges { + if e.Node != nil { + nodes = append(nodes, e.Node) + } + } + + if wantsJSON(cmd) { + return writeJSON(cmd, map[string]any{spec.field: nodes}) + } + if len(nodes) == 0 { + printEmpty(cmd, spec.what, queryProblems(cmd, clients.Observability, t, query)) + return nil + } + + attributeKeys, _ := cmd.Flags().GetStringSlice(attributesFlag) + maxLength, _ := cmd.Flags().GetInt(truncateFlag) + out := cmd.OutOrStdout() + for _, node := range nodes { + attributes, _ := node[spec.attributesField].(map[string]any) + line := spec.compact(node) + for i := range line { + line[i] = truncate(oneLine(line[i]), maxLength) + } + for _, key := range attributeKeys { + if value, ok := lookupAttribute(attributes, key); ok { + line = append(line, key+"="+truncate(oneLine(formatValue(value)), maxLength)) + } + } + fmt.Fprintln(out, strings.Join(line, " ")) + } + if len(nodes) == limit { + fmt.Fprintf(out, "Showed %d %s, the --%s value. More records can match.\n", len(nodes), spec.what, limitFlag) + } + + return nil +} + +// lookupAttribute finds a dotted key such as "code.function". It tries the full key +// first, then the nested path, because the API can return both forms. +func lookupAttribute(attributes map[string]any, key string) (any, bool) { + if attributes == nil { + return nil, false + } + if value, ok := attributes[key]; ok { + return value, true + } + parts := strings.SplitN(key, ".", 2) + if len(parts) < 2 { + return nil, false + } + child, ok := attributes[parts[0]].(map[string]any) + if !ok { + return nil, false + } + + return lookupAttribute(child, parts[1]) +} + +func formatValue(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case float64: + return fmt.Sprintf("%v", v) + default: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) + } +} + +func stringField(node map[string]any, key string) string { + return formatValue(node[key]) +} + +// shortTime prints an API timestamp in UTC with second precision. +func shortTime(value string) string { + t, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return value + } + + return t.UTC().Format("2006-01-02T15:04:05Z") +} + +func orDash(s string) string { + if s == "" { + return "-" + } + + return s +} diff --git a/cmd/observability/time_test.go b/cmd/observability/time_test.go new file mode 100644 index 000000000..7d701703e --- /dev/null +++ b/cmd/observability/time_test.go @@ -0,0 +1,37 @@ +package observability + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTime(t *testing.T) { + now := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC) + + for input, want := range map[string]time.Time{ + "": now, + "now": now, + "15m": now.Add(-15 * time.Minute), + "-15m": now.Add(-15 * time.Minute), + "2h30m": now.Add(-150 * time.Minute), + "7d": now.Add(-7 * 24 * time.Hour), + "1.5d": now.Add(-36 * time.Hour), + "2w": now.Add(-14 * 24 * time.Hour), + "2026-10-28T10:00:00Z": time.Date(2026, 10, 28, 10, 0, 0, 0, time.UTC), + "2026-10-28T10:00:00+02:00": time.Date(2026, 10, 28, 8, 0, 0, 0, time.UTC), + "2026-10-28T10:00:00": time.Date(2026, 10, 28, 10, 0, 0, 0, time.UTC), + "2026-10-28": time.Date(2026, 10, 28, 0, 0, 0, 0, time.UTC), + } { + got, err := parseTime(input, now) + require.NoError(t, err, input) + assert.True(t, want.Equal(got), "%q: want %s, got %s", input, want, got) + } + + for _, input := range []string{"-", "yesterday", "5x", "d", "-5-d"} { + _, err := parseTime(input, now) + assert.Error(t, err, input) + } +} diff --git a/cmd/observability/traces.go b/cmd/observability/traces.go new file mode 100644 index 000000000..cc80bd6ee --- /dev/null +++ b/cmd/observability/traces.go @@ -0,0 +1,78 @@ +package observability + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" +) + +func newTracesSearchCmd(clients Clients) *cobra.Command { + cmd := newSearchCmd( + clients, + "search", + "Search trace spans", + `Search trace spans in a time range, newest first. + +The compact output prints one line for each span: timestamp, duration, service, span name, status, and trace ID. To add attributes, use --attributes. To get the full spans with all attributes, use --json. + +To find the slowest spans, use --sort-column duration. Keep the time range at 24 hours or less. For longer time ranges, use 'ldcli observability aggregate'.`, + ` ldcli observability traces search --project default --query 'service_name=api' --start 1h + ldcli observability traces search --project default --query 'span_name="GET /users"' --sort-column duration + ldcli observability traces search --project default --query 'has_errors=true' --attributes http.route`, + searchSpec{ + what: "traces", + query: tracesQuery, + field: "traces", + attributesField: "traceAttributes", + compact: func(node map[string]any) []string { + status := "OK" + if hasErrors, _ := node["hasErrors"].(bool); hasErrors { + status = "ERROR" + } + if code := stringField(node, "statusCode"); strings.EqualFold(code, "error") { + status = "ERROR" + } + line := []string{ + shortTime(stringField(node, "timestamp")), + formatDuration(node["duration"]), + orDash(stringField(node, "serviceName")), + orDash(stringField(node, "spanName")), + status, + "trace=" + orDash(stringField(node, "traceID")), + } + if message := stringField(node, "statusMessage"); message != "" { + line = append(line, message) + } + return line + }, + }, + func(cmd *cobra.Command, params map[string]any) { + if column, _ := cmd.Flags().GetString(sortColumnFlag); column != "" { + direction, _ := cmd.Flags().GetString(directionFlag) + params["sort"] = map[string]any{"column": column, "direction": strings.ToUpper(direction)} + } + }, + ) + cmd.Flags().String(sortColumnFlag, "", "Column to sort by, for example duration. --direction sets the order. Defaults to timestamp") + + return cmd +} + +// formatDuration prints a span duration. The API gives it in nanoseconds. +func formatDuration(value any) string { + ns, ok := value.(float64) + if !ok { + return "-" + } + d := time.Duration(ns) + switch { + case d >= time.Second: + return fmt.Sprintf("%.2fs", d.Seconds()) + case d >= time.Millisecond: + return fmt.Sprintf("%.1fms", float64(d)/float64(time.Millisecond)) + default: + return fmt.Sprintf("%.0fus", float64(d)/float64(time.Microsecond)) + } +} diff --git a/cmd/root.go b/cmd/root.go index e86795e2e..7bc568393 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,7 @@ import ( flagscmd "github.com/launchdarkly/ldcli/cmd/flags" logincmd "github.com/launchdarkly/ldcli/cmd/login" memberscmd "github.com/launchdarkly/ldcli/cmd/members" + observabilitycmd "github.com/launchdarkly/ldcli/cmd/observability" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" sdkactivecmd "github.com/launchdarkly/ldcli/cmd/sdk_active" setupcmd "github.com/launchdarkly/ldcli/cmd/setup" @@ -39,6 +40,7 @@ import ( errs "github.com/launchdarkly/ldcli/internal/errors" "github.com/launchdarkly/ldcli/internal/flags" "github.com/launchdarkly/ldcli/internal/members" + "github.com/launchdarkly/ldcli/internal/observability" "github.com/launchdarkly/ldcli/internal/projects" "github.com/launchdarkly/ldcli/internal/resources" "github.com/launchdarkly/ldcli/internal/setup" @@ -50,10 +52,13 @@ type APIClients struct { EnvironmentsClient environments.Client FlagsClient flags.Client MembersClient members.Client - ProjectsClient projects.Client - ResourcesClient resources.Client - Detector setup.Detector - Installer setup.Installer + // ObservabilityClient is optional. If it is nil, the observability commands use + // an HTTP client. + ObservabilityClient observability.Client + ProjectsClient projects.Client + ResourcesClient resources.Client + Detector setup.Detector + Installer setup.Installer } type Command interface { @@ -305,6 +310,14 @@ func NewRootCommand( cmd.AddCommand(awsdevopsagentcmd.NewAWSDevOpsAgentCmd(analyticsTrackerFn)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) + observabilityClient := clients.ObservabilityClient + if observabilityClient == nil { + observabilityClient = observability.NewClient(version) + } + cmd.AddCommand(observabilitycmd.NewObservabilityCmd( + observabilitycmd.Clients{Resources: clients.ResourcesClient, Observability: observabilityClient}, + analyticsTrackerFn, + )) cmd.AddCommand(synccmd.NewSyncCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) diff --git a/cmd/templates.go b/cmd/templates.go index 4f9083f5d..f4ae6b3eb 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -30,6 +30,7 @@ Common resource commands: {{rpad "segments" 29}} List, create, modify, and delete segments {{rpad "sourcemaps" 29}} Manage sourcemaps for error monitoring {{rpad "symbols" 29}} Manage symbol files for error monitoring + {{rpad "observability" 29}} Query logs, traces, and aggregations from observability {{rpad "..." 29}} To see more resource commands, run 'ldcli resources' Flags: diff --git a/cmd/templates_test.go b/cmd/templates_test.go index 7bf16f9ac..8ad968518 100644 --- a/cmd/templates_test.go +++ b/cmd/templates_test.go @@ -27,6 +27,7 @@ func TestGetUsageTemplate_ListsTopLevelCommands(t *testing.T) { "segments", "sourcemaps", "symbols", + "observability", } { assert.Contains(t, template, `"`+name+`"`, "%s is missing from the root usage listing", name) } diff --git a/internal/observability/client.go b/internal/observability/client.go new file mode 100644 index 000000000..181797e28 --- /dev/null +++ b/internal/observability/client.go @@ -0,0 +1,161 @@ +// Package observability calls the LaunchDarkly observability GraphQL API, the API +// that the LaunchDarkly UI and the hosted LaunchDarkly MCP server use for logs, +// traces, aggregations, and keys. +package observability + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // DefaultEndpoint is the observability API of the LaunchDarkly production instance. + DefaultEndpoint = "https://pri.observability.app.launchdarkly.com" + + // endpointPrefix is the host prefix of the observability API on each LaunchDarkly + // instance. For example, app.launchdarkly.com has its API at + // pri.observability.app.launchdarkly.com. + endpointPrefix = "pri.observability." + + defaultTimeout = 60 * time.Second + maxErrorMessages = 10 + maxErrorLength = 512 +) + +// launchDarklyDomains are the domains of LaunchDarkly instances. The CLI derives the +// observability API host only from a base URI on one of these domains. +var launchDarklyDomains = []string{"launchdarkly.com", "catamorphic.com"} + +// Request is one GraphQL operation for one observability project. +type Request struct { + AccessToken string + Endpoint string + // ProjectID is the LaunchDarkly project _id. The observability API uses it as the + // project identifier. + ProjectID string + Query string + Variables map[string]any +} + +// Client runs observability GraphQL operations. +type Client interface { + Query(ctx context.Context, req Request) (json.RawMessage, error) +} + +// HTTPClient is the Client that sends requests over HTTP. +type HTTPClient struct { + cliVersion string + httpClient *http.Client +} + +var _ Client = HTTPClient{} + +func NewClient(cliVersion string) HTTPClient { + return HTTPClient{ + cliVersion: cliVersion, + httpClient: &http.Client{Timeout: defaultTimeout}, + } +} + +// Query sends the operation and returns the "data" object of the response. It +// returns an error if the response has GraphQL errors or a status that is not 2xx. +func (c HTTPClient) Query(ctx context.Context, req Request) (json.RawMessage, error) { + payload, err := json.Marshal(map[string]any{ + "query": req.Query, + "variables": req.Variables, + }) + if err != nil { + return nil, fmt.Errorf("encoding observability request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, req.Endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("building observability request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", fmt.Sprintf("launchdarkly-cli/v%s", c.cliVersion)) + httpReq.Header.Set("Gonfalon-Authorization", authorizationHeader(req.AccessToken)) + httpReq.Header.Set("x-project-id", req.ProjectID) + + res, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("requesting observability API: %w", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("reading observability response: %w", err) + } + + var decoded struct { + Data json.RawMessage `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + decodeErr := json.Unmarshal(body, &decoded) + + if len(decoded.Errors) > 0 { + messages := make([]string, 0, min(len(decoded.Errors), maxErrorMessages)) + for _, e := range decoded.Errors[:min(len(decoded.Errors), maxErrorMessages)] { + messages = append(messages, truncate(strings.TrimSpace(e.Message), maxErrorLength)) + } + return nil, fmt.Errorf("observability API error: %s", strings.Join(messages, "; ")) + } + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("observability API returned %d %s", res.StatusCode, http.StatusText(res.StatusCode)) + } + if decodeErr != nil { + return nil, fmt.Errorf("decoding observability response: %w", decodeErr) + } + if len(decoded.Data) == 0 || string(decoded.Data) == "null" { + return nil, fmt.Errorf("observability response has no data") + } + + return decoded.Data, nil +} + +// EndpointFor gives the observability API endpoint for a LaunchDarkly base URI. It +// returns an error if the base URI is not on a LaunchDarkly domain, because the CLI +// cannot know where a local or proxied stack serves its observability API. +func EndpointFor(baseURI string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(baseURI)) + if err != nil || parsed.Hostname() == "" { + return "", fmt.Errorf("cannot derive the observability API from base URI %q, set --observability-url", baseURI) + } + host := parsed.Hostname() + for _, domain := range launchDarklyDomains { + if host == domain || strings.HasSuffix(host, "."+domain) { + return "https://" + endpointPrefix + host, nil + } + } + + return "", fmt.Errorf("cannot derive the observability API from base URI %q, set --observability-url", baseURI) +} + +// authorizationHeader sends access tokens (api-...) as they are, and adds "Bearer" +// to other tokens, the same as the hosted LaunchDarkly MCP server. +func authorizationHeader(token string) string { + if strings.HasPrefix(token, "api-") || strings.HasPrefix(strings.ToLower(token), "bearer ") { + return token + } + + return "Bearer " + token +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + + return string(r[:n]) + "..." +} diff --git a/internal/observability/client_test.go b/internal/observability/client_test.go new file mode 100644 index 000000000..6483f290f --- /dev/null +++ b/internal/observability/client_test.go @@ -0,0 +1,88 @@ +package observability_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/observability" +) + +func TestQuery(t *testing.T) { + serve := func(t *testing.T, status int, body string) (string, *http.Header) { + var headers http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers = r.Header.Clone() + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + return server.URL, &headers + } + + t.Run("sends the token and project and returns the data", func(t *testing.T) { + url, headers := serve(t, http.StatusOK, `{"data":{"ok":true}}`) + data, err := observability.NewClient("1.0.0").Query(context.Background(), observability.Request{ + AccessToken: "api-123", Endpoint: url, ProjectID: "p1", Query: "query { ok }", + }) + + require.NoError(t, err) + assert.JSONEq(t, `{"ok":true}`, string(data)) + assert.Equal(t, "api-123", headers.Get("Gonfalon-Authorization")) + assert.Equal(t, "p1", headers.Get("x-project-id")) + assert.Empty(t, headers.Get("Authorization")) + assert.Equal(t, "launchdarkly-cli/v1.0.0", headers.Get("User-Agent")) + }) + + t.Run("adds Bearer to a token that is not an access token", func(t *testing.T) { + url, headers := serve(t, http.StatusOK, `{"data":{}}`) + _, err := observability.NewClient("1").Query(context.Background(), observability.Request{AccessToken: "oauth-token", Endpoint: url}) + + require.NoError(t, err) + assert.Equal(t, "Bearer oauth-token", headers.Get("Gonfalon-Authorization")) + }) + + t.Run("returns GraphQL errors, also with a status that is not 2xx", func(t *testing.T) { + url, _ := serve(t, http.StatusUnprocessableEntity, `{"errors":[{"message":"a"},{"message":"b"}]}`) + _, err := observability.NewClient("1").Query(context.Background(), observability.Request{Endpoint: url}) + + require.EqualError(t, err, "observability API error: a; b") + }) + + t.Run("returns the status for a response that is not JSON", func(t *testing.T) { + url, _ := serve(t, http.StatusBadGateway, `bad gateway`) + _, err := observability.NewClient("1").Query(context.Background(), observability.Request{Endpoint: url}) + + require.EqualError(t, err, "observability API returned 502 Bad Gateway") + }) + + t.Run("returns an error for a response with no data", func(t *testing.T) { + url, _ := serve(t, http.StatusOK, `{"data":null}`) + _, err := observability.NewClient("1").Query(context.Background(), observability.Request{Endpoint: url}) + + require.EqualError(t, err, "observability response has no data") + }) +} + +func TestEndpointFor(t *testing.T) { + for baseURI, want := range map[string]string{ + "https://app.launchdarkly.com": "https://pri.observability.app.launchdarkly.com", + "https://app.launchdarkly.com/": "https://pri.observability.app.launchdarkly.com", + "https://ld-stg.launchdarkly.com": "https://pri.observability.ld-stg.launchdarkly.com", + "https://app.ld.catamorphic.com": "https://pri.observability.app.ld.catamorphic.com", + "https://app.eu.launchdarkly.com": "https://pri.observability.app.eu.launchdarkly.com", + } { + got, err := observability.EndpointFor(baseURI) + require.NoError(t, err, baseURI) + assert.Equal(t, want, got, baseURI) + } + + for _, baseURI := range []string{"http://localhost:8080", "https://launchdarkly.com.example.com", "not a url"} { + _, err := observability.EndpointFor(baseURI) + assert.Error(t, err, baseURI) + } +} From 6123c6791f550e50c4277fd173f7acb2863981bd Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Fri, 30 Oct 2026 00:05:42 +0000 Subject: [PATCH 2/2] fix(observability): keep zero aggregate values and trim --attributes keys Co-Authored-By: Claude Opus 5.5 Generated-by: termag --- cmd/observability/aggregate.go | 11 +++++++++-- cmd/observability/search.go | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/observability/aggregate.go b/cmd/observability/aggregate.go index 12cd14196..e76714a19 100644 --- a/cmd/observability/aggregate.go +++ b/cmd/observability/aggregate.go @@ -195,9 +195,16 @@ func runAggregate(cmd *cobra.Command, clients Clients) error { fmt.Fprintf(out, "SAMPLED: sample factor %.2f. The API read a sample of the records, so the values are estimates. For exact counts, use a shorter time range.\n", metrics.SampleFactor) } + // Count and Sum buckets with no records come back as 0, so 0 means "no data" there. + // For other functions (Min, Avg, percentiles, ...) 0 is a real value; only a + // missing value means the bucket had no records. + countLike := function == "Count" || function == "Sum" || strings.HasPrefix(function, "CountDistinct") + hasValue := func(v *float64) bool { + return v != nil && (*v != 0 || !countLike) + } empty := true for _, b := range metrics.Buckets { - if b.MetricValue != nil && *b.MetricValue != 0 { + if hasValue(b.MetricValue) { empty = false } } @@ -215,7 +222,7 @@ func runAggregate(cmd *cobra.Command, clients Clients) error { totals := map[string]float64{} var groupOrder []string for _, b := range metrics.Buckets { - if b.MetricValue == nil || *b.MetricValue == 0 { + if !hasValue(b.MetricValue) { continue } row := bucketTime(b.BucketMin) + "\t" diff --git a/cmd/observability/search.go b/cmd/observability/search.go index 11eb3a98c..e34edc838 100644 --- a/cmd/observability/search.go +++ b/cmd/observability/search.go @@ -147,6 +147,10 @@ func runSearch(cmd *cobra.Command, clients Clients, spec searchSpec, extraParams line[i] = truncate(oneLine(line[i]), maxLength) } for _, key := range attributeKeys { + key = strings.TrimSpace(key) + if key == "" { + continue + } if value, ok := lookupAttribute(attributes, key); ok { line = append(line, key+"="+truncate(oneLine(formatValue(value)), maxLength)) }