Jaeger: Run dependency graph queries through backend behind the feature toggle (#103768)

* Start with dependency graph

* Update and add tests

* Fix lint and ordering in test
This commit is contained in:
Ivana Huckova 2025-04-14 10:54:40 +02:00 committed by GitHub
parent 7aa028122b
commit 242ac6bfa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 880 additions and 1 deletions

View File

@ -26,6 +26,20 @@ type ServicesResponse struct {
Total int `json:"total"`
}
type DependenciesResponse struct {
Data []ServiceDependency `json:"data"`
Errors []struct {
Code int `json:"code"`
Msg string `json:"msg"`
} `json:"errors"`
}
type ServiceDependency struct {
Parent string `json:"parent"`
Child string `json:"child"`
CallCount int `json:"callCount"`
}
func New(url string, hc *http.Client, logger log.Logger, traceIdTimeEnabled bool) (JaegerClient, error) {
client := JaegerClient{
logger: logger,
@ -158,3 +172,59 @@ func (j *JaegerClient) Trace(ctx context.Context, traceID string, start, end int
trace = response.Data[0]
return trace, err
}
func (j *JaegerClient) Dependencies(ctx context.Context, start, end int64) (DependenciesResponse, error) {
logger := j.logger.FromContext(ctx)
var dependencies DependenciesResponse
u, err := url.JoinPath(j.url, "/api/dependencies")
if err != nil {
return dependencies, backend.DownstreamError(fmt.Errorf("failed to join url: %w", err))
}
// Add time parameters
parsedURL, err := url.Parse(u)
if err != nil {
return dependencies, backend.DownstreamError(fmt.Errorf("failed to parse url: %w", err))
}
query := parsedURL.Query()
if end > 0 {
query.Set("endTs", fmt.Sprintf("%d", end))
}
if start > 0 {
lookback := end - start
query.Set("lookback", fmt.Sprintf("%d", lookback))
}
parsedURL.RawQuery = query.Encode()
u = parsedURL.String()
res, err := j.httpClient.Get(u)
if err != nil {
if backend.IsDownstreamHTTPError(err) {
return dependencies, backend.DownstreamError(err)
}
return dependencies, err
}
defer func() {
if err = res.Body.Close(); err != nil {
logger.Error("Failed to close response body", "error", err)
}
}()
if res != nil && res.StatusCode/100 != 2 {
err := backend.DownstreamError(fmt.Errorf("request failed: %s", res.Status))
if backend.ErrorSourceFromHTTPStatus(res.StatusCode) == backend.ErrorSourceDownstream {
return dependencies, backend.DownstreamError(err)
}
return dependencies, err
}
if err := json.NewDecoder(res.Body).Decode(&dependencies); err != nil {
return dependencies, err
}
return dependencies, nil
}

View File

@ -276,3 +276,112 @@ func TestJaegerClient_Trace(t *testing.T) {
})
}
}
func TestJaegerClient_Dependencies(t *testing.T) {
tests := []struct {
name string
start int64
end int64
mockResponse string
mockStatusCode int
mockStatus string
expectedURL string
expectError bool
expectedError error
}{
{
name: "Successful response with time range",
start: 1000,
end: 2000,
mockResponse: `{"data":[{"parent":"serviceA","child":"serviceB","callCount":1}]}`,
mockStatusCode: http.StatusOK,
mockStatus: "OK",
expectedURL: "/api/dependencies?endTs=2000&lookback=1000",
expectError: false,
expectedError: nil,
},
{
name: "Successful response without time range",
start: 0,
end: 0,
mockResponse: `{"data":[{"parent":"serviceA","child":"serviceB","callCount":1}]}`,
mockStatusCode: http.StatusOK,
mockStatus: "OK",
expectedURL: "/api/dependencies",
expectError: false,
expectedError: nil,
},
{
name: "Non-200 response",
start: 1000,
end: 2000,
mockResponse: "",
mockStatusCode: http.StatusInternalServerError,
mockStatus: "Internal Server Error",
expectedURL: "/api/dependencies?endTs=2000&lookback=1000",
expectError: true,
expectedError: backend.PluginError(errors.New("Internal Server Error")),
},
{
name: "Invalid JSON response",
start: 1000,
end: 2000,
mockResponse: `{invalid json`,
mockStatusCode: http.StatusOK,
mockStatus: "OK",
expectedURL: "/api/dependencies?endTs=2000&lookback=1000",
expectError: true,
expectedError: &json.SyntaxError{},
},
{
name: "Empty dependencies response and no errors",
start: 1000,
end: 2000,
mockResponse: `{"data":[]}`,
mockStatusCode: http.StatusOK,
mockStatus: "OK",
expectedURL: "/api/dependencies?endTs=2000&lookback=1000",
expectError: false,
expectedError: nil,
},
{
name: "Response with errors",
start: 1000,
end: 2000,
mockResponse: `{"data":[],"errors":[{"code":500,"msg":"Internal error"}]}`,
mockStatusCode: http.StatusOK,
mockStatus: "OK",
expectedURL: "/api/dependencies?endTs=2000&lookback=1000",
expectError: false,
expectedError: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var actualURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
actualURL = r.URL.String()
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
client, err := New(server.URL, server.Client(), log.NewNullLogger(), false)
assert.NoError(t, err)
dependencies, err := client.Dependencies(context.Background(), tt.start, tt.end)
if tt.expectError {
assert.Error(t, err)
if tt.expectedError != nil {
assert.IsType(t, tt.expectedError, err)
}
} else {
assert.NoError(t, err)
assert.NotNil(t, dependencies)
}
assert.Equal(t, tt.expectedURL, actualURL)
})
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
@ -45,6 +46,23 @@ func queryData(ctx context.Context, dsInfo *datasourceInfo, req *backend.QueryDa
Frames: []*data.Frame{frame},
}
}
if query.QueryType == "dependencyGraph" {
dependencies, err := dsInfo.JaegerClient.Dependencies(ctx, q.TimeRange.From.UnixMilli(), q.TimeRange.To.UnixMilli())
if err != nil {
response.Responses[q.RefID] = backend.ErrorResponseWithErrorSource(err)
continue
}
if len(dependencies.Errors) > 0 {
response.Responses[q.RefID] = backend.ErrorResponseWithErrorSource(backend.DownstreamError(fmt.Errorf("error while fetching dependencies, code: %v, message: %v", dependencies.Errors[0].Code, dependencies.Errors[0].Msg)))
continue
}
frames := transformDependenciesResponse(dependencies, q.RefID)
response.Responses[q.RefID] = backend.DataResponse{
Frames: frames,
}
}
}
return response, nil
@ -215,3 +233,70 @@ type TracesResponse struct {
Offset int `json:"offset"`
Total int `json:"total"`
}
func transformDependenciesResponse(dependencies DependenciesResponse, refID string) []*data.Frame {
// Create nodes frame
nodesFrame := data.NewFrame(refID+"_nodes",
data.NewField("id", nil, []string{}),
data.NewField("title", nil, []string{}),
)
nodesFrame.Meta = &data.FrameMeta{
PreferredVisualization: "nodeGraph",
}
// Create edges frame
mainStatField := data.NewField("mainstat", nil, []int64{})
mainStatField.Config = &data.FieldConfig{
DisplayName: "Call count",
}
edgesFrame := data.NewFrame(refID+"_edges",
data.NewField("id", nil, []string{}),
data.NewField("source", nil, []string{}),
data.NewField("target", nil, []string{}),
mainStatField,
)
edgesFrame.Meta = &data.FrameMeta{
PreferredVisualization: "nodeGraph",
}
// Return early if there are no dependencies
if len(dependencies.Data) == 0 {
return []*data.Frame{nodesFrame, edgesFrame}
}
// Create a map to store unique service nodes
servicesByName := make(map[string]bool)
// Process each dependency
for _, dependency := range dependencies.Data {
// Add services to the map to track unique services
servicesByName[dependency.Parent] = true
servicesByName[dependency.Child] = true
// Add edge data
edgesFrame.AppendRow(
dependency.Parent+"--"+dependency.Child,
dependency.Parent,
dependency.Child,
int64(dependency.CallCount),
)
}
// Convert map keys to slice and sort them - this is to ensure the returned nodes are in a consistent order
services := make([]string, 0, len(servicesByName))
for service := range servicesByName {
services = append(services, service)
}
sort.Strings(services)
// Add node data in sorted order
for _, service := range services {
nodesFrame.AppendRow(
service,
service,
)
}
return []*data.Frame{nodesFrame, edgesFrame}
}

