From d74e2516d08103873c6f6d3f62ac82533fed8757 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Wed, 4 Mar 2026 17:58:46 +0100 Subject: [PATCH] feat: add deep links in table output for `logs query`, `spans query` and `traces get` --- .chloggen/deep-links-query.yaml | 27 +++++++ internal/asset/deeplink.go | 108 +++++++++++++++++++++++++ internal/asset/deeplink_test.go | 139 ++++++++++++++++++++++++++++++++ internal/logging/query.go | 16 +++- internal/tracing/spans_query.go | 16 +++- internal/tracing/traces_get.go | 15 +++- 6 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 .chloggen/deep-links-query.yaml diff --git a/.chloggen/deep-links-query.yaml b/.chloggen/deep-links-query.yaml new file mode 100644 index 0000000..e86e319 --- /dev/null +++ b/.chloggen/deep-links-query.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern (e.g. dashboards, config, apply) +component: logs, spans, traces + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add explorer deep link URL as the first line of output for logs query, spans query, and traces get + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [71] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: | + The URL is printed after the table output and links to the corresponding Dash0 explorer view. + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with "chore" or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Default: '[user]' +change_logs: [] diff --git a/internal/asset/deeplink.go b/internal/asset/deeplink.go index 81e68bc..059153b 100644 --- a/internal/asset/deeplink.go +++ b/internal/asset/deeplink.go @@ -1,9 +1,12 @@ package asset import ( + "encoding/json" "fmt" "net/url" "strings" + + dash0api "github.com/dash0hq/dash0-api-client-go" ) // Deeplink path patterns per asset type. @@ -120,6 +123,111 @@ func domainSuffix(hostname string) string { return strings.Join(parts[len(parts)-2:], ".") } +// DeeplinkFilter represents a single filter criterion for explorer deep links. +type DeeplinkFilter struct { + Key string `json:"key"` + Operator string `json:"operator"` + Value string `json:"value,omitempty"` +} + +// FiltersToDeeplinkFilters converts parsed API filter criteria to deep link +// filter objects suitable for URL serialization. +func FiltersToDeeplinkFilters(filters *dash0api.FilterCriteria) []DeeplinkFilter { + if filters == nil { + return nil + } + + result := make([]DeeplinkFilter, 0, len(*filters)) + for _, f := range *filters { + df := DeeplinkFilter{ + Key: string(f.Key), + Operator: string(f.Operator), + } + + switch { + case f.Values != nil: + // Multi-value operators (is_one_of, is_not_one_of): join values with space. + var parts []string + for _, item := range *f.Values { + if sv, err := item.AsAttributeFilterStringValue(); err == nil { + parts = append(parts, sv) + } + } + df.Value = strings.Join(parts, " ") + case f.Value != nil: + // Single-value operators. + if sv, err := f.Value.AsAttributeFilterStringValue(); err == nil { + df.Value = sv + } + } + // No-value operators (is_set, is_not_set): Value stays empty. + + result = append(result, df) + } + return result +} + +// LogsExplorerURL builds a deep link URL to the Dash0 logs explorer. +// The URL includes filter criteria, time range, and optional dataset as query parameters. +// Returns an empty string if the API URL is empty or cannot be parsed. +func LogsExplorerURL(apiUrl string, filters []DeeplinkFilter, from, to string, dataset *string) string { + return explorerURL(apiUrl, deeplinkPathViewLogs, filters, from, to, dataset) +} + +// SpansExplorerURL builds a deep link URL to the Dash0 traces explorer. +// The URL includes optional filter criteria, time range, and optional dataset as query parameters. +// Returns an empty string if the API URL is empty or cannot be parsed. +func SpansExplorerURL(apiUrl string, filters []DeeplinkFilter, from, to string, dataset *string) string { + return explorerURL(apiUrl, deeplinkPathViewTracing, filters, from, to, dataset) +} + +// TracesExplorerURL builds a deep link URL to the Dash0 traces explorer for a +// specific trace. The URL includes the trace ID and optional dataset as query +// parameters. +// Returns an empty string if the API URL is empty or cannot be parsed. +func TracesExplorerURL(apiUrl, traceID string, dataset *string) string { + baseURL := deeplinkBaseURL(apiUrl) + if baseURL == "" { + return "" + } + + params := url.Values{} + if dataset != nil && *dataset != "" { + params.Set("dataset", *dataset) + } + params.Set("trace_id", traceID) + + return fmt.Sprintf("%s%s?%s", baseURL, deeplinkPathViewTracing, params.Encode()) +} + +// explorerURL builds a deep link URL to a Dash0 explorer page. +func explorerURL(apiUrl, path string, filters []DeeplinkFilter, from, to string, dataset *string) string { + baseURL := deeplinkBaseURL(apiUrl) + if baseURL == "" { + return "" + } + + params := explorerParams(filters, from, to, dataset) + return fmt.Sprintf("%s%s?%s", baseURL, path, params.Encode()) +} + +// explorerParams builds the common query parameters for explorer deep links. +func explorerParams(filters []DeeplinkFilter, from, to string, dataset *string) url.Values { + params := url.Values{} + if dataset != nil && *dataset != "" { + params.Set("dataset", *dataset) + } + if len(filters) > 0 { + filterJSON, err := json.Marshal(filters) + if err == nil { + params.Set("filter", string(filterJSON)) + } + } + params.Set("from", from) + params.Set("to", to) + return params +} + // deeplinkPathAndQuery returns the URL path and query parameter name for a // given asset type. func deeplinkPathAndQuery(assetType string) (string, string) { diff --git a/internal/asset/deeplink_test.go b/internal/asset/deeplink_test.go index 0501f0f..d12c556 100644 --- a/internal/asset/deeplink_test.go +++ b/internal/asset/deeplink_test.go @@ -3,7 +3,9 @@ package asset import ( "testing" + dash0api "github.com/dash0hq/dash0-api-client-go" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDeeplinkURL(t *testing.T) { @@ -211,3 +213,140 @@ func TestDeeplinkBaseURL(t *testing.T) { }) } } + +func TestLogsExplorerURL(t *testing.T) { + apiUrl := "https://api.us-west-2.aws.dash0.com" + + t.Run("with filters and time range", func(t *testing.T) { + filters := []DeeplinkFilter{ + {Key: "service.name", Operator: "is", Value: "my-service"}, + } + result := LogsExplorerURL(apiUrl, filters, "now-1h", "now", nil) + assert.Contains(t, result, "https://app.dash0.com/goto/logs?") + assert.Contains(t, result, "from=now-1h") + assert.Contains(t, result, "to=now") + assert.Contains(t, result, "filter=") + }) + + t.Run("without filters", func(t *testing.T) { + result := LogsExplorerURL(apiUrl, nil, "now-15m", "now", nil) + assert.Contains(t, result, "https://app.dash0.com/goto/logs?") + assert.Contains(t, result, "from=now-15m") + assert.Contains(t, result, "to=now") + assert.NotContains(t, result, "filter=") + }) + + t.Run("empty API URL", func(t *testing.T) { + result := LogsExplorerURL("", nil, "now-15m", "now", nil) + assert.Equal(t, "", result) + }) + + t.Run("with dataset", func(t *testing.T) { + ds := "my-dataset" + result := LogsExplorerURL(apiUrl, nil, "now-15m", "now", &ds) + assert.Contains(t, result, "dataset=my-dataset") + }) + + t.Run("nil dataset", func(t *testing.T) { + result := LogsExplorerURL(apiUrl, nil, "now-15m", "now", nil) + assert.NotContains(t, result, "dataset=") + }) +} + +func TestSpansExplorerURL(t *testing.T) { + apiUrl := "https://api.us-west-2.aws.dash0.com" + + t.Run("with filters and time range", func(t *testing.T) { + filters := []DeeplinkFilter{ + {Key: "service.name", Operator: "is", Value: "my-service"}, + } + result := SpansExplorerURL(apiUrl, filters, "now-1h", "now", nil) + assert.Contains(t, result, "https://app.dash0.com/goto/traces/explorer?") + assert.Contains(t, result, "filter=") + assert.Contains(t, result, "from=now-1h") + assert.Contains(t, result, "to=now") + assert.NotContains(t, result, "trace_id=") + }) + + t.Run("without filters", func(t *testing.T) { + result := SpansExplorerURL(apiUrl, nil, "now-15m", "now", nil) + assert.Contains(t, result, "https://app.dash0.com/goto/traces/explorer?") + assert.NotContains(t, result, "filter=") + }) + + t.Run("empty API URL", func(t *testing.T) { + result := SpansExplorerURL("", nil, "now-1h", "now", nil) + assert.Equal(t, "", result) + }) +} + +func TestTracesExplorerURL(t *testing.T) { + apiUrl := "https://api.us-west-2.aws.dash0.com" + + t.Run("with trace ID", func(t *testing.T) { + result := TracesExplorerURL(apiUrl, "0af7651916cd43dd8448eb211c80319c", nil) + assert.Contains(t, result, "https://app.dash0.com/goto/traces/explorer?") + assert.Contains(t, result, "trace_id=0af7651916cd43dd8448eb211c80319c") + assert.NotContains(t, result, "from=") + assert.NotContains(t, result, "to=") + assert.NotContains(t, result, "filter=") + }) + + t.Run("with dataset", func(t *testing.T) { + ds := "my-dataset" + result := TracesExplorerURL(apiUrl, "abc123", &ds) + assert.Contains(t, result, "trace_id=abc123") + assert.Contains(t, result, "dataset=my-dataset") + }) + + t.Run("empty API URL", func(t *testing.T) { + result := TracesExplorerURL("", "abc123", nil) + assert.Equal(t, "", result) + }) +} + +func TestFiltersToDeeplinkFilters(t *testing.T) { + t.Run("nil input", func(t *testing.T) { + result := FiltersToDeeplinkFilters(nil) + assert.Nil(t, result) + }) + + t.Run("single-value filter", func(t *testing.T) { + var val dash0api.AttributeFilter_Value + require.NoError(t, val.FromAttributeFilterStringValue("my-service")) + filters := dash0api.FilterCriteria{ + {Key: "service.name", Operator: dash0api.AttributeFilterOperatorIs, Value: &val}, + } + result := FiltersToDeeplinkFilters(&filters) + require.Len(t, result, 1) + assert.Equal(t, "service.name", result[0].Key) + assert.Equal(t, "is", result[0].Operator) + assert.Equal(t, "my-service", result[0].Value) + }) + + t.Run("multi-value filter", func(t *testing.T) { + var item1, item2 dash0api.AttributeFilter_Values_Item + require.NoError(t, item1.FromAttributeFilterStringValue("ERROR")) + require.NoError(t, item2.FromAttributeFilterStringValue("WARN")) + items := []dash0api.AttributeFilter_Values_Item{item1, item2} + filters := dash0api.FilterCriteria{ + {Key: "otel.log.severity.range", Operator: dash0api.AttributeFilterOperatorIsOneOf, Values: &items}, + } + result := FiltersToDeeplinkFilters(&filters) + require.Len(t, result, 1) + assert.Equal(t, "otel.log.severity.range", result[0].Key) + assert.Equal(t, "is_one_of", result[0].Operator) + assert.Equal(t, "ERROR WARN", result[0].Value) + }) + + t.Run("no-value filter", func(t *testing.T) { + filters := dash0api.FilterCriteria{ + {Key: "error.message", Operator: dash0api.AttributeFilterOperatorIsSet}, + } + result := FiltersToDeeplinkFilters(&filters) + require.Len(t, result, 1) + assert.Equal(t, "error.message", result[0].Key) + assert.Equal(t, "is_set", result[0].Operator) + assert.Equal(t, "", result[0].Value) + }) +} diff --git a/internal/logging/query.go b/internal/logging/query.go index 6c8f25b..865e42b 100644 --- a/internal/logging/query.go +++ b/internal/logging/query.go @@ -10,6 +10,7 @@ import ( dash0api "github.com/dash0hq/dash0-api-client-go" "github.com/dash0hq/dash0-cli/internal" + "github.com/dash0hq/dash0-cli/internal/asset" "github.com/dash0hq/dash0-cli/internal/client" colorpkg "github.com/dash0hq/dash0-cli/internal/color" "github.com/dash0hq/dash0-cli/internal/experimental" @@ -217,11 +218,15 @@ func runQuery(cmd *cobra.Command, flags *queryFlags) error { }, } + apiUrl := client.ResolveApiUrl(ctx, flags.ApiUrl) + deeplinkFilters := asset.FiltersToDeeplinkFilters(filters) + explorerURL := asset.LogsExplorerURL(apiUrl, deeplinkFilters, flags.From, flags.To, dataset) + iter := apiClient.GetLogRecordsIter(ctx, &request) switch format { case queryFormatTable: - return streamTable(iter, totalLimit, flags.SkipHeader, cols) + return streamTable(iter, totalLimit, flags.SkipHeader, cols, explorerURL) case queryFormatCSV: return streamCSV(iter, totalLimit, flags.SkipHeader, cols) case queryFormatJSON: @@ -327,7 +332,7 @@ func countRecords(resourceLogs []dash0api.ResourceLogs) int64 { return count } -func streamTable(iter *dash0api.Iter[dash0api.ResourceLogs], totalLimit int64, skipHeader bool, cols []query.ColumnDef) error { +func streamTable(iter *dash0api.Iter[dash0api.ResourceLogs], totalLimit int64, skipHeader bool, cols []query.ColumnDef, explorerURL string) error { var rows []map[string]string total, err := iterateRecords(iter, totalLimit, func(r flatRecord) { @@ -339,9 +344,12 @@ func streamTable(iter *dash0api.Iter[dash0api.ResourceLogs], totalLimit int64, s } if total == 0 { fmt.Println("No log records found.") - return nil + } else { + query.RenderTable(os.Stdout, cols, rows, skipHeader) + } + if explorerURL != "" { + fmt.Printf("\nOpen this query in Dash0:\n %s\n", explorerURL) } - query.RenderTable(os.Stdout, cols, rows, skipHeader) return nil } diff --git a/internal/tracing/spans_query.go b/internal/tracing/spans_query.go index 48a6d88..af7e3a5 100644 --- a/internal/tracing/spans_query.go +++ b/internal/tracing/spans_query.go @@ -10,6 +10,7 @@ import ( dash0api "github.com/dash0hq/dash0-api-client-go" "github.com/dash0hq/dash0-cli/internal" + "github.com/dash0hq/dash0-cli/internal/asset" "github.com/dash0hq/dash0-cli/internal/client" colorpkg "github.com/dash0hq/dash0-cli/internal/color" "github.com/dash0hq/dash0-cli/internal/experimental" @@ -228,11 +229,15 @@ func runQuery(cmd *cobra.Command, flags *queryFlags) error { }, } + apiUrl := client.ResolveApiUrl(ctx, flags.ApiUrl) + deeplinkFilters := asset.FiltersToDeeplinkFilters(filters) + explorerURL := asset.SpansExplorerURL(apiUrl, deeplinkFilters, flags.From, flags.To, dataset) + iter := apiClient.GetSpansIter(ctx, &request) switch format { case queryFormatTable: - return streamTable(iter, totalLimit, flags.SkipHeader, cols) + return streamTable(iter, totalLimit, flags.SkipHeader, cols, explorerURL) case queryFormatCSV: return streamCSV(iter, totalLimit, flags.SkipHeader, cols) case queryFormatJSON: @@ -350,7 +355,7 @@ func countSpans(resourceSpans []dash0api.ResourceSpans) int64 { return count } -func streamTable(iter *dash0api.Iter[dash0api.ResourceSpans], totalLimit int64, skipHeader bool, cols []query.ColumnDef) error { +func streamTable(iter *dash0api.Iter[dash0api.ResourceSpans], totalLimit int64, skipHeader bool, cols []query.ColumnDef, explorerURL string) error { var rows []map[string]string total, err := iterateSpans(iter, totalLimit, func(r flatSpanRecord) { @@ -362,9 +367,12 @@ func streamTable(iter *dash0api.Iter[dash0api.ResourceSpans], totalLimit int64, } if total == 0 { fmt.Println("No spans found.") - return nil + } else { + query.RenderTable(os.Stdout, cols, rows, skipHeader) + } + if explorerURL != "" { + fmt.Printf("\nOpen this query in Dash0:\n %s\n", explorerURL) } - query.RenderTable(os.Stdout, cols, rows, skipHeader) return nil } diff --git a/internal/tracing/traces_get.go b/internal/tracing/traces_get.go index e158d9f..acd8fa7 100644 --- a/internal/tracing/traces_get.go +++ b/internal/tracing/traces_get.go @@ -11,6 +11,7 @@ import ( dash0api "github.com/dash0hq/dash0-api-client-go" "github.com/dash0hq/dash0-cli/internal" + "github.com/dash0hq/dash0-cli/internal/asset" "github.com/dash0hq/dash0-cli/internal/client" colorpkg "github.com/dash0hq/dash0-cli/internal/color" "github.com/dash0hq/dash0-cli/internal/experimental" @@ -240,9 +241,12 @@ func runGet(cmd *cobra.Command, traceID string, flags *getFlags) error { } } + apiUrl := client.ResolveApiUrl(ctx, flags.ApiUrl) + explorerURL := asset.TracesExplorerURL(apiUrl, traceID, dataset) + switch format { case getFormatTable: - return renderTable(results, flags.SkipHeader, cols) + return renderTable(results, flags.SkipHeader, cols, explorerURL) case getFormatCSV: return renderCSV(results, flags.SkipHeader, cols) case getFormatJSON: @@ -427,7 +431,7 @@ func buildTree(spans []flatTraceSpan) []flatTraceSpan { return result } -func renderTable(results []traceGroup, skipHeader bool, cols []query.ColumnDef) error { +func renderTable(results []traceGroup, skipHeader bool, cols []query.ColumnDef, explorerURL string) error { var rows []map[string]string for _, tr := range results { @@ -440,9 +444,12 @@ func renderTable(results []traceGroup, skipHeader bool, cols []query.ColumnDef) } if len(rows) == 0 { fmt.Println("No spans found for this trace.") - return nil + } else { + query.RenderTable(os.Stdout, cols, rows, skipHeader) + } + if explorerURL != "" { + fmt.Printf("\nOpen this trace in Dash0:\n %s\n", explorerURL) } - query.RenderTable(os.Stdout, cols, rows, skipHeader) return nil }