Repository: DanielMSchmidt/apollo-opentracing Branch: master Commit: f967b17736e4 Files: 18 Total size: 42.8 KB Directory structure: gitextract___y3lay0/ ├── .all-contributorsrc ├── .github/ │ ├── stale.yml │ └── workflows/ │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── commitlint.config.js ├── package.json ├── renovate.json ├── src/ │ ├── __tests__/ │ │ ├── __snapshots__/ │ │ │ └── integration-test.ts.snap │ │ └── integration-test.ts │ ├── context.ts │ ├── index.ts │ └── test/ │ ├── span-serializer.ts │ └── types.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "projectName": "apollo-opentracing", "projectOwner": "DanielMSchmidt", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 100, "commit": true, "contributors": [ { "login": "DanielMSchmidt", "name": "Daniel Schmidt", "avatar_url": "https://avatars2.githubusercontent.com/u/1337046?v=4", "profile": "http://danielmschmidt.de/", "contributions": [ "code", "ideas" ] }, { "login": "cliedeman", "name": "Ciaran Liedeman", "avatar_url": "https://avatars2.githubusercontent.com/u/3578740?v=4", "profile": "https://github.com/cliedeman", "contributions": [ "bug", "code", "test" ] }, { "login": "Multiply", "name": "Jens Ulrich Hjuler Pedersen", "avatar_url": "https://avatars3.githubusercontent.com/u/453031?v=4", "profile": "http://juhp.net", "contributions": [ "bug", "ideas", "review" ] }, { "login": "frances3006", "name": "Francesca", "avatar_url": "https://avatars0.githubusercontent.com/u/9115596?v=4", "profile": "https://github.com/frances3006", "contributions": [ "code" ] }, { "login": "ricardocasares", "name": "Ricardo Casares", "avatar_url": "https://avatars2.githubusercontent.com/u/84963?v=4", "profile": "https://analogic.al", "contributions": [ "code" ] }, { "login": "mwieczorek", "name": "Michał Wieczorek", "avatar_url": "https://avatars2.githubusercontent.com/u/7051680?v=4", "profile": "https://keybase.io/mwieczorek", "contributions": [ "code" ] }, { "login": "koenpunt", "name": "Koen Punt", "avatar_url": "https://avatars2.githubusercontent.com/u/351038?v=4", "profile": "https://koen.pt", "contributions": [ "code" ] }, { "login": "zekenie", "name": "Zeke Nierenberg", "avatar_url": "https://avatars2.githubusercontent.com/u/962281?v=4", "profile": "https://github.com/zekenie", "contributions": [ "code" ] }, { "login": "voslartomas", "name": "Tomáš Voslař", "avatar_url": "https://avatars3.githubusercontent.com/u/1945040?v=4", "profile": "https://app.sport-buddy.net", "contributions": [ "code" ] }, { "login": "benkimball", "name": "Ben Kimball", "avatar_url": "https://avatars2.githubusercontent.com/u/40365?v=4", "profile": "http://iam.benkimball.com/", "contributions": [ "code" ] }, { "login": "liangjiapei", "name": "Jiapei Liang", "avatar_url": "https://avatars.githubusercontent.com/u/9281185?v=4", "profile": "https://jiapei.io/", "contributions": [ "code" ] }, { "login": "RichardWright", "name": "Richard W", "avatar_url": "https://avatars.githubusercontent.com/u/881815?v=4", "profile": "https://github.com/RichardWright", "contributions": [ "ideas", "research" ] }, { "login": "leeweisberger", "name": "Lee Weisberger", "avatar_url": "https://avatars.githubusercontent.com/u/6363419?v=4", "profile": "https://github.com/leeweisberger", "contributions": [ "code" ] }, { "login": "StarpTech", "name": "Dustin Deus", "avatar_url": "https://avatars.githubusercontent.com/u/1764424?v=4", "profile": "https://starptech.de/", "contributions": [ "bug" ] } ], "commitConvention": "none" } ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 60 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - pinned # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ================================================ FILE: .github/workflows/pr.yml ================================================ name: Node.js CI on: push: branches: [master, main] pull_request: branches: [master, main] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [15.x, 16.x] steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm test ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branches: - master - main jobs: release: name: Release runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v1 with: node-version: 16 - name: Install dependencies run: npm ci - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release ================================================ FILE: .gitignore ================================================ dist/ node_modules/ ================================================ FILE: .npmignore ================================================ * !src/**/* !dist/**/* dist/**/*.test.* !package.json !README.md ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020 Daniel M. Schmidt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Apollo Opentracing [![npm version](https://badge.fury.io/js/apollo-opentracing.svg)](https://badge.fury.io/js/apollo-opentracing) [![Build Status](https://travis-ci.com/DanielMSchmidt/apollo-opentracing.svg?branch=master)](https://travis-ci.com/DanielMSchmidt/apollo-opentracing) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![All Contributors](https://img.shields.io/badge/all_contributors-4-orange.svg?style=flat-square)](#contributors) Apollo Opentracing allows you to integrate open source baked performance tracing to your Apollo server based on industry standards for tracing. - 🚀 Request & Field level resolvers are traced out of the box - 🔍 Queries and results are logged, to make debugging easier - ⚙️ Select which requests you want to trace - 🔗 Spans transmitted through the HTTP Headers are picked up - 🔧 Use the opentracing compatible tracer you like, e.g. - [jaeger](https://www.jaegertracing.io/) - [zipkin](https://github.com/DanielMSchmidt/zipkin-javascript-opentracing) - 🦖 Support from node 6 on ## Installation Run `npm install --save apollo-opentracing` given that you already setup an opentracing tracer accordingly. ## Setup We need two types of tracer (which could be identical if you like): - server: Only used for the root (the first span we will start) - local: Used to start every other span ```diff const { graphqlExpress } = require("apollo-server-express"); const {serverTracer, localTracer} = require("./tracer"); +const OpentracingPlugin = require("apollo-opentracing").default; app.use( "/graphql", bodyParser.json(), graphqlExpress({ schema, + plugins: [OpentracingPlugin({ + server: serverTracer, + local: localTracer, + })] }) ) ``` ## Connecting Services ![example image](demo.png) To connect other services you need to use the opentracing [inject](http://opentracing.io/documentation/pages/api/cross-process-tracing.html) function of your tracer. We pass the current span down to your resolvers as `info.span`, so you should use it. You can also make use of it and add new logs or tags on the fly if you like. This may look something like this: ```js myFieldResolver(source, args, context, info) { const headers = {...}; const parentSpan = info.span; // please use the same tracer you passed down to the extension const networkSpan = tracer.startSpan("NetworkRequest:" + endpoint, { childOf: parentSpan }); // Let's transfer the span information to the headers tracer.inject( networkSpan, YourOpentracingImplementation.FORMAT_HTTP_HEADERS, headers ); return doNetworkRequest(endpoint, headers).then(result => { networkSpan.finish() return result; }, err => { networkSpan.log({ error: true, errorMessage: err }); networkSpan.finish(); return err; }); } ``` ## Selective Tracing Sometimes you don't want to trace everything, so we provide ways to select if you want to start a span right now or not. ### By Request If you construct the extension with `shouldTraceRequest` you get the option to opt-in or out on a request basis. When you don't start the span for the request the field resolvers will also not be used. The function is called with the same arguments as the `requestDidStart` function extensions can provide, which is documented [here](https://github.com/apollographql/apollo-server/blob/master/packages/graphql-extensions/src/index.ts#L35). When the request is not traced there will also be no traces of the field resolvers. ### By Field There might be certain field resolvers that are not worth the tracing, e.g. when they get a value out of an object and need no further tracing. To control if you want a field resolver to be traced you can pass the `shouldTraceFieldResolver` option to the constructor. The function is called with the same arguments as your field resolver and you can get the name of the field by `info.fieldName`. When you return false no traces will be made of this field resolvers and all underlying ones. ## Modifying span metadata If you'd like to add custom tags or logs to span you can construct the extension with `onRequestResolve`. The function is called with two arguments: span and infos `onRequestResolve?: (span: Span, info: RequestStart)` ## Using your own request span If you need to take control of initializing the request span (e.g because you need to use it during context initialization) you can do so by having creating it as `context.requestSpan`. ## Options - `server`: Opentracing Tracer for the incoming request - `local`: Opentracing Tracer for the local and outgoing requests - `onFieldResolve(source: any, args: { [argName: string]: any }, context: SpanContext, info: GraphQLResolveInfo)`: Allow users to add extra information to the span - `onFieldResolveFinish(error: Error | null, result: any, span: Span)`: Callback after a field was resolved - `shouldTraceRequest` & `shouldTraceFieldResolver`: See [Selective Tracing](#selective-tracing) - `onRequestResolve(span: Span, info: GraphQLRequestContext)`: Add extra information to the request span - `createCustomSpanName(name: String, info: GraphQLResolveInfo)`: Allow users to provide customized span name - `onRequestError(rootSpan: Span, info: GraphQLRequestContextDidEncounterErrors)`: Callback when a request errors ## Contributing Please feel free to add issues with new ideas, bugs and anything that might come up. Let's make performance measurement to everyone <3 ## Contributors Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)):

