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",