View File

@ -184,3 +184,97 @@ func TestTransformTraceResponse(t *testing.T) {
experimental.CheckGoldenJSONFrame(t, "./testdata", "complex_trace.golden", frame, false)
})
}
func TestTransformDependenciesResponse(t *testing.T) {
t.Run("simple_dependencies", func(t *testing.T) {
dependencies := DependenciesResponse{
Data: []ServiceDependency{
{
Parent: "serviceA",
Child: "serviceB",
CallCount: 1,
},
{
Parent: "serviceA",
Child: "serviceC",
CallCount: 2,
},
{
Parent: "serviceB",
Child: "serviceC",
CallCount: 3,
},
},
}
frames := transformDependenciesResponse(dependencies, "test")
experimental.CheckGoldenJSONFrame(t, "./testdata", "simple_dependencies_nodes.golden", frames[0], false)
experimental.CheckGoldenJSONFrame(t, "./testdata", "simple_dependencies_edges.golden", frames[1], false)
})
t.Run("empty_dependencies", func(t *testing.T) {
dependencies := DependenciesResponse{
Data: []ServiceDependency{},
}
frames := transformDependenciesResponse(dependencies, "test")
experimental.CheckGoldenJSONFrame(t, "./testdata", "empty_dependencies_nodes.golden", frames[0], false)
experimental.CheckGoldenJSONFrame(t, "./testdata", "empty_dependencies_edges.golden", frames[1], false)
})
t.Run("complex_dependencies", func(t *testing.T) {
dependencies := DependenciesResponse{
Data: []ServiceDependency{
{
Parent: "frontend",
Child: "auth-service",
CallCount: 150,
},
{
Parent: "frontend",
Child: "api-gateway",
CallCount: 300,
},
{
Parent: "api-gateway",
Child: "user-service",
CallCount: 200,
},
{
Parent: "api-gateway",
Child: "order-service",
CallCount: 100,
},
{
Parent: "order-service",
Child: "payment-service",
CallCount: 80,
},
{
Parent: "order-service",
Child: "inventory-service",
CallCount: 90,
},
{
Parent: "user-service",
Child: "database",
CallCount: 500,
},
{
Parent: "payment-service",
Child: "database",
CallCount: 200,
},
{
Parent: "inventory-service",
Child: "database",
CallCount: 300,
},
},
}
frames := transformDependenciesResponse(dependencies, "test")
experimental.CheckGoldenJSONFrame(t, "./testdata", "complex_dependencies_nodes.golden", frames[0], false)
experimental.CheckGoldenJSONFrame(t, "./testdata", "complex_dependencies_edges.golden", frames[1], false)
})
}

