Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .chloggen/deep-links-query.yaml
Original file line number Diff line number Diff line change
@@ -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: []
108 changes: 108 additions & 0 deletions internal/asset/deeplink.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
139 changes: 139 additions & 0 deletions internal/asset/deeplink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
})
}
16 changes: 12 additions & 4 deletions internal/logging/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}

Expand Down
Loading
Loading