diff --git a/Makefile b/Makefile index edf1f18b04c..15b7527ad99 100644 --- a/Makefile +++ b/Makefile @@ -851,6 +851,7 @@ TEST_INTEGRATIONS_80 := \ test_integrations_pdo \ test_integrations_elasticsearch7 \ test_integrations_googlespanner_latest \ + test_integrations_graphql_latest \ test_integrations_guzzle5 \ test_integrations_guzzle6 \ test_integrations_guzzle_latest \ @@ -906,6 +907,7 @@ TEST_INTEGRATIONS_81 := \ test_opentelemetry_1 \ test_opentelemetry_beta \ test_integrations_googlespanner_latest \ + test_integrations_graphql_latest \ test_integrations_guzzle_latest \ test_integrations_pcntl \ test_integrations_pdo \ @@ -964,6 +966,7 @@ TEST_INTEGRATIONS_82 := \ test_opentelemetry_1 \ test_opentelemetry_beta \ test_integrations_googlespanner_latest \ + test_integrations_graphql_latest \ test_integrations_guzzle_latest \ test_integrations_pcntl \ test_integrations_pdo \ @@ -1030,6 +1033,7 @@ TEST_INTEGRATIONS_83 := \ test_opentelemetry_1 \ test_opentelemetry_beta \ test_integrations_googlespanner_latest \ + test_integrations_graphql_latest \ test_integrations_guzzle_latest \ test_integrations_pcntl \ test_integrations_pdo \ @@ -1090,6 +1094,7 @@ TEST_INTEGRATIONS_84 := \ test_integrations_openai_latest \ test_opentelemetry_1 \ test_integrations_googlespanner_latest \ + test_integrations_graphql_latest \ test_integrations_guzzle_latest \ test_integrations_pcntl \ test_integrations_pdo \ @@ -1367,6 +1372,8 @@ test_integrations_googlespanner_latest: global_test_run_dependencies tests/Integ $(call run_tests_debug,tests/Integrations/GoogleSpanner/Latest) $(eval TEST_EXTRA_INI=) $(eval TEST_EXTRA_ENV=) +test_integrations_graphql_latest: global_test_run_dependencies tests/Integrations/GraphQL/Latest/composer.lock-php$(PHP_MAJOR_MINOR) + $(call run_tests_debug,tests/Integrations/GraphQL/Latest) test_integrations_sqlsrv: global_test_run_dependencies $(eval TEST_EXTRA_INI=-d extension=sqlsrv.so) $(call run_tests_debug,tests/Integrations/SQLSRV) diff --git a/ext/integrations/integrations.c b/ext/integrations/integrations.c index cd8fb9bf9a0..5ea99c7b7d3 100644 --- a/ext/integrations/integrations.c +++ b/ext/integrations/integrations.c @@ -307,6 +307,13 @@ void ddtrace_integrations_minit(void) { DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_GOOGLESPANNER, "Google\\Cloud\\Spanner\\Database", "__construct", "DDTrace\\Integrations\\GoogleSpanner\\GoogleSpannerIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_GRAPHQL, "GraphQL\\GraphQL", "promiseToExecute", + "DDTrace\\Integrations\\GraphQL\\GraphQLIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_GRAPHQL, "GraphQL\\Validator\\DocumentValidator", "validate", + "DDTrace\\Integrations\\GraphQL\\GraphQLIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_GRAPHQL, "GraphQL\\Language\\Parser", "parse", + "DDTrace\\Integrations\\GraphQL\\GraphQLIntegration"); + DD_SET_UP_DEFERRED_LOADING_BY_METHOD(DDTRACE_INTEGRATION_GUZZLE, "GuzzleHttp\\Client", "__construct", "DDTrace\\Integrations\\Guzzle\\GuzzleIntegration"); diff --git a/ext/integrations/integrations.h b/ext/integrations/integrations.h index 5f528450cad..54e81de36f0 100644 --- a/ext/integrations/integrations.h +++ b/ext/integrations/integrations.h @@ -23,6 +23,7 @@ INTEGRATION_CUSTOM_ENABLED(FILESYSTEM, "filesystem", is_filesystem_enabled) \ INTEGRATION(FRANKENPHP, "frankenphp") \ INTEGRATION(GOOGLESPANNER, "googlespanner") \ + INTEGRATION(GRAPHQL, "graphql") \ INTEGRATION(GUZZLE, "guzzle") \ INTEGRATION(KAFKA, "kafka") \ INTEGRATION(LAMINAS, "laminas") \ @@ -43,7 +44,7 @@ INTEGRATION(PHPREDIS, "phpredis") \ INTEGRATION(PREDIS, "predis") \ INTEGRATION(PSR18, "psr18") \ - INTEGRATION(RATCHET, "ratchet") \ + INTEGRATION(RATCHET, "ratchet") \ INTEGRATION(ROADRUNNER, "roadrunner") \ INTEGRATION(SQLSRV, "sqlsrv") \ INTEGRATION(SLIM, "slim") \ diff --git a/src/DDTrace/Integrations/GraphQL/GraphQLIntegration.php b/src/DDTrace/Integrations/GraphQL/GraphQLIntegration.php new file mode 100644 index 00000000000..832cc1034cb --- /dev/null +++ b/src/DDTrace/Integrations/GraphQL/GraphQLIntegration.php @@ -0,0 +1,136 @@ +name = 'graphql.execute'; + $span->type = Type::GRAPHQL; + $span->meta[Tag::COMPONENT] = GraphQLIntegration::NAME; + + // Args are: + // 0: PromiseAdapter $promiseAdapter + // 1: Schema $schema + // 2: DocumentNode $documentNode + // 3: $rootValue = null + // 4: $contextValue = null + // 5: ?array $variableValues = null + // 6: ?string $operationName = null + // 7: ?callable $fieldResolver = null + // 8: ?callable $argsMapper = null + + // Set graphql.source from the document node + if (isset($args[2]) && isset($args[2]->loc) && isset($args[2]->loc->source)) { + $span->meta['graphql.source'] = $args[2]->loc->source->body; + } + + // Find the operation definition + if (isset($args[2]) && isset($args[2]->definitions)) { + $operationName = $args[6] ?? null; + $operationDefinition = null; + + // If operation name is provided, find the matching operation + if ($operationName !== null) { + foreach ($args[2]->definitions as $definition) { + if ($definition instanceof OperationDefinitionNode && + isset($definition->name) && + $definition->name->value === $operationName) { + $operationDefinition = $definition; + break; + } + } + } + + // If no operation name or no matching operation found, use the first operation definition + if ($operationDefinition === null) { + foreach ($args[2]->definitions as $definition) { + if ($definition instanceof OperationDefinitionNode) { + $operationDefinition = $definition; + break; + } + } + } + + // Set operation type and name if we found an operation definition + if ($operationDefinition !== null) { + if (isset($operationDefinition->operation)) { + $span->meta['graphql.operation.type'] = $operationDefinition->operation; + } + if (isset($operationDefinition->name->value)) { + $span->meta['graphql.operation.name'] = $operationDefinition->name->value; + } + } + } + + // Set graphql.variables from the variable values + if (isset($args[5])) { + foreach ($args[5] as $key => $value) { + $span->meta["graphql.variables.$key"] = is_scalar($value) ? $value : json_encode($value); + } + } + } + ); + + \DDTrace\trace_method( + 'GraphQL\Validator\DocumentValidator', + 'validate', + function (SpanData $span, $args) use ($integration) { + $span->name = 'graphql.validate'; + $span->type = Type::GRAPHQL; + $span->meta[Tag::COMPONENT] = GraphQLIntegration::NAME; + + // Args are: + // 0: Schema $schema + // 1: DocumentNode $ast + // 2: array $rules = null + // 3: array $typeInfo = null + + // Set graphql.source from the document node + if (isset($args[1]) && isset($args[1]->loc) && isset($args[1]->loc->source)) { + $span->meta['graphql.source'] = $args[1]->loc->source->body; + } + } + ); + + \DDTrace\trace_method( + 'GraphQL\Language\Parser', + 'parse', + function (SpanData $span, $args) use ($integration) { + $span->name = 'graphql.parse'; + $span->type = Type::GRAPHQL; + $span->meta[Tag::COMPONENT] = GraphQLIntegration::NAME; + + // Args are: + // 0: Source|string $source + // 1: array $options = [] + + // Set graphql.source + if (isset($args[0])) { + if (is_string($args[0])) { + $span->meta['graphql.source'] = $args[0]; + } elseif (is_object($args[0]) && isset($args[0]->body)) { + $span->meta['graphql.source'] = $args[0]->body; + } + } + } + ); + + return Integration::LOADED; + } +} \ No newline at end of file diff --git a/src/api/Type.php b/src/api/Type.php index c2de9948f39..b1ff45aa2df 100644 --- a/src/api/Type.php +++ b/src/api/Type.php @@ -29,4 +29,6 @@ class Type const REDIS = 'redis'; const SYSTEM = 'system'; + + const GRAPHQL = 'graphql'; } diff --git a/tests/Integrations/GraphQL/Latest/GraphQLIntegrationTest.php b/tests/Integrations/GraphQL/Latest/GraphQLIntegrationTest.php new file mode 100644 index 00000000000..1a595b35030 --- /dev/null +++ b/tests/Integrations/GraphQL/Latest/GraphQLIntegrationTest.php @@ -0,0 +1,152 @@ +isolateTracer(function () { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'hello' => [ + 'type' => Type::string(), + 'resolve' => function () { + return 'world'; + } + ] + ] + ]) + ]); + + $query = 'query { hello }'; + $result = GraphQL::executeQuery($schema, $query); + }); + + $this->assertSpans($traces, [ + SpanAssertion::build('graphql.parse', 'phpunit', 'graphql', 'graphql.parse') + ->withExactTags([ + 'graphql.source' => 'query { hello }', + Tag::COMPONENT => 'graphql' + ]), + SpanAssertion::build('graphql.validate', 'phpunit', 'graphql', 'graphql.validate') + ->withExactTags([ + 'graphql.source' => 'query { hello }', + Tag::COMPONENT => 'graphql' + ]), + SpanAssertion::build('graphql.execute', 'phpunit', 'graphql', 'graphql.execute') + ->withExactTags([ + 'graphql.source' => 'query { hello }', + 'graphql.operation.type' => 'query', + Tag::COMPONENT => 'graphql' + ]) + ]); + } + + public function testQueryWithVariables() + { + $traces = $this->isolateTracer(function () { + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greet' => [ + 'type' => Type::string(), + 'args' => [ + 'name' => ['type' => Type::string()] + ], + 'resolve' => function ($root, $args) { + return "Hello, {$args['name']}!"; + } + ] + ] + ]) + ]); + + $query = 'query Greet($name: String!) { greet(name: $name) }'; + $variables = ['name' => 'World']; + $result = GraphQL::executeQuery($schema, $query, null, null, $variables); + }); + + $this->assertSpans($traces, [ + SpanAssertion::build('graphql.parse', 'phpunit', 'graphql', 'graphql.parse') + ->withExactTags([ + 'graphql.source' => 'query Greet($name: String!) { greet(name: $name) }', + Tag::COMPONENT => 'graphql' + ]), + SpanAssertion::build('graphql.validate', 'phpunit', 'graphql', 'graphql.validate') + ->withExactTags([ + 'graphql.source' => 'query Greet($name: String!) { greet(name: $name) }', + Tag::COMPONENT => 'graphql' + ]), + SpanAssertion::build('graphql.execute', 'phpunit', 'graphql', 'graphql.execute') + ->withExactTags([ + 'graphql.source' => 'query Greet($name: String!) { greet(name: $name) }', + 'graphql.operation.type' => 'query', + 'graphql.operation.name' => 'Greet', + 'graphql.variables.name' => 'World', + Tag::COMPONENT => 'graphql' + ]) + ]); + } + + public function testMutation() + { + $traces = $this->isolateTracer(function () { + $schema = new Schema([ + 'mutation' => new ObjectType([ + 'name' => 'Mutation', + 'fields' => [ + 'createUser' => [ + 'type' => Type::string(), + 'args' => [ + 'name' => ['type' => Type::string()] + ], + 'resolve' => function ($root, $args) { + return "Created user: {$args['name']}"; + } + ] + ] + ]) + ]); + + $query = 'mutation CreateUser($name: String!) { createUser(name: $name) }'; + $variables = ['name' => 'John']; + $result = GraphQL::executeQuery($schema, $query, null, null, $variables); + }); + + $this->assertSpans($traces, [ + SpanAssertion::build('graphql.parse', 'phpunit', 'graphql', 'graphql.parse') + ->withExactTags([ + 'graphql.source' => 'mutation CreateUser($name: String!) { createUser(name: $name) }', + Tag::COMPONENT => 'graphql' + ]), + SpanAssertion::build('graphql.validate', 'phpunit', 'graphql', 'graphql.validate') + ->withExactTags([ + 'graphql.source' => 'mutation CreateUser($name: String!) { createUser(name: $name) }', + Tag::COMPONENT => 'graphql' + ]), + SpanAssertion::build('graphql.execute', 'phpunit', 'graphql', 'graphql.execute') + ->withExactTags([ + 'graphql.source' => 'mutation CreateUser($name: String!) { createUser(name: $name) }', + 'graphql.operation.type' => 'mutation', + 'graphql.operation.name' => 'CreateUser', + 'graphql.variables.name' => 'John', + Tag::COMPONENT => 'graphql' + ]) + ]); + } +} \ No newline at end of file diff --git a/tests/Integrations/GraphQL/Latest/composer.json b/tests/Integrations/GraphQL/Latest/composer.json new file mode 100644 index 00000000000..be479ccf3ef --- /dev/null +++ b/tests/Integrations/GraphQL/Latest/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "webonyx/graphql-php": "15.20.0" + } +} \ No newline at end of file