Skip to content

Commit 92e97fa

Browse files
marcotcivoanjo
andauthored
GraphQL: Capture user-provided error extension values (#4325)
* GraphQL: Report multiple query errors * Update sig/datadog/tracing/contrib/graphql/ext.rbs Co-authored-by: Ivo Anjo <ivo.anjo@datadoghq.com> * Misc code reviews * Revert gemfile * GraphQL: Capture user-provided error extension values * Update with code review --------- Co-authored-by: Ivo Anjo <ivo.anjo@datadoghq.com>
1 parent aa7e0da commit 92e97fa

File tree

11 files changed

+190
-14
lines changed

11 files changed

+190
-14
lines changed

docs/GettingStarted.md

+2
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,8 @@ The `instrument :graphql` method accepts the following parameters. Additional op
883883
| `with_unified_tracer` | `DD_TRACE_GRAPHQL_WITH_UNIFIED_TRACER` | `Bool` | (Recommended) Enable to instrument with `UnifiedTrace` tracer for `graphql` >= v2.2, **enabling support for Endpoints list** in the Service Catalog. `with_deprecated_tracer` has priority over this. Default is `false`, using `GraphQL::Tracing::DataDogTrace` instead. This option is disabled by default to maintain backwards compatibility, but **will become the default in `datadog` 3.0.0**. | `false` |
884884
| `with_deprecated_tracer` | | `Bool` | (Not recommended) Enable to instrument with deprecated `GraphQL::Tracing::DataDogTracing`. This has priority over `with_unified_tracer`. Default is `false`, using `GraphQL::Tracing::DataDogTrace` instead | `false` |
885885
| `service_name` | | `String` | Service name used for graphql instrumentation | `'ruby-graphql'` |
886+
| `error_extensions` | `DD_TRACE_GRAPHQL_ERROR_EXTENSIONS` | `Array` | List of extension keys to include in the span event reported for GraphQL queries with errors. | `[]` |
887+
886888

887889
Once an instrumentation strategy is selected (`with_unified_tracer: true`, `with_deprecated_tracer: true`, or *no option set* which defaults to `GraphQL::Tracing::DataDogTrace`), it is not possible to change the instrumentation strategy in the same Ruby process.
888890
This is especially important for [auto instrumented applications](#rails-or-hanami-applications) because an automatic initial instrumentation is always applied at startup, thus such applications will always instrument GraphQL with the default strategy (`GraphQL::Tracing::DataDogTrace`).

lib/datadog/core/configuration/option_definition.rb

+2
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ def deprecated_env(value)
7979
@deprecated_env = value
8080
end
8181

82+
# Invoked when the option is first read, and {#env} is defined.
83+
# The block provided is only invoked if the environment variable is present (not-nil).
8284
def env_parser(&block)
8385
@env_parser = block
8486
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module Datadog
4+
module Tracing
5+
module Contrib
6+
module GraphQL
7+
module Configuration
8+
# Parses the environment variable `DD_TRACE_GRAPHQL_ERROR_EXTENSIONS` for error extension names declaration.
9+
class ErrorExtensionEnvParser
10+
# Parses the environment variable `DD_TRACE_GRAPHQL_ERROR_EXTENSIONS` into an array of error extension names.
11+
def self.call(values)
12+
# Split by comma, remove leading and trailing whitespaces,
13+
# remove empty values, and remove repeated values.
14+
values.split(',').each(&:strip!).reject(&:empty?).uniq
15+
end
16+
end
17+
end
18+
end
19+
end
20+
end
21+
end

lib/datadog/tracing/contrib/graphql/configuration/settings.rb

+10
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative '../../configuration/settings'
44
require_relative '../ext'
5+
require_relative 'error_extension_env_parser'
56

67
module Datadog
78
module Tracing
@@ -48,6 +49,15 @@ class Settings < Contrib::Configuration::Settings
4849
o.type :bool
4950
o.default false
5051
end
52+
53+
# Capture error extensions provided by the user in their GraphQL error responses.
54+
# The extensions can be anything, so the user is responsible for ensuring they are safe to capture.
55+
option :error_extensions do |o|
56+
o.env Ext::ENV_ERROR_EXTENSIONS
57+
o.type :array, nilable: false
58+
o.default []
59+
o.env_parser { |v| ErrorExtensionEnvParser.call(v) }
60+
end
5161
end
5262
end
5363
end

lib/datadog/tracing/contrib/graphql/ext.rb

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module Ext
1212
ENV_ANALYTICS_ENABLED = 'DD_TRACE_GRAPHQL_ANALYTICS_ENABLED'
1313
ENV_ANALYTICS_SAMPLE_RATE = 'DD_TRACE_GRAPHQL_ANALYTICS_SAMPLE_RATE'
1414
ENV_WITH_UNIFIED_TRACER = 'DD_TRACE_GRAPHQL_WITH_UNIFIED_TRACER'
15+
ENV_ERROR_EXTENSIONS = 'DD_TRACE_GRAPHQL_ERROR_EXTENSIONS'
1516
SERVICE_NAME = 'graphql'
1617
TAG_COMPONENT = 'graphql'
1718

lib/datadog/tracing/contrib/graphql/unified_trace.rb

+28-9
Original file line numberDiff line numberDiff line change
@@ -199,23 +199,42 @@ def multiplex_resource(multiplex)
199199
# These are represented in the Datadog App as special GraphQL errors,
200200
# given their event name `dd.graphql.query.error`.
201201
def add_query_error_events(span, errors)
202+
capture_extensions = Datadog.configuration.tracing[:graphql][:error_extensions]
202203
errors.each do |error|
203-
e = Core::Error.build_from(error)
204+
extensions = if !capture_extensions.empty? && (extensions = error.extensions)
205+
# Capture extensions, ensuring all values are primitives
206+
extensions.each_with_object({}) do |(key, value), hash|
207+
next unless capture_extensions.include?(key.to_s)
208+
209+
value = case value
210+
when TrueClass, FalseClass, Integer, Float
211+
value
212+
else
213+
# Stringify anything that is not a boolean or a number
214+
value.to_s
215+
end
216+
217+
hash["extensions.#{key}"] = value
218+
end
219+
else
220+
{}
221+
end
204222

205223
# {::GraphQL::Error#to_h} returns the error formatted in compliance with the GraphQL spec.
206224
# This is an unwritten contract in the `graphql` library.
207225
# See for an example: https://github.com/rmosolgo/graphql-ruby/blob/0afa241775e5a113863766cce126214dee093464/lib/graphql/execution_error.rb#L32
208-
err = error.to_h
226+
graphql_error = error.to_h
227+
error = Core::Error.build_from(error)
209228

210229
span.span_events << Datadog::Tracing::SpanEvent.new(
211230
Ext::EVENT_QUERY_ERROR,
212-
attributes: {
213-
message: err['message'],
214-
type: e.type,
215-
stacktrace: e.backtrace,
216-
locations: serialize_error_locations(err['locations']),
217-
path: err['path'],
218-
}
231+
attributes: extensions.merge!(
232+
message: graphql_error['message'],
233+
type: error.type,
234+
stacktrace: error.backtrace,
235+
locations: serialize_error_locations(graphql_error['locations']),
236+
path: graphql_error['path'],
237+
)
219238
)
220239
end
221240
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module Datadog
2+
module Tracing
3+
module Contrib
4+
module GraphQL
5+
module Configuration
6+
class ErrorExtensionEnvParser
7+
def self.call: (string values) -> Array[string]
8+
end
9+
end
10+
end
11+
end
12+
end
13+
end

sig/datadog/tracing/contrib/graphql/ext.rbs

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ module Datadog
1010
ENV_ANALYTICS_SAMPLE_RATE: "DD_TRACE_GRAPHQL_ANALYTICS_SAMPLE_RATE"
1111

1212
ENV_WITH_UNIFIED_TRACER: "DD_TRACE_GRAPHQL_WITH_UNIFIED_TRACER"
13+
ENV_ERROR_EXTENSIONS: "DD_TRACE_GRAPHQL_ERROR_EXTENSIONS"
1314
EVENT_QUERY_ERROR: "dd.graphql.query.error"
1415
SERVICE_NAME: "graphql"
1516

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
require 'datadog/tracing/contrib/graphql/configuration/error_extension_env_parser'
2+
3+
RSpec.describe Datadog::Tracing::Contrib::GraphQL::Configuration::ErrorExtensionEnvParser do
4+
describe '.call' do
5+
subject(:call) { described_class.call(value) }
6+
7+
context 'when value is an empty string' do
8+
let(:value) { '' }
9+
it 'returns an empty array' do
10+
is_expected.to eq([])
11+
end
12+
end
13+
14+
context 'when value contains multiple commas' do
15+
let(:value) { 'foo,bar,baz' }
16+
it 'returns an array with split values' do
17+
is_expected.to eq(['foo', 'bar', 'baz'])
18+
end
19+
end
20+
21+
context 'when value contains leading and trailing whitespace' do
22+
let(:value) { ' foo , bar , baz ' }
23+
it 'removes whitespace around values' do
24+
is_expected.to eq(['foo', 'bar', 'baz'])
25+
end
26+
end
27+
28+
context 'when value contains empty elements' do
29+
let(:value) { ',foo,,bar,,baz,' }
30+
it 'removes the empty elements' do
31+
is_expected.to eq(['foo', 'bar', 'baz'])
32+
end
33+
end
34+
35+
context 'when value contains repeated elements' do
36+
let(:value) { 'foo,foo' }
37+
it 'remove repeated elements' do
38+
is_expected.to eq(['foo'])
39+
end
40+
end
41+
end
42+
end

spec/datadog/tracing/contrib/graphql/configuration/settings_spec.rb

+32
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,36 @@
8080
end
8181
end
8282
end
83+
84+
describe 'error_extensions' do
85+
context 'when default' do
86+
it do
87+
settings = described_class.new
88+
89+
expect(settings.error_extensions).to eq([])
90+
end
91+
end
92+
93+
context 'when given an array' do
94+
it do
95+
error_extension = double
96+
97+
settings = described_class.new(error_extensions: [error_extension])
98+
99+
expect(settings.error_extensions).to eq([error_extension])
100+
end
101+
102+
context 'via the environment variable' do
103+
it do
104+
error_extension = 'foo,bar'
105+
106+
ClimateControl.modify('DD_TRACE_GRAPHQL_ERROR_EXTENSIONS' => error_extension) do
107+
settings = described_class.new
108+
109+
expect(settings.error_extensions).to eq(['foo', 'bar'])
110+
end
111+
end
112+
end
113+
end
114+
end
83115
end

spec/datadog/tracing/contrib/graphql/test_schema_examples.rb

+38-5
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ class #{prefix}TestGraphQLSchema < ::GraphQL::Schema
3535
3636
rescue_from(RuntimeError) do |err, obj, args, ctx, field|
3737
raise GraphQL::ExecutionError.new(err.message, extensions: {
38-
'int-1': 1,
39-
'str-1': '1',
40-
'array-1-2': [1,'2'],
41-
'': 'empty string',
42-
',': 'comma',
38+
'int': 1,
39+
'bool': true,
40+
'str': '1',
41+
'array-1-2': [1, '2'],
42+
'hash-a-b': {a: 'b'},
43+
'object': ::Object.new,
44+
'extra-int': 2, # This should not be included
4345
})
4446
end
4547
end
@@ -193,5 +195,36 @@ def unload_test_schema(prefix: '')
193195
)
194196
)
195197
end
198+
199+
context 'with error extension capture enabled' do
200+
around do |ex|
201+
ClimateControl.modify('DD_TRACE_GRAPHQL_ERROR_EXTENSIONS' => 'int,str,bool,array-1-2,hash-a-b,object') { ex.run }
202+
end
203+
204+
it 'creates query span for error with extensions' do
205+
expect(result.to_h['errors'][0]['message']).to eq('GraphQL error')
206+
207+
expect(graphql_execute.events[0]).to match(
208+
a_span_event_with(
209+
name: 'dd.graphql.query.error',
210+
attributes: {
211+
'path' => ['err1'],
212+
'locations' => ['1:14'],
213+
'message' => 'GraphQL error',
214+
'type' => 'GraphQL::ExecutionError',
215+
'stacktrace' => include(__FILE__),
216+
'extensions.int' => 1,
217+
'extensions.bool' => true,
218+
'extensions.str' => '1',
219+
'extensions.array-1-2' => '[1, "2"]',
220+
'extensions.hash-a-b' => { a: 'b' }.to_s, # Hash#to_s changes per Ruby version: 3.3: '{:a=>1}', 3.4: '{a: 1}'
221+
'extensions.object' => start_with('#<Object:'),
222+
}
223+
)
224+
)
225+
226+
expect(graphql_execute.events[0].attributes).to_not include('extensions.extra-int')
227+
end
228+
end
196229
end
197230
end

0 commit comments

Comments
 (0)