Skip to content

Commit

Permalink
contrib: support tracing for GraphQL (#1380)
Browse files Browse the repository at this point in the history
* 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
katiehockman and ajgajg1134 committed Aug 5, 2022
1 parent 5317b5f commit 3918783
Show file tree
Hide file tree
Showing 7 changed files with 416 additions and 6 deletions.
33 changes: 33 additions & 0 deletions contrib/99designs/gqlgen/example_test.go
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))
}
49 changes: 49 additions & 0 deletions contrib/99designs/gqlgen/option.go
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
}
}
128 changes: 128 additions & 0 deletions contrib/99designs/gqlgen/tracer.go
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{}
158 changes: 158 additions & 0 deletions contrib/99designs/gqlgen/tracer_test.go
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)
}
3 changes: 3 additions & 0 deletions ddtrace/ext/app_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,7 @@ const (

// SpanTypeConsul marks a span as a Consul operation.
SpanTypeConsul = "consul"

// SpanTypeGraphql marks a span as a graphql operation.
SpanTypeGraphQL = "graphql"
)
Loading

0 comments on commit 3918783

Please sign in to comment.