From d8ade4df4d343cd2ad441ebcb88c87a7f986dde6 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 15 Jul 2019 03:59:35 -0400 Subject: [PATCH] [BE-308] Federated metrics support (#2900) * [BE-308] Federated metrics support Augments apollo-engine-reporting and @apollo/gateway to provide federated metrics to Engine from the gateway. Extend trace reports to contain a QueryPlan tree when traces are received from a gateway, mimicking the query plan that the gateway makes internally. FetchNodes on the tree correspond to GraphQL operations executed against federated services; each FetchNode contains its own tree of Trace.Nodes. - apollo-engine-reporting - Factor out core tree building logic to EngineReportingTreeBuilder class - New EngineFederatedTracingExtension adds a base64-encoded protobuf of a Trace (with just the node tree and top level timestamps) to an extension on federated service responses (if requested to do so via an HTTP header from gateway). - Implement new originalFieldName trace field (similar goal to #2401, BE-158). - If @apollo/gateway built us a query plan trace, add it to the trace - @apollo/gateway - Builds up a protobuf representation of the query plan, including sub-traces from federated services on FetchNodes - apollo-engine-reporting-protobuf - Copy reports.proto from internal repo - Set editorconfig settings to match internal repo - Always use normal numbers, not long.js objects - apollo-server-core - Thread some data through between reporting and gateway modules. - apollo-server-integration-testsuite - Clean up some ts warnings TODO record __resolveReference as multiple nodes? TODO is it actually OK to include the operation text in fetch trace nodes? TODO more tests, especially of error cases TODO internal docs (eg FIXME comment in reports.proto) TODO external docs * Update protobuf to match cloud repo * No longer set nonexistent operation field * Update proto to fix typo * Convert trace failure into metric Previously, the logic within executing a query plan would throw an error and fail the entire operation if it could not parse a trace. Instead, it adds the information that it failed to parse the trace into the overall fetch node itself. This commit also adds some comments. * add snapshot-serializer test * Add to comment justifying console.warn * update snapshot * prettier * Fixup some imports Build still breaking :( * Rely on serviceName instead of error code We used to rely on the error code matching "DOWNSTREAM_SERVICE_ERROR" in order to determine that an error was from an underlying service rather than from the gateway itself. In the past few weeks, error handling in the gateway has been modified to allow for overriding this error code with whatever code has come from the gateway, but we are guaranteed to always have a serviceName if the error comes from a downstream service. Note, however, that this means if users pass "serviceName" in an error extension, it will NOT be reported for metrics, even if it occurred at the Gateway level. We may want to find something less flimsy than this. * lint fix * Remove invalid null arguments * Take suggested log changes * Publish - apollo-cache-control@0.7.6-alpha.10 - apollo-datasource-rest@0.5.2-alpha.1 - apollo-engine-reporting-protobuf@0.3.2-alpha.0 - apollo-engine-reporting@1.4.0-alpha.10 - @apollo/federation@0.6.11-alpha.10 - @apollo/gateway@0.7.0-alpha.10 - apollo-server-azure-functions@2.7.0-alpha.10 - apollo-server-cloud-functions@2.7.0-alpha.10 - apollo-server-cloudflare@2.7.0-alpha.10 - apollo-server-core@2.7.0-alpha.10 - apollo-server-express@2.7.0-alpha.10 - apollo-server-fastify@2.7.0-alpha.10 - apollo-server-hapi@2.7.0-alpha.10 - apollo-server-integration-testsuite@2.7.0-alpha.10 - apollo-server-koa@2.7.0-alpha.10 - apollo-server-lambda@2.7.0-alpha.10 - apollo-server-micro@2.7.0-alpha.10 - apollo-server-plugin-base@0.6.0-alpha.10 - apollo-server-plugin-response-cache@0.2.7-alpha.10 - apollo-server-testing@2.7.0-alpha.10 - apollo-server-types@0.1.1-alpha.1 - apollo-server@2.7.0-alpha.10 - apollo-tracing@0.7.5-alpha.10 - graphql-extensions@0.8.0-alpha.10 * empty commit * Publish - apollo-server-azure-functions@2.7.0-alpha.11 - apollo-server-cloud-functions@2.7.0-alpha.11 - apollo-server-cloudflare@2.7.0-alpha.11 - apollo-server-express@2.7.0-alpha.11 - apollo-server-fastify@2.7.0-alpha.11 - apollo-server-hapi@2.7.0-alpha.11 - apollo-server-integration-testsuite@2.7.0-alpha.11 - apollo-server-koa@2.7.0-alpha.11 - apollo-server-lambda@2.7.0-alpha.11 - apollo-server-micro@2.7.0-alpha.11 - apollo-server-testing@2.7.0-alpha.11 - apollo-server@2.7.0-alpha.11 * anotha one * Publish - apollo-server-azure-functions@2.7.0-alpha.12 - apollo-server-cloud-functions@2.7.0-alpha.12 - apollo-server-cloudflare@2.7.0-alpha.12 - apollo-server-express@2.7.0-alpha.12 - apollo-server-fastify@2.7.0-alpha.12 - apollo-server-hapi@2.7.0-alpha.12 - apollo-server-integration-testsuite@2.7.0-alpha.12 - apollo-server-koa@2.7.0-alpha.12 - apollo-server-lambda@2.7.0-alpha.12 - apollo-server-micro@2.7.0-alpha.12 - apollo-server-testing@2.7.0-alpha.12 - apollo-server@2.7.0-alpha.12 --- package-lock.json | 143 +++-- package.json | 3 +- packages/apollo-cache-control/package.json | 2 +- packages/apollo-datasource-rest/package.json | 2 +- .../package.json | 2 +- .../src/.editorconfig | 12 + .../src/index.js | 7 + .../src/reports.proto | 120 +++- packages/apollo-engine-reporting/package.json | 2 +- .../src/__tests__/extension.test.ts | 6 +- .../apollo-engine-reporting/src/extension.ts | 220 +------ .../src/federatedExtension.ts | 88 +++ packages/apollo-engine-reporting/src/index.ts | 1 + .../src/treeBuilder.ts | 253 ++++++++ packages/apollo-federation/package.json | 2 +- packages/apollo-federation/src/types.ts | 1 + packages/apollo-gateway/package.json | 5 +- .../src/__tests__/gateway/reporting.test.ts | 590 ++++++++++++++++++ .../datasources/RemoteGraphQLDatasource.ts | 3 +- .../apollo-gateway/src/executeQueryPlan.ts | 205 +++++- .../package.json | 2 +- .../package.json | 2 +- .../apollo-server-cloudflare/package.json | 2 +- packages/apollo-server-core/package.json | 3 +- .../apollo-server-core/src/ApolloServer.ts | 57 ++ .../apollo-server-core/src/graphqlOptions.ts | 2 + .../apollo-server-core/src/runHttpQuery.ts | 5 + packages/apollo-server-express/package.json | 2 +- packages/apollo-server-fastify/package.json | 2 +- packages/apollo-server-hapi/package.json | 2 +- .../package.json | 2 +- .../src/ApolloServer.ts | 156 ++++- packages/apollo-server-koa/package.json | 2 +- packages/apollo-server-lambda/package.json | 2 +- packages/apollo-server-micro/package.json | 2 +- .../apollo-server-plugin-base/package.json | 2 +- .../package.json | 2 +- packages/apollo-server-testing/package.json | 2 +- packages/apollo-server-types/package.json | 3 +- packages/apollo-server-types/src/index.ts | 4 + packages/apollo-server/package.json | 2 +- packages/apollo-tracing/package.json | 2 +- packages/graphql-extensions/package.json | 2 +- 43 files changed, 1582 insertions(+), 347 deletions(-) create mode 100644 packages/apollo-engine-reporting-protobuf/src/.editorconfig create mode 100644 packages/apollo-engine-reporting/src/federatedExtension.ts create mode 100644 packages/apollo-engine-reporting/src/treeBuilder.ts create mode 100644 packages/apollo-gateway/src/__tests__/gateway/reporting.test.ts diff --git a/package-lock.json b/package-lock.json index d6b4227e6dd..39c4253c26b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "version": "file:packages/apollo-gateway", "requires": { "@apollo/federation": "file:packages/apollo-federation", + "apollo-engine-reporting-protobuf": "file:packages/apollo-engine-reporting-protobuf", "apollo-env": "^0.5.1", "apollo-graphql": "^0.3.3", "apollo-server-caching": "file:packages/apollo-server-caching", @@ -3081,6 +3082,12 @@ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-12.0.9.tgz", "integrity": "sha512-sCZy4SxP9rN2w30Hlmg5dtdRwgYQfYRiLo9usw8X9cxlf+H4FqM1xX7+sNH7NNKVdbXMJWqva7iyy+fxh/V7fA==" }, + "@types/yup": { + "version": "0.26.18", + "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.26.18.tgz", + "integrity": "sha512-bKGlHqe+SrvdZONwB+H7hihsvl4yAaOIhN6Sgnnuo6NQOJ0bBNc53Ztfe8ORZHBcPC/OVxhFrxnJIjsGsDbR8w==", + "dev": true + }, "@wry/equality": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.1.9.tgz", @@ -3452,6 +3459,7 @@ "apollo-cache-control": "file:packages/apollo-cache-control", "apollo-datasource": "file:packages/apollo-datasource", "apollo-engine-reporting": "file:packages/apollo-engine-reporting", + "apollo-engine-reporting-protobuf": "file:packages/apollo-engine-reporting-protobuf", "apollo-server-caching": "file:packages/apollo-server-caching", "apollo-server-env": "file:packages/apollo-server-env", "apollo-server-errors": "file:packages/apollo-server-errors", @@ -3535,8 +3543,7 @@ }, "@types/express": { "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.0.tgz", - "integrity": "sha512-CjaMu57cjgjuZbh9DpkloeGxV45CnMGlVd+XpG7Gm9QgVrd7KFq+X4HY0vM+2v0bczS48Wg7bvnMY5TN+Xmcfw==", + "bundled": true, "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "*", @@ -6552,28 +6559,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "resolved": false, "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "resolved": false, "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "resolved": false, "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "resolved": false, "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "dev": true, "optional": true, @@ -6584,14 +6591,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -6602,42 +6609,42 @@ }, "chownr": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", + "resolved": false, "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "resolved": false, "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "resolved": false, "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "resolved": false, "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "optional": true, @@ -6647,28 +6654,28 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "resolved": false, "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "resolved": false, "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "resolved": false, "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", + "resolved": false, "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "dev": true, "optional": true, @@ -6678,14 +6685,14 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "resolved": false, "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "resolved": false, "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -6702,7 +6709,7 @@ }, "glob": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "resolved": false, "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "optional": true, @@ -6717,14 +6724,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "resolved": false, "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "resolved": false, "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "optional": true, @@ -6734,7 +6741,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", + "resolved": false, "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -6744,7 +6751,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "resolved": false, "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -6755,21 +6762,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "resolved": false, "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "resolved": false, "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "resolved": false, "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -6779,14 +6786,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "resolved": false, "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -6796,14 +6803,14 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": false, "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true }, "minipass": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", + "resolved": false, "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, "optional": true, @@ -6814,7 +6821,7 @@ }, "minizlib": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", + "resolved": false, "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", "dev": true, "optional": true, @@ -6824,7 +6831,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": false, "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -6834,14 +6841,14 @@ }, "ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "resolved": false, "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true, "optional": true }, "needle": { "version": "2.2.4", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.2.4.tgz", + "resolved": false, "integrity": "sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA==", "dev": true, "optional": true, @@ -6853,7 +6860,7 @@ }, "node-pre-gyp": { "version": "0.10.3", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz", + "resolved": false, "integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==", "dev": true, "optional": true, @@ -6872,7 +6879,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "resolved": false, "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -6883,14 +6890,14 @@ }, "npm-bundled": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.5.tgz", + "resolved": false, "integrity": "sha512-m/e6jgWu8/v5niCUKQi9qQl8QdeEduFA96xHDDzFGqly0OOjI7c+60KM/2sppfnUU9JJagf+zs+yGhqSOFj71g==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.2.0.tgz", + "resolved": false, "integrity": "sha512-7Mni4Z8Xkx0/oegoqlcao/JpPCPEMtUvsmB0q7mgvlMinykJLSRTYuFqoQLYgGY8biuxIeiHO+QNJKbCfljewQ==", "dev": true, "optional": true, @@ -6901,7 +6908,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "resolved": false, "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -6914,21 +6921,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "resolved": false, "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "resolved": false, "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "resolved": false, "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -6938,21 +6945,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "resolved": false, "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -6963,21 +6970,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": false, "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "resolved": false, "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "resolved": false, "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "optional": true, @@ -6990,7 +6997,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": false, "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true @@ -6999,7 +7006,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": false, "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -7015,7 +7022,7 @@ }, "rimraf": { "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "resolved": false, "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, "optional": true, @@ -7025,49 +7032,49 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "resolved": false, "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": false, "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "resolved": false, "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "resolved": false, "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "resolved": false, "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "resolved": false, "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "resolved": false, "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -7079,7 +7086,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": false, "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -7089,7 +7096,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": false, "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -7099,14 +7106,14 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "resolved": false, "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "4.4.8", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz", + "resolved": false, "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", "dev": true, "optional": true, @@ -7122,14 +7129,14 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": false, "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "resolved": false, "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "dev": true, "optional": true, @@ -7139,14 +7146,14 @@ }, "wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true }, "yallist": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "resolved": false, "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "dev": true, "optional": true diff --git a/package.json b/package.json index ba50ce2ec9d..501a608d9a9 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@types/test-listen": "1.1.0", "@types/type-is": "1.6.2", "@types/ws": "6.0.1", + "@types/yup": "0.26.18", "apollo-fetch": "0.7.0", "apollo-link": "1.2.12", "apollo-link-http": "1.5.15", @@ -129,7 +130,7 @@ "memcached-mock": "0.1.0", "mock-req": "0.2.0", "multer": "1.4.1", - "nock": "^10.0.6", + "nock": "10.0.6", "node-fetch": "2.3.0", "prettier": "1.18.2", "prettier-check": "2.0.0", diff --git a/packages/apollo-cache-control/package.json b/packages/apollo-cache-control/package.json index 12bdeb198d7..66a4e7fab1d 100644 --- a/packages/apollo-cache-control/package.json +++ b/packages/apollo-cache-control/package.json @@ -1,6 +1,6 @@ { "name": "apollo-cache-control", - "version": "0.7.6-alpha.9", + "version": "0.7.6-alpha.10", "description": "A GraphQL extension for cache control", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/apollo-datasource-rest/package.json b/packages/apollo-datasource-rest/package.json index b3b8be57c96..4c16997aed2 100644 --- a/packages/apollo-datasource-rest/package.json +++ b/packages/apollo-datasource-rest/package.json @@ -1,6 +1,6 @@ { "name": "apollo-datasource-rest", - "version": "0.5.2-alpha.0", + "version": "0.5.2-alpha.1", "author": "opensource@apollographql.com", "license": "MIT", "repository": { diff --git a/packages/apollo-engine-reporting-protobuf/package.json b/packages/apollo-engine-reporting-protobuf/package.json index f6439cbbccb..62b80fe2f37 100644 --- a/packages/apollo-engine-reporting-protobuf/package.json +++ b/packages/apollo-engine-reporting-protobuf/package.json @@ -1,6 +1,6 @@ { "name": "apollo-engine-reporting-protobuf", - "version": "0.3.1", + "version": "0.3.2-alpha.0", "description": "Protobuf format for Apollo Engine", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-engine-reporting-protobuf/src/.editorconfig b/packages/apollo-engine-reporting-protobuf/src/.editorconfig new file mode 100644 index 00000000000..bd27d8d5d2a --- /dev/null +++ b/packages/apollo-engine-reporting-protobuf/src/.editorconfig @@ -0,0 +1,12 @@ +# reports.proto is copied from an internal Apollo repository which applies these +# editorconfig standards. + +root = true + +[reports.proto] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/packages/apollo-engine-reporting-protobuf/src/index.js b/packages/apollo-engine-reporting-protobuf/src/index.js index 31fd01481f4..0bfbcae555c 100644 --- a/packages/apollo-engine-reporting-protobuf/src/index.js +++ b/packages/apollo-engine-reporting-protobuf/src/index.js @@ -1,4 +1,11 @@ const protobuf = require('./protobuf'); +const protobufJS = require('protobufjs/minimal'); + +// Remove Long support. Our uint64s tend to be small (less +// than 104 days). +// https://github.com/protobufjs/protobuf.js/issues/1253 +protobufJS.util.Long = undefined; +protobufJS.configure(); // Override the generated protobuf Traces.encode function so that it will look // for Traces that are already encoded to Buffer as well as unencoded diff --git a/packages/apollo-engine-reporting-protobuf/src/reports.proto b/packages/apollo-engine-reporting-protobuf/src/reports.proto index fde9e6529c4..55cb2b39c93 100644 --- a/packages/apollo-engine-reporting-protobuf/src/reports.proto +++ b/packages/apollo-engine-reporting-protobuf/src/reports.proto @@ -80,16 +80,26 @@ message Trace { uint32 column = 2; } + // We store information on each resolver execution as a Node on a tree. + // The structure of the tree corresponds to the structure of the GraphQL + // response; it does not indicate the order in which resolvers were + // invoked. Note that nodes representing indexes (and the root node) + // don't contain all Node fields (eg types and times). message Node { - // The name of the field (for Nodes representing a resolver call) or - // the index in a list (for intermediate Nodes representing elements of - // a list). Note that nodes representing indexes (and the root node) - // don't contain all Node fields (eg types and times). + // The name of the field (for Nodes representing a resolver call) or the + // index in a list (for intermediate Nodes representing elements of a list). + // field_name is the name of the field as it appears in the GraphQL + // response: ie, it may be an alias. (In that case, the original_field_name + // field holds the actual field name from the schema.) In any context where + // we're building up a path, we use the response_name rather than the + // original_field_name. oneof id { - string field_name = 1; + string response_name = 1; uint32 index = 2; } + string original_field_name = 14; + // The field's return type; e.g. "String!" for User.email:String! string type = 3; @@ -109,6 +119,62 @@ message Trace { reserved 4; } + // represents a node in the query plan, under which there is a trace tree for that service fetch. + // In particular, each fetch node represents a call to an implementing service, and calls to implementing + // services may not be unique. See https://github.com/apollographql/apollo-server/blob/master/packages/apollo-gateway/src/QueryPlan.ts + // for more information and details. + message QueryPlanNode { + // This represents a set of nodes to be executed sequentially by the Gateway executor + message SequenceNode { + repeated QueryPlanNode nodes = 1; + } + // This represents a set of nodes to be executed in parallel by the Gateway executor + message ParallelNode { + repeated QueryPlanNode nodes = 1; + } + // This represents a node to send an operation to an implementing service + message FetchNode { + // XXX When we want to include more details about the sub-operation that was + // executed against this service, we should include that here in each fetch node. + // This might include an operation signature, requires directive, reference resolutions, etc. + string serviceName = 1; + + bool traceParsingFailed = 2; + + // This Trace only contains start_time, end_time, duration_ns, and root; + // all timings were calculated **on the federated service**, and clock skew + // will be handled by the ingress server. + Trace trace = 3; + + // relative to the outer trace's start_time, in ns, measured in the gateway. + uint64 sent_time_offset = 4; + + // Wallclock times measured in the gateway for when this operation was + // sent and received. + google.protobuf.Timestamp sent_time = 5; + google.protobuf.Timestamp received_time = 6; + } + + // This node represents a way to reach into the response path and attach related entities. + // XXX Flatten is really not the right name and this node may be renamed in the query planner. + message FlattenNode { + repeated ResponsePathElement response_path = 1; + QueryPlanNode node = 2; + } + message ResponsePathElement { + oneof id { + string field_name = 1; + uint32 index = 2; + } + } + oneof node { + SequenceNode sequence = 1; + ParallelNode parallel = 2; + FetchNode fetch = 3; + FlattenNode flatten = 4; + } + } + // Wallclock time when the trace began. google.protobuf.Timestamp start_time = 4; // required // Wallclock time when the trace ended. @@ -116,11 +182,13 @@ message Trace { // High precision duration of the trace; may not equal end_time-start_time // (eg, if your machine's clock changed during the trace). uint64 duration_ns = 11; // required + // A tree containing information about all resolvers run directly by this + // service, including errors. + Node root = 14; - // These fields are specific to engineproxy. - google.protobuf.Timestamp origin_reported_start_time = 15; - google.protobuf.Timestamp origin_reported_end_time = 16; - uint64 origin_reported_duration_ns = 17; + // ------------------------------------------------------------------------- + // Fields below this line are *not* included in federated traces (the traces + // sent from federated services to the gateway). // In addition to details.raw_query, we include a "signature" of the query, // which can be normalized: for example, you may want to discard aliases, drop @@ -134,14 +202,6 @@ message Trace { // instead. string signature = 19; - // Older agents (eg the Go engineproxy) relied to some degree on the Engine - // backend to run their own semi-compatible implementation of a specific - // variant of query signatures. The backend does not do this for new agents (which - // set the above 'signature' field). It used to still "re-sign" signatures - // from engineproxy, but we've now simplified the backend to no longer do this. - // Deprecated and ignored in FullTracesReports. - string legacy_signature_needs_resigning = 5; - Details details = 6; // Note: engineproxy always sets client_name, client_version, and client_address to "none". @@ -155,7 +215,11 @@ message Trace { CachePolicy cache_policy = 18; - Node root = 14; + // If this Trace was created by a gateway, this is the query plan, including + // sub-Traces for federated services. Note that the 'root' tree on the + // top-level Trace won't contain any resolvers (though it could contain errors + // that occurred in the gateway itself). + QueryPlanNode query_plan = 26; // Was this response served from a full query response cache? (In that case // the node tree will have no resolvers.) @@ -174,6 +238,21 @@ message Trace { // Was this operation forbidden due to lack of safelisting? bool forbidden_operation = 25; + // -------------------------------------------------------------- + // Fields below this line are only set by the old Go engineproxy. + google.protobuf.Timestamp origin_reported_start_time = 15; + google.protobuf.Timestamp origin_reported_end_time = 16; + uint64 origin_reported_duration_ns = 17; + + // Older agents (eg the Go engineproxy) relied to some degree on the Engine + // backend to run their own semi-compatible implementation of a specific + // variant of query signatures. The backend does not do this for new agents (which + // set the above 'signature' field). It used to still "re-sign" signatures + // from engineproxy, but we've now simplified the backend to no longer do this. + // Deprecated and ignored in FullTracesReports. + string legacy_signature_needs_resigning = 5; + + // removed: Node parse = 12; Node validate = 13; // Id128 server_id = 1; Id128 client_id = 2; reserved 12, 13, 1, 2; @@ -280,7 +359,7 @@ message FieldStat { message TypeStat { string name = 1; // deprecated; only set when stored in QueryStats.per_type - repeated FieldStat field = 2; // deprecated; use per_field_stat instead + repeated FieldStat field = 2; // deprecated; use per_field_stat instead // Key is (eg) "email" for User.email:String! map per_field_stat = 3; } @@ -358,6 +437,7 @@ message StatsReport { // FullTracesReports. uint64 realtime_duration = 10; + // Maps from query descriptor to QueryStats. Required unless // legacy_per_query_missing_operation_name is set. The keys are strings of the // form `# operationName\nsignature` (literal hash and space), with @@ -405,4 +485,4 @@ message Traces { message TraceV1 { ReportHeader header = 1; Trace trace = 2; -} \ No newline at end of file +} diff --git a/packages/apollo-engine-reporting/package.json b/packages/apollo-engine-reporting/package.json index 83bd50e310a..23f022e1640 100644 --- a/packages/apollo-engine-reporting/package.json +++ b/packages/apollo-engine-reporting/package.json @@ -1,6 +1,6 @@ { "name": "apollo-engine-reporting", - "version": "1.4.0-alpha.9", + "version": "1.4.0-alpha.10", "description": "Send reports about your GraphQL services to Apollo Engine", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/apollo-engine-reporting/src/__tests__/extension.test.ts b/packages/apollo-engine-reporting/src/__tests__/extension.test.ts index ab0691ce37a..10de6ff7361 100644 --- a/packages/apollo-engine-reporting/src/__tests__/extension.test.ts +++ b/packages/apollo-engine-reporting/src/__tests__/extension.test.ts @@ -8,9 +8,7 @@ import { Request } from 'node-fetch'; import { EngineReportingExtension, makeTraceDetails, - makeTraceDetailsLegacy, makeHTTPRequestHeaders, - makeHTTPRequestHeadersLegacy, } from '../extension'; import { Headers } from 'apollo-server-env'; import { InMemoryLRUCache } from 'apollo-server-caching'; @@ -104,7 +102,7 @@ const variables: Record = { describe('check variableJson output for sendVariableValues null or undefined (default)', () => { it('Case 1: No keys/values in variables to be filtered/not filtered', () => { const emptyOutput = new Trace.Details(); - expect(makeTraceDetails({}, null)).toEqual(emptyOutput); + expect(makeTraceDetails({})).toEqual(emptyOutput); expect(makeTraceDetails({}, undefined)).toEqual(emptyOutput); expect(makeTraceDetails({})).toEqual(emptyOutput); }); @@ -114,7 +112,7 @@ describe('check variableJson output for sendVariableValues null or undefined (de filteredOutput.variablesJson[name] = ''; }); expect(makeTraceDetails(variables)).toEqual(filteredOutput); - expect(makeTraceDetails(variables, null)).toEqual(filteredOutput); + expect(makeTraceDetails(variables)).toEqual(filteredOutput); expect(makeTraceDetails(variables, undefined)).toEqual(filteredOutput); }); }); diff --git a/packages/apollo-engine-reporting/src/extension.ts b/packages/apollo-engine-reporting/src/extension.ts index 4a304e05563..df32cb4e59c 100644 --- a/packages/apollo-engine-reporting/src/extension.ts +++ b/packages/apollo-engine-reporting/src/extension.ts @@ -2,14 +2,12 @@ import { GraphQLRequestContext, WithRequired } from 'apollo-server-types'; import { Request, Headers } from 'apollo-server-env'; import { GraphQLResolveInfo, - responsePathAsArray, - ResponsePath, DocumentNode, ExecutionArgs, GraphQLError, } from 'graphql'; import { GraphQLExtension, EndHandler } from 'graphql-extensions'; -import { Trace, google } from 'apollo-engine-reporting-protobuf'; +import { Trace } from 'apollo-engine-reporting-protobuf'; import { EngineReportingOptions, @@ -18,6 +16,7 @@ import { VariableValueOptions, SendValuesBaseOptions, } from './agent'; +import { EngineReportingTreeBuilder } from './treeBuilder'; const clientNameHeaderKey = 'apollographql-client-name'; const clientReferenceIdHeaderKey = 'apollographql-client-reference-id'; @@ -31,9 +30,7 @@ const clientVersionHeaderKey = 'apollographql-client-version'; // Its public methods all implement the GraphQLExtension interface. export class EngineReportingExtension implements GraphQLExtension { - public trace = new Trace(); - private nodes = new Map(); - private startHrTime!: [number, number]; + private treeBuilder: EngineReportingTreeBuilder; private explicitOperationName?: string | null; private queryString?: string; private documentAST?: DocumentNode; @@ -50,11 +47,12 @@ export class EngineReportingExtension ...options, }; this.addTrace = addTrace; - const root = new Trace.Node(); - this.trace.root = root; - this.nodes.set(responsePathAsString(undefined), root); this.generateClientInfo = options.generateClientInfo || defaultGenerateClientInfo; + + this.treeBuilder = new EngineReportingTreeBuilder({ + rewriteError: options.rewriteError, + }); } public requestDidStart(o: { @@ -69,8 +67,8 @@ export class EngineReportingExtension 'metrics' | 'queryHash' >; }): EndHandler { - this.trace.startTime = dateToTimestamp(new Date()); - this.startHrTime = process.hrtime(); + this.treeBuilder.startTiming(); + o.requestContext.metrics.startHrTime = this.treeBuilder.startHrTime; // Generally, we'll get queryString here and not parsedQuery; we only get // parsedQuery if you're using an OperationStore. In normal cases we'll get @@ -79,7 +77,7 @@ export class EngineReportingExtension this.queryString = o.queryString; this.documentAST = o.parsedQuery; - this.trace.http = new Trace.HTTP({ + this.treeBuilder.trace.http = new Trace.HTTP({ method: Trace.HTTP.Method[o.request.method as keyof typeof Trace.HTTP.Method] || Trace.HTTP.Method.UNKNOWN, @@ -96,21 +94,21 @@ export class EngineReportingExtension if (this.options.sendHeaders) { makeHTTPRequestHeaders( - this.trace.http, + this.treeBuilder.trace.http, o.request.headers, this.options.sendHeaders, ); if (o.requestContext.metrics.persistedQueryHit) { - this.trace.persistedQueryHit = true; + this.treeBuilder.trace.persistedQueryHit = true; } if (o.requestContext.metrics.persistedQueryRegister) { - this.trace.persistedQueryRegister = true; + this.treeBuilder.trace.persistedQueryRegister = true; } } if (o.variables) { - this.trace.details = makeTraceDetails( + this.treeBuilder.trace.details = makeTraceDetails( o.variables, this.options.sendVariableValues, o.queryString, @@ -125,22 +123,19 @@ export class EngineReportingExtension const { clientName, clientVersion, clientReferenceId } = clientInfo; // the backend makes the choice of mapping clientName => clientReferenceId if // no custom reference id is provided - this.trace.clientVersion = clientVersion || ''; - this.trace.clientReferenceId = clientReferenceId || ''; - this.trace.clientName = clientName || ''; + this.treeBuilder.trace.clientVersion = clientVersion || ''; + this.treeBuilder.trace.clientReferenceId = clientReferenceId || ''; + this.treeBuilder.trace.clientName = clientName || ''; } return () => { - this.trace.durationNs = durationHrTimeToNanos( - process.hrtime(this.startHrTime), - ); - this.trace.endTime = dateToTimestamp(new Date()); + this.treeBuilder.stopTiming(); - this.trace.fullQueryCacheHit = !!o.requestContext.metrics + this.treeBuilder.trace.fullQueryCacheHit = !!o.requestContext.metrics .responseCacheHit; - this.trace.forbiddenOperation = !!o.requestContext.metrics + this.treeBuilder.trace.forbiddenOperation = !!o.requestContext.metrics .forbiddenOperation; - this.trace.registeredOperation = !!o.requestContext.metrics + this.treeBuilder.trace.registeredOperation = !!o.requestContext.metrics .registeredOperation; // If the user did not explicitly specify an operation name (which we @@ -154,12 +149,19 @@ export class EngineReportingExtension this.explicitOperationName || o.requestContext.operationName || ''; const documentAST = this.documentAST || o.requestContext.document; + // If this was a federated operation and we're the gateway, add the query plan + // to the trace. + if (o.requestContext.metrics.queryPlanTrace) { + this.treeBuilder.trace.queryPlan = + o.requestContext.metrics.queryPlanTrace; + } + this.addTrace({ operationName, queryHash, documentAST, queryString: this.queryString || '', - trace: this.trace, + trace: this.treeBuilder.trace, schemaHash: this.schemaHash, }); }; @@ -187,173 +189,19 @@ export class EngineReportingExtension _context: TContext, info: GraphQLResolveInfo, ): ((error: Error | null, result: any) => void) | void { - const path = info.path; - const node = this.newNode(path); - node.type = info.returnType.toString(); - node.parentType = info.parentType.toString(); - node.startTime = durationHrTimeToNanos(process.hrtime(this.startHrTime)); - - return () => { - node.endTime = durationHrTimeToNanos(process.hrtime(this.startHrTime)); - // We could save the error into the trace here, but it won't have all - // the information that graphql-js adds to it later, like 'locations'. - }; + return this.treeBuilder.willResolveField(info); + // We could save the error into the trace during the end handler, but it + // won't have all the information that graphql-js adds to it later, like + // 'locations'. } public didEncounterErrors(errors: GraphQLError[]) { - errors.forEach(err => { - // In terms of reporting, errors can be re-written by the user by - // utilizing the `rewriteError` parameter. This allows changing - // the message or stack to remove potentially sensitive information. - // Returning `null` will result in the error not being reported at all. - const errorForReporting = this.rewriteError(err); - - if (errorForReporting === null) { - return; - } - - this.addError(errorForReporting); - }); - } - - private rewriteError(err: GraphQLError): GraphQLError | null { - if (this.options.rewriteError) { - // Before passing the error to the user-provided `rewriteError` function, - // we'll make a shadow copy of the error so the user is free to change - // the object as they see fit. - - // At this stage, this error is only for the purposes of reporting, but - // this is even more important since this is still a reference to the - // original error object and changing it would also change the error which - // is returned in the response to the client. - - // For the clone, we'll create a new object which utilizes the exact same - // prototype of the error being reported. - const clonedError = Object.assign( - Object.create(Object.getPrototypeOf(err)), - err, - ); - - const rewrittenError = this.options.rewriteError(clonedError); - - // Returning an explicit `null` means the user is requesting that, in - // terms of Engine reporting, the error be buried. - if (rewrittenError === null) { - return null; - } - - // We don't want users to be inadvertently not reporting errors, so if - // they haven't returned an explicit `GraphQLError` (or `null`, handled - // above), then we'll report the error as usual. - if (!(rewrittenError instanceof GraphQLError)) { - return err; - } - - // We only allow rewriteError to change the message and extensions of the - // error; we keep everything else the same. That way people don't have to - // do extra work to keep the error on the same trace node. We also keep - // extensions the same if it isn't explicitly changed (to, eg, {}). (Note - // that many of the fields of GraphQLError are not enumerable and won't - // show up in the trace (even in the json field) anyway.) - return new GraphQLError( - rewrittenError.message, - err.nodes, - err.source, - err.positions, - err.path, - err.originalError, - rewrittenError.extensions || err.extensions, - ); - } - return err; - } - - private addError(error: GraphQLError): void { - // By default, put errors on the root node. - let node = this.nodes.get(''); - if (error.path) { - const specificNode = this.nodes.get(error.path.join('.')); - if (specificNode) { - node = specificNode; - } - } - - node!.error!.push( - new Trace.Error({ - message: error.message, - location: (error.locations || []).map( - ({ line, column }) => new Trace.Location({ line, column }), - ), - json: JSON.stringify(error), - }), - ); - } - - private newNode(path: ResponsePath): Trace.Node { - const node = new Trace.Node(); - const id = path.key; - if (typeof id === 'number') { - node.index = id; - } else { - node.fieldName = id; - } - this.nodes.set(responsePathAsString(path), node); - const parentNode = this.ensureParentNode(path); - parentNode.child.push(node); - return node; - } - - private ensureParentNode(path: ResponsePath): Trace.Node { - const parentPath = responsePathAsString(path.prev); - const parentNode = this.nodes.get(parentPath); - if (parentNode) { - return parentNode; - } - // Because we set up the root path in the constructor, we now know that - // path.prev isn't undefined. - return this.newNode(path.prev!); + this.treeBuilder.didEncounterErrors(errors); } } // Helpers for producing traces. -// Convert from the linked-list ResponsePath format to a dot-joined -// string. Includes the full path (field names and array indices). -function responsePathAsString(p: ResponsePath | undefined) { - if (p === undefined) { - return ''; - } - return responsePathAsArray(p).join('.'); -} - -// Converts a JS Date into a Timestamp. -function dateToTimestamp(date: Date): google.protobuf.Timestamp { - const totalMillis = +date; - const millis = totalMillis % 1000; - return new google.protobuf.Timestamp({ - seconds: (totalMillis - millis) / 1000, - nanos: millis * 1e6, - }); -} - -// Converts an hrtime array (as returned from process.hrtime) to nanoseconds. -// -// ONLY CALL THIS ON VALUES REPRESENTING DELTAS, NOT ON THE RAW RETURN VALUE -// FROM process.hrtime() WITH NO ARGUMENTS. -// -// The entire point of the hrtime data structure is that the JavaScript Number -// type can't represent all int64 values without loss of precision: -// Number.MAX_SAFE_INTEGER nanoseconds is about 104 days. Calling this function -// on a duration that represents a value less than 104 days is fine. Calling -// this function on an absolute time (which is generally roughly time since -// system boot) is not a good idea. -// -// XXX We should probably use google.protobuf.Duration on the wire instead of -// ever trying to store durations in a single number. -function durationHrTimeToNanos(hrtime: [number, number]) { - return hrtime[0] * 1e9 + hrtime[1]; -} - function defaultGenerateClientInfo({ request }: GraphQLRequestContext) { // Default to using the `apollo-client-x` header fields if present. // If none are present, fallback on the `clientInfo` query extension diff --git a/packages/apollo-engine-reporting/src/federatedExtension.ts b/packages/apollo-engine-reporting/src/federatedExtension.ts new file mode 100644 index 00000000000..cf3c592ee63 --- /dev/null +++ b/packages/apollo-engine-reporting/src/federatedExtension.ts @@ -0,0 +1,88 @@ +import { GraphQLResolveInfo, GraphQLError } from 'graphql'; +import { GraphQLExtension, EndHandler } from 'graphql-extensions'; +import { Trace } from 'apollo-engine-reporting-protobuf'; +import { GraphQLRequestContext } from 'apollo-server-types'; + +import { EngineReportingTreeBuilder } from './treeBuilder'; + +export class EngineFederatedTracingExtension + implements GraphQLExtension { + private enabled = false; + private done = false; + private treeBuilder: EngineReportingTreeBuilder; + + public constructor(options: { + rewriteError?: (err: GraphQLError) => GraphQLError | null; + }) { + this.treeBuilder = new EngineReportingTreeBuilder({ + rewriteError: options.rewriteError, + }); + } + + public requestDidStart(o: { + requestContext: GraphQLRequestContext; + }) { + // XXX Provide a mechanism to customize this logic. + const http = o.requestContext.request.http; + if ( + http && + http.headers.get('apollo-federation-include-trace') === 'ftv1' + ) { + this.enabled = true; + } + + if (this.enabled) { + this.treeBuilder.startTiming(); + } + } + + public willResolveField( + _source: any, + _args: { [argName: string]: any }, + _context: TContext, + info: GraphQLResolveInfo, + ): ((error: Error | null, result: any) => void) | void { + if (this.enabled) { + return this.treeBuilder.willResolveField(info); + } + } + + public didEncounterErrors(errors: GraphQLError[]) { + if (this.enabled) { + this.treeBuilder.didEncounterErrors(errors); + } + } + + public executionDidStart(): EndHandler | void { + if (this.enabled) { + // It's a little odd that we record the end time after execution rather than + // at the end of the whole request, but because we need to include our + // formatted trace in the request itself, we have to record it before the + // request is over! It's also odd that we don't do traces for parse or + // validation errors, but runQuery doesn't currently support that, as + // format() is only invoked after execution. + return () => { + this.treeBuilder.stopTiming(); + this.done = true; + }; + } + } + + // The ftv1 extension is a base64'd Trace protobuf containing only the + // durationNs, startTime, endTime, and root fields. + public format(): [string, string] | undefined { + if (!this.enabled) { + return; + } + if (!this.done) { + throw Error('format called before end of execution?'); + } + const encodedUint8Array = Trace.encode(this.treeBuilder.trace).finish(); + const encodedBuffer = Buffer.from( + encodedUint8Array, + encodedUint8Array.byteOffset, + encodedUint8Array.byteLength, + ); + return ['ftv1', encodedBuffer.toString('base64')]; + } +} diff --git a/packages/apollo-engine-reporting/src/index.ts b/packages/apollo-engine-reporting/src/index.ts index 1f7d07fbb74..5770edf02c3 100644 --- a/packages/apollo-engine-reporting/src/index.ts +++ b/packages/apollo-engine-reporting/src/index.ts @@ -1 +1,2 @@ export { EngineReportingOptions, EngineReportingAgent } from './agent'; +export { EngineFederatedTracingExtension } from './federatedExtension'; diff --git a/packages/apollo-engine-reporting/src/treeBuilder.ts b/packages/apollo-engine-reporting/src/treeBuilder.ts new file mode 100644 index 00000000000..c3ac661c382 --- /dev/null +++ b/packages/apollo-engine-reporting/src/treeBuilder.ts @@ -0,0 +1,253 @@ +import { + GraphQLResolveInfo, + GraphQLError, + ResponsePath, + responsePathAsArray, +} from 'graphql'; +import { Trace, google } from 'apollo-engine-reporting-protobuf'; + +export class EngineReportingTreeBuilder { + private rootNode = new Trace.Node(); + public trace = new Trace({ root: this.rootNode }); + public startHrTime?: [number, number]; + private stopped = false; + private nodes = new Map([ + [rootResponsePath, this.rootNode], + ]); + private rewriteError?: (err: GraphQLError) => GraphQLError | null; + + public constructor(options: { + rewriteError?: (err: GraphQLError) => GraphQLError | null; + }) { + this.rewriteError = options.rewriteError; + } + + public startTiming() { + if (this.startHrTime) { + throw Error('startTiming called twice!'); + } + if (this.stopped) { + throw Error('startTiming called after stopTiming!'); + } + this.trace.startTime = dateToProtoTimestamp(new Date()); + this.startHrTime = process.hrtime(); + } + + public stopTiming() { + if (!this.startHrTime) { + throw Error('stopTiming called before startTiming!'); + } + if (this.stopped) { + throw Error('stopTiming called twice!'); + } + + this.trace.durationNs = durationHrTimeToNanos( + process.hrtime(this.startHrTime), + ); + this.trace.endTime = dateToProtoTimestamp(new Date()); + this.stopped = true; + } + + public willResolveField(info: GraphQLResolveInfo): () => void { + if (!this.startHrTime) { + throw Error('willResolveField called before startTiming!'); + } + if (this.stopped) { + throw Error('willResolveField called after stopTiming!'); + } + + const path = info.path; + const node = this.newNode(path); + node.type = info.returnType.toString(); + node.parentType = info.parentType.toString(); + node.startTime = durationHrTimeToNanos(process.hrtime(this.startHrTime)); + if (typeof path.key === 'string' && path.key !== info.fieldName) { + // This field was aliased; send the original field name too (for FieldStats). + node.originalFieldName = info.fieldName; + } + + return () => { + node.endTime = durationHrTimeToNanos(process.hrtime(this.startHrTime)); + }; + } + + public didEncounterErrors(errors: GraphQLError[]) { + errors.forEach(err => { + // This is an error from a federated service. We will already be reporting + // it in the nested Trace in the query plan. + if (err.extensions && err.extensions.serviceName) { + return; + } + + // In terms of reporting, errors can be re-written by the user by + // utilizing the `rewriteError` parameter. This allows changing + // the message or stack to remove potentially sensitive information. + // Returning `null` will result in the error not being reported at all. + const errorForReporting = this.rewriteAndNormalizeError(err); + + if (errorForReporting === null) { + return; + } + + this.addProtobufError( + errorForReporting.path, + errorToProtobufError(errorForReporting), + ); + }); + } + + private addProtobufError( + path: ReadonlyArray | undefined, + error: Trace.Error, + ) { + if (!this.startHrTime) { + throw Error('addProtobufError called before startTiming!'); + } + if (this.stopped) { + throw Error('addProtobufError called after stopTiming!'); + } + + // By default, put errors on the root node. + let node = this.rootNode; + if (path) { + const specificNode = this.nodes.get(path.join('.')); + if (specificNode) { + node = specificNode; + } else { + console.warn( + `Could not find node with path ${path.join( + '.', + )}; defaulting to put errors on root node.`, + ); + } + } + + node.error.push(error); + } + + private newNode(path: ResponsePath): Trace.Node { + const node = new Trace.Node(); + const id = path.key; + if (typeof id === 'number') { + node.index = id; + } else { + node.responseName = id; + } + this.nodes.set(responsePathAsString(path), node); + const parentNode = this.ensureParentNode(path); + parentNode.child.push(node); + return node; + } + + private ensureParentNode(path: ResponsePath): Trace.Node { + const parentPath = responsePathAsString(path.prev); + const parentNode = this.nodes.get(parentPath); + if (parentNode) { + return parentNode; + } + // Because we set up the root path when creating this.nodes, we now know + // that path.prev isn't undefined. + return this.newNode(path.prev!); + } + + private rewriteAndNormalizeError(err: GraphQLError): GraphQLError | null { + if (this.rewriteError) { + // Before passing the error to the user-provided `rewriteError` function, + // we'll make a shadow copy of the error so the user is free to change + // the object as they see fit. + + // At this stage, this error is only for the purposes of reporting, but + // this is even more important since this is still a reference to the + // original error object and changing it would also change the error which + // is returned in the response to the client. + + // For the clone, we'll create a new object which utilizes the exact same + // prototype of the error being reported. + const clonedError = Object.assign( + Object.create(Object.getPrototypeOf(err)), + err, + ); + + const rewrittenError = this.rewriteError(clonedError); + + // Returning an explicit `null` means the user is requesting that, in + // terms of Engine reporting, the error be buried. + if (rewrittenError === null) { + return null; + } + + // We don't want users to be inadvertently not reporting errors, so if + // they haven't returned an explicit `GraphQLError` (or `null`, handled + // above), then we'll report the error as usual. + if (!(rewrittenError instanceof GraphQLError)) { + return err; + } + + // We only allow rewriteError to change the message and extensions of the + // error; we keep everything else the same. That way people don't have to + // do extra work to keep the error on the same trace node. We also keep + // extensions the same if it isn't explicitly changed (to, eg, {}). (Note + // that many of the fields of GraphQLError are not enumerable and won't + // show up in the trace (even in the json field) anyway.) + return new GraphQLError( + rewrittenError.message, + err.nodes, + err.source, + err.positions, + err.path, + err.originalError, + rewrittenError.extensions || err.extensions, + ); + } + return err; + } +} + +// Converts an hrtime array (as returned from process.hrtime) to nanoseconds. +// +// ONLY CALL THIS ON VALUES REPRESENTING DELTAS, NOT ON THE RAW RETURN VALUE +// FROM process.hrtime() WITH NO ARGUMENTS. +// +// The entire point of the hrtime data structure is that the JavaScript Number +// type can't represent all int64 values without loss of precision: +// Number.MAX_SAFE_INTEGER nanoseconds is about 104 days. Calling this function +// on a duration that represents a value less than 104 days is fine. Calling +// this function on an absolute time (which is generally roughly time since +// system boot) is not a good idea. +// +// XXX We should probably use google.protobuf.Duration on the wire instead of +// ever trying to store durations in a single number. +function durationHrTimeToNanos(hrtime: [number, number]) { + return hrtime[0] * 1e9 + hrtime[1]; +} + +// Convert from the linked-list ResponsePath format to a dot-joined +// string. Includes the full path (field names and array indices). +function responsePathAsString(p: ResponsePath | undefined) { + if (p === undefined) { + return ''; + } + return responsePathAsArray(p).join('.'); +} + +const rootResponsePath = responsePathAsString(undefined); + +function errorToProtobufError(error: GraphQLError): Trace.Error { + return new Trace.Error({ + message: error.message, + location: (error.locations || []).map( + ({ line, column }) => new Trace.Location({ line, column }), + ), + json: JSON.stringify(error), + }); +} + +// Converts a JS Date into a Timestamp. +function dateToProtoTimestamp(date: Date): google.protobuf.Timestamp { + const totalMillis = +date; + const millis = totalMillis % 1000; + return new google.protobuf.Timestamp({ + seconds: (totalMillis - millis) / 1000, + nanos: millis * 1e6, + }); +} diff --git a/packages/apollo-federation/package.json b/packages/apollo-federation/package.json index 15f3190b2ba..128aa9dfff6 100644 --- a/packages/apollo-federation/package.json +++ b/packages/apollo-federation/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/federation", - "version": "0.6.11-alpha.9", + "version": "0.6.11-alpha.10", "description": "Apollo Federation Utilities", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-federation/src/types.ts b/packages/apollo-federation/src/types.ts index b78bf635382..7126b32c611 100644 --- a/packages/apollo-federation/src/types.ts +++ b/packages/apollo-federation/src/types.ts @@ -95,6 +95,7 @@ export const entitiesField: GraphQLFieldConfig = { return reference; }; + // FIXME somehow get this to show up special in Engine traces? const result = resolveReference(reference, context, info); if (isPromise(result)) { diff --git a/packages/apollo-gateway/package.json b/packages/apollo-gateway/package.json index 412b80bc647..3fe3fcd2b63 100644 --- a/packages/apollo-gateway/package.json +++ b/packages/apollo-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@apollo/gateway", - "version": "0.7.0-alpha.9", + "version": "0.7.0-alpha.10", "description": "Apollo Gateway", "author": "opensource@apollographql.com", "main": "dist/index.js", @@ -20,10 +20,13 @@ }, "dependencies": { "@apollo/federation": "file:../apollo-federation", + "apollo-engine-reporting-protobuf": "file:../apollo-engine-reporting-protobuf", "apollo-env": "^0.5.1", "apollo-graphql": "^0.3.3", "apollo-server-caching": "file:../apollo-server-caching", + "apollo-server-core": "file:../apollo-server-core", "apollo-server-env": "file:../apollo-server-env", + "apollo-server-types": "file:../apollo-server-types", "loglevel": "^1.6.1", "loglevel-debug": "^0.0.1", "pretty-format": "^24.7.0" diff --git a/packages/apollo-gateway/src/__tests__/gateway/reporting.test.ts b/packages/apollo-gateway/src/__tests__/gateway/reporting.test.ts new file mode 100644 index 00000000000..ebd8ef7ddee --- /dev/null +++ b/packages/apollo-gateway/src/__tests__/gateway/reporting.test.ts @@ -0,0 +1,590 @@ +import path from 'path'; +import { gunzipSync } from 'zlib'; +import nock from 'nock'; +import { GraphQLSchemaModule } from 'apollo-graphql'; +import gql from 'graphql-tag'; +import { buildFederatedSchema } from '@apollo/federation'; +import { ApolloServer } from 'apollo-server'; +import { FullTracesReport } from 'apollo-engine-reporting-protobuf'; +import { execute, toPromise } from 'apollo-link'; +import { createHttpLink } from 'apollo-link-http'; +import fetch from 'node-fetch'; +import { ApolloGateway } from '../..'; +import { Plugin, Config, Refs } from 'pretty-format'; + +// Normalize specific fields that change often (eg timestamps) to static values, +// to make snapshot testing viable. (If these helpers are more generally +// useful, they could be moved to a different file.) + +const alreadyProcessed = '__already_processed__'; + +function replaceFieldValuesSerializer( + replacements: Record, +): Plugin { + const fieldNames = Object.keys(replacements); + return { + test(value: any) { + return ( + value && + typeof value === 'object' && + !value[alreadyProcessed] && + fieldNames.some(n => n in value) + ); + }, + + serialize( + value: Record, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: any, + ): string { + // Clone object so pretty-format doesn't consider it as a circular + // reference. Put a special (non-enumerable) property on it so that *we* + // don't reprocess it ourselves. + const newValue = { ...value }; + Object.defineProperty(newValue, alreadyProcessed, { value: true }); + fieldNames.forEach(fn => { + if (fn in value) { + const replacement = replacements[fn]; + if (typeof replacement === 'function') { + newValue[fn] = replacement(value[fn]); + } else { + newValue[fn] = replacement; + } + } + }); + return printer(newValue, config, indentation, depth, refs, printer); + }, + }; +} + +expect.addSnapshotSerializer( + replaceFieldValuesSerializer({ + header: '
', + // We do want to differentiate between zero and non-zero in these numbers. + durationNs: (v: number) => (v ? 12345 : 0), + sentTimeOffset: (v: number) => (v ? 23456 : 0), + // endTime and startTime are annoyingly used both for top-level Timestamps + // and for node-level nanosecond offsets. The Timestamps will get normalized + // by the nanos/seconds below. + startTime: (v: any) => (typeof v === 'string' ? '34567' : v), + endTime: (v: any) => (typeof v === 'string' ? '45678' : v), + nanos: 123000000, + seconds: '1562203363', + }), +); + +async function startFederatedServer(modules: GraphQLSchemaModule[]) { + const schema = buildFederatedSchema(modules); + const server = new ApolloServer({ schema }); + const { url } = await server.listen({ port: 0 }); + return { url, server }; +} + +describe('reporting', () => { + let backendServers: ApolloServer[]; + let gatewayServer: ApolloServer; + let gatewayUrl: string; + let reportPromise: Promise; + let nockScope: nock.Scope; + + beforeEach(async () => { + let reportResolver: (report: any) => void; + reportPromise = new Promise(resolve => { + reportResolver = resolve; + }); + + nockScope = nock('https://engine-report.apollodata.com') + .post('/api/ingress/traces') + .reply(200, (_: any, requestBody: string) => { + reportResolver(requestBody); + return 'ok'; + }); + + backendServers = []; + const serviceList = []; + for (const serviceName of [ + 'accounts', + 'product', + 'inventory', + 'reviews', + 'books', + ]) { + const { server, url } = await startFederatedServer([ + require(path.join(__dirname, '../__fixtures__/schemas', serviceName)), + ]); + backendServers.push(server); + serviceList.push({ name: serviceName, url }); + } + + const gateway = new ApolloGateway({ serviceList }); + const { schema, executor } = await gateway.load(); + gatewayServer = new ApolloServer({ + schema, + executor, + engine: { + apiKey: 'service:foo:bar', + sendReportsImmediately: true, + }, + }); + ({ url: gatewayUrl } = await gatewayServer.listen({ port: 0 })); + }); + + afterEach(async () => { + for (const server of backendServers) { + await server.stop(); + } + if (gatewayServer) { + await gatewayServer.stop(); + } + nockScope.done(); + }); + + it(`queries three services`, async () => { + const query = gql` + query { + me { + name + } + topProducts { + name + } + } + `; + + const result = await toPromise( + execute(createHttpLink({ uri: gatewayUrl, fetch: fetch as any }), { + query, + }), + ); + expect(result).toMatchInlineSnapshot(` + Object { + "data": Object { + "me": Object { + "name": "Ada Lovelace", + }, + "topProducts": Array [ + Object { + "name": "Table", + }, + Object { + "name": "Couch", + }, + Object { + "name": "Chair", + }, + Object { + "name": "Structure and Interpretation of Computer Programs (1996)", + }, + Object { + "name": "Object Oriented Software Construction (1997)", + }, + ], + }, + } + `); + const reportBody = await reportPromise; + // nock returns binary bodies as hex strings + const gzipReportBuffer = Buffer.from(reportBody, 'hex'); + const reportBuffer = gunzipSync(gzipReportBuffer); + const report = FullTracesReport.decode(reportBuffer); + + // Some handwritten tests to capture salient properties. + const statsReportKey = '# -\n{me{name}topProducts{name}}'; + expect(Object.keys(report.tracesPerQuery)).toStrictEqual([statsReportKey]); + expect(report.tracesPerQuery[statsReportKey]!.trace!.length).toBe(1); + const trace = report.tracesPerQuery[statsReportKey]!.trace![0]!; + // In the gateway, the root trace is just an empty node (unless there are errors). + expect(trace.root!.child).toStrictEqual([]); + // The query plan has (among other things) a fetch against 'accounts' and a + // fetch against 'product'. + expect(trace.queryPlan).toBeTruthy(); + const queryPlan = trace.queryPlan!; + expect(queryPlan.parallel).toBeTruthy(); + expect(queryPlan.parallel!.nodes![0]!.fetch!.serviceName).toBe('accounts'); + expect( + queryPlan.parallel!.nodes![0]!.fetch!.trace!.root!.child![0]! + .responseName, + ).toBe('me'); + expect(queryPlan.parallel!.nodes![1]!.sequence).toBeTruthy(); + expect( + queryPlan.parallel!.nodes![1]!.sequence!.nodes![0]!.fetch!.serviceName, + ).toBe('product'); + expect( + queryPlan.parallel!.nodes![1]!.sequence!.nodes![0]!.fetch!.trace!.root! + .child![0].responseName, + ).toBe('topProducts'); + + expect(report).toMatchInlineSnapshot(` + Object { + "header": "
", + "tracesPerQuery": Object { + "# - + {me{name}topProducts{name}}": Object { + "trace": Array [ + Object { + "clientName": "", + "clientReferenceId": "", + "clientVersion": "", + "details": Object {}, + "durationNs": 12345, + "endTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "forbiddenOperation": false, + "fullQueryCacheHit": false, + "http": Object { + "method": "POST", + }, + "queryPlan": Object { + "parallel": Object { + "nodes": Array [ + Object { + "fetch": Object { + "receivedTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTimeOffset": 23456, + "serviceName": "accounts", + "trace": Object { + "durationNs": 12345, + "endTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "root": Object { + "child": Array [ + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "User", + "responseName": "name", + "startTime": "34567", + "type": "String", + }, + ], + "endTime": "45678", + "parentType": "Query", + "responseName": "me", + "startTime": "34567", + "type": "User", + }, + ], + }, + "startTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + }, + "traceParsingFailed": false, + }, + }, + Object { + "sequence": Object { + "nodes": Array [ + Object { + "fetch": Object { + "receivedTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTimeOffset": 23456, + "serviceName": "product", + "trace": Object { + "durationNs": 12345, + "endTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "root": Object { + "child": Array [ + Object { + "child": Array [ + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Furniture", + "responseName": "name", + "startTime": "34567", + "type": "String", + }, + ], + "index": 0, + }, + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Furniture", + "responseName": "name", + "startTime": "34567", + "type": "String", + }, + ], + "index": 1, + }, + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Furniture", + "responseName": "name", + "startTime": "34567", + "type": "String", + }, + ], + "index": 2, + }, + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "isbn", + "startTime": "34567", + "type": "String!", + }, + ], + "index": 3, + }, + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "isbn", + "startTime": "34567", + "type": "String!", + }, + ], + "index": 4, + }, + ], + "endTime": "45678", + "parentType": "Query", + "responseName": "topProducts", + "startTime": "34567", + "type": "[Product]", + }, + ], + }, + "startTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + }, + "traceParsingFailed": false, + }, + }, + Object { + "flatten": Object { + "node": Object { + "fetch": Object { + "receivedTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTimeOffset": 23456, + "serviceName": "books", + "trace": Object { + "durationNs": 12345, + "endTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "root": Object { + "child": Array [ + Object { + "child": Array [ + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "isbn", + "startTime": "34567", + "type": "String!", + }, + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "title", + "startTime": "34567", + "type": "String", + }, + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "year", + "startTime": "34567", + "type": "Int", + }, + ], + "index": 0, + }, + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "isbn", + "startTime": "34567", + "type": "String!", + }, + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "title", + "startTime": "34567", + "type": "String", + }, + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "year", + "startTime": "34567", + "type": "Int", + }, + ], + "index": 1, + }, + ], + "endTime": "45678", + "parentType": "Query", + "responseName": "_entities", + "startTime": "34567", + "type": "[_Entity]!", + }, + ], + }, + "startTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + }, + "traceParsingFailed": false, + }, + }, + "responsePath": Array [ + Object { + "fieldName": "topProducts", + }, + Object { + "fieldName": "@", + }, + ], + }, + }, + Object { + "flatten": Object { + "node": Object { + "fetch": Object { + "receivedTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "sentTimeOffset": 23456, + "serviceName": "product", + "trace": Object { + "durationNs": 12345, + "endTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + "root": Object { + "child": Array [ + Object { + "child": Array [ + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "name", + "startTime": "34567", + "type": "String", + }, + ], + "index": 0, + }, + Object { + "child": Array [ + Object { + "endTime": "45678", + "parentType": "Book", + "responseName": "name", + "startTime": "34567", + "type": "String", + }, + ], + "index": 1, + }, + ], + "endTime": "45678", + "parentType": "Query", + "responseName": "_entities", + "startTime": "34567", + "type": "[_Entity]!", + }, + ], + }, + "startTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + }, + "traceParsingFailed": false, + }, + }, + "responsePath": Array [ + Object { + "fieldName": "topProducts", + }, + Object { + "fieldName": "@", + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + "registeredOperation": false, + "root": Object {}, + "startTime": Object { + "nanos": 123000000, + "seconds": "1562203363", + }, + }, + ], + }, + }, + } + `); + }); +}); diff --git a/packages/apollo-gateway/src/datasources/RemoteGraphQLDatasource.ts b/packages/apollo-gateway/src/datasources/RemoteGraphQLDatasource.ts index 2f384f4c5d8..70cba5264ff 100644 --- a/packages/apollo-gateway/src/datasources/RemoteGraphQLDatasource.ts +++ b/packages/apollo-gateway/src/datasources/RemoteGraphQLDatasource.ts @@ -37,7 +37,8 @@ export class RemoteGraphQLDataSource implements GraphQLDataSource { }: Pick, 'request' | 'context'>): Promise< GraphQLResponse > { - const headers = new Headers(); + // Respect incoming http headers (eg, apollo-federation-include-trace). + const headers = (request.http && request.http.headers) || new Headers(); headers.set('Content-Type', 'application/json'); request.http = { diff --git a/packages/apollo-gateway/src/executeQueryPlan.ts b/packages/apollo-gateway/src/executeQueryPlan.ts index 0c06339fafa..b724fe47402 100644 --- a/packages/apollo-gateway/src/executeQueryPlan.ts +++ b/packages/apollo-gateway/src/executeQueryPlan.ts @@ -2,6 +2,7 @@ import { GraphQLExecutionResult, GraphQLRequestContext, } from 'apollo-server-types'; +import { Headers } from 'apollo-server-env'; import { execute, GraphQLError, @@ -14,6 +15,7 @@ import { VariableDefinitionNode, GraphQLFieldResolver, } from 'graphql'; +import { Trace, google } from 'apollo-engine-reporting-protobuf'; import { GraphQLDataSource } from './datasources/types'; import { FetchNode, @@ -57,8 +59,21 @@ export async function executeQueryPlan( let data: ResultMap | undefined = Object.create(null); + const captureTraces = !!( + requestContext.metrics && requestContext.metrics.captureTraces + ); + if (queryPlan.node) { - await executeNode(context, queryPlan.node, data!, []); + const traceNode = await executeNode( + context, + queryPlan.node, + data!, + [], + captureTraces, + ); + if (captureTraces) { + requestContext.metrics!.queryPlanTrace = traceNode; + } } // FIXME: Re-executing the query is a pretty heavy handed way of making sure @@ -91,44 +106,91 @@ export async function executeQueryPlan( return errors.length === 0 ? { data } : { errors, data }; } +// Note: this function always returns a protobuf QueryPlanNode tree, even if +// we're going to ignore it, because it makes the code much simpler and more +// typesafe. However, it doesn't actually ask for traces from the backend +// service unless we are capturing traces for Engine. async function executeNode( context: ExecutionContext, node: PlanNode, results: ResultMap | ResultMap[], path: ResponsePath, -): Promise { + captureTraces: boolean, +): Promise { if (!results) { - return; + // XXX I don't understand `results` threading well enough to understand when this happens + // and if this corresponds to a real query plan node that should be reported or not. + // + // This may be if running something like `query { fooOrNullFromServiceA { + // somethingFromServiceB } }` and the first field is null, then we don't bother to run the + // inner field at all. + return new Trace.QueryPlanNode(); } - try { - switch (node.kind) { - case 'Sequence': - for (const childNode of node.nodes) { - await executeNode(context, childNode, results, path); - } - break; - case 'Parallel': - await Promise.all( - node.nodes.map(async childNode => - executeNode(context, childNode, results, path), - ), + switch (node.kind) { + case 'Sequence': { + const traceNode = new Trace.QueryPlanNode.SequenceNode(); + for (const childNode of node.nodes) { + const childTraceNode = await executeNode( + context, + childNode, + results, + path, + captureTraces, ); - break; - case 'Flatten': - await executeNode( + traceNode.nodes.push(childTraceNode!); + } + return new Trace.QueryPlanNode({ sequence: traceNode }); + } + case 'Parallel': { + const childTraceNodes = await Promise.all( + node.nodes.map(async childNode => + executeNode(context, childNode, results, path, captureTraces), + ), + ); + return new Trace.QueryPlanNode({ + parallel: new Trace.QueryPlanNode.ParallelNode({ + nodes: childTraceNodes, + }), + }); + } + case 'Flatten': { + return new Trace.QueryPlanNode({ + flatten: new Trace.QueryPlanNode.FlattenNode({ + responsePath: node.path.map( + id => + new Trace.QueryPlanNode.ResponsePathElement( + typeof id === 'string' ? { fieldName: id } : { index: id }, + ), + ), + node: await executeNode( + context, + node.node, + flattenResultsAtPath(results, node.path), + [...path, ...node.path], + captureTraces, + ), + }), + }); + } + case 'Fetch': { + const traceNode = new Trace.QueryPlanNode.FetchNode({ + serviceName: node.serviceName, + // executeFetch will fill in the other fields if desired. + }); + try { + await executeFetch( context, - node.node, - flattenResultsAtPath(results, node.path), - [...path, ...node.path], + node, + results, + path, + captureTraces ? traceNode : null, ); - break; - case 'Fetch': - await executeFetch(context, node, results, path); - break; + } catch (error) { + context.errors.push(error); + } + return new Trace.QueryPlanNode({ fetch: traceNode }); } - } catch (error) { - context.errors.push(error); } } @@ -137,6 +199,7 @@ async function executeFetch( fetch: FetchNode, results: ResultMap | ResultMap[], _path: ResponsePath, + traceNode: Trace.QueryPlanNode.FetchNode | null, ): Promise { const service = context.serviceMap[fetch.serviceName]; if (!service) { @@ -227,11 +290,33 @@ async function executeFetch( variables: Record, ): Promise { const source = print(operation); + // We declare this as 'any' because it is missing url and method, which + // GraphQLRequest.http is supposed to have if it exists. + let http: any; + + // If we're capturing a trace for Engine, then save the operation text to + // the node we're building and tell the federated service to include a trace + // in its response. + if (traceNode) { + http = { + headers: new Headers({ 'apollo-federation-include-trace': 'ftv1' }), + }; + if ( + context.requestContext.metrics && + context.requestContext.metrics.startHrTime + ) { + traceNode.sentTimeOffset = durationHrTimeToNanos( + process.hrtime(context.requestContext.metrics.startHrTime), + ); + } + traceNode.sentTime = dateToProtoTimestamp(new Date()); + } const response = await service.process({ request: { query: source, variables, + http, }, context: context.requestContext.context, }); @@ -250,6 +335,42 @@ async function executeFetch( context.errors.push(...errors); } + // If we're capturing a trace for Engine, save the received trace into the + // query plan. + if (traceNode) { + traceNode.receivedTime = dateToProtoTimestamp(new Date()); + + if (response.extensions && response.extensions.ftv1) { + const traceBase64 = response.extensions.ftv1; + + let traceBuffer: Buffer | undefined; + let traceParsingFailed = false; + try { + // XXX support non-Node implementations by using Uint8Array? protobufjs + // supports that, but there's not a no-deps base64 implementation. + traceBuffer = Buffer.from(traceBase64, 'base64'); + } catch (err) { + console.error( + `error decoding base64 for federated trace from ${fetch.serviceName}: ${err}`, + ); + traceParsingFailed = true; + } + + if (traceBuffer) { + try { + const trace = Trace.decode(traceBuffer); + traceNode.trace = trace; + } catch (err) { + console.error( + `error decoding protobuf for federated trace from ${fetch.serviceName}: ${err}`, + ); + traceParsingFailed = true; + } + } + traceNode.traceParsingFailed = traceParsingFailed; + } + } + return response.data; } } @@ -334,6 +455,8 @@ function downstreamServiceError( } extensions = { code: 'DOWNSTREAM_SERVICE_ERROR', + // XXX The presence of a serviceName in extensions is used to + // determine if this error should be captured for metrics reporting. serviceName, query, variables, @@ -436,3 +559,31 @@ export const defaultFieldResolverWithAliasSupport: GraphQLFieldResolver< return property; } }; + +// Converts an hrtime array (as returned from process.hrtime) to nanoseconds. +// +// ONLY CALL THIS ON VALUES REPRESENTING DELTAS, NOT ON THE RAW RETURN VALUE +// FROM process.hrtime() WITH NO ARGUMENTS. +// +// The entire point of the hrtime data structure is that the JavaScript Number +// type can't represent all int64 values without loss of precision: +// Number.MAX_SAFE_INTEGER nanoseconds is about 104 days. Calling this function +// on a duration that represents a value less than 104 days is fine. Calling +// this function on an absolute time (which is generally roughly time since +// system boot) is not a good idea. +// +// XXX We should probably use google.protobuf.Duration on the wire instead of +// ever trying to store durations in a single number. +function durationHrTimeToNanos(hrtime: [number, number]) { + return hrtime[0] * 1e9 + hrtime[1]; +} + +// Converts a JS Date into a Timestamp. +function dateToProtoTimestamp(date: Date): google.protobuf.Timestamp { + const totalMillis = +date; + const millis = totalMillis % 1000; + return new google.protobuf.Timestamp({ + seconds: (totalMillis - millis) / 1000, + nanos: millis * 1e6, + }); +} diff --git a/packages/apollo-server-azure-functions/package.json b/packages/apollo-server-azure-functions/package.json index bef060d630d..bd019eb6d9f 100644 --- a/packages/apollo-server-azure-functions/package.json +++ b/packages/apollo-server-azure-functions/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-azure-functions", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Azure Functions", "keywords": [ "GraphQL", diff --git a/packages/apollo-server-cloud-functions/package.json b/packages/apollo-server-cloud-functions/package.json index a6a12b7690b..bf52229eba9 100644 --- a/packages/apollo-server-cloud-functions/package.json +++ b/packages/apollo-server-cloud-functions/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-cloud-functions", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Google Cloud Functions", "keywords": [ "GraphQL", diff --git a/packages/apollo-server-cloudflare/package.json b/packages/apollo-server-cloudflare/package.json index 4bf2393c3fe..e5e6ee2e656 100644 --- a/packages/apollo-server-cloudflare/package.json +++ b/packages/apollo-server-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-cloudflare", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Cloudflare workers", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-core/package.json b/packages/apollo-server-core/package.json index a08cd4f2042..3d5f0ac6fdf 100644 --- a/packages/apollo-server-core/package.json +++ b/packages/apollo-server-core/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-core", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.10", "description": "Core engine for Apollo GraphQL server", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -30,6 +30,7 @@ "apollo-cache-control": "file:../apollo-cache-control", "apollo-datasource": "file:../apollo-datasource", "apollo-engine-reporting": "file:../apollo-engine-reporting", + "apollo-engine-reporting-protobuf": "file:../apollo-engine-reporting-protobuf", "apollo-server-caching": "file:../apollo-server-caching", "apollo-server-env": "file:../apollo-server-env", "apollo-server-errors": "file:../apollo-server-errors", diff --git a/packages/apollo-server-core/src/ApolloServer.ts b/packages/apollo-server-core/src/ApolloServer.ts index 7060a3bb89a..271cbd4ed00 100644 --- a/packages/apollo-server-core/src/ApolloServer.ts +++ b/packages/apollo-server-core/src/ApolloServer.ts @@ -14,6 +14,8 @@ import { ValidationContext, FieldDefinitionNode, DocumentNode, + isObjectType, + isScalarType, } from 'graphql'; import { GraphQLExtension } from 'graphql-extensions'; import { @@ -311,6 +313,7 @@ export class ApolloServerBase { this.engineReportingAgent = new EngineReportingAgent( typeof engine === 'object' ? engine : Object.create(null), ); + // Don't add the extension here (we want to add it later in generateSchemaDerivedData). } if (gateway && subscriptions !== false) { @@ -492,12 +495,40 @@ export class ApolloServerBase { } const extensions = []; + + const schemaIsFederated = this.schemaIsFederated(schema); + const { engine } = this.config; // Keep this extension second so it wraps everything, except error formatting if (this.engineReportingAgent) { + if (schemaIsFederated) { + // XXX users can configure a federated Apollo Server to send metrics, but the + // Gateway should be responsible for that. It's possible that users are running + // their own gateway or running a federated service on its own. Nonetheless, in + // the likely case it was accidental, we warn users that they should only report + // metrics from the Gateway. + console.warn( + "It looks like you're running a federated schema and you've configured your service " + + 'to report metrics to Apollo Engine. You should only configure your Apollo gateway ' + + 'to report metrics to Apollo Engine.', + ); + } extensions.push(() => this.engineReportingAgent!.newExtension(schemaHash), ); + } else if (engine !== false && schemaIsFederated) { + // We haven't configured this app to use Engine directly. But it looks like + // we are a federated service backend, so we should be capable of including + // our trace in a response extension if we are asked to by the gateway. + const { + EngineFederatedTracingExtension, + } = require('apollo-engine-reporting'); + const rewriteError = + engine && typeof engine === 'object' ? engine.rewriteError : undefined; + extensions.push( + () => new EngineFederatedTracingExtension({ rewriteError }), + ); } + // Note: doRunQuery will add its own extensions if you set tracing, // or cacheControl. extensions.push(...(_extensions || [])); @@ -628,6 +659,31 @@ export class ApolloServerBase { return false; } + // Returns true if it appears that the schema was returned from + // @apollo/federation's buildFederatedSchema. This strategy avoids depending + // explicitly on @apollo/federation or relying on something that might not + // survive transformations like monkey-patching a boolean field onto the + // schema. + // + // The only thing this is used for is determining whether traces should be + // added to responses if requested with an HTTP header; if there's a false + // positive, that feature can be disabled by specifying `engine: false`. + private schemaIsFederated(schema: GraphQLSchema): boolean { + const serviceType = schema.getType('_Service'); + if (!(serviceType && isObjectType(serviceType))) { + return false; + } + const sdlField = serviceType.getFields().sdl; + if (!sdlField) { + return false; + } + const sdlFieldType = sdlField.type; + if (!isScalarType(sdlFieldType)) { + return false; + } + return sdlFieldType.name == 'String'; + } + private ensurePluginInstantiation(plugins?: PluginDefinition[]): void { if (!plugins || !plugins.length) { return; @@ -691,6 +747,7 @@ export class ApolloServerBase { any >, parseOptions: this.parseOptions, + reporting: !!this.engineReportingAgent, ...this.requestOptions, } as GraphQLOptions; } diff --git a/packages/apollo-server-core/src/graphqlOptions.ts b/packages/apollo-server-core/src/graphqlOptions.ts index 988d721bb8a..1f6605988bc 100644 --- a/packages/apollo-server-core/src/graphqlOptions.ts +++ b/packages/apollo-server-core/src/graphqlOptions.ts @@ -27,6 +27,7 @@ import { GraphQLExecutor, ValueOrPromise } from 'apollo-server-types'; * - (optional) debug: a boolean that will print additional debug logging if execution errors occur * - (optional) extensions: an array of functions which create GraphQLExtensions (each GraphQLExtension object is used for one request) * - (optional) parseOptions: options to pass when parsing schemas and queries + * - (optional) reporting: set if we are directly reporting to Engine * */ export interface GraphQLServerOptions< @@ -51,6 +52,7 @@ export interface GraphQLServerOptions< plugins?: ApolloServerPlugin[]; documentStore?: InMemoryLRUCache; parseOptions?: GraphQLParseOptions; + reporting?: boolean; } export type DataSources = { diff --git a/packages/apollo-server-core/src/runHttpQuery.ts b/packages/apollo-server-core/src/runHttpQuery.ts index c59a5fa4b66..e010b1b24ab 100644 --- a/packages/apollo-server-core/src/runHttpQuery.ts +++ b/packages/apollo-server-core/src/runHttpQuery.ts @@ -179,6 +179,8 @@ export async function runHttpQuery( debug: options.debug, plugins: options.plugins || [], + + reporting: options.reporting, }; return processHTTPRequest(config, request); @@ -248,6 +250,9 @@ export async function processHTTPRequest( context, cache: options.cache, debug: options.debug, + metrics: { + captureTraces: !!options.reporting, + }, }; } diff --git a/packages/apollo-server-express/package.json b/packages/apollo-server-express/package.json index 1b380c42516..7923f6bb58a 100644 --- a/packages/apollo-server-express/package.json +++ b/packages/apollo-server-express/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-express", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Express and Connect", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-fastify/package.json b/packages/apollo-server-fastify/package.json index 0d0d6dd8f43..453d42faa92 100644 --- a/packages/apollo-server-fastify/package.json +++ b/packages/apollo-server-fastify/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-fastify", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Fastify", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-hapi/package.json b/packages/apollo-server-hapi/package.json index 33707307263..1c9e89dfe55 100644 --- a/packages/apollo-server-hapi/package.json +++ b/packages/apollo-server-hapi/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-hapi", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Hapi", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-integration-testsuite/package.json b/packages/apollo-server-integration-testsuite/package.json index e3c09a8eddc..38c8a7ecdb4 100644 --- a/packages/apollo-server-integration-testsuite/package.json +++ b/packages/apollo-server-integration-testsuite/package.json @@ -1,7 +1,7 @@ { "name": "apollo-server-integration-testsuite", "private": true, - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Apollo Server Integrations testsuite", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts index 6b83929a399..dd601e19e42 100644 --- a/packages/apollo-server-integration-testsuite/src/ApolloServer.ts +++ b/packages/apollo-server-integration-testsuite/src/ApolloServer.ts @@ -1,12 +1,11 @@ /* tslint:disable:no-unused-expression */ import http from 'http'; -import net from 'net'; import { sha256 } from 'js-sha256'; import express = require('express'); import bodyParser = require('body-parser'); import yup = require('yup'); -import { FullTracesReport } from 'apollo-engine-reporting-protobuf'; +import { FullTracesReport, Trace } from 'apollo-engine-reporting-protobuf'; import { GraphQLSchema, @@ -50,7 +49,7 @@ import { TracingFormat } from 'apollo-tracing'; import ApolloServerPluginResponseCache from 'apollo-server-plugin-response-cache'; import { GraphQLRequestContext } from 'apollo-server-types'; -import { mockDate, unmockDate, advanceTimeBy } from '__mocks__/date'; +import { mockDate, unmockDate, advanceTimeBy } from '../../../__mocks__/date'; import { EngineReportingOptions } from 'apollo-engine-reporting'; export function createServerInfo( @@ -58,7 +57,7 @@ export function createServerInfo( httpServer: http.Server, ): ServerInfo { const serverInfo: any = { - ...(httpServer.address() as net.AddressInfo), + ...httpServer.address(), server, httpServer, }; @@ -723,7 +722,7 @@ export function testApolloServer( } return new Promise(resolve => { - this.server && this.server.close(resolve); + this.server && this.server.close(() => resolve()); }); } @@ -737,11 +736,7 @@ export function testApolloServer( if (!this.server) { throw new Error('must listen before getting URL'); } - const { - family, - address, - port, - } = this.server.address() as net.AddressInfo; + const { family, address, port } = this.server.address(); if (family !== 'IPv4') { throw new Error(`The family was unexpectedly ${family}.`); @@ -1302,7 +1297,7 @@ export function testApolloServer( `; const resolvers = { Query: { - hello: (_parent, _args, context) => { + hello: (_parent: any, _args: any, context: any) => { expect(context).toEqual(Promise.resolve(uniqueContext)); return 'hi'; }, @@ -1335,7 +1330,7 @@ export function testApolloServer( `; const resolvers = { Query: { - hello: (_parent, _args, context) => { + hello: (_parent: any, _args: any, context: any) => { expect(context.key).toEqual('major'); context.key = 'minor'; return spy(); @@ -1389,7 +1384,7 @@ export function testApolloServer( `; const resolvers = { Query: { - hello: (_parent, _args, context) => { + hello: (_parent: any, _args: any, context: any) => { expect(context.key).toEqual('major'); return spy(); }, @@ -1539,9 +1534,13 @@ export function testApolloServer( describe('subscriptions', () => { const SOMETHING_CHANGED_TOPIC = 'something_changed'; const pubsub = new PubSub(); - let subscription; + let subscription: + | { + unsubscribe: () => void; + } + | undefined; - function createEvent(num) { + function createEvent(num: number) { return setTimeout( () => pubsub.publish(SOMETHING_CHANGED_TOPIC, { @@ -1963,7 +1962,7 @@ export function testApolloServer( const latestEndOffset = tracing.execution.resolvers .map(resolver => resolver.startOffset + resolver.duration) .reduce((currentLatestEndOffset, nextEndOffset) => - Math.min(currentLatestEndOffset, nextEndOffset), + Math.max(currentLatestEndOffset, nextEndOffset), ); const resolverDuration = latestEndOffset - earliestStartOffset; @@ -1972,6 +1971,131 @@ export function testApolloServer( }); }); + describe('Federated tracing', () => { + // Enable federated tracing by pretending to be federated. + const federationTypeDefs = gql` + type _Service { + sdl: String + } + `; + + const baseTypeDefs = gql` + type Book { + title: String + author: String + } + + type Movie { + title: String + } + + type Query { + books: [Book] + movies: [Movie] + } + `; + + const allTypeDefs = [federationTypeDefs, baseTypeDefs]; + + const resolvers = { + Query: { + books: () => + new Promise(resolve => + setTimeout(() => resolve([{ title: 'H', author: 'J' }]), 10), + ), + movies: () => + new Promise(resolve => + setTimeout(() => resolve([{ title: 'H' }]), 12), + ), + }, + }; + + function createApolloFetchAsIfFromGateway(uri: string): ApolloFetch { + return createApolloFetch({ uri }).use(({ options }, next) => { + options.headers = { 'apollo-federation-include-trace': 'ftv1' }; + next(); + }); + } + + it("doesn't include federated trace without the special header", async () => { + const { url: uri } = await createApolloServer({ + typeDefs: allTypeDefs, + resolvers, + }); + + const apolloFetch = createApolloFetch({ uri }); + + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + + expect(result.extensions).toBeUndefined(); + }); + + it("doesn't include federated trace without _Service in the schema", async () => { + const { url: uri } = await createApolloServer({ + typeDefs: baseTypeDefs, + resolvers, + }); + + const apolloFetch = createApolloFetchAsIfFromGateway(uri); + + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + + expect(result.extensions).toBeUndefined(); + }); + + it('reports a total duration that is longer than the duration of its resolvers', async () => { + const { url: uri } = await createApolloServer({ + typeDefs: allTypeDefs, + resolvers, + }); + + const apolloFetch = createApolloFetchAsIfFromGateway(uri); + apolloFetch.use(({ options }, next) => { + options.headers = { 'apollo-federation-include-trace': 'ftv1' }; + next(); + }); + + const result = await apolloFetch({ + query: `{ books { title author } }`, + }); + + const ftv1: string = result.extensions.ftv1; + + expect(ftv1).toBeTruthy(); + const encoded = Buffer.from(ftv1, 'base64'); + const trace = Trace.decode(encoded); + + let earliestStartOffset = Infinity; + let latestEndOffset = -Infinity; + function walk(node: Trace.INode) { + if (node.startTime !== 0 && node.endTime !== 0) { + earliestStartOffset = Math.min(earliestStartOffset, node.startTime); + latestEndOffset = Math.max(latestEndOffset, node.endTime); + } + node.child.forEach(n => walk(n)); + } + walk(trace.root); + expect(earliestStartOffset).toBeLessThan(Infinity); + expect(latestEndOffset).toBeGreaterThan(-Infinity); + const resolverDuration = latestEndOffset - earliestStartOffset; + expect(resolverDuration).toBeGreaterThan(0); + expect(trace.durationNs).toBeGreaterThanOrEqual(resolverDuration); + + expect(trace.startTime.seconds).toBeLessThanOrEqual( + trace.endTime.seconds, + ); + if (trace.startTime.seconds === trace.endTime.seconds) { + expect(trace.startTime.nanos).toBeLessThanOrEqual( + trace.endTime.nanos, + ); + } + }); + }); + describe('Response caching', () => { beforeAll(() => { mockDate(); diff --git a/packages/apollo-server-koa/package.json b/packages/apollo-server-koa/package.json index 065216c9580..12f637c0937 100644 --- a/packages/apollo-server-koa/package.json +++ b/packages/apollo-server-koa/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-koa", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Koa", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-lambda/package.json b/packages/apollo-server-lambda/package.json index f8d1d3bea50..1fb4ddc9c11 100644 --- a/packages/apollo-server-lambda/package.json +++ b/packages/apollo-server-lambda/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-lambda", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for AWS Lambda", "keywords": [ "GraphQL", diff --git a/packages/apollo-server-micro/package.json b/packages/apollo-server-micro/package.json index c7fe9df24d8..83b7eefdce5 100644 --- a/packages/apollo-server-micro/package.json +++ b/packages/apollo-server-micro/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-micro", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production-ready Node.js GraphQL server for Micro", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-plugin-base/package.json b/packages/apollo-server-plugin-base/package.json index b718062e6e3..ad2c53ead89 100644 --- a/packages/apollo-server-plugin-base/package.json +++ b/packages/apollo-server-plugin-base/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-plugin-base", - "version": "0.6.0-alpha.9", + "version": "0.6.0-alpha.10", "description": "Apollo Server plugin base classes", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-plugin-response-cache/package.json b/packages/apollo-server-plugin-response-cache/package.json index 3807ef70b8b..5ffd54517db 100644 --- a/packages/apollo-server-plugin-response-cache/package.json +++ b/packages/apollo-server-plugin-response-cache/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-plugin-response-cache", - "version": "0.2.7-alpha.9", + "version": "0.2.7-alpha.10", "description": "Apollo Server full query response cache", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-testing/package.json b/packages/apollo-server-testing/package.json index 748d6ca44c5..c6ad6640368 100644 --- a/packages/apollo-server-testing/package.json +++ b/packages/apollo-server-testing/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-testing", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Test utils for apollo-server", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apollo-server-types/package.json b/packages/apollo-server-types/package.json index dc59b2069cf..bf82e14946d 100644 --- a/packages/apollo-server-types/package.json +++ b/packages/apollo-server-types/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server-types", - "version": "0.1.1-alpha.0", + "version": "0.1.1-alpha.1", "description": "Apollo Server shared types", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -11,6 +11,7 @@ "node": ">=6" }, "dependencies": { + "apollo-engine-reporting-protobuf": "file:../apollo-engine-reporting-protobuf", "apollo-server-caching": "file:../apollo-server-caching", "apollo-server-env": "file:../apollo-server-env" }, diff --git a/packages/apollo-server-types/src/index.ts b/packages/apollo-server-types/src/index.ts index 91c0a7c3597..d0d7a1c1cc2 100644 --- a/packages/apollo-server-types/src/index.ts +++ b/packages/apollo-server-types/src/index.ts @@ -11,6 +11,7 @@ import { // This seems like it could live in this package too. import { KeyValueCache } from 'apollo-server-caching'; +import { Trace } from 'apollo-engine-reporting-protobuf'; export type ValueOrPromise = T | Promise; export type WithRequired = T & Required>; @@ -47,11 +48,14 @@ export interface GraphQLResponse { } export interface GraphQLRequestMetrics { + captureTraces?: boolean; persistedQueryHit?: boolean; persistedQueryRegister?: boolean; responseCacheHit?: boolean; forbiddenOperation?: boolean; registeredOperation?: boolean; + startHrTime?: [number, number]; + queryPlanTrace?: Trace.QueryPlanNode; } export interface GraphQLRequestContext> { diff --git a/packages/apollo-server/package.json b/packages/apollo-server/package.json index 4e0e40871d1..b4e85c8fc7b 100644 --- a/packages/apollo-server/package.json +++ b/packages/apollo-server/package.json @@ -1,6 +1,6 @@ { "name": "apollo-server", - "version": "2.7.0-alpha.9", + "version": "2.7.0-alpha.12", "description": "Production ready GraphQL Server", "author": "opensource@apollographql.com", "main": "dist/index.js", diff --git a/packages/apollo-tracing/package.json b/packages/apollo-tracing/package.json index 671c88c13f9..0b82c854df0 100644 --- a/packages/apollo-tracing/package.json +++ b/packages/apollo-tracing/package.json @@ -1,6 +1,6 @@ { "name": "apollo-tracing", - "version": "0.7.5-alpha.9", + "version": "0.7.5-alpha.10", "description": "Collect and expose trace data for GraphQL requests", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/graphql-extensions/package.json b/packages/graphql-extensions/package.json index e03264aca95..72fb248b3d2 100644 --- a/packages/graphql-extensions/package.json +++ b/packages/graphql-extensions/package.json @@ -1,6 +1,6 @@ { "name": "graphql-extensions", - "version": "0.8.0-alpha.9", + "version": "0.8.0-alpha.10", "description": "Add extensions to GraphQL servers", "main": "./dist/index.js", "types": "./dist/index.d.ts",