From cfc5a26f5eef7fdd29b218dbbd8023e009c68fba Mon Sep 17 00:00:00 2001 From: Bartlomiej Obecny Date: Wed, 26 Aug 2020 14:39:05 +0200 Subject: [PATCH 1/3] chore: gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9900542847..8738ce6646 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ package.json.lerna_backup # VsCode configs .vscode/ + +*.iml +.idea From 8728f5efe3e9662829bb3cf68176564e41a6cd05 Mon Sep 17 00:00:00 2001 From: Bartlomiej Obecny Date: Tue, 20 Oct 2020 15:31:48 +0200 Subject: [PATCH 2/3] feat: graphql instrumentation --- examples/graphql/README.md | 57 + examples/graphql/client.js | 49 + examples/graphql/docker/collector-config.yaml | 29 + examples/graphql/docker/docker-compose.yaml | 30 + examples/graphql/docker/prometheus.yaml | 9 + examples/graphql/package.json | 52 + examples/graphql/schema.js | 202 +++ examples/graphql/server-apollo.js | 21 + examples/graphql/server-express.js | 19 + examples/graphql/tracer.js | 33 + package.json | 5 +- .../.eslintignore | 1 + .../.eslintrc.js | 7 + .../.gitignore | 2 + .../.npmignore | 4 + .../LICENSE | 201 +++ .../README.md | 76 ++ .../package.json | 67 + .../src/enum.ts | 66 + .../src/graphql.ts | 433 +++++++ .../src/index.ts | 18 + .../src/symbols.ts | 23 + .../src/types.ts | 149 +++ .../src/utils.ts | 376 ++++++ .../src/version.ts | 18 + .../test/graphql.test.ts | 1124 +++++++++++++++++ .../test/helper.ts | 38 + .../test/schema.ts | 233 ++++ .../tsconfig.json | 11 + 29 files changed, 3352 insertions(+), 1 deletion(-) create mode 100644 examples/graphql/README.md create mode 100644 examples/graphql/client.js create mode 100644 examples/graphql/docker/collector-config.yaml create mode 100644 examples/graphql/docker/docker-compose.yaml create mode 100644 examples/graphql/docker/prometheus.yaml create mode 100644 examples/graphql/package.json create mode 100644 examples/graphql/schema.js create mode 100644 examples/graphql/server-apollo.js create mode 100644 examples/graphql/server-express.js create mode 100644 examples/graphql/tracer.js create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/.eslintignore create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/.eslintrc.js create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/.gitignore create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/.npmignore create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/LICENSE create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/README.md create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/package.json create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/src/enum.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/src/index.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/src/symbols.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/src/types.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/src/utils.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/src/version.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/test/helper.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/test/schema.ts create mode 100644 plugins/node/opentelemetry-instrumentation-graphql/tsconfig.json diff --git a/examples/graphql/README.md b/examples/graphql/README.md new file mode 100644 index 0000000000..465439fd47 --- /dev/null +++ b/examples/graphql/README.md @@ -0,0 +1,57 @@ +# Overview OpenTelemetry GraphQL Instrumentation Example + +This example shows how to use 2 popular graphql servers + +- [Apollo GraphQL](https://www.npmjs.com/package/apollo-server) +- [GraphQL HTTP Server Middleware](https://www.npmjs.com/package/express-graphql) + +and [@opentelemetry/instrumentation-graphql](https://github.com/open-telemetry/opentelemetry-js/tree/master/packages/opentelemetry-instrumentation-graphql) to instrument a simple Node.js application. + +This instrumentation should work with any graphql server as it instruments graphql directly. + +This example will export spans data simultaneously using [Exporter Collector](https://github.com/open-telemetry/opentelemetry-js/tree/master/packages/opentelemetry-exporter-collector). + +## Installation + +```shell script +# from this directory +npm install +``` + +## Run the Application + +1. Run docker + + ```shell script + # from this directory + npm run docker:start + ``` + +2. Run server - depends on your preference + + ```shell script + # from this directory + npm run server:express + // or + npm run server:apollo + ``` + +3. Open page at - you should be able to see the spans in zipkin + +4. Run example client + + ```shell script + # from this directory + npm run client + ``` + +5. You can also write your own queries, open page `http://localhost:4000/graphql` + +## Useful links + +- For more information on OpenTelemetry, visit: +- For more information on tracing, visit: + +## LICENSE + +Apache License 2.0 diff --git a/examples/graphql/client.js b/examples/graphql/client.js new file mode 100644 index 0000000000..3aa487a1ad --- /dev/null +++ b/examples/graphql/client.js @@ -0,0 +1,49 @@ +'use strict'; + +const url = require('url'); +const http = require('http'); +// Construct a schema, using GraphQL schema language + +const source = ` +query { + books { + name + authors { + name + address { + country + } + } + } +} +`; + +makeRequest(source).then(console.log); + +function makeRequest(query) { + return new Promise((resolve, reject) => { + const parsedUrl = new url.URL('http://localhost:4000/graphql'); + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }; + const req = http.request(options, (res) => { + const data = []; + res.on('data', (chunk) => data.push(chunk)); + res.on('end', () => { + resolve(data.toString()); + }); + res.on('error', (err) => { + reject(err); + }); + }); + + req.write(JSON.stringify({ query })); + req.end(); + }); +} diff --git a/examples/graphql/docker/collector-config.yaml b/examples/graphql/docker/collector-config.yaml new file mode 100644 index 0000000000..e9a909d78f --- /dev/null +++ b/examples/graphql/docker/collector-config.yaml @@ -0,0 +1,29 @@ +receivers: + otlp: + protocols: + grpc: + http: + cors_allowed_origins: + - http://* + - https://* + +exporters: + zipkin: + endpoint: "http://zipkin-all-in-one:9411/api/v2/spans" + prometheus: + endpoint: "0.0.0.0:9464" + +processors: + batch: + queued_retry: + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [zipkin] + processors: [batch, queued_retry] + metrics: + receivers: [otlp] + exporters: [prometheus] + processors: [batch, queued_retry] diff --git a/examples/graphql/docker/docker-compose.yaml b/examples/graphql/docker/docker-compose.yaml new file mode 100644 index 0000000000..d4e0a42a19 --- /dev/null +++ b/examples/graphql/docker/docker-compose.yaml @@ -0,0 +1,30 @@ +version: "3" +services: + # Collector + collector: +# image: otel/opentelemetry-collector:latest + image: otel/opentelemetry-collector:0.13.0 + command: ["--config=/conf/collector-config.yaml", "--log-level=DEBUG"] + volumes: + - ./collector-config.yaml:/conf/collector-config.yaml + ports: + - "9464:9464" + - "55680:55680" + - "55681:55681" + depends_on: + - zipkin-all-in-one + + # Zipkin + zipkin-all-in-one: + image: openzipkin/zipkin:latest + ports: + - "9411:9411" + + # Prometheus + prometheus: + container_name: prometheus + image: prom/prometheus:latest + volumes: + - ./prometheus.yaml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" diff --git a/examples/graphql/docker/prometheus.yaml b/examples/graphql/docker/prometheus.yaml new file mode 100644 index 0000000000..b027daf9a0 --- /dev/null +++ b/examples/graphql/docker/prometheus.yaml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s # Default is every 1 minute. + +scrape_configs: + - job_name: 'collector' + # metrics_path defaults to '/metrics' + # scheme defaults to 'http'. + static_configs: + - targets: ['collector:9464'] diff --git a/examples/graphql/package.json b/examples/graphql/package.json new file mode 100644 index 0000000000..1451eae8b3 --- /dev/null +++ b/examples/graphql/package.json @@ -0,0 +1,52 @@ +{ + "name": "opentelemetry-plugin-graphql-example", + "private": true, + "version": "0.11.0", + "description": "Example of using @opentelemetry/plugin-graphql with OpenTelemetry", + "main": "index.js", + "scripts": { + "client": "node ./client.js", + "docker:start": "cd ./docker && docker-compose down && docker-compose up", + "docker:startd": "cd ./docker && docker-compose down && docker-compose up -d", + "docker:stop": "cd ./docker && docker-compose down", + "server:express": "node ./server-express.js", + "server:apollo": "node ./server-apollo.js" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/open-telemetry/opentelemetry-js.git" + }, + "keywords": [ + "opentelemetry", + "http", + "tracing", + "graphql" + ], + "engines": { + "node": ">=8" + }, + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/open-telemetry/opentelemetry-js/issues" + }, + "dependencies": { + "@opentelemetry/api": "^0.12.0", + "@opentelemetry/exporter-collector": "^0.12.0", + "@opentelemetry/node": "^0.12.0", + "@opentelemetry/plugin-express": "^0.10.0", + "@opentelemetry/instrumentation-graphql": "^0.11.0", + "@opentelemetry/plugin-http": "^0.12.0", + "@opentelemetry/plugin-https": "^0.12.0", + "@opentelemetry/tracing": "^0.12.0", + "apollo-server": "^2.18.1", + "express": "^4.17.1", + "express-graphql": "^0.11.0", + "graphql": "^15.3.0", + "qs-middleware": "^1.0.3" + }, + "homepage": "https://github.com/open-telemetry/opentelemetry-js#readme", + "devDependencies": { + "cross-env": "^6.0.0" + } +} diff --git a/examples/graphql/schema.js b/examples/graphql/schema.js new file mode 100644 index 0000000000..be98379c40 --- /dev/null +++ b/examples/graphql/schema.js @@ -0,0 +1,202 @@ +'use strict'; + +const https = require('https'); +const graphql = require('graphql'); + +const url1 = 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json'; + +function getData(url) { + return new Promise((resolve, reject) => { + https.get(url, (response) => { + let data = ''; + response.on('data', (chunk) => { + data += chunk; + }); + response.on('end', () => { + resolve(JSON.parse(data)); + }); + }).on('error', (err) => { + reject(err); + }); + }); +} + +const authors = []; +const books = []; + +function addBook(name, authorIds) { + let authorIdsLocal = authorIds; + if (typeof authorIdsLocal === 'string') { + authorIdsLocal = authorIdsLocal.split(',').map((id) => parseInt(id, 10)); + } + const id = books.length; + books.push({ + id, + name, + authorIds: authorIdsLocal, + }); + return books[books.length - 1]; +} + +function addAuthor(name, country, city) { + const id = authors.length; + authors.push({ + id, + name, + address: { + country, + city, + }, + }); + return authors[authors.length - 1]; +} + +function getBook(id) { + return books[id]; +} + +function getAuthor(id) { + return authors[id]; +} + +function prepareData() { + addAuthor('John', 'Poland', 'Szczecin'); + addAuthor('Alice', 'Poland', 'Warsaw'); + addAuthor('Bob', 'England', 'London'); + addAuthor('Christine', 'France', 'Paris'); + addBook('First Book', [0, 1]); + addBook('Second Book', [2]); + addBook('Third Book', [3]); +} + +prepareData(); +module.exports = function buildSchema() { + const Author = new graphql.GraphQLObjectType({ + name: 'Author', + fields: { + id: { + type: graphql.GraphQLString, + resolve(obj, _args) { + return obj.id; + }, + }, + name: { + type: graphql.GraphQLString, + resolve(obj, _args) { + return obj.name; + }, + }, + description: { + type: graphql.GraphQLString, + resolve(_obj, _args) { + return new Promise((resolve, reject) => { + getData(url1).then((response) => { + resolve(response.description); + }, reject); + }); + }, + }, + address: { + type: new graphql.GraphQLObjectType({ + name: 'Address', + fields: { + country: { + type: graphql.GraphQLString, + resolve(obj, _args) { + return obj.country; + }, + }, + city: { + type: graphql.GraphQLString, + resolve(obj, _args) { + return obj.city; + }, + }, + }, + }), + resolve(obj, _args) { + return obj.address; + }, + }, + }, + }); + + const Book = new graphql.GraphQLObjectType({ + name: 'Book', + fields: { + id: { + type: graphql.GraphQLInt, + resolve(obj, _args) { + return obj.id; + }, + }, + name: { + type: graphql.GraphQLString, + resolve(obj, _args) { + return obj.name; + }, + }, + authors: { + type: new graphql.GraphQLList(Author), + resolve(obj, _args) { + return obj.authorIds.map((id) => authors[id]); + }, + }, + }, + }); + + const query = new graphql.GraphQLObjectType({ + name: 'Query', + fields: { + author: { + type: Author, + args: { + id: { type: graphql.GraphQLInt }, + }, + resolve(obj, args, _context) { + return Promise.resolve(getAuthor(args.id)); + }, + }, + authors: { + type: new graphql.GraphQLList(Author), + resolve(_obj, _args, _context) { + return Promise.resolve(authors); + }, + }, + book: { + type: Book, + args: { + id: { type: graphql.GraphQLInt }, + }, + resolve(obj, args, _context) { + return Promise.resolve(getBook(args.id)); + }, + }, + books: { + type: new graphql.GraphQLList(Book), + resolve(_obj, _args, _context) { + return Promise.resolve(books); + }, + }, + }, + }); + + const mutation = new graphql.GraphQLObjectType({ + name: 'Mutation', + fields: { + addBook: { + type: Book, + args: { + name: { type: new graphql.GraphQLNonNull(graphql.GraphQLString) }, + authorIds: { type: new graphql.GraphQLNonNull(graphql.GraphQLString) }, + }, + resolve(obj, args, _context) { + return Promise.resolve(addBook(args.name, args.authorIds)); + }, + }, + }, + }); + + const schema = new graphql.GraphQLSchema({ query, mutation }); + return schema; +}; diff --git a/examples/graphql/server-apollo.js b/examples/graphql/server-apollo.js new file mode 100644 index 0000000000..287e95700d --- /dev/null +++ b/examples/graphql/server-apollo.js @@ -0,0 +1,21 @@ +'use strict'; + +require('./tracer'); + +const { ApolloServer } = require('apollo-server'); +const buildSchema = require('./schema'); + +// Construct a schema, using GraphQL schema language +const schema = buildSchema(); + +const server = new ApolloServer({ schema }); +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); + +// app.use('/graphql', server); +// app.listen(4000); + +// server.applyMiddleware({ app }); + +console.log('Running a GraphQL API server at http://localhost:4000/graphql'); diff --git a/examples/graphql/server-express.js b/examples/graphql/server-express.js new file mode 100644 index 0000000000..6bba89f817 --- /dev/null +++ b/examples/graphql/server-express.js @@ -0,0 +1,19 @@ +'use strict'; + +require('./tracer'); + +const express = require('express'); +const { graphqlHTTP } = require('express-graphql'); +const buildSchema = require('./schema'); + +const schema = buildSchema(); + +const app = express(); +app.use('/graphql', graphqlHTTP({ + schema, + graphiql: true, +})); + +app.listen(4000); + +console.log('Running a GraphQL API server at http://localhost:4000/graphql'); diff --git a/examples/graphql/tracer.js b/examples/graphql/tracer.js new file mode 100644 index 0000000000..4be5684dfd --- /dev/null +++ b/examples/graphql/tracer.js @@ -0,0 +1,33 @@ +'use strict'; + +const { GraphQLInstrumentation } = require('@opentelemetry/instrumentation-graphql'); + +const { ConsoleSpanExporter, SimpleSpanProcessor } = require('@opentelemetry/tracing'); +const { NodeTracerProvider } = require('@opentelemetry/node'); +const { CollectorTraceExporter } = require('@opentelemetry/exporter-collector'); + +const exporter = new CollectorTraceExporter({ + serviceName: 'basic-service', +}); + +const provider = new NodeTracerProvider({ + plugins: { + http: { enabled: false, path: '@opentelemetry/plugin-http' }, + https: { enabled: false, path: '@opentelemetry/plugin-https' }, + express: { enabled: false, path: '@opentelemetry/plugin-express' }, + }, +}); + +const graphQLInstrumentation = new GraphQLInstrumentation({ + // allowAttributes: true, + // depth: 2, + // mergeItems: true, +}); + +graphQLInstrumentation.setTracerProvider(provider); + +graphQLInstrumentation.enable(); + +provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); +provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); +provider.register(); diff --git a/package.json b/package.json index 59ca9f3b01..502e2e9051 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,9 @@ "internal": ":house: Internal", "documentation": ":memo: Documentation" }, - "ignoreCommitters": ["renovate-bot", "dependabot"] + "ignoreCommitters": [ + "renovate-bot", + "dependabot" + ] } } diff --git a/plugins/node/opentelemetry-instrumentation-graphql/.eslintignore b/plugins/node/opentelemetry-instrumentation-graphql/.eslintignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/.eslintignore @@ -0,0 +1 @@ +build diff --git a/plugins/node/opentelemetry-instrumentation-graphql/.eslintrc.js b/plugins/node/opentelemetry-instrumentation-graphql/.eslintrc.js new file mode 100644 index 0000000000..f756f4488b --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "env": { + "mocha": true, + "node": true + }, + ...require('../../../eslint.config.js') +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/.gitignore b/plugins/node/opentelemetry-instrumentation-graphql/.gitignore new file mode 100644 index 0000000000..ba80b4026b --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/.gitignore @@ -0,0 +1,2 @@ +# Dependency directories +!test/instrumentation/node_modules diff --git a/plugins/node/opentelemetry-instrumentation-graphql/.npmignore b/plugins/node/opentelemetry-instrumentation-graphql/.npmignore new file mode 100644 index 0000000000..9505ba9450 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/.npmignore @@ -0,0 +1,4 @@ +/bin +/coverage +/doc +/test diff --git a/plugins/node/opentelemetry-instrumentation-graphql/LICENSE b/plugins/node/opentelemetry-instrumentation-graphql/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/node/opentelemetry-instrumentation-graphql/README.md b/plugins/node/opentelemetry-instrumentation-graphql/README.md new file mode 100644 index 0000000000..80890ebbd5 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/README.md @@ -0,0 +1,76 @@ +# OpenTelemetry Instrumentation GraphQL + +[![Gitter chat][gitter-image]][gitter-url] +[![NPM Published Version][npm-img]][npm-url] +[![dependencies][dependencies-image]][dependencies-url] +[![devDependencies][devDependencies-image]][devDependencies-url] +[![Apache License][license-image]][license-image] + +This module provides *automated instrumentation and tracing* for GraphQL in Node.js applications. + +## Installation + +```shell script +npm install @opentelemetry/instrumentation-graphql +``` + +## Usage + +```js +'use strict'; + +const { GraphQLInstrumentation } = require('@opentelemetry/instrumentation-graphql'); + +const { ConsoleSpanExporter, SimpleSpanProcessor } = require('@opentelemetry/tracing'); +const { NodeTracerProvider } = require('@opentelemetry/node'); +const { CollectorTraceExporter } = require('@opentelemetry/exporter-collector'); + +const exporter = new CollectorTraceExporter({ + serviceName: 'basic-service', +}); + +const provider = new NodeTracerProvider({ + plugins: { + http: { enabled: false, path: '@opentelemetry/plugin-http' }, + https: { enabled: false, path: '@opentelemetry/plugin-https' }, + express: { enabled: false, path: '@opentelemetry/plugin-express' }, + }, +}); + +const graphQLInstrumentation = new GraphQLInstrumentation({ +// optional params + // allowAttributes: true, + // depth: 2, + // mergeItems: true, +}); + +graphQLInstrumentation.setTracerProvider(provider); + +graphQLInstrumentation.enable(); + +provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); +provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter())); +provider.register(); + +``` + +## Useful links + +- For more information on OpenTelemetry, visit: +- For more about OpenTelemetry JavaScript: +- For help or feedback on this project, join us on [gitter][gitter-url] + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. + +[gitter-image]: https://badges.gitter.im/open-telemetry/opentelemetry-js.svg +[gitter-url]: https://gitter.im/open-telemetry/opentelemetry-node?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge +[license-url]: https://github.com/open-telemetry/opentelemetry-js/blob/master/LICENSE +[license-image]: https://img.shields.io/badge/license-Apache_2.0-green.svg?style=flat +[dependencies-image]: https://david-dm.org/open-telemetry/opentelemetry-js/status.svg?path=packages/opentelemetry-instrumentation-graphql +[dependencies-url]: https://david-dm.org/open-telemetry/opentelemetry-js?path=packages%2Fopentelemetry-instrumentation-graphql +[devDependencies-image]: https://david-dm.org/open-telemetry/opentelemetry-js/dev-status.svg?path=packages/opentelemetry-instrumentation-graphql +[devDependencies-url]: https://david-dm.org/open-telemetry/opentelemetry-js?path=packages%2Fopentelemetry-instrumentation-graphql&type=dev +[npm-url]: https://www.npmjs.com/package/@opentelemetry/instrumentation-graphql +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-graphql.svg diff --git a/plugins/node/opentelemetry-instrumentation-graphql/package.json b/plugins/node/opentelemetry-instrumentation-graphql/package.json new file mode 100644 index 0000000000..0ed50176b2 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/package.json @@ -0,0 +1,67 @@ +{ + "name": "@opentelemetry/instrumentation-graphql", + "version": "0.11.0", + "description": "OpenTelemetry @opentelemetry/instrumentation-graphql automatic instrumentation package.", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": "open-telemetry/opentelemetry-js", + "scripts": { + "clean": "rimraf build/*", + "codecov": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../../", + "compile": "npm run version:update && tsc -p .", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "precompile": "tsc --version", + "prepare": "npm run compile", + "test": "nyc ts-mocha -p tsconfig.json 'test/**/*.test.ts'", + "tdd": "npm run test -- --watch-extensions ts --watch", + "version:update": "node ../../../scripts/version-update.js", + "watch": "tsc -w" + }, + "keywords": [ + "opentelemetry", + "nodejs", + "tracing", + "metrics", + "stats", + "graphql" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts", + "doc", + "LICENSE", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@opentelemetry/tracing": "^0.12.0", + "@types/graphql": "^14.5.0", + "@types/mocha": "8.0.1", + "@types/node": "14.0.27", + "@types/semver": "7.3.1", + "codecov": "3.7.2", + "graphql": "^15.3.0", + "gts": "2.0.2", + "mocha": "7.2.0", + "nyc": "15.1.0", + "rimraf": "3.0.2", + "semver": "^7.1.3", + "shimmer": "1.2.1", + "ts-mocha": "7.0.0", + "ts-node": "8.10.2", + "typescript": "3.9.7" + }, + "dependencies": { + "@opentelemetry/api": "^0.12.0", + "@opentelemetry/instrumentation": "^0.12.0" + } +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/enum.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/enum.ts new file mode 100644 index 0000000000..20d7e0c914 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/enum.ts @@ -0,0 +1,66 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export enum AllowedOperationTypes { + QUERY = 'query', + MUTATION = 'mutation', + SUBSCRIPTION = 'subscription', +} + +export enum TokenKind { + SOF = '', + EOF = '', + BANG = '!', + DOLLAR = '$', + AMP = '&', + PAREN_L = '(', + PAREN_R = ')', + SPREAD = '...', + COLON = ':', + EQUALS = '=', + AT = '@', + BRACKET_L = '[', + BRACKET_R = ']', + BRACE_L = '{', + PIPE = '|', + BRACE_R = '}', + NAME = 'Name', + INT = 'Int', + FLOAT = 'Float', + STRING = 'String', + BLOCK_STRING = 'BlockString', + COMMENT = 'Comment', +} + +export enum SpanAttributes { + COMPONENT = 'graphql', + SOURCE = 'graphql.source', + FIELD_NAME = 'graphql.field.name', + FIELD_PATH = 'graphql.field.path', + FIELD_TYPE = 'graphql.field.type', + OPERATION = 'graphql.operation.name', + VARIABLES = 'graphql.variables.', + ERROR_VALIDATION_NAME = 'graphql.validation.error', +} + +export enum SpanNames { + EXECUTE = 'graphql.execute', + PARSE = 'graphql.parse', + RESOLVE = 'graphql.resolve', + VALIDATE = 'graphql.validate', + SCHEMA_VALIDATE = 'graphql.validateSchema', + SCHEMA_PARSE = 'graphql.parseSchema', +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts new file mode 100644 index 0000000000..8434d6facf --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts @@ -0,0 +1,433 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + isWrapped, + InstrumentationBase, + InstrumentationConfig, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { Maybe } from 'graphql/jsutils/Maybe'; +import type * as graphqlTypes from 'graphql'; +import { GraphQLFieldResolver } from 'graphql/type/definition'; +import { SpanAttributes, SpanNames } from './enum'; +import { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_SPAN_SYMBOL } from './symbols'; + +import { + executeFunctionWithObj, + executeArgumentsArray, + executeType, + parseType, + validateType, + GraphQLInstrumentationConfig, + GraphQLInstrumentationParsedConfig, + OtelExecutionArgs, + ObjectWithGraphQLData, + ObjectWithOtelSpan, + OPERATION_NOT_SUPPORTED, +} from './types'; +import { + addSpanSource, + endSpan, + getOperation, + wrapFieldResolver, + wrapFields, +} from './utils'; + +import { VERSION } from './version'; +import * as api from '@opentelemetry/api'; +import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; + +const DEFAULT_CONFIG: GraphQLInstrumentationConfig = { + mergeItems: false, + depth: -1, + allowValues: false, +}; + +export class GraphQLInstrumentation extends InstrumentationBase { + constructor(config: GraphQLInstrumentationConfig & InstrumentationConfig = {}) { + super('graphql', VERSION, Object.assign({}, DEFAULT_CONFIG, config)); + } + + private _getConfig(): GraphQLInstrumentationParsedConfig { + return this._config as GraphQLInstrumentationParsedConfig; + } + + setConfig(config: GraphQLInstrumentationConfig & InstrumentationConfig = {}) { + this._config = Object.assign({}, DEFAULT_CONFIG, config); + } + + protected init() { + const module = new InstrumentationNodeModuleDefinition( + 'graphql', + ['15.*'] + ); + module.files.push(this._addPatchingExecute()); + module.files.push(this._addPatchingParser()); + module.files.push(this._addPatchingValidate()); + + return module; + } + + private _addPatchingExecute(): InstrumentationNodeModuleFile< + typeof graphqlTypes + > { + return new InstrumentationNodeModuleFile( + 'graphql/execution/execute.js', + // cannot make it work with appropriate type as execute function has 2 + //types and/cannot import function but only types + (moduleExports: any) => { + if (isWrapped(moduleExports.execute)) { + this._unwrap(moduleExports, 'execute'); + } + this._wrap( + moduleExports, + 'execute', + this._patchExecute(moduleExports.defaultFieldResolver) + ); + return moduleExports; + }, + moduleExports => { + if (moduleExports) { + this._unwrap(moduleExports, 'execute'); + } + } + ); + } + + private _addPatchingParser(): InstrumentationNodeModuleFile< + typeof graphqlTypes + > { + return new InstrumentationNodeModuleFile( + 'graphql/language/parser.js', + moduleExports => { + if (isWrapped(moduleExports.execute)) { + this._unwrap(moduleExports, 'parse'); + } + this._wrap(moduleExports, 'parse', this._patchParse()); + return moduleExports; + }, + moduleExports => { + if (moduleExports) { + this._unwrap(moduleExports, 'parse'); + } + } + ); + } + + private _addPatchingValidate(): InstrumentationNodeModuleFile< + typeof graphqlTypes + > { + return new InstrumentationNodeModuleFile( + 'graphql/validation/validate.js', + moduleExports => { + if (isWrapped(moduleExports.execute)) { + this._unwrap(moduleExports, 'validate'); + } + this._wrap(moduleExports, 'validate', this._patchValidate()); + return moduleExports; + }, + moduleExports => { + if (moduleExports) { + this._unwrap(moduleExports, 'validate'); + } + } + ); + } + + private _patchExecute( + defaultFieldResolved: GraphQLFieldResolver + ): (original: executeType) => executeType { + const instrumentation = this; + return function execute(original) { + return function patchExecute( + this: executeType + ): PromiseOrValue { + let processedArgs: OtelExecutionArgs; + + // case when apollo server is used for example + if (arguments.length >= 2) { + const args = (arguments as unknown) as executeArgumentsArray; + processedArgs = instrumentation._wrapExecuteArgs( + args[0], + args[1], + args[2], + args[3], + args[4], + args[5], + args[6] || defaultFieldResolved, + args[7] + ); + } else { + const args = arguments[0] as graphqlTypes.ExecutionArgs; + processedArgs = instrumentation._wrapExecuteArgs( + args.schema, + args.document, + args.rootValue, + args.contextValue, + args.variableValues, + args.operationName, + args.fieldResolver || defaultFieldResolved, + args.typeResolver + ); + } + + const operation = getOperation( + processedArgs.document, + processedArgs.operationName + ); + + const span = instrumentation._createExecuteSpan(operation, processedArgs); + + processedArgs.contextValue[OTEL_GRAPHQL_DATA_SYMBOL] = { + source: processedArgs.document + ? processedArgs.document || + (processedArgs.document as ObjectWithGraphQLData)[ + OTEL_GRAPHQL_DATA_SYMBOL + ] + : undefined, + span, + fields: {}, + }; + + return instrumentation.tracer.withSpan(span, () => { + return safeExecuteInTheMiddle< + PromiseOrValue + >( + () => { + return (original as executeFunctionWithObj).apply(this, [ + processedArgs, + ]); + }, + err => { + endSpan(span, err); + } + ); + }); + }; + }; + } + + private _patchParse(): (original: parseType) => parseType { + const instrumentation = this; + return function parse(original) { + return function patchParse( + this: parseType, + source: string | graphqlTypes.Source, + options?: graphqlTypes.ParseOptions + ): graphqlTypes.DocumentNode { + return instrumentation._parse(this, original, source, options); + }; + }; + } + + private _patchValidate(): (original: validateType) => validateType { + const instrumentation = this; + return function validate(original: validateType) { + return function patchValidate( + this: validateType, + schema: graphqlTypes.GraphQLSchema, + documentAST: graphqlTypes.DocumentNode, + rules?: ReadonlyArray, + typeInfo?: graphqlTypes.TypeInfo, + options?: { maxErrors?: number } + ): ReadonlyArray { + return instrumentation._validate( + this, + original, + schema, + documentAST, + rules, + typeInfo, + options + ); + }; + }; + } + + private _parse( + obj: parseType, + original: parseType, + source: string | graphqlTypes.Source, + options?: graphqlTypes.ParseOptions + ): graphqlTypes.DocumentNode { + const config = this._getConfig(); + const span = this.tracer.startSpan(SpanNames.PARSE); + + return this.tracer.withSpan(span, () => { + return safeExecuteInTheMiddle< + graphqlTypes.DocumentNode & ObjectWithGraphQLData + >( + () => { + return original.call(obj, source, options); + }, + (err, result) => { + if (result) { + (result as ObjectWithOtelSpan)[OTEL_SPAN_SYMBOL] = span; + const operation = getOperation(result); + if (!operation) { + span.updateName(SpanNames.SCHEMA_PARSE); + } else if (result.loc) { + addSpanSource(span, result.loc, config.allowValues); + } + } + endSpan(span, err); + } + ); + }); + } + + private _validate( + obj: validateType, + original: validateType, + schema: graphqlTypes.GraphQLSchema, + documentAST: graphqlTypes.DocumentNode, + rules?: ReadonlyArray, + typeInfo?: graphqlTypes.TypeInfo, + options?: { maxErrors?: number } + ): ReadonlyArray { + const document = documentAST as ObjectWithOtelSpan; + const span = this.tracer.startSpan(SpanNames.VALIDATE, { + parent: document[OTEL_SPAN_SYMBOL], + }); + document[OTEL_SPAN_SYMBOL] = span; + + return this.tracer.withSpan(span, () => { + return safeExecuteInTheMiddle>( + () => { + return original.call( + obj, + schema, + documentAST, + rules, + typeInfo, + options + ); + }, + (err, errors) => { + if (!documentAST.loc) { + span.updateName(SpanNames.SCHEMA_VALIDATE); + } + if (errors && errors.length) { + span.recordException({ + name: SpanAttributes.ERROR_VALIDATION_NAME, + message: JSON.stringify(errors), + }); + } + endSpan(span, err); + } + ); + }); + } + + private _createExecuteSpan( + operation: graphqlTypes.DefinitionNode | undefined, + processedArgs: graphqlTypes.ExecutionArgs + ): api.Span { + const config = this._getConfig(); + const document = processedArgs.document as ObjectWithOtelSpan; + const span = this.tracer.startSpan(SpanNames.EXECUTE, { + parent: document[OTEL_SPAN_SYMBOL], + }); + if (operation) { + const name = (operation as graphqlTypes.OperationDefinitionNode) + .operation; + if (name) { + span.setAttribute(SpanAttributes.OPERATION, name); + } + } else { + let operationName = ' '; + if (processedArgs.operationName) { + operationName = ` "${processedArgs.operationName}" `; + } + operationName = OPERATION_NOT_SUPPORTED.replace( + '$operationName$', + operationName + ); + span.setAttribute(SpanAttributes.OPERATION, operationName); + } + + if (processedArgs.document?.loc) { + addSpanSource(span, processedArgs.document.loc, config.allowValues); + } + + if (processedArgs.variableValues && config.allowValues) { + Object.entries(processedArgs.variableValues).forEach(([key, value]) => { + span.setAttribute(`${SpanAttributes.VARIABLES}${String(key)}`, value); + }); + } + + return span; + } + + private _wrapExecuteArgs( + schema: graphqlTypes.GraphQLSchema, + document: graphqlTypes.DocumentNode, + rootValue: any, + contextValue: any, + variableValues: Maybe<{ [key: string]: any }>, + operationName: Maybe, + fieldResolver: Maybe>, + typeResolver: Maybe> + ): OtelExecutionArgs { + if (!contextValue) { + contextValue = {}; + } + if (contextValue[OTEL_GRAPHQL_DATA_SYMBOL]) { + return { + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + typeResolver, + }; + } + fieldResolver = wrapFieldResolver( + this.tracer, + this._getConfig.bind(this), + fieldResolver + ); + + if (schema) { + wrapFields( + schema.getQueryType(), + this.tracer, + this._getConfig.bind(this) + ); + wrapFields( + schema.getMutationType(), + this.tracer, + this._getConfig.bind(this) + ); + } + + return { + schema, + document, + rootValue, + contextValue, + variableValues, + operationName, + fieldResolver, + typeResolver, + }; + } +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/index.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/index.ts new file mode 100644 index 0000000000..11a3f30042 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './graphql'; +export * from './symbols'; diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/symbols.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/symbols.ts new file mode 100644 index 0000000000..88cff1c233 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/symbols.ts @@ -0,0 +1,23 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const OTEL_PATCHED_SYMBOL = Symbol.for('opentelemetry.patched'); + +export const OTEL_GRAPHQL_DATA_SYMBOL = Symbol.for( + 'opentelemetry.graphql_data' +); + +export const OTEL_SPAN_SYMBOL = Symbol.for('opentelemetry.span'); diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts new file mode 100644 index 0000000000..c3c78b3653 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts @@ -0,0 +1,149 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import type * as graphqlTypes from 'graphql'; +import type * as api from '@opentelemetry/api'; +import type { Maybe } from 'graphql/jsutils/Maybe'; +import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'; +import { DocumentNode } from 'graphql/language/ast'; +import { + GraphQLFieldResolver, + GraphQLTypeResolver, +} from 'graphql/type/definition'; +import { GraphQLSchema } from 'graphql/type/schema'; +import { + OTEL_GRAPHQL_DATA_SYMBOL, + OTEL_PATCHED_SYMBOL, + OTEL_SPAN_SYMBOL, +} from './symbols'; + +export const OPERATION_NOT_SUPPORTED = + 'Operation$operationName$not' + ' supported'; + +export interface GraphQLInstrumentationConfig { + /** + * When set to true it will not remove attributes values from schema source. + * By default all values that can be sensitive are removed and replaced + * with "*" + * + * @default false + */ + allowValues?: boolean; + /** + * The maximum depth of fields/resolvers to instrument. + * When set to 0 it will not instrument fields and resolvers + * + * @default undefined + */ + depth?: number; + /** + * Whether to merge list items into a single element. + * + * @example `users.*.name` instead of `users.0.name`, `users.1.name` + * + * @default false + */ + mergeItems?: boolean; +} + +/** + * Merged and parsed config of default instrumentation config and GraphQL + */ +export type GraphQLInstrumentationParsedConfig = Required & + InstrumentationConfig; + +export type executeFunctionWithObj = ( + args: graphqlTypes.ExecutionArgs +) => PromiseOrValue; + +export type executeArgumentsArray = [ + graphqlTypes.GraphQLSchema, + graphqlTypes.DocumentNode, + any, + any, + Maybe<{ [key: string]: any }>, + Maybe, + Maybe>, + Maybe> +]; + +export type executeFunctionWithArgs = ( + schema: graphqlTypes.GraphQLSchema, + document: graphqlTypes.DocumentNode, + rootValue?: any, + contextValue?: any, + variableValues?: Maybe<{ [key: string]: any }>, + operationName?: Maybe, + fieldResolver?: Maybe>, + typeResolver?: Maybe> +) => PromiseOrValue; + +export interface OtelExecutionArgs { + schema: GraphQLSchema; + document: DocumentNode & ObjectWithGraphQLData; + rootValue?: any; + contextValue?: any & ObjectWithGraphQLData; + variableValues?: Maybe<{ [key: string]: any }>; + operationName?: Maybe; + fieldResolver?: Maybe & OtelPatched>; + typeResolver?: Maybe>; +} + +export type executeType = executeFunctionWithObj | executeFunctionWithArgs; + +export type parseType = ( + source: string | graphqlTypes.Source, + options?: graphqlTypes.ParseOptions +) => graphqlTypes.DocumentNode; + +export type validateType = ( + schema: graphqlTypes.GraphQLSchema, + documentAST: graphqlTypes.DocumentNode, + rules?: ReadonlyArray, + typeInfo?: graphqlTypes.TypeInfo, + options?: { maxErrors?: number } +) => ReadonlyArray; + +export interface GraphQLField { + parent: api.Span; + span: api.Span; + error: Error | null; +} + +interface OtelGraphQLData { + source?: any; + span: api.Span; + fields: { [key: string]: GraphQLField }; +} + +export interface ObjectWithOtelSpan { + [OTEL_SPAN_SYMBOL]?: api.Span; +} + +export interface ObjectWithGraphQLData { + [OTEL_GRAPHQL_DATA_SYMBOL]?: OtelGraphQLData; +} + +export interface OtelPatched { + [OTEL_PATCHED_SYMBOL]?: boolean; +} + +export interface GraphQLPath { + prev: GraphQLPath | undefined; + key: string | number; + typename: string | undefined; +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/utils.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/utils.ts new file mode 100644 index 0000000000..74817454d0 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/utils.ts @@ -0,0 +1,376 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { safeExecuteInTheMiddle } from '@opentelemetry/instrumentation'; +import type * as graphqlTypes from 'graphql'; +import * as api from '@opentelemetry/api'; +import type { Maybe } from 'graphql/jsutils/Maybe'; +import { GraphQLObjectType } from 'graphql/type/definition'; +import { + AllowedOperationTypes, + SpanAttributes, + SpanNames, + TokenKind, +} from './enum'; +import { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_PATCHED_SYMBOL } from './symbols'; +import { + GraphQLField, + GraphQLPath, + GraphQLInstrumentationConfig, + GraphQLInstrumentationParsedConfig, + ObjectWithGraphQLData, + OtelPatched, +} from './types'; + +const OPERATION_VALUES = Object.values(AllowedOperationTypes); + +export function addSpanSource( + span: api.Span, + loc: graphqlTypes.Location, + allowValues?: boolean, + start?: number, + end?: number +): void { + const source = getSourceFromLocation(loc, allowValues, start, end); + span.setAttribute(SpanAttributes.SOURCE, source); +} + +function createFieldIfNotExists( + tracer: api.Tracer, + getConfig: () => GraphQLInstrumentationParsedConfig, + contextValue: any, + info: graphqlTypes.GraphQLResolveInfo, + path: string[] +): { + field: any; + spanAdded: boolean; +} { + let field = getField(contextValue, path); + + let spanAdded = false; + + if (!field) { + spanAdded = true; + const parent = getParentField(contextValue, path); + + field = { + parent, + span: createResolverSpan( + tracer, + getConfig, + contextValue, + info, + path, + parent.span + ), + error: null, + }; + + addField(contextValue, path, field); + } + + return { spanAdded, field }; +} + +function createResolverSpan( + tracer: api.Tracer, + getConfig: () => GraphQLInstrumentationParsedConfig, + contextValue: any, + info: graphqlTypes.GraphQLResolveInfo, + path: string[], + parentSpan?: api.Span +): api.Span { + const attributes: api.Attributes = { + [SpanAttributes.FIELD_NAME]: info.fieldName, + [SpanAttributes.FIELD_PATH]: path.join('.'), + [SpanAttributes.FIELD_TYPE]: info.returnType.toString(), + }; + + const span = tracer.startSpan(SpanNames.RESOLVE, { + attributes, + parent: parentSpan, + }); + + const document = contextValue[OTEL_GRAPHQL_DATA_SYMBOL].source; + const fieldNode = info.fieldNodes.find( + fieldNode => fieldNode.kind === 'Field' + ); + + if (fieldNode) { + addSpanSource( + span, + document.loc, + getConfig().allowValues, + fieldNode.loc?.start, + fieldNode.loc?.end + ); + } + + return span; +} + +export function endSpan(span: api.Span, error?: Error): void { + if (error) { + span.recordException(error); + } + span.end(); +} + +export function getOperation( + document: graphqlTypes.DocumentNode, + operationName?: Maybe +): graphqlTypes.DefinitionNode | undefined { + if (!document || !Array.isArray(document.definitions)) { + return undefined; + } + + if (operationName) { + return document.definitions + .filter( + definition => OPERATION_VALUES.indexOf(definition?.operation) !== -1 + ) + .find( + definition => + operationName === (definition.name && definition.name.value) + ); + } else { + return document.definitions.find( + definition => OPERATION_VALUES.indexOf(definition?.operation) !== -1 + ); + } +} + +function addField(contextValue: any, path: string[], field: GraphQLField) { + return (contextValue[OTEL_GRAPHQL_DATA_SYMBOL].fields[ + path.join('.') + ] = field); +} + +function getField(contextValue: any, path: string[]) { + return contextValue[OTEL_GRAPHQL_DATA_SYMBOL].fields[path.join('.')]; +} + +function getParentField(contextValue: any, path: string[]) { + for (let i = path.length - 1; i > 0; i--) { + const field = getField(contextValue, path.slice(0, i)); + + if (field) { + return field; + } + } + + return { + span: contextValue[OTEL_GRAPHQL_DATA_SYMBOL].span, + }; +} + +function pathToArray(mergeItems: boolean, path: GraphQLPath): string[] { + const flattened: string[] = []; + let curr: GraphQLPath | undefined = path; + while (curr) { + let key = curr.key; + + if (mergeItems && typeof key === 'number') { + key = '*'; + } + flattened.push(String(key)); + curr = curr.prev; + } + return flattened.reverse(); +} + +function repeatBreak(i: number): string { + return repeatChar('\n', i); +} + +function repeatSpace(i: number): string { + return repeatChar(' ', i); +} + +function repeatChar(char: string, to: number): string { + let text = ''; + for (let i = 0; i < to; i++) { + text += char; + } + return text; +} + +const KindsToBeRemoved: string[] = [ + TokenKind.FLOAT, + TokenKind.STRING, + TokenKind.INT, + TokenKind.BLOCK_STRING, +]; + +export function getSourceFromLocation( + loc: graphqlTypes.Location, + allowValues = false, + start: number = loc.start, + end: number = loc.end +): string { + let source = ''; + + if (loc.startToken) { + let next: graphqlTypes.Token | null = loc.startToken.next; + let previousLine: number | undefined = 1; + while (next) { + if (next.start < start) { + next = next.next; + previousLine = next?.line; + continue; + } + if (next.end > end) { + next = next.next; + previousLine = next?.line; + continue; + } + let value = next.value || next.kind; + let space = ''; + if (!allowValues && KindsToBeRemoved.indexOf(next.kind) >= 0) { + // value = repeatChar('*', value.length); + value = '*'; + } + if (next.kind === TokenKind.STRING) { + value = `"${value}"`; + } + if (next.kind === TokenKind.EOF) { + value = ''; + } + if (next.line > previousLine!) { + source += repeatBreak(next.line - previousLine!); + previousLine = next.line; + space = repeatSpace(next.column - 1); + } else { + if (next.line === next.prev?.line) { + space = repeatSpace(next.start - (next.prev?.end || 0)); + } + } + source += space + value; + if (next) { + next = next.next!; + } + } + } + + return source; +} + +export function wrapFields( + type: Maybe, + tracer: api.Tracer, + getConfig: () => GraphQLInstrumentationParsedConfig +): void { + if ( + !type || + typeof type.getFields !== 'function' || + type[OTEL_PATCHED_SYMBOL] + ) { + return; + } + const fields = type.getFields(); + + type[OTEL_PATCHED_SYMBOL] = true; + + Object.keys(fields).forEach(key => { + const field = fields[key]; + + if (!field) { + return; + } + + if (field.resolve) { + field.resolve = wrapFieldResolver(tracer, getConfig, field.resolve); + } + + if (field.type) { + let unwrappedType: any = field.type; + + while (unwrappedType.ofType) { + unwrappedType = unwrappedType.ofType; + } + wrapFields(unwrappedType, tracer, getConfig); + } + }); +} + +export function wrapFieldResolver( + tracer: api.Tracer, + getConfig: () => Required, + fieldResolver: Maybe< + graphqlTypes.GraphQLFieldResolver & OtelPatched + > +): graphqlTypes.GraphQLFieldResolver & OtelPatched { + if ( + (wrappedFieldResolver as OtelPatched)[OTEL_PATCHED_SYMBOL] || + typeof fieldResolver !== 'function' + ) { + return fieldResolver!; + } + + function wrappedFieldResolver( + this: graphqlTypes.GraphQLFieldResolver, + source: TSource, + args: TArgs, + contextValue: TContext & ObjectWithGraphQLData, + info: graphqlTypes.GraphQLResolveInfo + ) { + if (!fieldResolver) { + return undefined; + } + const config = getConfig(); + + if (!contextValue[OTEL_GRAPHQL_DATA_SYMBOL]) { + return fieldResolver.call(this, source, args, contextValue, info); + } + const path = pathToArray(config.mergeItems, info && info.path); + const depth = path.filter((item: any) => typeof item === 'string').length; + + let field: any; + let shouldEndSpan = false; + if (config.depth >= 0 && config.depth < depth) { + field = getParentField(contextValue, path); + } else { + const newField = createFieldIfNotExists( + tracer, + getConfig, + contextValue, + info, + path + ); + field = newField.field; + shouldEndSpan = newField.spanAdded; + } + + return tracer.withSpan(field.span, () => { + return safeExecuteInTheMiddle< + Maybe> + >( + () => { + return fieldResolver.call(this, source, args, contextValue, info); + }, + err => { + if (shouldEndSpan) { + endSpan(field.span, err); + } + } + ); + }); + } + + (wrappedFieldResolver as OtelPatched)[OTEL_PATCHED_SYMBOL] = true; + + return wrappedFieldResolver; +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/version.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/version.ts new file mode 100644 index 0000000000..714520138d --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/version.ts @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// this is autogenerated file, see scripts/version-update.js +export const VERSION = '0.11.0'; diff --git a/plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts b/plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts new file mode 100644 index 0000000000..0b05712e63 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts @@ -0,0 +1,1124 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + BasicTracerProvider, + InMemorySpanExporter, + ReadableSpan, + SimpleSpanProcessor, +} from '@opentelemetry/tracing'; +import * as assert from 'assert'; +import { GraphQLInstrumentation } from '../src'; +import { SpanAttributes, SpanNames } from '../src/enum'; +import { GraphQLInstrumentationConfig } from '../src/types'; +import { assertResolveSpan } from './helper'; + +const defaultConfig: GraphQLInstrumentationConfig = {}; +const graphQLInstrumentation = new GraphQLInstrumentation(defaultConfig); +graphQLInstrumentation.enable(); +graphQLInstrumentation.disable(); + +// now graphql can be required + +import { buildSchema } from './schema'; +import { graphql } from 'graphql'; +// Construct a schema, using GraphQL schema language +const schema = buildSchema(); + +const sourceList1 = ` + query { + books { + name + } + } +`; + +const sourceBookById = ` + query { + book(id: 0) { + name + } + } +`; + +const sourceAddBook = ` + mutation { + addBook( + name: "Fifth Book" + authorIds: "0,2" + ) { + id + } + } +`; + +const sourceFindUsingVariable = ` + query Query1 ($id: Int!) { + book(id: $id) { + name + } + } +`; + +const badQuery = ` + query foo bar +`; + +const queryInvalid = ` + query { + book(id: "a") { + name + } + } +`; + +const exporter = new InMemorySpanExporter(); +const provider = new BasicTracerProvider(); +provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); +graphQLInstrumentation.setTracerProvider(provider); + +describe('graphql', () => { + function create(config: GraphQLInstrumentationConfig = {}) { + graphQLInstrumentation.setConfig(config); + graphQLInstrumentation.enable(); + } + + describe('when depth is not set', () => { + describe('AND source is query to get a list of books', () => { + let spans: ReadableSpan[]; + beforeEach(async () => { + create({}); + await graphql(schema, sourceList1); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 7 spans', () => { + assert.deepStrictEqual(spans.length, 7); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' books {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' books {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'query' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + + it('should instrument resolvers', () => { + const executeSpan = spans[3]; + const resolveParentSpan = spans[2]; + const span1 = spans[4]; + const span2 = spans[5]; + const span3 = spans[6]; + + assertResolveSpan( + resolveParentSpan, + 'books', + 'books', + '[Book]', + 'books {\n' + ' name\n' + ' }', + executeSpan.spanContext.spanId + ); + const parentId = resolveParentSpan.spanContext.spanId; + assertResolveSpan( + span1, + 'name', + 'books.0.name', + 'String', + 'name', + parentId + ); + assertResolveSpan( + span2, + 'name', + 'books.1.name', + 'String', + 'name', + parentId + ); + assertResolveSpan( + span3, + 'name', + 'books.2.name', + 'String', + 'name', + parentId + ); + }); + }); + describe('AND source is query with param', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({}); + await graphql(schema, sourceBookById); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 5 spans', () => { + assert.deepStrictEqual(spans.length, 5); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' book(id: *) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' book(id: *) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'query' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + + it('should instrument resolvers', () => { + const executeSpan = spans[3]; + const resolveParentSpan = spans[2]; + const span1 = spans[4]; + + assertResolveSpan( + resolveParentSpan, + 'book', + 'book', + 'Book', + 'book(id: *) {\n' + ' name\n' + ' }', + executeSpan.spanContext.spanId + ); + const parentId = resolveParentSpan.spanContext.spanId; + assertResolveSpan( + span1, + 'name', + 'book.name', + 'String', + 'name', + parentId + ); + }); + }); + describe('AND source is query with param and variables', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({}); + await graphql(schema, sourceFindUsingVariable, null, null, { + id: 2, + }); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 5 spans', () => { + assert.deepStrictEqual(spans.length, 5); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query Query1 ($id: Int!) {\n' + + ' book(id: $id) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query Query1 ($id: Int!) {\n' + + ' book(id: $id) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'query' + ); + assert.deepStrictEqual( + executeSpan.attributes[`${SpanAttributes.VARIABLES}id`], + undefined + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + + it('should instrument resolvers', () => { + const executeSpan = spans[3]; + const resolveParentSpan = spans[2]; + const span1 = spans[4]; + + assertResolveSpan( + resolveParentSpan, + 'book', + 'book', + 'Book', + 'book(id: $id) {\n' + ' name\n' + ' }', + executeSpan.spanContext.spanId + ); + const parentId = resolveParentSpan.spanContext.spanId; + assertResolveSpan( + span1, + 'name', + 'book.name', + 'String', + 'name', + parentId + ); + }); + }); + }); + + describe('when depth is set to 0', () => { + describe('AND source is query to get a list of books', () => { + let spans: ReadableSpan[]; + beforeEach(async () => { + create({ + depth: 0, + }); + await graphql(schema, sourceList1); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 3 spans', () => { + assert.deepStrictEqual(spans.length, 3); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' books {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[2]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' books {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'query' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + }); + }); + + describe('when mergeItems is set to true', () => { + describe('AND source is query to get a list of books', () => { + let spans: ReadableSpan[]; + beforeEach(async () => { + create({ + mergeItems: true, + }); + await graphql(schema, sourceList1); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 5 spans', () => { + assert.deepStrictEqual(spans.length, 5); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' books {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' books {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'query' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + }); + + describe('AND depth is set to 0', () => { + let spans: ReadableSpan[]; + beforeEach(async () => { + create({ + mergeItems: true, + depth: 0, + }); + await graphql(schema, sourceList1); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 3 spans', () => { + assert.deepStrictEqual(spans.length, 3); + }); + }); + }); + + describe('when allowValues is set to true', () => { + describe('AND source is query with param', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({ + allowValues: true, + }); + await graphql(schema, sourceBookById); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 5 spans', () => { + assert.deepStrictEqual(spans.length, 5); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' book(id: 0) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' book(id: 0) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'query' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + + it('should instrument resolvers', () => { + const executeSpan = spans[3]; + const resolveParentSpan = spans[2]; + const span1 = spans[4]; + + assertResolveSpan( + resolveParentSpan, + 'book', + 'book', + 'Book', + 'book(id: 0) {\n' + ' name\n' + ' }', + executeSpan.spanContext.spanId + ); + const parentId = resolveParentSpan.spanContext.spanId; + assertResolveSpan( + span1, + 'name', + 'book.name', + 'String', + 'name', + parentId + ); + }); + }); + describe('AND mutation is called', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({ + allowValues: true, + }); + await graphql(schema, sourceAddBook); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 5 spans', () => { + assert.deepStrictEqual(spans.length, 5); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' mutation {\n' + + ' addBook(\n' + + ' name: "Fifth Book"\n' + + ' authorIds: "0,2"\n' + + ' ) {\n' + + ' id\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' mutation {\n' + + ' addBook(\n' + + ' name: "Fifth Book"\n' + + ' authorIds: "0,2"\n' + + ' ) {\n' + + ' id\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'mutation' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + + it('should instrument resolvers', () => { + const executeSpan = spans[3]; + const resolveParentSpan = spans[2]; + const span1 = spans[4]; + + assertResolveSpan( + resolveParentSpan, + 'addBook', + 'addBook', + 'Book', + 'addBook(\n' + + ' name: "Fifth Book"\n' + + ' authorIds: "0,2"\n' + + ' ) {\n' + + ' id\n' + + ' }', + executeSpan.spanContext.spanId + ); + const parentId = resolveParentSpan.spanContext.spanId; + assertResolveSpan(span1, 'id', 'addBook.id', 'Int', 'id', parentId); + }); + }); + describe('AND source is query with param and variables', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({ + allowValues: true, + }); + await graphql(schema, sourceFindUsingVariable, null, null, { + id: 2, + }); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 5 spans', () => { + assert.deepStrictEqual(spans.length, 5); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query Query1 ($id: Int!) {\n' + + ' book(id: $id) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query Query1 ($id: Int!) {\n' + + ' book(id: $id) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'query' + ); + assert.deepStrictEqual( + executeSpan.attributes[`${SpanAttributes.VARIABLES}id`], + 2 + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + + it('should instrument resolvers', () => { + const executeSpan = spans[3]; + const resolveParentSpan = spans[2]; + const span1 = spans[4]; + + assertResolveSpan( + resolveParentSpan, + 'book', + 'book', + 'Book', + 'book(id: $id) {\n' + ' name\n' + ' }', + executeSpan.spanContext.spanId + ); + const parentId = resolveParentSpan.spanContext.spanId; + assertResolveSpan( + span1, + 'name', + 'book.name', + 'String', + 'name', + parentId + ); + }); + }); + }); + + describe('when mutation is called', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({ + // allowValues: true + }); + await graphql(schema, sourceAddBook); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 5 spans', () => { + assert.deepStrictEqual(spans.length, 5); + }); + + it('should instrument parse', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' mutation {\n' + + ' addBook(\n' + + ' name: "*"\n' + + ' authorIds: "*"\n' + + ' ) {\n' + + ' id\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + }); + + it('should instrument execute', () => { + const executeSpan = spans[3]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' mutation {\n' + + ' addBook(\n' + + ' name: "*"\n' + + ' authorIds: "*"\n' + + ' ) {\n' + + ' id\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'mutation' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + + it('should instrument resolvers', () => { + const executeSpan = spans[3]; + const resolveParentSpan = spans[2]; + const span1 = spans[4]; + + assertResolveSpan( + resolveParentSpan, + 'addBook', + 'addBook', + 'Book', + 'addBook(\n' + + ' name: "*"\n' + + ' authorIds: "*"\n' + + ' ) {\n' + + ' id\n' + + ' }', + executeSpan.spanContext.spanId + ); + const parentId = resolveParentSpan.spanContext.spanId; + assertResolveSpan(span1, 'id', 'addBook.id', 'Int', 'id', parentId); + }); + }); + + describe('when query is not correct', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({}); + await graphql(schema, badQuery); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 1 span', () => { + assert.deepStrictEqual(spans.length, 1); + }); + + it('should instrument parse with error', () => { + const parseSpan = spans[0]; + const event = parseSpan.events[0]; + + assert.ok(event); + + assert.deepStrictEqual( + event.attributes!['exception.type'], + 'GraphQLError' + ); + assert.ok(event.attributes!['exception.message']); + assert.ok(event.attributes!['exception.stacktrace']); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + }); + + describe('when query is correct but cannot be validated', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({}); + await graphql(schema, queryInvalid); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 2 spans', () => { + assert.deepStrictEqual(spans.length, 2); + }); + + it('should instrument parse with error', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' book(id: "*") {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + const event = validateSpan.events[0]; + + assert.deepStrictEqual(event.name, 'exception'); + assert.deepStrictEqual( + event.attributes!['exception.type'], + SpanAttributes.ERROR_VALIDATION_NAME + ); + assert.ok(event.attributes!['exception.message']); + }); + }); + + describe('when query operation is not supported', () => { + let spans: ReadableSpan[]; + + beforeEach(async () => { + create({}); + await graphql({ + schema, + source: sourceBookById, + operationName: 'foo', + }); + spans = exporter.getFinishedSpans(); + }); + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + it('should have 3 spans', () => { + assert.deepStrictEqual(spans.length, 3); + }); + + it('should instrument parse with error', () => { + const parseSpan = spans[0]; + assert.deepStrictEqual( + parseSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' book(id: *) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual(parseSpan.name, SpanNames.PARSE); + }); + + it('should instrument validate', () => { + const parseSpan = spans[0]; + const validateSpan = spans[1]; + + assert.deepStrictEqual(validateSpan.name, SpanNames.VALIDATE); + assert.deepStrictEqual( + validateSpan.parentSpanId, + parseSpan.spanContext.spanId + ); + const event = validateSpan.events[0]; + + assert.ok(!event); + }); + + it('should instrument execute', () => { + const executeSpan = spans[2]; + const validateSpan = spans[1]; + + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.SOURCE], + '\n' + + ' query {\n' + + ' book(id: *) {\n' + + ' name\n' + + ' }\n' + + ' }\n' + ); + assert.deepStrictEqual( + executeSpan.attributes[SpanAttributes.OPERATION], + 'Operation "foo" not supported' + ); + assert.deepStrictEqual(executeSpan.name, SpanNames.EXECUTE); + assert.deepStrictEqual( + executeSpan.parentSpanId, + validateSpan.spanContext.spanId + ); + }); + }); +}); diff --git a/plugins/node/opentelemetry-instrumentation-graphql/test/helper.ts b/plugins/node/opentelemetry-instrumentation-graphql/test/helper.ts new file mode 100644 index 0000000000..f633d6bd82 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/test/helper.ts @@ -0,0 +1,38 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReadableSpan } from '@opentelemetry/tracing'; +import * as assert from 'assert'; +import { SpanAttributes, SpanNames } from '../src/enum'; + +export function assertResolveSpan( + span: ReadableSpan, + fieldName: string, + fieldPath: string, + fieldType: string, + source: string, + parentSpanId?: string +) { + const attrs = span.attributes; + assert.deepStrictEqual(span.name, SpanNames.RESOLVE); + assert.deepStrictEqual(attrs[SpanAttributes.FIELD_NAME], fieldName); + assert.deepStrictEqual(attrs[SpanAttributes.FIELD_PATH], fieldPath); + assert.deepStrictEqual(attrs[SpanAttributes.FIELD_TYPE], fieldType); + assert.deepStrictEqual(attrs[SpanAttributes.SOURCE], source); + if (parentSpanId) { + assert.deepStrictEqual(span.parentSpanId, parentSpanId); + } +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/test/schema.ts b/plugins/node/opentelemetry-instrumentation-graphql/test/schema.ts new file mode 100644 index 0000000000..a55982c6c6 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/test/schema.ts @@ -0,0 +1,233 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as https from 'https'; +import * as graphql from 'graphql'; + +const url1 = + 'https://raw.githubusercontent.com/open-telemetry/opentelemetry-js/master/package.json'; + +function getData(url: string): any { + return new Promise((resolve, reject) => { + https + .get(url, response => { + let data = ''; + response.on('data', chunk => { + data += chunk; + }); + response.on('end', () => { + resolve(JSON.parse(data)); + }); + }) + .on('error', err => { + reject(err); + }); + }); +} + +const authors: Author[] = []; +const books: Book[] = []; + +interface Book { + id: number; + name: string; + authorIds: number[]; +} + +interface Address { + country: string; + city: string; +} + +interface Author { + id: number; + name: string; + address: Address; +} + +function addBook(name: string, authorIds: string | number[] = []) { + if (typeof authorIds === 'string') { + authorIds = authorIds.split(',').map(id => parseInt(id, 10)); + } + const id = books.length; + books.push({ + id: id, + name: name, + authorIds: authorIds, + }); + return books[books.length - 1]; +} + +function addAuthor(name: string, country: string, city: string) { + const id = authors.length; + authors.push({ id, name, address: { country, city } }); + return authors[authors.length - 1]; +} + +function getBook(id: number) { + return books[id]; +} + +function getAuthor(id: number) { + return authors[id]; +} + +function prepareData() { + addAuthor('John', 'Poland', 'Szczecin'); + addAuthor('Alice', 'Poland', 'Warsaw'); + addAuthor('Bob', 'England', 'London'); + addAuthor('Christine', 'France', 'Paris'); + addBook('First Book', [0, 1]); + addBook('Second Book', [2]); + addBook('Third Book', [3]); +} + +prepareData(); + +export function buildSchema() { + const Author = new graphql.GraphQLObjectType({ + name: 'Author', + fields: { + id: { + type: graphql.GraphQLString, + resolve(obj, args) { + return obj.id; + }, + }, + name: { + type: graphql.GraphQLString, + resolve(obj, args) { + return obj.name; + }, + }, + description: { + type: graphql.GraphQLString, + resolve(obj, args) { + return new Promise((resolve, reject) => { + getData(url1).then((response: { [key: string]: string }) => { + resolve(response.description); + }, reject); + }); + }, + }, + address: { + type: new graphql.GraphQLObjectType({ + name: 'Address', + fields: { + country: { + type: graphql.GraphQLString, + resolve(obj, args) { + return obj.country; + }, + }, + city: { + type: graphql.GraphQLString, + resolve(obj, args) { + return obj.city; + }, + }, + }, + }), + resolve(obj, args) { + return obj.address; + }, + }, + }, + }); + + const Book = new graphql.GraphQLObjectType({ + name: 'Book', + fields: { + id: { + type: graphql.GraphQLInt, + resolve(obj, args) { + return obj.id; + }, + }, + name: { + type: graphql.GraphQLString, + resolve(obj, args) { + return obj.name; + }, + }, + authors: { + type: new graphql.GraphQLList(Author), + resolve(obj, args) { + return obj.authorIds.map((id: number) => { + return authors[id]; + }); + }, + }, + }, + }); + + const query = new graphql.GraphQLObjectType({ + name: 'Query', + fields: { + author: { + type: Author, + args: { + id: { type: graphql.GraphQLInt }, + }, + resolve(obj, args, context) { + return Promise.resolve(getAuthor(args.id)); + }, + }, + authors: { + type: new graphql.GraphQLList(Author), + resolve(obj, args, context) { + return Promise.resolve(authors); + }, + }, + book: { + type: Book, + args: { + id: { type: graphql.GraphQLInt }, + }, + resolve(obj, args, context) { + return Promise.resolve(getBook(args.id)); + }, + }, + books: { + type: new graphql.GraphQLList(Book), + resolve(obj, args, context) { + return Promise.resolve(books); + }, + }, + }, + }); + + const mutation = new graphql.GraphQLObjectType({ + name: 'Mutation', + fields: { + addBook: { + type: Book, + args: { + name: { type: new graphql.GraphQLNonNull(graphql.GraphQLString) }, + authorIds: { + type: new graphql.GraphQLNonNull(graphql.GraphQLString), + }, + }, + resolve(obj, args, context) { + return Promise.resolve(addBook(args.name, args.authorIds)); + }, + }, + }, + }); + + const schema = new graphql.GraphQLSchema({ query, mutation }); + return schema; +} diff --git a/plugins/node/opentelemetry-instrumentation-graphql/tsconfig.json b/plugins/node/opentelemetry-instrumentation-graphql/tsconfig.json new file mode 100644 index 0000000000..28be80d266 --- /dev/null +++ b/plugins/node/opentelemetry-instrumentation-graphql/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} From a47431bf73452fc6dc27c8561c1564bee5470d8a Mon Sep 17 00:00:00 2001 From: Bartlomiej Obecny Date: Tue, 20 Oct 2020 15:47:47 +0200 Subject: [PATCH 3/3] feat: lint & review --- .../opentelemetry-instrumentation-graphql/src/graphql.ts | 9 +++++++-- .../opentelemetry-instrumentation-graphql/src/index.ts | 1 - .../opentelemetry-instrumentation-graphql/src/types.ts | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts index 8434d6facf..6260810b89 100644 --- a/plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/graphql.ts @@ -60,7 +60,9 @@ const DEFAULT_CONFIG: GraphQLInstrumentationConfig = { }; export class GraphQLInstrumentation extends InstrumentationBase { - constructor(config: GraphQLInstrumentationConfig & InstrumentationConfig = {}) { + constructor( + config: GraphQLInstrumentationConfig & InstrumentationConfig = {} + ) { super('graphql', VERSION, Object.assign({}, DEFAULT_CONFIG, config)); } @@ -192,7 +194,10 @@ export class GraphQLInstrumentation extends InstrumentationBase { processedArgs.operationName ); - const span = instrumentation._createExecuteSpan(operation, processedArgs); + const span = instrumentation._createExecuteSpan( + operation, + processedArgs + ); processedArgs.contextValue[OTEL_GRAPHQL_DATA_SYMBOL] = { source: processedArgs.document diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/index.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/index.ts index 11a3f30042..9bdb560f36 100644 --- a/plugins/node/opentelemetry-instrumentation-graphql/src/index.ts +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/index.ts @@ -15,4 +15,3 @@ */ export * from './graphql'; -export * from './symbols'; diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts index c3c78b3653..9c157e5d92 100644 --- a/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts @@ -63,7 +63,9 @@ export interface GraphQLInstrumentationConfig { /** * Merged and parsed config of default instrumentation config and GraphQL */ -export type GraphQLInstrumentationParsedConfig = Required & +export type GraphQLInstrumentationParsedConfig = Required< + GraphQLInstrumentationConfig +> & InstrumentationConfig; export type executeFunctionWithObj = (