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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# Changelog
## 1.3.5 (2026-07-14)
* Expose span status in the trace view: populate `kind`, `statusCode`, and `statusMessage` data frame fields, derived from Cloud Trace span labels (`/error/message`, `/error/name`, and HTTP status code labels per OpenTelemetry HTTP semantics)
* Mark failed spans with an `error: true` tag so they are highlighted with the error icon in the Grafana trace view
* Show the span's `/stacktrace` label in the "Stack Traces" section via the `stackTraces` field
* Document that the Cloud Trace v1 read API does not return span events (`TimeEvents`), so error details are only available from span labels

## 1.3.4 (2026-05-08)
* Upgrade @grafana/google-sdk from 0.0.3 to 0.3.5 to fix ConnectionConfig mutating React props ([#44](https://github.com/GoogleCloudPlatform/cloud-trace-data-source-plugin/issues/44))
* Upgrade @grafana/data, @grafana/runtime, @grafana/ui to ^11.0.0
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ You can combine multiple patterns (one per line) to match the union of all patte
After making a `Filter` query, a table will be displayed with all of the matching traces
(Example: `http.scheme:http http.server_name:testserver MinLatency:500ms`)

### Span status and errors

The trace view marks spans as failed (error icon and `statusCode`/`statusMessage` fields) based on
the span's Cloud Trace labels:

- `/error/message` or `/error/name`: the span is marked as an error, using the label value as the
status message
- `/http/status_code`, `http.status_code`, or `http.response.status_code`: the span is marked as an
error for status codes >= 500 (>= 400 for client spans, per OpenTelemetry HTTP semantics)

If the span has a `/stacktrace` label, its value is shown in the span's "Stack Traces" section.

Note: the Cloud Trace v1 read API used by this plugin does not return span events (`TimeEvents`),
so events such as OpenTelemetry exceptions attached to a span cannot be displayed. Error details
are only available when they are present as span labels.

### Annotations

The plugin supports Grafana annotations. You can use trace queries as annotation sources to overlay trace data on your dashboards.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "googlecloud-trace-datasource",
"version": "1.3.4",
"version": "1.3.5",
"description": "Backend Grafana plugin that enables visualization of GCP Cloud Trace traces and spans in Grafana.",
"scripts": {
"postinstall": "rm -rf node_modules/flatted/golang",
Expand Down
101 changes: 97 additions & 4 deletions pkg/plugin/cloudtrace/cloudtrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"

Expand All @@ -32,6 +33,21 @@ const (
gaeServiceVersionKey = "g.co/gae/app/version"
otelMethodKey = "http.method"
cloudTraceMethodKey = "/http/method"

errorMessageKey = "/error/message"
errorNameKey = "/error/name"
cloudTraceStatusKey = "/http/status_code"
otelStatusKey = "http.status_code"
otelHTTPResponseStatus = "http.response.status_code"
stackTraceKey = "/stacktrace"
errorTagKey = "error"
)

// Span status codes as expected by the Grafana trace schema,
// following the OpenTelemetry convention
const (
StatusCodeUnset int64 = 0
StatusCodeError int64 = 2
)

// Regex for individual filters within query text
Expand Down Expand Up @@ -90,13 +106,21 @@ func GetSpanOperationName(span *tracepb.TraceSpan) string {
// GetTags converts Google Trace labels to Grafana service and span tags
func GetTags(span *tracepb.TraceSpan) (serviceTags json.RawMessage, spanTags json.RawMessage, err error) {
spanLabels := span.GetLabels()
serviceTagsMapArray := []map[string]string{}
spanTagsMapArray := []map[string]string{}
serviceTagsMapArray := []map[string]any{}
spanTagsMapArray := []map[string]any{}
for key, value := range spanLabels {
if strings.HasPrefix(key, servicePrefix) || strings.HasPrefix(key, gaeServicePrefix) {
serviceTagsMapArray = append(serviceTagsMapArray, map[string]string{"key": key, "value": value})
serviceTagsMapArray = append(serviceTagsMapArray, map[string]any{"key": key, "value": value})
} else {
spanTagsMapArray = append(spanTagsMapArray, map[string]string{"key": key, "value": value})
spanTagsMapArray = append(spanTagsMapArray, map[string]any{"key": key, "value": value})
}
}

// Grafana's trace view highlights spans that carry an `error: true` tag,
// so mark failed spans for versions that don't key off the statusCode field
if code, _ := GetStatus(span); code == StatusCodeError {
if _, ok := spanLabels[errorTagKey]; !ok {
spanTagsMapArray = append(spanTagsMapArray, map[string]any{"key": errorTagKey, "value": true})
}
}

Expand All @@ -113,6 +137,75 @@ func GetTags(span *tracepb.TraceSpan) (serviceTags json.RawMessage, spanTags jso
return serviceTags, spanTags, nil
}

// GetStatus derives a Grafana span status code and message from the span's
// labels. The Cloud Trace v1 read API has no structured status field, so
// error information is only available through well-known labels: the
// canonical `/error/*` labels, or an HTTP status code label indicating a
// failed request.
func GetStatus(span *tracepb.TraceSpan) (int64, string) {
labels := span.GetLabels()

errMsg := labels[errorMessageKey]
errName := labels[errorNameKey]
if errMsg != "" || errName != "" {
if errMsg == "" {
errMsg = errName
}
return StatusCodeError, errMsg
}

httpStatus := labels[cloudTraceStatusKey]
if httpStatus == "" {
httpStatus = labels[otelStatusKey]
}
if httpStatus == "" {
httpStatus = labels[otelHTTPResponseStatus]
}
if code, err := strconv.Atoi(httpStatus); err == nil {
// Per OpenTelemetry HTTP semantics, 4xx responses are errors for
// client spans but not for server spans
threshold := 500
if span.GetKind() == tracepb.TraceSpan_RPC_CLIENT {
threshold = 400
}
if code >= threshold {
return StatusCodeError, fmt.Sprintf("HTTP %d", code)
}
}

return StatusCodeUnset, ""
}

// GetSpanKind maps the Cloud Trace span kind to the values expected by the
// Grafana trace schema
func GetSpanKind(span *tracepb.TraceSpan) string {
switch span.GetKind() {
case tracepb.TraceSpan_RPC_SERVER:
return "server"
case tracepb.TraceSpan_RPC_CLIENT:
return "client"
default:
return ""
}
}

// GetStackTraces returns the span's `/stacktrace` label as the JSON string
// array expected by the Grafana trace schema, or nil if the span has none
func GetStackTraces(span *tracepb.TraceSpan) *json.RawMessage {
st := span.GetLabels()[stackTraceKey]
if st == "" {
return nil
}

b, err := json.Marshal([]string{st})
if err != nil {
return nil
}

raw := json.RawMessage(b)
return &raw
}

// GetListTracesFilter takes the raw query text from a user and converts it
// to a filter string as expected by the Cloud Trace API
func GetListTracesFilter(queryText string) (string, error) {
Expand Down
196 changes: 196 additions & 0 deletions pkg/plugin/cloudtrace/cloudtrace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,199 @@ func TestGetListTracesFilter(t *testing.T) {
})
}
}

func TestGetStatus(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
span *tracepb.TraceSpan
expectedCode int64
expectedMessage string
}{
{
name: "Span with no labels",
span: &tracepb.TraceSpan{},
expectedCode: cloudtrace.StatusCodeUnset,
expectedMessage: "",
},
{
name: "Span with error message label",
span: &tracepb.TraceSpan{
Labels: map[string]string{
"/error/message": "something went wrong",
"/error/name": "RuntimeError",
},
},
expectedCode: cloudtrace.StatusCodeError,
expectedMessage: "something went wrong",
},
{
name: "Span with only error name label",
span: &tracepb.TraceSpan{
Labels: map[string]string{
"/error/name": "RuntimeError",
},
},
expectedCode: cloudtrace.StatusCodeError,
expectedMessage: "RuntimeError",
},
{
name: "Server span with HTTP 500",
span: &tracepb.TraceSpan{
Kind: tracepb.TraceSpan_RPC_SERVER,
Labels: map[string]string{
"/http/status_code": "500",
},
},
expectedCode: cloudtrace.StatusCodeError,
expectedMessage: "HTTP 500",
},
{
name: "Server span with HTTP 404 is not an error",
span: &tracepb.TraceSpan{
Kind: tracepb.TraceSpan_RPC_SERVER,
Labels: map[string]string{
"/http/status_code": "404",
},
},
expectedCode: cloudtrace.StatusCodeUnset,
expectedMessage: "",
},
{
name: "Client span with HTTP 404 is an error",
span: &tracepb.TraceSpan{
Kind: tracepb.TraceSpan_RPC_CLIENT,
Labels: map[string]string{
"/http/status_code": "404",
},
},
expectedCode: cloudtrace.StatusCodeError,
expectedMessage: "HTTP 404",
},
{
name: "Span with OTel status code label",
span: &tracepb.TraceSpan{
Labels: map[string]string{
"http.status_code": "503",
},
},
expectedCode: cloudtrace.StatusCodeError,
expectedMessage: "HTTP 503",
},
{
name: "Span with new OTel semconv status code label",
span: &tracepb.TraceSpan{
Labels: map[string]string{
"http.response.status_code": "502",
},
},
expectedCode: cloudtrace.StatusCodeError,
expectedMessage: "HTTP 502",
},
{
name: "Span with successful HTTP status",
span: &tracepb.TraceSpan{
Labels: map[string]string{
"/http/status_code": "200",
},
},
expectedCode: cloudtrace.StatusCodeUnset,
expectedMessage: "",
},
{
name: "Span with unparsable HTTP status",
span: &tracepb.TraceSpan{
Labels: map[string]string{
"/http/status_code": "abc",
},
},
expectedCode: cloudtrace.StatusCodeUnset,
expectedMessage: "",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
code, message := cloudtrace.GetStatus(tc.span)

require.Equal(t, tc.expectedCode, code)
require.Equal(t, tc.expectedMessage, message)
})
}
}

func TestGetSpanKind(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
span *tracepb.TraceSpan
expectedKind string
}{
{
name: "Unspecified span kind",
span: &tracepb.TraceSpan{},
expectedKind: "",
},
{
name: "Server span",
span: &tracepb.TraceSpan{Kind: tracepb.TraceSpan_RPC_SERVER},
expectedKind: "server",
},
{
name: "Client span",
span: &tracepb.TraceSpan{Kind: tracepb.TraceSpan_RPC_CLIENT},
expectedKind: "client",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expectedKind, cloudtrace.GetSpanKind(tc.span))
})
}
}

