diff --git a/README.md b/README.md index 621da7a1..f542db98 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 00000000..e76714a1 --- /dev/null +++ b/cmd/observability/aggregate.go @@ -0,0 +1,271 @@ +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) + } + + // 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 hasValue(b.MetricValue) { + 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 !hasValue(b.MetricValue) { + 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 00000000..8d98fe5e --- /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 00000000..2036fe81 --- /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 00000000..6eaed163 --- /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 00000000..1df08080 --- /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 00000000..24f2fa28 --- /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 00000000..e34edc83 --- /dev/null +++ b/cmd/observability/search.go @@ -0,0 +1,225 @@ +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 { + key = strings.TrimSpace(key) + if key == "" { + continue + } + 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 00000000..7d701703 --- /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 00000000..cc80bd6e --- /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 e86795e2..7bc56839 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 4f9083f5..f4ae6b3e 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 7bf16f9a..8ad96851 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 00000000..181797e2 --- /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 00000000..6483f290 --- /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) + } +}