Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

contrib: support tracing for GraphQL #1380

Merged
merged 11 commits into from
Aug 5, 2022
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 (
katiehockman marked this conversation as resolved.
Show resolved Hide resolved
graphQLQuery = "graphql.query"

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 := graphQLQuery
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(graphQLQuery, 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, graphQLQuery})
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