Daniel Schmidt

💻 🤔

Ciaran Liedeman

🐛 💻 ⚠️

Jens Ulrich Hjuler Pedersen

🐛 🤔 👀

Francesca

💻

Ricardo Casares

💻

Michał Wieczorek

💻

Koen Punt

💻

Zeke Nierenberg

💻

Tomáš Voslař

💻

Ben Kimball

💻

Jiapei Liang

💻

Richard W

🤔 🔬

Lee Weisberger

💻

Dustin Deus

🐛
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! ## License [MIT](LICENSE) ================================================ FILE: commitlint.config.js ================================================ module.exports = { extends: ["@commitlint/config-conventional"], rules: { "scope-case": [0], }, }; ================================================ FILE: package.json ================================================ { "name": "apollo-opentracing", "version": "0.0.0-development", "description": "Trace your GraphQL server with Opentracing", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { "clean": "rm -rf dist", "compile": "tsc", "prepare": "npm run clean && npm run compile", "test": "jest", "semantic-release": "semantic-release", "travis-deploy-once": "travis-deploy-once --pro", "contributors:add": "all-contributors add", "contributors:generate": "all-contributors generate" }, "jest": { "transform": { "^.+\\.tsx?$": "ts-jest" }, "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", "moduleFileExtensions": [ "ts", "tsx", "js", "jsx", "json", "node" ] }, "repository": "DanielMSchmidt/apollo-opentracing", "author": "Daniel Schmidt ", "license": "MIT", "peerDependencies": { "apollo-server": ">=3.0.0", "apollo-server-env": "*", "graphql": ">=0.10.x", "opentracing": "*" }, "dependencies": { "apollo-server-plugin-base": "^3.0.0", "apollo-server-types": "^3.0.0" }, "devDependencies": { "@commitlint/config-conventional": "11.0.0", "@commitlint/travis-cli": "17.8.1", "@types/jest": "26.0.24", "@types/node": "14.18.63", "@types/supertest": "2.0.16", "all-contributors-cli": "6.26.1", "apollo-server": "3.13.0", "apollo-server-env": "4.2.1", "express": "4.22.1", "graphql": "15.10.1", "graphql-tools": "6.2.6", "jest": "27.5.1", "opentracing": "0.14.7", "semantic-release": "20.1.3", "supertest": "5.0.0", "travis-deploy-once": "5.0.11", "ts-jest": "27.0.4", "typescript": "4.9.5" } } ================================================ FILE: renovate.json ================================================ { "extends": ["config:base", ":semanticCommits"], "automerge": true, "commitMessagePrefix": "fix:", "major": { "automerge": false } } ================================================ FILE: src/__tests__/__snapshots__/integration-test.ts.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`integration with apollo-server alias with fragment works 1`] = ` request:1 finished: true +-- a:2 finished: true +-- dos:3 finished: true `; exports[`integration with apollo-server alias works 1`] = ` request:1 finished: true +-- a:2 finished: true +-- uno:3 finished: true +-- two:4 finished: true `; exports[`integration with apollo-server correct span nesting 1`] = ` request:1 finished: true +-- a:2 finished: true +-- one:3 finished: true +-- two:4 finished: true `; exports[`integration with apollo-server does not start a field resolver span if the parent field resolver was not traced 1`] = ` request:1 finished: true +-- b:2 finished: true +-- four:3 finished: true `; exports[`integration with apollo-server implements traces for arrays 1`] = ` request:1 finished: true +-- as:2 finished: true +-- one:3 finished: true +-- two:4 finished: true +-- one:5 finished: true +-- two:6 finished: true +-- one:7 finished: true +-- two:8 finished: true `; exports[`integration with apollo-server onFieldResolve & onFieldResolveFinish 1`] = ` request:1 finished: true +-- a:2 finished: true logs: 1. {"onFieldResolve":"yes"} 2. {"onFieldResolveFinish":"yes"} +-- one:3 finished: true logs: 1. {"onFieldResolve":"yes"} 2. {"onFieldResolveFinish":"yes"} +-- two:4 finished: true logs: 1. {"onFieldResolve":"yes"} 2. {"onFieldResolveFinish":"yes"} +-- b:5 finished: true logs: 1. {"onFieldResolve":"yes"} 2. {"onFieldResolveFinish":"yes"} +-- four:6 finished: true logs: 1. {"onFieldResolve":"yes"} 2. {"onFieldResolveFinish":"yes"} `; exports[`integration with apollo-server onRequestError 1`] = ` request:1 finished: true logs: 1. {"onRequestError":"yes"} +-- e:2 finished: true `; exports[`integration with apollo-server onRequestResolve 1`] = ` request:1 finished: true logs: 1. {"onRequestResolve":"yes"} +-- a:2 finished: true +-- one:3 finished: true +-- two:4 finished: true +-- b:5 finished: true +-- four:6 finished: true `; exports[`integration with apollo-server picks up external spans 1`] = ` external:-1 finished: false +-- request:1 finished: true +-- a:2 finished: true +-- one:3 finished: true `; ================================================ FILE: src/__tests__/integration-test.ts ================================================ import * as express from "express"; import * as request from "supertest"; import { ApolloServer } from "apollo-server-express"; import { Tracer } from "opentracing"; import { MockSpan, MockSpanTree } from "../test/types"; import spanSerializer from "../test/span-serializer"; import ApolloOpentracing, { InitOptions, SpanContext } from "../"; expect.addSnapshotSerializer(spanSerializer); let mockSpanId = 1; // Stable spanId's beforeEach(() => { mockSpanId = 1; }); const buildSpanTree = (spans: MockSpan[]) => { // TODO we currently assume there is only one null parent entry. const rootSpan = spans.find((span) => !span.parentId); if (!rootSpan) { throw new Error("No root span found"); } const spansByParentId = spans.reduce((acc, span) => { // Check for root if (span.parentId) { if (acc.has(span.parentId)) { acc.get(span.parentId)?.push(span); } else { acc.set(span.parentId, [span]); } } return acc; }, new Map()); expect(rootSpan).toBeDefined(); const tree: MockSpanTree = { ...rootSpan, children: [], }; buildTree(tree, spansByParentId); // Lost Spans expect(spansByParentId.size).toBe(0); return tree; }; const buildTree = ( parent: MockSpanTree, spansByParentId: Map ) => { if (spansByParentId.has(parent.id)) { const spans = spansByParentId.get(parent.id); if (!spans) { throw new Error( "Could not find the spans for parent " + parent.name + " with id " + parent.id ); } spansByParentId.delete(parent.id); // TODO: do we need to sort? for (const span of spans) { const node = { ...span, children: [], }; parent.children.push(node); buildTree(node, spansByParentId); } } }; function buildEmptySpan( id: number, name: string, parentId?: number, options?: any ) { return { id, parentId, name, options, logs: [], tags: [], finished: false, }; } class MockTracer { spans: MockSpan[]; constructor() { this.spans = []; } extract(_idk: any, header: Record) { // we use this as a name and -1 as id const externalSpanId = header["x-b3-spanid"]; if (!externalSpanId) { return null; } const externalSpan = buildEmptySpan(-1, externalSpanId); this.spans.push(externalSpan); return externalSpan; } startSpan(name: string, options: any) { const spanId = mockSpanId++; this.spans.push( buildEmptySpan(spanId, name, options?.childOf?.id, options) ); const self = this; return { log(object: any) { self.spans.find((span) => span.id === spanId)?.logs.push(object); }, setTag(key: string, value: any) { self.spans .find((span) => span.id === spanId) ?.tags.push({ key: key, value: value }); }, id: spanId, // Added for debugging name, finish() { const span = self.spans.find((span) => span.id === spanId); if (span) { span.finished = true; } }, }; } } async function createApp({ tracer, ...params }: { tracer: MockTracer } & Omit< InitOptions, "server" | "local" >) { const app = express(); const server = new ApolloServer({ typeDefs: ` type A { one: String two: String three: [B] } type B { four: String } type Query { a: A b: B e: B as: [A] bs: [B] } `, resolvers: { Query: { a() { return { one: "1", two: "2", three: [{ four: "4" }, { four: "IV" }], }; }, b() { return { four: "4", }; }, e() { return new Error("error!"); }, as() { return [ { one: "1", two: "2", }, { one: "I", two: "II", }, { one: "eins", two: "zwei", }, ]; }, }, }, plugins: [ ApolloOpentracing({ ...params, server: tracer as unknown as Tracer, local: tracer as unknown as Tracer, }), ], }); await server.start(); server.applyMiddleware({ app }); return app; } describe("integration with apollo-server", () => { it("closes all spans", async () => { const tracer = new MockTracer(); const app = await createApp({ tracer }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { a { one } }`, }) .expect(200); expect(tracer.spans.length).toBe(3); expect(tracer.spans.filter((span) => span.finished).length).toBe(3); }); it("correct span nesting", async () => { const tracer = new MockTracer(); const app = await createApp({ tracer }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { a { one two } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); }); it("does not start a field resolver span if the parent field resolver was not traced", async () => { const tracer = new MockTracer(); const shouldTraceFieldResolver = ( _source: any, _args: any, _ctx: any, info: any ) => { if (info.fieldName === "a") { return false; } return true; }; const app = await createApp({ tracer, shouldTraceFieldResolver }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { a { one two } b { four } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); }); it("implements traces for arrays", async () => { const tracer = new MockTracer(); const app = await createApp({ tracer }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { as { one two } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); }); it("alias works", async () => { const tracer = new MockTracer(); const app = await createApp({ tracer }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { a { uno: one two } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); }); it("alias with fragment works", async () => { const tracer = new MockTracer(); const app = await createApp({ tracer }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: ` fragment F on A { dos: two } query { a { ...F } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); }); it("onFieldResolve & onFieldResolveFinish", async () => { const tracer = new MockTracer(); const onFieldResolve = jest.fn( (_s: any, _args: any, _context: any, info: any) => { info.span.log({ onFieldResolve: "yes" }); } ); const onFieldResolveFinish = jest.fn( (_err: any, _result: any, span: any) => { span.log({ onFieldResolveFinish: "yes" }); } ); const app = await createApp({ tracer, onFieldResolve, onFieldResolveFinish, }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { a { one two } b { four } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); expect(onFieldResolve).toHaveBeenCalledTimes(5); expect(onFieldResolveFinish).toHaveBeenCalledTimes(5); }); it("shouldTraceRequest disables tracing", async () => { const tracer = new MockTracer(); const shouldTraceRequest = jest.fn(() => false); const app = await createApp({ tracer, shouldTraceRequest }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { a { one } }`, }) .expect(200); expect(shouldTraceRequest).toHaveBeenCalledTimes(1); expect(tracer.spans.length).toBe(0); }); it("onRequestResolve", async () => { const tracer = new MockTracer(); const onRequestResolve = jest.fn((span: any) => { span.log({ onRequestResolve: "yes" }); }); const app = await createApp({ tracer, onRequestResolve }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { a { one two } b { four } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); expect(onRequestResolve).toHaveBeenCalledTimes(1); }); it("onRequestError", async () => { const tracer = new MockTracer(); const onRequestError = jest.fn((span: any) => { span.log({ onRequestError: "yes" }); }); const app = await createApp({ tracer, onRequestError }); await request(app) .post("/graphql") .set("Accept", "application/json") .send({ query: `query { e { four } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); expect(onRequestError).toHaveBeenCalledTimes(1); }); it("picks up external spans", async () => { const tracer = new MockTracer(); const app = await createApp({ tracer }); await request(app) .post("/graphql") .set("Accept", "application/json") .set("x-b3-traceid", "external") .set("x-b3-spanid", "external") .send({ query: `query { a { one } }`, }) .expect(200); const tree = buildSpanTree(tracer.spans); expect(tree).toMatchSnapshot(); }); }); ================================================ FILE: src/context.ts ================================================ import { Span } from "opentracing"; import { GraphQLResolveInfo, ResponsePath } from "graphql"; import { GraphQLRequestContext } from "apollo-server-plugin-base"; function isArrayPath(path: ResponsePath) { return typeof path.key === "number"; } export function buildPath(path: ResponsePath | undefined) { let current = path; const segments = []; while (current != null) { if (isArrayPath(current)) { segments.push(`[${current.key}]`); } else { segments.push(current.key); } current = current.prev; } return segments.reverse().join("."); } export interface SpanContext extends Object { _spans: Map; getSpanByPath(info: ResponsePath): Span | undefined; addSpan(span: Span, info: GraphQLResolveInfo): void; // Passed in from the outside context requestSpan?: Span; } function isSpanContext(obj: any): obj is SpanContext { return ( obj.getSpanByPath instanceof Function && obj.addSpan instanceof Function ); } export function requestIsInstanceContextRequest( request: GraphQLRequestContext ): request is GraphQLRequestContext { return isSpanContext(request.context); } // TODO: think about using symbols to hide these export function addContextHelpers(obj: any): SpanContext { if (isSpanContext(obj)) { return obj; } obj._spans = new Map(); obj.getSpanByPath = function (path: ResponsePath): Span | undefined { return this._spans.get(buildPath(isArrayPath(path) ? path.prev : path)); }; obj.addSpan = function (span: Span, info: GraphQLResolveInfo): void { this._spans.set(buildPath(info.path), span); }; return obj; } ================================================ FILE: src/index.ts ================================================ import { Request } from "apollo-server-env"; import { ApolloServerPlugin, GraphQLRequestContext, GraphQLRequestListener, GraphQLRequestExecutionListener, GraphQLFieldResolverParams, GraphQLRequestContextDidEncounterErrors, } from "apollo-server-plugin-base"; import { DocumentNode, GraphQLResolveInfo } from "graphql"; import { FORMAT_HTTP_HEADERS, Span, Tracer } from "opentracing"; import { addContextHelpers, SpanContext, requestIsInstanceContextRequest, } from "./context"; export { SpanContext, addContextHelpers }; const alwaysTrue = () => true; const emptyFunction = () => {}; export interface InitOptions { server?: Tracer; local?: Tracer; onFieldResolveFinish?: (error: Error | null, result: any, span: Span) => void; onFieldResolve?: ( source: any, args: { [argName: string]: any }, context: SpanContext, info: GraphQLResolveInfo ) => void; shouldTraceRequest?: (info: GraphQLRequestContext) => boolean; shouldTraceFieldResolver?: ( source: any, args: { [argName: string]: any }, context: SpanContext, info: GraphQLResolveInfo ) => boolean; onRequestResolve?: ( span: Span, info: GraphQLRequestContext ) => void; createCustomSpanName?: (name: string, info: GraphQLResolveInfo) => string; onRequestError?: ( rootSpan: Span, info: GraphQLRequestContextDidEncounterErrors ) => void; } export interface ExtendedGraphQLResolveInfo extends GraphQLResolveInfo { span?: Span; } export interface RequestStart { request: Pick; queryString?: string; parsedQuery?: DocumentNode; operationName?: string; variables?: { [key: string]: any }; persistedQueryHit?: boolean; persistedQueryRegister?: boolean; context: TContext; requestContext: GraphQLRequestContext; } function getFieldName(info: GraphQLResolveInfo) { if ( info.fieldNodes && info.fieldNodes.length > 0 && info.fieldNodes[0].alias ) { return info.fieldNodes[0].alias.value; } return info.fieldName || "field"; } function headersToOject( headerIterator: Iterator<[string, string], any, undefined> | undefined ): Record { if (!headerIterator) { return {}; } const headers: Record = {}; let header: | IteratorYieldResult<[string, string]> | IteratorReturnResult | undefined; do { header = headerIterator?.next(); if (header?.value) { const [key, value] = header?.value; headers[key] = value; } } while (!header?.done); return headers; } export default function OpentracingPlugin({ server, local, onFieldResolveFinish = emptyFunction, onFieldResolve = emptyFunction, shouldTraceRequest = alwaysTrue, shouldTraceFieldResolver = alwaysTrue, onRequestResolve = emptyFunction, onRequestError = emptyFunction, createCustomSpanName = (name, _) => name, }: InitOptions): ApolloServerPlugin { if (!server) { throw new Error( "ApolloOpentracing needs a server tracer, please provide it to the constructor. e.g. new ApolloOpentracing({ server: serverTracer, local: localTracer })" ); } if (!local) { throw new Error( "ApolloOpentracing needs a local tracer, please provide it to the constructor. e.g. new ApolloOpentracing({ server: serverTracer, local: localTracer })" ); } const serverTracer = server; const localTracer = local; let requestSpan: Span | null = null; return { async requestDidStart( infos: GraphQLRequestContext ): Promise | void> { addContextHelpers(infos.context); if (!requestIsInstanceContextRequest(infos)) { console.warn( "Context in request passed to apollo-opentracing#requestDidStart is not a SpanContext, aborting tracing" ); return; } if (!shouldTraceRequest(infos)) { return; } const headers = headersToOject(infos.request.http?.headers.entries()); const externalSpan = headers ? serverTracer.extract(FORMAT_HTTP_HEADERS, headers) : undefined; const rootSpan = infos.context.requestSpan || serverTracer.startSpan(infos.operationName || "request", { childOf: externalSpan ? externalSpan : undefined, }); onRequestResolve(rootSpan, infos); requestSpan = rootSpan; return { async willSendResponse() { rootSpan.finish(); }, async executionDidStart(): Promise< GraphQLRequestExecutionListener > { return { willResolveField({ source, args, context, info, }: GraphQLFieldResolverParams) { if ( // we don't trace the request !requestSpan || // we should not trace this resolver !shouldTraceFieldResolver(source, args, context, info) || // the previous resolver was not traced (info.path && info.path.prev && !context.getSpanByPath(info.path.prev)) ) { return; } // idempotent method to add helpers to the first context available (which will be propagated by apollo) addContextHelpers(context); const name = createCustomSpanName(getFieldName(info), info); const parentSpan = info.path && info.path.prev ? context.getSpanByPath(info.path.prev) : requestSpan; const span = localTracer.startSpan(name, { childOf: parentSpan || undefined, }); context.addSpan(span, info); // expose to field (although type does not contain it) (info as any).span = span; onFieldResolve(source, args, context, info); return (error: Error | null, result: any) => { onFieldResolveFinish(error, result, span); span.finish(); }; }, }; }, didEncounterErrors: async (requestContext) => { onRequestError(rootSpan, requestContext); }, }; }, }; } ================================================ FILE: src/test/span-serializer.ts ================================================ import "jest"; import { MockSpanTree } from "./types"; const TAB = " "; function prefix(depth: number) { return TAB.repeat(depth) + `+-- `; } function logLine(log: any, index: number, depth: number) { return `${TAB.repeat(depth + 1)}${index}. ${JSON.stringify(log).replace( /\\"/g, "" )}`; } function logs(span: MockSpanTree, depth: number) { if (span.logs && span.logs.length > 0) { return `${TAB.repeat(depth + 1)}logs:\n${span.logs .map((log, index) => logLine(log, index + 1, depth)) .join("\n")}\n`; } return ""; } function tags(span: MockSpanTree, depth: number) { if (span.tags && span.tags.length > 0) { return `${TAB.repeat(depth + 1)}tags:\n${span.tags .map((tag, index) => logLine(tag, index + 1, depth)) .join("\n")}\n`; } return ""; } function tag(span: MockSpanTree, depth: number) { return `${span.name}:${span.id}\n${TAB.repeat(depth + 1)}finished: ${ span.finished }\n${logs(span, depth)}\n${tags(span, depth)}`; } function buildSpan(span: MockSpanTree, depth = 0) { let result = ""; result += tag(span, depth); if (span.children) { for (const child of span.children) { result += `${prefix(depth)}${buildSpan(child, depth + 1)}`; } } return result; } export default { test: (val: any) => val.id && val.name && val.logs && val.finished != null, print(val: any) { return buildSpan(val as MockSpanTree); }, }; ================================================ FILE: src/test/types.ts ================================================ export interface MockSpan { id: number; parentId?: number; name: string; options?: any; logs: any[]; tags: any[]; finished: boolean; } export interface MockSpanTree extends MockSpan { children: MockSpanTree[]; } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2016", "module": "commonjs", "moduleResolution": "node", "sourceMap": true, "declaration": true, "declarationMap": true, "removeComments": true, "strict": true, "skipLibCheck": true, "noImplicitAny": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUnusedParameters": true, "noUnusedLocals": true, "forceConsistentCasingInFileNames": true, "lib": ["es2017", "esnext.asynciterable"], "rootDir": "./src", "outDir": "./dist" }, "include": ["src/**/*"], "exclude": ["**/__tests__", "**/__mocks__"] }