View File

@ -0,0 +1,127 @@
// 🌟 This was machine generated. Do not edit. 🌟
//
// Frame[0] {
// "typeVersion": [
// 0,
// 0
// ],
// "preferredVisualisationType": "nodeGraph"
// }
// Name: test_edges
// Dimensions: 4 Fields by 9 Rows
// +----------------------------------+-------------------+-------------------+----------------+
// | Name: id | Name: source | Name: target | Name: mainstat |
// | Labels: | Labels: | Labels: | Labels: |
// | Type: []string | Type: []string | Type: []string | Type: []int64 |
// +----------------------------------+-------------------+-------------------+----------------+
// | frontend--auth-service | frontend | auth-service | 150 |
// | frontend--api-gateway | frontend | api-gateway | 300 |
// | api-gateway--user-service | api-gateway | user-service | 200 |
// | api-gateway--order-service | api-gateway | order-service | 100 |
// | order-service--payment-service | order-service | payment-service | 80 |
// | order-service--inventory-service | order-service | inventory-service | 90 |
// | user-service--database | user-service | database | 500 |
// | payment-service--database | payment-service | database | 200 |
// | inventory-service--database | inventory-service | database | 300 |
// +----------------------------------+-------------------+-------------------+----------------+
//
//
// 🌟 This was machine generated. Do not edit. 🌟
{
"status": 200,
"frames": [
{
"schema": {
"name": "test_edges",
"meta": {
"typeVersion": [
0,
0
],
"preferredVisualisationType": "nodeGraph"
},
"fields": [
{
"name": "id",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "source",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "target",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "mainstat",
"type": "number",
"typeInfo": {
"frame": "int64"
},
"config": {
"displayName": "Call count"
}
}
]
},
"data": {
"values": [
[
"frontend--auth-service",
"frontend--api-gateway",
"api-gateway--user-service",
"api-gateway--order-service",
"order-service--payment-service",
"order-service--inventory-service",
"user-service--database",
"payment-service--database",
"inventory-service--database"
],
[
"frontend",
"frontend",
"api-gateway",
"api-gateway",
"order-service",
"order-service",
"user-service",
"payment-service",
"inventory-service"
],
[
"auth-service",
"api-gateway",
"user-service",
"order-service",
"payment-service",
"inventory-service",
"database",
"database",
"database"
],
[
150,
300,
200,
100,
80,
90,
500,
200,
300
]
]
}
}
]
}

