diff --git a/index.js b/index.js index e646a1c5..e5e1ce83 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,7 @@ const { MER_ERR_METHOD_NOT_ALLOWED, MER_ERR_INVALID_METHOD } = require('./lib/errors') +const tracer = require('./lib/tracer') const kLoaders = Symbol('mercurius.loaders') const kFactory = Symbol('mercurius.loadersFactory') @@ -382,12 +383,18 @@ const plugin = fp(async function (app, opts) { } async function fastifyGraphQl (source, context, variables, operationName) { + const span = tracer.startSpan('mercurius - graphql', { parent: tracer.getCurrentSpan() }) + context = Object.assign({ app: this, lruGatewayResolvers }, context) const reply = context.reply // Parse, with a little lru const cached = lru !== null && lru.get(source) let document = null + + // Add opentelemetry attributes + span.setAttribute('mercurius.cached', !!cached) + if (!cached) { // We use two caches to avoid errors bust the good // cache. This is a protection against DoS attacks @@ -397,6 +404,7 @@ const plugin = fp(async function (app, opts) { // this query errored const err = new MER_ERR_GQL_VALIDATION() err.errors = cachedError.validationErrors + span.end() throw err } @@ -405,6 +413,7 @@ const plugin = fp(async function (app, opts) { } catch (syntaxError) { const err = new MER_ERR_GQL_VALIDATION() err.errors = [syntaxError] + span.end() throw err } @@ -425,6 +434,7 @@ const plugin = fp(async function (app, opts) { } const err = new MER_ERR_GQL_VALIDATION() err.errors = validationErrors + span.end() throw err } @@ -434,6 +444,7 @@ const plugin = fp(async function (app, opts) { if (queryDepthErrors.length > 0) { const err = new MER_ERR_GQL_VALIDATION() err.errors = queryDepthErrors + span.end() throw err } } @@ -451,6 +462,7 @@ const plugin = fp(async function (app, opts) { if (operationAST.operation !== 'query') { const err = new MER_ERR_METHOD_NOT_ALLOWED() err.errors = [new Error('Operation cannot be performed via a GET request')] + span.end() throw err } } @@ -463,7 +475,9 @@ const plugin = fp(async function (app, opts) { if (cached && cached.jit !== null) { const execution = await cached.jit.query(root, context, variables || {}) - return maybeFormatErrors(execution, context) + const resWithFormatedErrors = maybeFormatErrors(execution, context) + span.end() + return resWithFormatedErrors } // Validate variables @@ -472,6 +486,7 @@ const plugin = fp(async function (app, opts) { if (Array.isArray(executionContext)) { const err = new MER_ERR_GQL_VALIDATION() err.errors = executionContext + span.end() throw err } } @@ -485,7 +500,9 @@ const plugin = fp(async function (app, opts) { operationName ) - return maybeFormatErrors(execution, context) + const resWithFormatedErrors = maybeFormatErrors(execution, context) + span.end() + return resWithFormatedErrors } function maybeFormatErrors (execution, context) { diff --git a/lib/tracer.js b/lib/tracer.js new file mode 100644 index 00000000..d7b7645e --- /dev/null +++ b/lib/tracer.js @@ -0,0 +1,5 @@ +'use strict' +const api = require('@opentelemetry/api') +const meta = require('../package.json') + +module.exports = api.trace.getTracer(meta.name, meta.version) diff --git a/package.json b/package.json index 82fb1e10..afd0c984 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@graphql-tools/merge": "^6.2.4", "@graphql-tools/schema": "^6.2.4", "@graphql-tools/utils": "^6.2.4", + "@opentelemetry/api": "^0.13.0", "@sinonjs/fake-timers": "^6.0.1", "@types/node": "^14.0.23", "@types/ws": "^7.2.6", diff --git a/test/opentelemetry.js b/test/opentelemetry.js new file mode 100644 index 00000000..0b76aef0 --- /dev/null +++ b/test/opentelemetry.js @@ -0,0 +1,75 @@ +'use strict' +const { test } = require('tap') +const Fastify = require('fastify') +const api = require('@opentelemetry/api') +const GQL = require('..') + +test('Should add opentelemetry span and cached attribute', async (t) => { + t.plan(6) + + const testSpanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: api.TraceFlags.NONE + } + class TestSpan extends api.NoopSpan { + setAttribute (name, value) { + t.is(name, 'mercurius.cached') + t.is(value, false) + } + + end () { + t.pass() + } + } + const dummySpan = new TestSpan(testSpanContext) + class TestTracer extends api.NoopTracer { + startSpan (name, opts) { + t.is(name, 'mercurius - graphql') + t.deepEquals(opts.parent.context(), dummySpan.context()) + return new TestSpan() + } + + getCurrentSpan () { + return dummySpan + } + } + class TestTracerProvider extends api.NoopTracerProvider { + getTracer () { + return new TestTracer() + } + } + api.trace.setGlobalTracerProvider(new TestTracerProvider()) + + const app = Fastify() + const schema = ` + type Query { + add(x: Int, y: Int): Int + } + ` + + const resolvers = { + add: async ({ x, y }) => x + y + } + + app.register(GQL, { + schema, + resolvers + }) + + app.get('/', async function (req, reply) { + const query = '{ add(x: 2, y: 2) }' + return reply.graphql(query) + }) + + const res = await app.inject({ + method: 'GET', + url: '/' + }) + + t.deepEqual(JSON.parse(res.body), { + data: { + add: 4 + } + }) +})