func TestGetStackTraces(t *testing.T) {
t.Parallel()

t.Run("Span without stack trace", func(t *testing.T) {
require.Nil(t, cloudtrace.GetStackTraces(&tracepb.TraceSpan{}))
})

t.Run("Span with stack trace", func(t *testing.T) {
span := &tracepb.TraceSpan{
Labels: map[string]string{
"/stacktrace": `{"stack_frame":[{"method_name":"main"}]}`,
},
}

stackTraces := cloudtrace.GetStackTraces(span)
require.NotNil(t, stackTraces)

var traces []string
require.NoError(t, json.Unmarshal(*stackTraces, &traces))
require.Equal(t, []string{`{"stack_frame":[{"method_name":"main"}]}`}, traces)
})
}

func TestGetTagsErrorSpan(t *testing.T) {
t.Parallel()

span := &tracepb.TraceSpan{
Labels: map[string]string{
"/error/message": "something went wrong",
},
}

_, spanTags, err := cloudtrace.GetTags(span)
require.NoError(t, err)

var spanTagsMap []map[string]any
require.NoError(t, json.Unmarshal(spanTags, &spanTagsMap))
require.ElementsMatch(t, []map[string]any{
{"key": "/error/message", "value": "something went wrong"},
{"key": "error", "value": true},
}, spanTagsMap)
}
Loading
Loading