View File

@ -0,0 +1,85 @@
// 🌟 This was machine generated. Do not edit. 🌟
//
// Frame[0] {
// "typeVersion": [
// 0,
// 0
// ],
// "preferredVisualisationType": "nodeGraph"
// }
// Name: test_nodes
// Dimensions: 2 Fields by 8 Rows
// +-------------------+-------------------+
// | Name: id | Name: title |
// | Labels: | Labels: |
// | Type: []string | Type: []string |
// +-------------------+-------------------+
// | api-gateway | api-gateway |
// | auth-service | auth-service |
// | database | database |
// | frontend | frontend |
// | inventory-service | inventory-service |
// | order-service | order-service |
// | payment-service | payment-service |
// | user-service | user-service |
// +-------------------+-------------------+
//
//
// 🌟 This was machine generated. Do not edit. 🌟
{
"status": 200,
"frames": [
{
"schema": {
"name": "test_nodes",
"meta": {
"typeVersion": [
0,
0
],
"preferredVisualisationType": "nodeGraph"
},
"fields": [
{
"name": "id",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "title",
"type": "string",
"typeInfo": {
"frame": "string"
}
}
]
},
"data": {
"values": [
[
"api-gateway",
"auth-service",
"database",
"frontend",
"inventory-service",
"order-service",
"payment-service",
"user-service"
],
[
"api-gateway",
"auth-service",
"database",
"frontend",
"inventory-service",
"order-service",
"payment-service",
"user-service"
]
]
}
}
]
}

View File

@ -0,0 +1,78 @@
// 🌟 This was machine generated. Do not edit. 🌟
//
// Frame[0] {
// "typeVersion": [
// 0,
// 0
// ],
// "preferredVisualisationType": "nodeGraph"
// }
// Name: test_edges
// Dimensions: 4 Fields by 0 Rows
// +----------------+----------------+----------------+----------------+
// | Name: id | Name: source | Name: target | Name: mainstat |
// | Labels: | Labels: | Labels: | Labels: |
// | Type: []string | Type: []string | Type: []string | Type: []int64 |
// +----------------+----------------+----------------+----------------+
// +----------------+----------------+----------------+----------------+
//
//
// 🌟 This was machine generated. Do not edit. 🌟
{
"status": 200,
"frames": [
{
"schema": {
"name": "test_edges",
"meta": {
"typeVersion": [
0,
0
],
"preferredVisualisationType": "nodeGraph"
},
"fields": [
{
"name": "id",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "source",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "target",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "mainstat",
"type": "number",
"typeInfo": {
"frame": "int64"
},
"config": {
"displayName": "Call count"
}
}
]
},
"data": {
"values": [
[],
[],
[],
[]
]
}
}
]
}

View File

@ -0,0 +1,59 @@
// 🌟 This was machine generated. Do not edit. 🌟
//
// Frame[0] {
// "typeVersion": [
// 0,
// 0
// ],
// "preferredVisualisationType": "nodeGraph"
// }
// Name: test_nodes
// Dimensions: 2 Fields by 0 Rows
// +----------------+----------------+
// | Name: id | Name: title |
// | Labels: | Labels: |
// | Type: []string | Type: []string |
// +----------------+----------------+
// +----------------+----------------+
//
//
// 🌟 This was machine generated. Do not edit. 🌟
{
"status": 200,
"frames": [
{
"schema": {
"name": "test_nodes",
"meta": {
"typeVersion": [
0,
0
],
"preferredVisualisationType": "nodeGraph"
},
"fields": [
{
"name": "id",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "title",
"type": "string",
"typeInfo": {
"frame": "string"
}
}
]
},
"data": {
"values": [
[],
[]
]
}
}
]
}

