-
Notifications
You must be signed in to change notification settings - Fork 425
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
contrib: support tracing for GraphQL (#1380)
* contrib: support tracing for GraphQL This change adds basic support for tracing GraphQL functions that use github.com/99designs/gqlgen. Query and mutation operations can be traced, with child spans generated for reading, parsing, and validating operation strings. Variables of the query are not a part of the span. Support for subscriptions is limited to mutations that occur within the context of a subscription, since many subscription operations are long running (e.g. a trace would remain open the entire time a client is subscribed to an endpoint). Support for field operations is not supported. A warning from the docs: Data obfuscation hasn't been implemented for graphql queries yet, and any sensitive data in the query will be sent to Datadog as the resource name of the span. To ensure no sensitive data is included in your spans, always use parameterized graphql queries with sensitive data in variables. Updates #507 * remove outdated godoc comment * Update ddtrace/ext/app_types.go Co-authored-by: Andrew Glaude <andrew.glaude@datadoghq.com> * contrib: clean up context usage and errors * run lint * go mod tidy * document and test obfuscation * change service name to graphql * change default operation name Co-authored-by: Andrew Glaude <andrew.glaude@datadoghq.com>
- Loading branch information
1 parent
5317b5f
commit 3918783
Showing
7 changed files
with
416 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
// Unless explicitly stated otherwise all files in this repository are licensed | ||
// under the Apache License Version 2.0. | ||
// This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
// Copyright 2022 Datadog, Inc. | ||
|
||
// Package gqlgen provides functions to trace the 99designs/gqlgen package (https://github.com/99designs/gqlgen). | ||
package gqlgen_test | ||
|
||
import ( | ||
"log" | ||
"net/http" | ||
|
||
"github.com/99designs/gqlgen/example/todo" | ||
"github.com/99designs/gqlgen/graphql/handler" | ||
|
||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" | ||
|
||
gqlgentrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/99designs/gqlgen" | ||
) | ||
|
||
func Example() { | ||
tracer.Start() | ||
defer tracer.Stop() | ||
|
||
t := gqlgentrace.NewTracer( | ||
gqlgentrace.WithAnalytics(true), | ||
gqlgentrace.WithServiceName("todo.server"), | ||
) | ||
h := handler.NewDefaultServer(todo.NewExecutableSchema(todo.New())) | ||
h.Use(t) | ||
http.Handle("/query", h) | ||
log.Fatal(http.ListenAndServe(":8080", nil)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
// Unless explicitly stated otherwise all files in this repository are licensed | ||
// under the Apache License Version 2.0. | ||
// This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
// Copyright 2022 Datadog, Inc. | ||
|
||
package gqlgen | ||
|
||
import ( | ||
"math" | ||
|
||
"gopkg.in/DataDog/dd-trace-go.v1/internal/globalconfig" | ||
) | ||
|
||
const defaultServiceName = "graphql" | ||
|
||
type config struct { | ||
serviceName string | ||
analyticsRate float64 | ||
} | ||
|
||
// An Option configures the gqlgen integration. | ||
type Option func(t *config) | ||
|
||
func defaults(t *config) { | ||
t.serviceName = defaultServiceName | ||
t.analyticsRate = globalconfig.AnalyticsRate() | ||
} | ||
|
||
// WithAnalytics enables or disables Trace Analytics for all started spans. | ||
func WithAnalytics(on bool) Option { | ||
if on { | ||
return WithAnalyticsRate(1.0) | ||
} | ||
return WithAnalyticsRate(math.NaN()) | ||
} | ||
|
||
// WithAnalyticsRate sets the sampling rate for Trace Analytics events correlated to started spans. | ||
func WithAnalyticsRate(rate float64) Option { | ||
return func(t *config) { | ||
t.analyticsRate = rate | ||
} | ||
} | ||
|
||
// WithServiceName sets the given service name for the gqlgen server. | ||
func WithServiceName(name string) Option { | ||
return func(t *config) { | ||
t.serviceName = name | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
// Unless explicitly stated otherwise all files in this repository are licensed | ||
// under the Apache License Version 2.0. | ||
// This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
// Copyright 2022 Datadog, Inc. | ||
|
||
// Package gqlgen contains an implementation of a gqlgen tracer, and functions | ||
// to construct and configure the tracer. The tracer can be passed to the gqlgen | ||
// handler (see package github.com/99designs/gqlgen/handler) | ||
// | ||
// Warning: Data obfuscation hasn't been implemented for graphql queries yet, | ||
// any sensitive data in the query will be sent to Datadog as the resource name | ||
// of the span. To ensure no sensitive data is included in your spans, always | ||
// use parameterized graphql queries with sensitive data in variables. | ||
package gqlgen | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"math" | ||
"strings" | ||
"time" | ||
|
||
"github.com/99designs/gqlgen/graphql" | ||
"github.com/vektah/gqlparser/v2/ast" | ||
|
||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace" | ||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" | ||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" | ||
) | ||
|
||
const ( | ||
defaultGraphqlOperation = "graphql.request" | ||
|
||
readOp = "graphql.read" | ||
parsingOp = "graphql.parse" | ||
validationOp = "graphql.validate" | ||
) | ||
|
||
type gqlTracer struct { | ||
cfg *config | ||
} | ||
|
||
// NewTracer creates a graphql.HandlerExtension instance that can be used with | ||
// a graphql.handler.Server. | ||
// Options can be passed in for further configuration. | ||
func NewTracer(opts ...Option) graphql.HandlerExtension { | ||
cfg := new(config) | ||
defaults(cfg) | ||
for _, fn := range opts { | ||
fn(cfg) | ||
} | ||
return &gqlTracer{cfg: cfg} | ||
} | ||
|
||
func (t *gqlTracer) ExtensionName() string { | ||
return "DatadogTracing" | ||
} | ||
|
||
func (t *gqlTracer) Validate(schema graphql.ExecutableSchema) error { | ||
return nil // unimplemented | ||
} | ||
|
||
func (t *gqlTracer) InterceptResponse(ctx context.Context, next graphql.ResponseHandler) *graphql.Response { | ||
opts := []ddtrace.StartSpanOption{ | ||
tracer.SpanType(ext.SpanTypeGraphQL), | ||
tracer.ServiceName(t.cfg.serviceName), | ||
} | ||
if !math.IsNaN(t.cfg.analyticsRate) { | ||
opts = append(opts, tracer.Tag(ext.EventSampleRate, t.cfg.analyticsRate)) | ||
} | ||
var ( | ||
octx *graphql.OperationContext | ||
) | ||
name := defaultGraphqlOperation | ||
if graphql.HasOperationContext(ctx) { | ||
// Variables in the operation will be left out of the tags | ||
// until obfuscation is implemented in the agent. | ||
octx = graphql.GetOperationContext(ctx) | ||
if octx.Operation != nil { | ||
if octx.Operation.Operation == ast.Subscription { | ||
// These are long running queries for a subscription, | ||
// remaining open indefinitely until a subscription ends. | ||
// Return early and do not create these spans. | ||
return next(ctx) | ||
} | ||
name = fmt.Sprintf("%s.%s", ext.SpanTypeGraphQL, octx.Operation.Operation) | ||
} | ||
if octx.RawQuery != "" { | ||
opts = append(opts, tracer.ResourceName(octx.RawQuery)) | ||
} | ||
opts = append(opts, tracer.StartTime(octx.Stats.OperationStart)) | ||
} | ||
var span ddtrace.Span | ||
span, ctx = tracer.StartSpanFromContext(ctx, name, opts...) | ||
defer func() { | ||
var errs []string | ||
for _, err := range graphql.GetErrors(ctx) { | ||
errs = append(errs, err.Message) | ||
} | ||
var err error | ||
if len(errs) > 0 { | ||
err = fmt.Errorf(strings.Join(errs, ", ")) | ||
} | ||
span.Finish(tracer.WithError(err)) | ||
}() | ||
|
||
if octx != nil { | ||
// Create child spans based on the stats in the operation context. | ||
createChildSpan := func(name string, start, finish time.Time) { | ||
var childOpts []ddtrace.StartSpanOption | ||
childOpts = append(childOpts, tracer.StartTime(start)) | ||
childOpts = append(childOpts, tracer.ResourceName(name)) | ||
var childSpan ddtrace.Span | ||
childSpan, _ = tracer.StartSpanFromContext(ctx, name, childOpts...) | ||
childSpan.Finish(tracer.FinishTime(finish)) | ||
} | ||
createChildSpan(readOp, octx.Stats.Read.Start, octx.Stats.Read.End) | ||
createChildSpan(parsingOp, octx.Stats.Parsing.Start, octx.Stats.Parsing.End) | ||
createChildSpan(validationOp, octx.Stats.Validation.Start, octx.Stats.Validation.End) | ||
} | ||
return next(ctx) | ||
} | ||
|
||
// Ensure all of these interfaces are implemented. | ||
var _ interface { | ||
graphql.HandlerExtension | ||
graphql.ResponseInterceptor | ||
} = &gqlTracer{} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
// Unless explicitly stated otherwise all files in this repository are licensed | ||
// under the Apache License Version 2.0. | ||
// This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
// Copyright 2022 Datadog, Inc. | ||
|
||
package gqlgen | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/99designs/gqlgen/client" | ||
"github.com/99designs/gqlgen/graphql" | ||
"github.com/99designs/gqlgen/graphql/handler/testserver" | ||
"github.com/99designs/gqlgen/graphql/handler/transport" | ||
"github.com/stretchr/testify/assert" | ||
|
||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext" | ||
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/mocktracer" | ||
) | ||
|
||
func TestOptions(t *testing.T) { | ||
query := `{ name }` | ||
for name, tt := range map[string]struct { | ||
tracerOpts []Option | ||
test func(assert *assert.Assertions, root mocktracer.Span) | ||
}{ | ||
"default": { | ||
test: func(assert *assert.Assertions, root mocktracer.Span) { | ||
assert.Equal("graphql.query", root.OperationName()) | ||
assert.Equal(query, root.Tag(ext.ResourceName)) | ||
assert.Equal(defaultServiceName, root.Tag(ext.ServiceName)) | ||
assert.Equal(ext.SpanTypeGraphQL, root.Tag(ext.SpanType)) | ||
assert.Nil(root.Tag(ext.EventSampleRate)) | ||
}, | ||
}, | ||
"WithServiceName": { | ||
tracerOpts: []Option{WithServiceName("TestServer")}, | ||
test: func(assert *assert.Assertions, root mocktracer.Span) { | ||
assert.Equal("TestServer", root.Tag(ext.ServiceName)) | ||
}, | ||
}, | ||
"WithAnalytics/true": { | ||
tracerOpts: []Option{WithAnalytics(true)}, | ||
test: func(assert *assert.Assertions, root mocktracer.Span) { | ||
assert.Equal(1.0, root.Tag(ext.EventSampleRate)) | ||
}, | ||
}, | ||
"WithAnalytics/false": { | ||
tracerOpts: []Option{WithAnalytics(false)}, | ||
test: func(assert *assert.Assertions, root mocktracer.Span) { | ||
assert.Nil(root.Tag(ext.EventSampleRate)) | ||
}, | ||
}, | ||
"WithAnalyticsRate": { | ||
tracerOpts: []Option{WithAnalyticsRate(0.5)}, | ||
test: func(assert *assert.Assertions, root mocktracer.Span) { | ||
assert.Equal(0.5, root.Tag(ext.EventSampleRate)) | ||
}, | ||
}, | ||
} { | ||
t.Run(name, func(t *testing.T) { | ||
assert := assert.New(t) | ||
mt := mocktracer.Start() | ||
defer mt.Stop() | ||
c := newTestClient(t, testserver.New(), NewTracer(tt.tracerOpts...)) | ||
var resp struct { | ||
Name string | ||
} | ||
c.MustPost(query, &resp) | ||
var root mocktracer.Span | ||
for _, span := range mt.FinishedSpans() { | ||
if span.ParentID() == 0 { | ||
root = span | ||
} | ||
} | ||
assert.NotNil(root) | ||
tt.test(assert, root) | ||
assert.Nil(root.Tag(ext.Error)) | ||
}) | ||
} | ||
} | ||
|
||
func TestError(t *testing.T) { | ||
assert := assert.New(t) | ||
mt := mocktracer.Start() | ||
defer mt.Stop() | ||
c := newTestClient(t, testserver.NewError(), NewTracer()) | ||
var resp struct { | ||
Name string | ||
} | ||
err := c.Post(`{ name }`, &resp) | ||
assert.NotNil(err) | ||
var root mocktracer.Span | ||
for _, span := range mt.FinishedSpans() { | ||
if span.ParentID() == 0 { | ||
root = span | ||
} | ||
} | ||
assert.NotNil(root) | ||
assert.NotNil(root.Tag(ext.Error)) | ||
} | ||
|
||
func TestObfuscation(t *testing.T) { | ||
assert := assert.New(t) | ||
mt := mocktracer.Start() | ||
defer mt.Stop() | ||
c := newTestClient(t, testserver.New(), NewTracer()) | ||
var resp struct { | ||
Name string | ||
} | ||
query := `query($id: Int!) { | ||
name | ||
find(id: $id) | ||
} | ||
` | ||
err := c.Post(query, &resp, client.Var("id", 12345)) | ||
assert.Nil(err) | ||
|
||
// No spans should contain the sensitive ID. | ||
for _, span := range mt.FinishedSpans() { | ||
assert.NotContains(span.Tag(ext.ResourceName), "12345") | ||
} | ||
} | ||
|
||
func TestChildSpans(t *testing.T) { | ||
assert := assert.New(t) | ||
mt := mocktracer.Start() | ||
defer mt.Stop() | ||
c := newTestClient(t, testserver.New(), NewTracer()) | ||
var resp struct { | ||
Name string | ||
} | ||
query := `{ name }` | ||
err := c.Post(query, &resp) | ||
assert.Nil(err) | ||
var root mocktracer.Span | ||
allSpans := mt.FinishedSpans() | ||
var resNames []string | ||
var opNames []string | ||
for _, span := range allSpans { | ||
if span.ParentID() == 0 { | ||
root = span | ||
} | ||
resNames = append(resNames, span.Tag(ext.ResourceName).(string)) | ||
opNames = append(opNames, span.OperationName()) | ||
} | ||
assert.ElementsMatch(resNames, []string{readOp, validationOp, parsingOp, query}) | ||
assert.ElementsMatch(opNames, []string{readOp, validationOp, parsingOp, "graphql.query"}) | ||
assert.NotNil(root) | ||
assert.Nil(root.Tag(ext.Error)) | ||
} | ||
|
||
func newTestClient(t *testing.T, h *testserver.TestServer, tracer graphql.HandlerExtension) *client.Client { | ||
t.Helper() | ||
h.AddTransport(transport.POST{}) | ||
h.Use(tracer) | ||
return client.New(h) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.