View File

@ -0,0 +1,97 @@
// 🌟 This was machine generated. Do not edit. 🌟
//
// Frame[0] {
// "typeVersion": [
// 0,
// 0
// ],
// "preferredVisualisationType": "nodeGraph"
// }
// Name: test_edges
// Dimensions: 4 Fields by 3 Rows
// +--------------------+----------------+----------------+----------------+
// | Name: id | Name: source | Name: target | Name: mainstat |
// | Labels: | Labels: | Labels: | Labels: |
// | Type: []string | Type: []string | Type: []string | Type: []int64 |
// +--------------------+----------------+----------------+----------------+
// | serviceA--serviceB | serviceA | serviceB | 1 |
// | serviceA--serviceC | serviceA | serviceC | 2 |
// | serviceB--serviceC | serviceB | serviceC | 3 |
// +--------------------+----------------+----------------+----------------+
//
//
// 🌟 This was machine generated. Do not edit. 🌟
{
"status": 200,
"frames": [
{
"schema": {
"name": "test_edges",
"meta": {
"typeVersion": [
0,
0
],
"preferredVisualisationType": "nodeGraph"
},
"fields": [
{
"name": "id",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "source",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "target",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "mainstat",
"type": "number",
"typeInfo": {
"frame": "int64"
},
"config": {
"displayName": "Call count"
}
}
]
},
"data": {
"values": [
[
"serviceA--serviceB",
"serviceA--serviceC",
"serviceB--serviceC"
],
[
"serviceA",
"serviceA",
"serviceB"
],
[
"serviceB",
"serviceC",
"serviceC"
],
[
1,
2,
3
]
]
}
}
]
}

View File

@ -0,0 +1,70 @@
// 🌟 This was machine generated. Do not edit. 🌟
//
// Frame[0] {
// "typeVersion": [
// 0,
// 0
// ],
// "preferredVisualisationType": "nodeGraph"
// }
// Name: test_nodes
// Dimensions: 2 Fields by 3 Rows
// +----------------+----------------+
// | Name: id | Name: title |
// | Labels: | Labels: |
// | Type: []string | Type: []string |
// +----------------+----------------+
// | serviceA | serviceA |
// | serviceB | serviceB |
// | serviceC | serviceC |
// +----------------+----------------+
//
//
// 🌟 This was machine generated. Do not edit. 🌟
{
"status": 200,
"frames": [
{
"schema": {
"name": "test_nodes",
"meta": {
"typeVersion": [
0,
0
],
"preferredVisualisationType": "nodeGraph"
},
"fields": [
{
"name": "id",
"type": "string",
"typeInfo": {
"frame": "string"
}
},
{
"name": "title",
"type": "string",
"typeInfo": {
"frame": "string"
}
}
]
},
"data": {
"values": [
[
"serviceA",
"serviceB",
"serviceC"
],
[
"serviceA",
"serviceB",
"serviceC"
]
]
}
}
]
}

View File

@ -72,10 +72,15 @@ export class JaegerDatasource extends DataSourceWithBackend<JaegerQuery, JaegerJ
// No query type means that the query is a trace ID query
// If all targets are trace ID queries, we can use the backend querying
const allTargetsTraceIdQuery = options.targets.every((target) => !target.queryType);
const allTargetsDependencyGraph = options.targets.every((target) => target.queryType === 'dependencyGraph');
// We have not migrated the node graph to the backend
// If the node graph is disabled, we can use the backend migration
const nodeGraphDisabled = !this.nodeGraph?.enabled;
if (config.featureToggles.jaegerBackendMigration && allTargetsTraceIdQuery && nodeGraphDisabled) {
if (
config.featureToggles.jaegerBackendMigration &&
(allTargetsTraceIdQuery || allTargetsDependencyGraph) &&
nodeGraphDisabled
) {
return super.query(options);
}