From 17c621101e7e62254b95cad981872da7d0cee870 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Fri, 17 Dec 2021 13:41:24 -0800 Subject: [PATCH] Usage reporting: report referenced fields in addition to executed fields (#5956) In Apollo Studio, the "Fields" page lets you see how often your fields are *executed* by operations --- ie, how often their resolvers run. It additionally lets you see which clients and operations ran those operations. However, knowing if an operation *executed* a field doesn't tell the whole story about the relationship between operations and fields. You might also be curious to learn if a field was textually referenced in the operation itself. It's possible for a field to be referenced without executing. In fact, there are many reasons this can happen: - The field is nested under another field which evaluates to null - The field is nested under another field which evaluates to an empty list - The field is nested under a non-matching fragment - The field is nested under `@include` or `@skip` If you're using the Fields page to determine "what are all the operations that use this field", you probably want to know about these usages too! It's also possible for a field to be executed without being referenced. That's because when we track "executed fields", we are always tracking the concrete object type that is being executed, not an interface type it may have been resolved through. So with schema: ```graphql interface Animal { legs: Int } type Dog implements Animal { legs: Int } type Query { myFavoriteAnimal: Animal # In practice, always returns a Dog } ``` the operation `{ myFavoriteAnimal { legs } }` "references" `Animal.legs`, not `Dog.legs`, but it is the field `Dog.legs` that is executed. (Additionally, when using federation, fields can be executed without being referenced in the operation if the query plan requires them to be executed to fulfill an `@requires` or `@key` directive.) This PR extends Apollo Server's usage reporting plugin to provide a list of "referenced fields" along with every operation. Note that these fields depend only on the operation, not on the variables passed or anything at execution time, so we don't need one set per trace, just one set per operation. In addition to the fact that this statistic is an interesting one, this will also mean that the Fields page can be useful without needing full execution tracing. There is a real performance impact of full execution tracing, especially in the federation context where the ftv1 protocol involves sending subgraph traces to the gateway/router in-band in the response. And not every GraphQL server supports full execution tracing in the first place. With support for referenced fields on the Fields page, you will be able to run a gateway/router in front of an arbitrary subgraph without federated tracing (whether you want to do this for performance or lack of implementation reasons) and still get some useful data on the Fields page. (You can then perhaps run full tracing on a sampled subset of queries to get reasonable approximate execution data too.) Note that the new Studio functionality may not be in general availability when this PR merges into the Apollo Server release branch or when the first alphas are created; it will be fully enabled (including docs) by the time this PR is released in a non-prerelease. As part of the implementation, we extend the existing "signature cache" to cache the referenced field list as well. Note that the signature is a pure function of the operation, whereas the referenced field list also depends on the schema, so we add a little mechanism to throw out the cache if the schema changes. Fixes #5708. --- CHANGELOG.md | 1 + .../generated/protobuf.d.ts | 169 ++----- .../generated/protobuf.js | 418 ++++++------------ .../generated/reports.proto | 37 +- .../src/reports.proto | 37 +- .../operationDerivedDataCache.test.ts | 13 + .../__tests__/referencedFields.test.ts | 299 +++++++++++++ .../__tests__/signatureCache.test.ts | 13 - .../operationDerivedDataCache.ts | 61 +++ .../src/plugin/usageReporting/plugin.ts | 80 +++- .../plugin/usageReporting/referencedFields.ts | 82 ++++ .../plugin/usageReporting/signatureCache.ts | 56 --- .../src/plugin/usageReporting/stats.ts | 42 +- 13 files changed, 764 insertions(+), 544 deletions(-) create mode 100644 packages/apollo-server-core/src/plugin/usageReporting/__tests__/operationDerivedDataCache.test.ts create mode 100644 packages/apollo-server-core/src/plugin/usageReporting/__tests__/referencedFields.test.ts delete mode 100644 packages/apollo-server-core/src/plugin/usageReporting/__tests__/signatureCache.test.ts create mode 100644 packages/apollo-server-core/src/plugin/usageReporting/operationDerivedDataCache.ts create mode 100644 packages/apollo-server-core/src/plugin/usageReporting/referencedFields.ts delete mode 100644 packages/apollo-server-core/src/plugin/usageReporting/signatureCache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7181117b3..394e0664be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The version headers in this history reflect the versions of Apollo Server itself ## vNEXT (minor!) - `apollo-server-core`: Usage reporting no longer sends a "client reference ID" to Apollo Studio (along with the client name and client version). This little-used feature has not been documented [since 2019](https://github.com/apollographql/apollo-server/pull/3180) and is currently entirely ignored by Apollo Studio. This is technically incompatible as the interface `ClientInfo` no longer has the field `clientReferenceId`; if you were one of the few users who explicitly set this field and you get a TypeScript compilation failure upon upgrading to v3.6.0, just stop using the field. [PR #5890](https://github.com/apollographql/apollo-server/pull/5890) +- `apollo-server-core`: Preliminary support for referenced field reporting. THIS ENTRY NEEDS TO BE EXPANDED BEFORE THE v3.6.0 RELEASE. [Issue #5708](https://github.com/apollographql/apollo-server/issues/5708) [PR #5956](https://github.com/apollographql/apollo-server/pull/5956) - `apollo-server-core`: Remove dependency on `apollo-graphql` package (by inlining the code which generates usage reporting signatures). That package has not yet been published with a `graphql@16` peer dependency, so Apollo Server v3.5 did not fully support `graphql@16` without overriding peer dependencies. [Issue #5941](https://github.com/apollographql/apollo-server/issues/5941) [PR #5955](https://github.com/apollographql/apollo-server/pull/5955) ## v3.5.0 diff --git a/packages/apollo-reporting-protobuf/generated/protobuf.d.ts b/packages/apollo-reporting-protobuf/generated/protobuf.d.ts index a94f5e1da16..79fb9ea0b7d 100644 --- a/packages/apollo-reporting-protobuf/generated/protobuf.d.ts +++ b/packages/apollo-reporting-protobuf/generated/protobuf.d.ts @@ -32,9 +32,6 @@ export interface ITrace { /** Trace clientVersion */ clientVersion?: (string|null); - /** Trace clientAddress */ - clientAddress?: (string|null); - /** Trace http */ http?: (Trace.IHTTP|null); @@ -99,9 +96,6 @@ export class Trace implements ITrace { /** Trace clientVersion. */ public clientVersion: string; - /** Trace clientAddress. */ - public clientAddress: string; - /** Trace http. */ public http?: (Trace.IHTTP|null); @@ -2323,179 +2317,90 @@ export class TypeStat implements ITypeStat { public toJSON(): { [k: string]: any }; } -/** Properties of a Field. */ -export interface IField { +/** Properties of a ReferencedFieldsForType. */ +export interface IReferencedFieldsForType { - /** Field name */ - name?: (string|null); + /** ReferencedFieldsForType fieldNames */ + fieldNames?: (string[]|null); - /** Field returnType */ - returnType?: (string|null); + /** ReferencedFieldsForType isInterface */ + isInterface?: (boolean|null); } -/** Represents a Field. */ -export class Field implements IField { +/** Represents a ReferencedFieldsForType. */ +export class ReferencedFieldsForType implements IReferencedFieldsForType { /** - * Constructs a new Field. + * Constructs a new ReferencedFieldsForType. * @param [properties] Properties to set */ - constructor(properties?: IField); + constructor(properties?: IReferencedFieldsForType); - /** Field name. */ - public name: string; + /** ReferencedFieldsForType fieldNames. */ + public fieldNames: string[]; - /** Field returnType. */ - public returnType: string; + /** ReferencedFieldsForType isInterface. */ + public isInterface: boolean; /** - * Creates a new Field instance using the specified properties. + * Creates a new ReferencedFieldsForType instance using the specified properties. * @param [properties] Properties to set - * @returns Field instance + * @returns ReferencedFieldsForType instance */ - public static create(properties?: IField): Field; + public static create(properties?: IReferencedFieldsForType): ReferencedFieldsForType; /** - * Encodes the specified Field message. Does not implicitly {@link Field.verify|verify} messages. - * @param message Field message or plain object to encode + * Encodes the specified ReferencedFieldsForType message. Does not implicitly {@link ReferencedFieldsForType.verify|verify} messages. + * @param message ReferencedFieldsForType message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encode(message: IField, writer?: $protobuf.Writer): $protobuf.Writer; + public static encode(message: IReferencedFieldsForType, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Encodes the specified Field message, length delimited. Does not implicitly {@link Field.verify|verify} messages. - * @param message Field message or plain object to encode + * Encodes the specified ReferencedFieldsForType message, length delimited. Does not implicitly {@link ReferencedFieldsForType.verify|verify} messages. + * @param message ReferencedFieldsForType message or plain object to encode * @param [writer] Writer to encode to * @returns Writer */ - public static encodeDelimited(message: IField, writer?: $protobuf.Writer): $protobuf.Writer; + public static encodeDelimited(message: IReferencedFieldsForType, writer?: $protobuf.Writer): $protobuf.Writer; /** - * Decodes a Field message from the specified reader or buffer. + * Decodes a ReferencedFieldsForType message from the specified reader or buffer. * @param reader Reader or buffer to decode from * @param [length] Message length if known beforehand - * @returns Field + * @returns ReferencedFieldsForType * @throws {Error} If the payload is not a reader or valid buffer * @throws {$protobuf.util.ProtocolError} If required fields are missing */ - public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): Field; + public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): ReferencedFieldsForType; /** - * Decodes a Field message from the specified reader or buffer, length delimited. + * Decodes a ReferencedFieldsForType message from the specified reader or buffer, length delimited. * @param reader Reader or buffer to decode from - * @returns Field + * @returns ReferencedFieldsForType * @throws {Error} If the payload is not a reader or valid buffer * @throws {$protobuf.util.ProtocolError} If required fields are missing */ - public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): Field; + public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): ReferencedFieldsForType; /** - * Verifies a Field message. + * Verifies a ReferencedFieldsForType message. * @param message Plain object to verify * @returns `null` if valid, otherwise the reason why it is not */ public static verify(message: { [k: string]: any }): (string|null); /** - * Creates a plain object from a Field message. Also converts values to other types if specified. - * @param message Field + * Creates a plain object from a ReferencedFieldsForType message. Also converts values to other types if specified. + * @param message ReferencedFieldsForType * @param [options] Conversion options * @returns Plain object */ - public static toObject(message: Field, options?: $protobuf.IConversionOptions): { [k: string]: any }; + public static toObject(message: ReferencedFieldsForType, options?: $protobuf.IConversionOptions): { [k: string]: any }; /** - * Converts this Field to JSON. - * @returns JSON object - */ - public toJSON(): { [k: string]: any }; -} - -/** Properties of a Type. */ -export interface IType { - - /** Type name */ - name?: (string|null); - - /** Type field */ - field?: (IField[]|null); -} - -/** Represents a Type. */ -export class Type implements IType { - - /** - * Constructs a new Type. - * @param [properties] Properties to set - */ - constructor(properties?: IType); - - /** Type name. */ - public name: string; - - /** Type field. */ - public field: IField[]; - - /** - * Creates a new Type instance using the specified properties. - * @param [properties] Properties to set - * @returns Type instance - */ - public static create(properties?: IType): Type; - - /** - * Encodes the specified Type message. Does not implicitly {@link Type.verify|verify} messages. - * @param message Type message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encode(message: IType, writer?: $protobuf.Writer): $protobuf.Writer; - - /** - * Encodes the specified Type message, length delimited. Does not implicitly {@link Type.verify|verify} messages. - * @param message Type message or plain object to encode - * @param [writer] Writer to encode to - * @returns Writer - */ - public static encodeDelimited(message: IType, writer?: $protobuf.Writer): $protobuf.Writer; - - /** - * Decodes a Type message from the specified reader or buffer. - * @param reader Reader or buffer to decode from - * @param [length] Message length if known beforehand - * @returns Type - * @throws {Error} If the payload is not a reader or valid buffer - * @throws {$protobuf.util.ProtocolError} If required fields are missing - */ - public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): Type; - - /** - * Decodes a Type message from the specified reader or buffer, length delimited. - * @param reader Reader or buffer to decode from - * @returns Type - * @throws {Error} If the payload is not a reader or valid buffer - * @throws {$protobuf.util.ProtocolError} If required fields are missing - */ - public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): Type; - - /** - * Verifies a Type message. - * @param message Plain object to verify - * @returns `null` if valid, otherwise the reason why it is not - */ - public static verify(message: { [k: string]: any }): (string|null); - - /** - * Creates a plain object from a Type message. Also converts values to other types if specified. - * @param message Type - * @param [options] Conversion options - * @returns Plain object - */ - public static toObject(message: Type, options?: $protobuf.IConversionOptions): { [k: string]: any }; - - /** - * Converts this Type to JSON. + * Converts this ReferencedFieldsForType to JSON. * @returns JSON object */ public toJSON(): { [k: string]: any }; @@ -2700,6 +2605,9 @@ export interface ITracesAndStats { /** TracesAndStats statsWithContext */ statsWithContext?: ($protobuf.ToArray|IContextualizedStats[]|null); + /** TracesAndStats referencedFieldsByType */ + referencedFieldsByType?: ({ [k: string]: IReferencedFieldsForType }|null); + /** TracesAndStats internalTracesContributingToStats */ internalTracesContributingToStats?: ((ITrace|Uint8Array)[]|null); } @@ -2719,6 +2627,9 @@ export class TracesAndStats implements ITracesAndStats { /** TracesAndStats statsWithContext. */ public statsWithContext: IContextualizedStats[]; + /** TracesAndStats referencedFieldsByType. */ + public referencedFieldsByType: { [k: string]: IReferencedFieldsForType }; + /** TracesAndStats internalTracesContributingToStats. */ public internalTracesContributingToStats: (ITrace|Uint8Array)[]; diff --git a/packages/apollo-reporting-protobuf/generated/protobuf.js b/packages/apollo-reporting-protobuf/generated/protobuf.js index 913efe2e80f..68b3768bc79 100644 --- a/packages/apollo-reporting-protobuf/generated/protobuf.js +++ b/packages/apollo-reporting-protobuf/generated/protobuf.js @@ -25,7 +25,6 @@ $root.Trace = (function() { * @property {Trace.IDetails|null} [details] Trace details * @property {string|null} [clientName] Trace clientName * @property {string|null} [clientVersion] Trace clientVersion - * @property {string|null} [clientAddress] Trace clientAddress * @property {Trace.IHTTP|null} [http] Trace http * @property {Trace.ICachePolicy|null} [cachePolicy] Trace cachePolicy * @property {Trace.IQueryPlanNode|null} [queryPlan] Trace queryPlan @@ -131,14 +130,6 @@ $root.Trace = (function() { */ Trace.prototype.clientVersion = ""; - /** - * Trace clientAddress. - * @member {string} clientAddress - * @memberof Trace - * @instance - */ - Trace.prototype.clientAddress = ""; - /** * Trace http. * @member {Trace.IHTTP|null|undefined} http @@ -237,8 +228,6 @@ $root.Trace = (function() { writer.uint32(/* id 7, wireType 2 =*/58).string(message.clientName); if (message.clientVersion != null && Object.hasOwnProperty.call(message, "clientVersion")) writer.uint32(/* id 8, wireType 2 =*/66).string(message.clientVersion); - if (message.clientAddress != null && Object.hasOwnProperty.call(message, "clientAddress")) - writer.uint32(/* id 9, wireType 2 =*/74).string(message.clientAddress); if (message.http != null && Object.hasOwnProperty.call(message, "http")) $root.Trace.HTTP.encode(message.http, writer.uint32(/* id 10, wireType 2 =*/82).fork()).ldelim(); if (message.durationNs != null && Object.hasOwnProperty.call(message, "durationNs")) @@ -329,9 +318,6 @@ $root.Trace = (function() { case 8: message.clientVersion = reader.string(); break; - case 9: - message.clientAddress = reader.string(); - break; case 10: message.http = $root.Trace.HTTP.decode(reader, reader.uint32()); break; @@ -429,9 +415,6 @@ $root.Trace = (function() { if (message.clientVersion != null && message.hasOwnProperty("clientVersion")) if (!$util.isString(message.clientVersion)) return "clientVersion: string expected"; - if (message.clientAddress != null && message.hasOwnProperty("clientAddress")) - if (!$util.isString(message.clientAddress)) - return "clientAddress: string expected"; if (message.http != null && message.hasOwnProperty("http")) { var error = $root.Trace.HTTP.verify(message.http); if (error) @@ -484,7 +467,6 @@ $root.Trace = (function() { object.details = null; object.clientName = ""; object.clientVersion = ""; - object.clientAddress = ""; object.http = null; if ($util.Long) { var long = new $util.Long(0, 0, true); @@ -513,8 +495,6 @@ $root.Trace = (function() { object.clientName = message.clientName; if (message.clientVersion != null && message.hasOwnProperty("clientVersion")) object.clientVersion = message.clientVersion; - if (message.clientAddress != null && message.hasOwnProperty("clientAddress")) - object.clientAddress = message.clientAddress; if (message.http != null && message.hasOwnProperty("http")) object.http = $root.Trace.HTTP.toObject(message.http, options); if (message.durationNs != null && message.hasOwnProperty("durationNs")) @@ -5978,25 +5958,26 @@ $root.TypeStat = (function() { return TypeStat; })(); -$root.Field = (function() { +$root.ReferencedFieldsForType = (function() { /** - * Properties of a Field. - * @exports IField - * @interface IField - * @property {string|null} [name] Field name - * @property {string|null} [returnType] Field returnType + * Properties of a ReferencedFieldsForType. + * @exports IReferencedFieldsForType + * @interface IReferencedFieldsForType + * @property {Array.|null} [fieldNames] ReferencedFieldsForType fieldNames + * @property {boolean|null} [isInterface] ReferencedFieldsForType isInterface */ /** - * Constructs a new Field. - * @exports Field - * @classdesc Represents a Field. - * @implements IField + * Constructs a new ReferencedFieldsForType. + * @exports ReferencedFieldsForType + * @classdesc Represents a ReferencedFieldsForType. + * @implements IReferencedFieldsForType * @constructor - * @param {IField=} [properties] Properties to set + * @param {IReferencedFieldsForType=} [properties] Properties to set */ - function Field(properties) { + function ReferencedFieldsForType(properties) { + this.fieldNames = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -6004,283 +5985,91 @@ $root.Field = (function() { } /** - * Field name. - * @member {string} name - * @memberof Field + * ReferencedFieldsForType fieldNames. + * @member {Array.} fieldNames + * @memberof ReferencedFieldsForType * @instance */ - Field.prototype.name = ""; + ReferencedFieldsForType.prototype.fieldNames = $util.emptyArray; /** - * Field returnType. - * @member {string} returnType - * @memberof Field + * ReferencedFieldsForType isInterface. + * @member {boolean} isInterface + * @memberof ReferencedFieldsForType * @instance */ - Field.prototype.returnType = ""; + ReferencedFieldsForType.prototype.isInterface = false; /** - * Creates a new Field instance using the specified properties. + * Creates a new ReferencedFieldsForType instance using the specified properties. * @function create - * @memberof Field + * @memberof ReferencedFieldsForType * @static - * @param {IField=} [properties] Properties to set - * @returns {Field} Field instance + * @param {IReferencedFieldsForType=} [properties] Properties to set + * @returns {ReferencedFieldsForType} ReferencedFieldsForType instance */ - Field.create = function create(properties) { - return new Field(properties); + ReferencedFieldsForType.create = function create(properties) { + return new ReferencedFieldsForType(properties); }; /** - * Encodes the specified Field message. Does not implicitly {@link Field.verify|verify} messages. + * Encodes the specified ReferencedFieldsForType message. Does not implicitly {@link ReferencedFieldsForType.verify|verify} messages. * @function encode - * @memberof Field + * @memberof ReferencedFieldsForType * @static - * @param {IField} message Field message or plain object to encode + * @param {IReferencedFieldsForType} message ReferencedFieldsForType message or plain object to encode * @param {$protobuf.Writer} [writer] Writer to encode to * @returns {$protobuf.Writer} Writer */ - Field.encode = function encode(message, writer) { + ReferencedFieldsForType.encode = function encode(message, writer) { if (!writer) writer = $Writer.create(); - if (message.name != null && Object.hasOwnProperty.call(message, "name")) - writer.uint32(/* id 2, wireType 2 =*/18).string(message.name); - if (message.returnType != null && Object.hasOwnProperty.call(message, "returnType")) - writer.uint32(/* id 3, wireType 2 =*/26).string(message.returnType); + if (message.fieldNames != null && message.fieldNames.length) + for (var i = 0; i < message.fieldNames.length; ++i) + writer.uint32(/* id 1, wireType 2 =*/10).string(message.fieldNames[i]); + if (message.isInterface != null && Object.hasOwnProperty.call(message, "isInterface")) + writer.uint32(/* id 2, wireType 0 =*/16).bool(message.isInterface); return writer; }; /** - * Encodes the specified Field message, length delimited. Does not implicitly {@link Field.verify|verify} messages. + * Encodes the specified ReferencedFieldsForType message, length delimited. Does not implicitly {@link ReferencedFieldsForType.verify|verify} messages. * @function encodeDelimited - * @memberof Field + * @memberof ReferencedFieldsForType * @static - * @param {IField} message Field message or plain object to encode + * @param {IReferencedFieldsForType} message ReferencedFieldsForType message or plain object to encode * @param {$protobuf.Writer} [writer] Writer to encode to * @returns {$protobuf.Writer} Writer */ - Field.encodeDelimited = function encodeDelimited(message, writer) { + ReferencedFieldsForType.encodeDelimited = function encodeDelimited(message, writer) { return this.encode(message, writer).ldelim(); }; /** - * Decodes a Field message from the specified reader or buffer. + * Decodes a ReferencedFieldsForType message from the specified reader or buffer. * @function decode - * @memberof Field + * @memberof ReferencedFieldsForType * @static * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from * @param {number} [length] Message length if known beforehand - * @returns {Field} Field + * @returns {ReferencedFieldsForType} ReferencedFieldsForType * @throws {Error} If the payload is not a reader or valid buffer * @throws {$protobuf.util.ProtocolError} If required fields are missing */ - Field.decode = function decode(reader, length) { + ReferencedFieldsForType.decode = function decode(reader, length) { if (!(reader instanceof $Reader)) reader = $Reader.create(reader); - var end = length === undefined ? reader.len : reader.pos + length, message = new $root.Field(); - while (reader.pos < end) { - var tag = reader.uint32(); - switch (tag >>> 3) { - case 2: - message.name = reader.string(); - break; - case 3: - message.returnType = reader.string(); - break; - default: - reader.skipType(tag & 7); - break; - } - } - return message; - }; - - /** - * Decodes a Field message from the specified reader or buffer, length delimited. - * @function decodeDelimited - * @memberof Field - * @static - * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from - * @returns {Field} Field - * @throws {Error} If the payload is not a reader or valid buffer - * @throws {$protobuf.util.ProtocolError} If required fields are missing - */ - Field.decodeDelimited = function decodeDelimited(reader) { - if (!(reader instanceof $Reader)) - reader = new $Reader(reader); - return this.decode(reader, reader.uint32()); - }; - - /** - * Verifies a Field message. - * @function verify - * @memberof Field - * @static - * @param {Object.} message Plain object to verify - * @returns {string|null} `null` if valid, otherwise the reason why it is not - */ - Field.verify = function verify(message) { - if (typeof message !== "object" || message === null) - return "object expected"; - if (message.name != null && message.hasOwnProperty("name")) - if (!$util.isString(message.name)) - return "name: string expected"; - if (message.returnType != null && message.hasOwnProperty("returnType")) - if (!$util.isString(message.returnType)) - return "returnType: string expected"; - return null; - }; - - /** - * Creates a plain object from a Field message. Also converts values to other types if specified. - * @function toObject - * @memberof Field - * @static - * @param {Field} message Field - * @param {$protobuf.IConversionOptions} [options] Conversion options - * @returns {Object.} Plain object - */ - Field.toObject = function toObject(message, options) { - if (!options) - options = {}; - var object = {}; - if (options.defaults) { - object.name = ""; - object.returnType = ""; - } - if (message.name != null && message.hasOwnProperty("name")) - object.name = message.name; - if (message.returnType != null && message.hasOwnProperty("returnType")) - object.returnType = message.returnType; - return object; - }; - - /** - * Converts this Field to JSON. - * @function toJSON - * @memberof Field - * @instance - * @returns {Object.} JSON object - */ - Field.prototype.toJSON = function toJSON() { - return this.constructor.toObject(this, $protobuf.util.toJSONOptions); - }; - - return Field; -})(); - -$root.Type = (function() { - - /** - * Properties of a Type. - * @exports IType - * @interface IType - * @property {string|null} [name] Type name - * @property {Array.|null} [field] Type field - */ - - /** - * Constructs a new Type. - * @exports Type - * @classdesc Represents a Type. - * @implements IType - * @constructor - * @param {IType=} [properties] Properties to set - */ - function Type(properties) { - this.field = []; - if (properties) - for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) - if (properties[keys[i]] != null) - this[keys[i]] = properties[keys[i]]; - } - - /** - * Type name. - * @member {string} name - * @memberof Type - * @instance - */ - Type.prototype.name = ""; - - /** - * Type field. - * @member {Array.} field - * @memberof Type - * @instance - */ - Type.prototype.field = $util.emptyArray; - - /** - * Creates a new Type instance using the specified properties. - * @function create - * @memberof Type - * @static - * @param {IType=} [properties] Properties to set - * @returns {Type} Type instance - */ - Type.create = function create(properties) { - return new Type(properties); - }; - - /** - * Encodes the specified Type message. Does not implicitly {@link Type.verify|verify} messages. - * @function encode - * @memberof Type - * @static - * @param {IType} message Type message or plain object to encode - * @param {$protobuf.Writer} [writer] Writer to encode to - * @returns {$protobuf.Writer} Writer - */ - Type.encode = function encode(message, writer) { - if (!writer) - writer = $Writer.create(); - if (message.name != null && Object.hasOwnProperty.call(message, "name")) - writer.uint32(/* id 1, wireType 2 =*/10).string(message.name); - if (message.field != null && message.field.length) - for (var i = 0; i < message.field.length; ++i) - $root.Field.encode(message.field[i], writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim(); - return writer; - }; - - /** - * Encodes the specified Type message, length delimited. Does not implicitly {@link Type.verify|verify} messages. - * @function encodeDelimited - * @memberof Type - * @static - * @param {IType} message Type message or plain object to encode - * @param {$protobuf.Writer} [writer] Writer to encode to - * @returns {$protobuf.Writer} Writer - */ - Type.encodeDelimited = function encodeDelimited(message, writer) { - return this.encode(message, writer).ldelim(); - }; - - /** - * Decodes a Type message from the specified reader or buffer. - * @function decode - * @memberof Type - * @static - * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from - * @param {number} [length] Message length if known beforehand - * @returns {Type} Type - * @throws {Error} If the payload is not a reader or valid buffer - * @throws {$protobuf.util.ProtocolError} If required fields are missing - */ - Type.decode = function decode(reader, length) { - if (!(reader instanceof $Reader)) - reader = $Reader.create(reader); - var end = length === undefined ? reader.len : reader.pos + length, message = new $root.Type(); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.ReferencedFieldsForType(); while (reader.pos < end) { var tag = reader.uint32(); switch (tag >>> 3) { case 1: - message.name = reader.string(); + if (!(message.fieldNames && message.fieldNames.length)) + message.fieldNames = []; + message.fieldNames.push(reader.string()); break; case 2: - if (!(message.field && message.field.length)) - message.field = []; - message.field.push($root.Field.decode(reader, reader.uint32())); + message.isInterface = reader.bool(); break; default: reader.skipType(tag & 7); @@ -6291,86 +6080,84 @@ $root.Type = (function() { }; /** - * Decodes a Type message from the specified reader or buffer, length delimited. + * Decodes a ReferencedFieldsForType message from the specified reader or buffer, length delimited. * @function decodeDelimited - * @memberof Type + * @memberof ReferencedFieldsForType * @static * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from - * @returns {Type} Type + * @returns {ReferencedFieldsForType} ReferencedFieldsForType * @throws {Error} If the payload is not a reader or valid buffer * @throws {$protobuf.util.ProtocolError} If required fields are missing */ - Type.decodeDelimited = function decodeDelimited(reader) { + ReferencedFieldsForType.decodeDelimited = function decodeDelimited(reader) { if (!(reader instanceof $Reader)) reader = new $Reader(reader); return this.decode(reader, reader.uint32()); }; /** - * Verifies a Type message. + * Verifies a ReferencedFieldsForType message. * @function verify - * @memberof Type + * @memberof ReferencedFieldsForType * @static * @param {Object.} message Plain object to verify * @returns {string|null} `null` if valid, otherwise the reason why it is not */ - Type.verify = function verify(message) { + ReferencedFieldsForType.verify = function verify(message) { if (typeof message !== "object" || message === null) return "object expected"; - if (message.name != null && message.hasOwnProperty("name")) - if (!$util.isString(message.name)) - return "name: string expected"; - if (message.field != null && message.hasOwnProperty("field")) { - if (!Array.isArray(message.field)) - return "field: array expected"; - for (var i = 0; i < message.field.length; ++i) { - var error = $root.Field.verify(message.field[i]); - if (error) - return "field." + error; - } + if (message.fieldNames != null && message.hasOwnProperty("fieldNames")) { + if (!Array.isArray(message.fieldNames)) + return "fieldNames: array expected"; + for (var i = 0; i < message.fieldNames.length; ++i) + if (!$util.isString(message.fieldNames[i])) + return "fieldNames: string[] expected"; } + if (message.isInterface != null && message.hasOwnProperty("isInterface")) + if (typeof message.isInterface !== "boolean") + return "isInterface: boolean expected"; return null; }; /** - * Creates a plain object from a Type message. Also converts values to other types if specified. + * Creates a plain object from a ReferencedFieldsForType message. Also converts values to other types if specified. * @function toObject - * @memberof Type + * @memberof ReferencedFieldsForType * @static - * @param {Type} message Type + * @param {ReferencedFieldsForType} message ReferencedFieldsForType * @param {$protobuf.IConversionOptions} [options] Conversion options * @returns {Object.} Plain object */ - Type.toObject = function toObject(message, options) { + ReferencedFieldsForType.toObject = function toObject(message, options) { if (!options) options = {}; var object = {}; if (options.arrays || options.defaults) - object.field = []; + object.fieldNames = []; if (options.defaults) - object.name = ""; - if (message.name != null && message.hasOwnProperty("name")) - object.name = message.name; - if (message.field && message.field.length) { - object.field = []; - for (var j = 0; j < message.field.length; ++j) - object.field[j] = $root.Field.toObject(message.field[j], options); + object.isInterface = false; + if (message.fieldNames && message.fieldNames.length) { + object.fieldNames = []; + for (var j = 0; j < message.fieldNames.length; ++j) + object.fieldNames[j] = message.fieldNames[j]; } + if (message.isInterface != null && message.hasOwnProperty("isInterface")) + object.isInterface = message.isInterface; return object; }; /** - * Converts this Type to JSON. + * Converts this ReferencedFieldsForType to JSON. * @function toJSON - * @memberof Type + * @memberof ReferencedFieldsForType * @instance * @returns {Object.} JSON object */ - Type.prototype.toJSON = function toJSON() { + ReferencedFieldsForType.prototype.toJSON = function toJSON() { return this.constructor.toObject(this, $protobuf.util.toJSONOptions); }; - return Type; + return ReferencedFieldsForType; })(); $root.Report = (function() { @@ -6853,6 +6640,7 @@ $root.TracesAndStats = (function() { * @interface ITracesAndStats * @property {Array.|null} [trace] TracesAndStats trace * @property {$protobuf.ToArray.|Array.|null} [statsWithContext] TracesAndStats statsWithContext + * @property {Object.|null} [referencedFieldsByType] TracesAndStats referencedFieldsByType * @property {Array.|null} [internalTracesContributingToStats] TracesAndStats internalTracesContributingToStats */ @@ -6867,6 +6655,7 @@ $root.TracesAndStats = (function() { function TracesAndStats(properties) { this.trace = []; this.statsWithContext = []; + this.referencedFieldsByType = {}; this.internalTracesContributingToStats = []; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) @@ -6890,6 +6679,14 @@ $root.TracesAndStats = (function() { */ TracesAndStats.prototype.statsWithContext = $util.emptyArray; + /** + * TracesAndStats referencedFieldsByType. + * @member {Object.} referencedFieldsByType + * @memberof TracesAndStats + * @instance + */ + TracesAndStats.prototype.referencedFieldsByType = $util.emptyObject; + /** * TracesAndStats internalTracesContributingToStats. * @member {Array.} internalTracesContributingToStats @@ -6944,6 +6741,11 @@ $root.TracesAndStats = (function() { writer.bytes(message.internalTracesContributingToStats[i]); } else $root.Trace.encode(message.internalTracesContributingToStats[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); + if (message.referencedFieldsByType != null && Object.hasOwnProperty.call(message, "referencedFieldsByType")) + for (var keys = Object.keys(message.referencedFieldsByType), i = 0; i < keys.length; ++i) { + writer.uint32(/* id 4, wireType 2 =*/34).fork().uint32(/* id 1, wireType 2 =*/10).string(keys[i]); + $root.ReferencedFieldsForType.encode(message.referencedFieldsByType[keys[i]], writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim().ldelim(); + } return writer; }; @@ -6974,7 +6776,7 @@ $root.TracesAndStats = (function() { TracesAndStats.decode = function decode(reader, length) { if (!(reader instanceof $Reader)) reader = $Reader.create(reader); - var end = length === undefined ? reader.len : reader.pos + length, message = new $root.TracesAndStats(); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.TracesAndStats(), key; while (reader.pos < end) { var tag = reader.uint32(); switch (tag >>> 3) { @@ -6988,6 +6790,14 @@ $root.TracesAndStats = (function() { message.statsWithContext = []; message.statsWithContext.push($root.ContextualizedStats.decode(reader, reader.uint32())); break; + case 4: + reader.skip().pos++; + if (message.referencedFieldsByType === $util.emptyObject) + message.referencedFieldsByType = {}; + key = reader.string(); + reader.pos++; + message.referencedFieldsByType[key] = $root.ReferencedFieldsForType.decode(reader, reader.uint32()); + break; case 3: if (!(message.internalTracesContributingToStats && message.internalTracesContributingToStats.length)) message.internalTracesContributingToStats = []; @@ -7052,6 +6862,16 @@ $root.TracesAndStats = (function() { return "statsWithContext." + error; } } + if (message.referencedFieldsByType != null && message.hasOwnProperty("referencedFieldsByType")) { + if (!$util.isObject(message.referencedFieldsByType)) + return "referencedFieldsByType: object expected"; + var key = Object.keys(message.referencedFieldsByType); + for (var i = 0; i < key.length; ++i) { + var error = $root.ReferencedFieldsForType.verify(message.referencedFieldsByType[key[i]]); + if (error) + return "referencedFieldsByType." + error; + } + } if (message.internalTracesContributingToStats != null && message.hasOwnProperty("internalTracesContributingToStats")) { if (!Array.isArray(message.internalTracesContributingToStats)) return "internalTracesContributingToStats: array expected"; @@ -7083,6 +6903,8 @@ $root.TracesAndStats = (function() { object.statsWithContext = []; object.internalTracesContributingToStats = []; } + if (options.objects || options.defaults) + object.referencedFieldsByType = {}; if (message.trace && message.trace.length) { object.trace = []; for (var j = 0; j < message.trace.length; ++j) @@ -7098,6 +6920,12 @@ $root.TracesAndStats = (function() { for (var j = 0; j < message.internalTracesContributingToStats.length; ++j) object.internalTracesContributingToStats[j] = $root.Trace.toObject(message.internalTracesContributingToStats[j], options); } + var keys2; + if (message.referencedFieldsByType && (keys2 = Object.keys(message.referencedFieldsByType)).length) { + object.referencedFieldsByType = {}; + for (var j = 0; j < keys2.length; ++j) + object.referencedFieldsByType[keys2[j]] = $root.ReferencedFieldsForType.toObject(message.referencedFieldsByType[keys2[j]], options); + } return object; }; diff --git a/packages/apollo-reporting-protobuf/generated/reports.proto b/packages/apollo-reporting-protobuf/generated/reports.proto index 05e4d014f11..2e97902fd1c 100644 --- a/packages/apollo-reporting-protobuf/generated/reports.proto +++ b/packages/apollo-reporting-protobuf/generated/reports.proto @@ -202,13 +202,8 @@ message Trace { Details details = 6; - // Note: engineproxy always sets client_name, client_version, and client_address to "none". - // apollo-engine-reporting allows for them to be set by the user. string client_name = 7; string client_version = 8; - string client_address = 9; - // string client_reference_id = 23; - reserved 23; HTTP http = 10; @@ -241,7 +236,8 @@ message Trace { // removed: Node parse = 12; Node validate = 13; // Id128 server_id = 1; Id128 client_id = 2; - reserved 12, 13, 1, 2; + // String client_reference_id = 23; String client_address = 9; + reserved 1, 2, 9, 12, 13, 23; } // The `service` value embedded within the header key is not guaranteed to contain an actual service, @@ -316,8 +312,13 @@ message ContextualizedTypeStats { message FieldStat { string return_type = 3; // required; eg "String!" for User.email:String! + // Number of errors whose path is this field. uint64 errors_count = 4; + // Number of times that the resolver for this field is executed. uint64 count = 5; + // Number of times the resolver for this field is executed that resulted in + // at least one error. "Request" is a misnomer here as this corresponds to + // resolver calls, not overall operations. uint64 requests_with_errors_count = 6; repeated sint64 latency_count = 9 [(js_use_toArray)=true]; // Duration histogram; see docs/histograms.md reserved 1, 2, 7, 8; @@ -329,16 +330,14 @@ message TypeStat { reserved 1, 2; } - -message Field { - string name = 2; // required; eg "email" for User.email:String! - string return_type = 3; // required; eg "String!" for User.email:String! +message ReferencedFieldsForType { + // Contains (eg) "email" for User.email:String! + repeated string field_names = 1; + // True if this type is an interface. + bool is_interface = 2; } -message Type { - string name = 1; // required; eg "User" for User.email:String! - repeated Field field = 2; -} + // This is the top-level message used by the new traces ingress. This // is designed for the apollo-engine-reporting TypeScript agent and will @@ -376,10 +375,18 @@ message ContextualizedStats { } -// A sequence of traces and stats. An individual trace should either be counted as a stat or trace +// A sequence of traces and stats. An individual operation should either be described as a trace +// or as part of stats, but not both. message TracesAndStats { repeated Trace trace = 1 [(js_preEncoded)=true]; repeated ContextualizedStats stats_with_context = 2 [(js_use_toArray)=true]; + // This describes the fields referenced in the operation. Note that this may + // include fields that don't show up in FieldStats (due to being interface fields, + // being nested under null fields or empty lists or non-matching fragments or + // `@include` or `@skip`, etc). It also may be missing fields that show up in FieldStats + // (as FieldStats will include the concrete object type for fields referenced + // via an interface type). + map referenced_fields_by_type = 4; // This field is used to validate that the algorithm used to construct `stats_with_context` // matches similar algorithms in Apollo's servers. It is otherwise ignored and should not // be included in reports. diff --git a/packages/apollo-reporting-protobuf/src/reports.proto b/packages/apollo-reporting-protobuf/src/reports.proto index 05e4d014f11..2e97902fd1c 100644 --- a/packages/apollo-reporting-protobuf/src/reports.proto +++ b/packages/apollo-reporting-protobuf/src/reports.proto @@ -202,13 +202,8 @@ message Trace { Details details = 6; - // Note: engineproxy always sets client_name, client_version, and client_address to "none". - // apollo-engine-reporting allows for them to be set by the user. string client_name = 7; string client_version = 8; - string client_address = 9; - // string client_reference_id = 23; - reserved 23; HTTP http = 10; @@ -241,7 +236,8 @@ message Trace { // removed: Node parse = 12; Node validate = 13; // Id128 server_id = 1; Id128 client_id = 2; - reserved 12, 13, 1, 2; + // String client_reference_id = 23; String client_address = 9; + reserved 1, 2, 9, 12, 13, 23; } // The `service` value embedded within the header key is not guaranteed to contain an actual service, @@ -316,8 +312,13 @@ message ContextualizedTypeStats { message FieldStat { string return_type = 3; // required; eg "String!" for User.email:String! + // Number of errors whose path is this field. uint64 errors_count = 4; + // Number of times that the resolver for this field is executed. uint64 count = 5; + // Number of times the resolver for this field is executed that resulted in + // at least one error. "Request" is a misnomer here as this corresponds to + // resolver calls, not overall operations. uint64 requests_with_errors_count = 6; repeated sint64 latency_count = 9 [(js_use_toArray)=true]; // Duration histogram; see docs/histograms.md reserved 1, 2, 7, 8; @@ -329,16 +330,14 @@ message TypeStat { reserved 1, 2; } - -message Field { - string name = 2; // required; eg "email" for User.email:String! - string return_type = 3; // required; eg "String!" for User.email:String! +message ReferencedFieldsForType { + // Contains (eg) "email" for User.email:String! + repeated string field_names = 1; + // True if this type is an interface. + bool is_interface = 2; } -message Type { - string name = 1; // required; eg "User" for User.email:String! - repeated Field field = 2; -} + // This is the top-level message used by the new traces ingress. This // is designed for the apollo-engine-reporting TypeScript agent and will @@ -376,10 +375,18 @@ message ContextualizedStats { } -// A sequence of traces and stats. An individual trace should either be counted as a stat or trace +// A sequence of traces and stats. An individual operation should either be described as a trace +// or as part of stats, but not both. message TracesAndStats { repeated Trace trace = 1 [(js_preEncoded)=true]; repeated ContextualizedStats stats_with_context = 2 [(js_use_toArray)=true]; + // This describes the fields referenced in the operation. Note that this may + // include fields that don't show up in FieldStats (due to being interface fields, + // being nested under null fields or empty lists or non-matching fragments or + // `@include` or `@skip`, etc). It also may be missing fields that show up in FieldStats + // (as FieldStats will include the concrete object type for fields referenced + // via an interface type). + map referenced_fields_by_type = 4; // This field is used to validate that the algorithm used to construct `stats_with_context` // matches similar algorithms in Apollo's servers. It is otherwise ignored and should not // be included in reports. diff --git a/packages/apollo-server-core/src/plugin/usageReporting/__tests__/operationDerivedDataCache.test.ts b/packages/apollo-server-core/src/plugin/usageReporting/__tests__/operationDerivedDataCache.test.ts new file mode 100644 index 00000000000..6b72e9cc1b9 --- /dev/null +++ b/packages/apollo-server-core/src/plugin/usageReporting/__tests__/operationDerivedDataCache.test.ts @@ -0,0 +1,13 @@ +import { operationDerivedDataCacheKey } from '../operationDerivedDataCache'; + +describe('operation-derived data cache key', () => { + it('generates without the operationName', () => { + expect(operationDerivedDataCacheKey('abc123', '')).toEqual('abc123'); + }); + + it('generates with the operationName', () => { + expect(operationDerivedDataCacheKey('abc123', 'myOperation')).toEqual( + 'abc123:myOperation', + ); + }); +}); diff --git a/packages/apollo-server-core/src/plugin/usageReporting/__tests__/referencedFields.test.ts b/packages/apollo-server-core/src/plugin/usageReporting/__tests__/referencedFields.test.ts new file mode 100644 index 00000000000..3cc6448798e --- /dev/null +++ b/packages/apollo-server-core/src/plugin/usageReporting/__tests__/referencedFields.test.ts @@ -0,0 +1,299 @@ +import { buildASTSchema, DocumentNode, validate } from 'graphql'; +import gql from 'graphql-tag'; +import { calculateReferencedFieldsByType } from '../referencedFields'; + +const schema = buildASTSchema(gql` + type Query { + f1: Int + f2: Int + a: A + aa: A + myInterface: MyInterface + } + + type A implements MyInterface { + x: ID + y: String! + } + + interface MyInterface { + x: ID + } +`); + +function validateAndCalculate({ + document, + resolvedOperationName = null, +}: { + document: DocumentNode; + resolvedOperationName?: string | null; +}) { + // First validate the document, since calculateReferencedFieldsByType expects + // that. + expect(validate(schema, document)).toStrictEqual([]); + return calculateReferencedFieldsByType({ + schema, + document, + resolvedOperationName, + }); +} + +describe('calculateReferencedFieldsByType', () => { + it('basic', () => { + expect( + validateAndCalculate({ + document: gql` + { + f1 + } + `, + }), + ).toMatchInlineSnapshot(` + Object { + "Query": Object { + "fieldNames": Array [ + "f1", + ], + "isInterface": false, + }, + } + `); + }); + + it('aliases use actual field name', () => { + expect( + validateAndCalculate({ + document: gql` + { + aliased: f1 + } + `, + }), + ).toMatchInlineSnapshot(` + Object { + "Query": Object { + "fieldNames": Array [ + "f1", + ], + "isInterface": false, + }, + } + `); + }); + + it('multiple operations and fragments', () => { + expect( + validateAndCalculate({ + document: gql` + query Q1 { + f1 + a { + ...AStuff + } + } + query Q2 { + f2 + aa { + ...OtherAStuff + } + } + fragment AStuff on A { + x + } + fragment OtherAStuff on A { + y + } + `, + resolvedOperationName: 'Q1', + }), + ).toMatchInlineSnapshot(` + Object { + "A": Object { + "fieldNames": Array [ + "x", + ], + "isInterface": false, + }, + "Query": Object { + "fieldNames": Array [ + "f1", + "a", + ], + "isInterface": false, + }, + } + `); + }); + + it('interfaces', () => { + expect( + validateAndCalculate({ + document: gql` + query { + myInterface { + x + } + } + `, + }), + ).toMatchInlineSnapshot(` + Object { + "MyInterface": Object { + "fieldNames": Array [ + "x", + ], + "isInterface": true, + }, + "Query": Object { + "fieldNames": Array [ + "myInterface", + ], + "isInterface": false, + }, + } + `); + }); + + it('interface with fragment', () => { + expect( + validateAndCalculate({ + document: gql` + query { + myInterface { + x + ... on A { + y + } + } + } + `, + }), + ).toMatchInlineSnapshot(` + Object { + "A": Object { + "fieldNames": Array [ + "y", + ], + "isInterface": false, + }, + "MyInterface": Object { + "fieldNames": Array [ + "x", + ], + "isInterface": true, + }, + "Query": Object { + "fieldNames": Array [ + "myInterface", + ], + "isInterface": false, + }, + } + `); + }); +}); + +it('interface with fragment that uses interface field', () => { + expect( + validateAndCalculate({ + document: gql` + query { + myInterface { + ... on A { + # Even though x exists on the interface, we only want this to + # count towards A.x below, because this operation would work just + # as well if x were removed from the interface as long as it was + # left on A. + x + } + } + } + `, + }), + ).toMatchInlineSnapshot(` + Object { + "A": Object { + "fieldNames": Array [ + "x", + ], + "isInterface": false, + }, + "Query": Object { + "fieldNames": Array [ + "myInterface", + ], + "isInterface": false, + }, + } + `); +}); + +it('using field both with interface and object should work', () => { + expect( + validateAndCalculate({ + document: gql` + query { + myInterface { + x + ... on A { + x + } + } + } + `, + }), + ).toMatchInlineSnapshot(` + Object { + "A": Object { + "fieldNames": Array [ + "x", + ], + "isInterface": false, + }, + "MyInterface": Object { + "fieldNames": Array [ + "x", + ], + "isInterface": true, + }, + "Query": Object { + "fieldNames": Array [ + "myInterface", + ], + "isInterface": false, + }, + } + `); +}); + +it('using field multiple times (same level or otherwise) de-dups', () => { + expect( + validateAndCalculate({ + document: gql` + query { + a1: a { + y + } + a2: a { + y + } + } + `, + }), + ).toMatchInlineSnapshot(` + Object { + "A": Object { + "fieldNames": Array [ + "y", + ], + "isInterface": false, + }, + "Query": Object { + "fieldNames": Array [ + "a", + ], + "isInterface": false, + }, + } + `); +}); diff --git a/packages/apollo-server-core/src/plugin/usageReporting/__tests__/signatureCache.test.ts b/packages/apollo-server-core/src/plugin/usageReporting/__tests__/signatureCache.test.ts deleted file mode 100644 index ae2a041f6ee..00000000000 --- a/packages/apollo-server-core/src/plugin/usageReporting/__tests__/signatureCache.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { signatureCacheKey } from '../signatureCache'; - -describe('signature cache key', () => { - it('generates without the operationName', () => { - expect(signatureCacheKey('abc123', '')).toEqual('abc123'); - }); - - it('generates with the operationName', () => { - expect(signatureCacheKey('abc123', 'myOperation')).toEqual( - 'abc123:myOperation', - ); - }); -}); diff --git a/packages/apollo-server-core/src/plugin/usageReporting/operationDerivedDataCache.ts b/packages/apollo-server-core/src/plugin/usageReporting/operationDerivedDataCache.ts new file mode 100644 index 00000000000..499100c4b85 --- /dev/null +++ b/packages/apollo-server-core/src/plugin/usageReporting/operationDerivedDataCache.ts @@ -0,0 +1,61 @@ +import LRUCache from 'lru-cache'; +import type { Logger } from 'apollo-server-types'; +import type { ReferencedFieldsByType } from './referencedFields'; + +export interface OperationDerivedData { + signature: string; + referencedFieldsByType: ReferencedFieldsByType; +} + +export function createOperationDerivedDataCache({ + logger, +}: { + logger: Logger; +}): LRUCache { + let lastWarn: Date; + let lastDisposals: number = 0; + return new LRUCache({ + // Calculate the length of cache objects by the JSON.stringify byteLength. + length(obj) { + return Buffer.byteLength(JSON.stringify(obj), 'utf8'); + }, + // 10MiB limit, very much approximately since we can't be sure how V8 might + // be storing this data internally. Though this should be enough to store a + // fair amount of operation data, depending on their overall complexity. A + // future version of this might expose some configuration option to grow the + // cache, but ideally, we could do that dynamically based on the resources + // available to the server, and not add more configuration surface area. + // Hopefully the warning message will allow us to evaluate the need with + // more validated input from those that receive it. + max: Math.pow(2, 20) * 10, + dispose() { + // Count the number of disposals between warning messages. + lastDisposals++; + + // Only show a message warning about the high turnover every 60 seconds. + if (!lastWarn || new Date().getTime() - lastWarn.getTime() > 60000) { + // Log the time that we last displayed the message. + lastWarn = new Date(); + logger.warn( + [ + 'This server is processing a high number of unique operations. ', + `A total of ${lastDisposals} records have been `, + 'ejected from the ApolloServerPluginUsageReporting signature cache in the past ', + 'interval. If you see this warning frequently, please open an ', + 'issue on the Apollo Server repository.', + ].join(''), + ); + + // Reset the disposal counter for the next message interval. + lastDisposals = 0; + } + }, + }); +} + +export function operationDerivedDataCacheKey( + queryHash: string, + operationName: string, +) { + return `${queryHash}${operationName && ':' + operationName}`; +} diff --git a/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts b/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts index 02ed5f4a99f..e296d82aea4 100644 --- a/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts +++ b/packages/apollo-server-core/src/plugin/usageReporting/plugin.ts @@ -15,7 +15,11 @@ import { GraphQLRequestContextDidResolveOperation, GraphQLRequestContextWillSendResponse, } from 'apollo-server-types'; -import { createSignatureCache, signatureCacheKey } from './signatureCache'; +import { + createOperationDerivedDataCache, + OperationDerivedData, + operationDerivedDataCacheKey, +} from './operationDerivedDataCache'; import type { ApolloServerPluginUsageReportingOptions, SendValuesBaseOptions, @@ -27,6 +31,11 @@ import { computeCoreSchemaHash } from '../schemaReporting'; import type { InternalApolloServerPlugin } from '../../internalPlugin'; import { OurReport } from './stats'; import { defaultSendOperationsAsTrace } from './defaultSendOperationsAsTrace'; +import { + calculateReferencedFieldsByType, + ReferencedFieldsByType, +} from './referencedFields'; +import type LRUCache from 'lru-cache'; const reportHeaderDefaults = { hostname: os.hostname(), @@ -110,10 +119,15 @@ export function ApolloServerPluginUsageReporting( const sendReportsImmediately = options.sendReportsImmediately ?? serverlessFramework; - // Since calculating the signature for usage reporting is potentially an - // expensive operation, we'll cache the signatures we generate and re-use - // them based on repeated traces for the same `queryHash`. - const signatureCache = createSignatureCache({ logger }); + // Since calculating the signature and referenced fields for usage + // reporting is potentially an expensive operation, we'll cache the data + // we generate and re-use them for repeated operations for the same + // `queryHash`. However, because referenced fields depend on the current + // schema, we want to throw it out entirely any time the schema changes. + let operationDerivedDataCache: { + forSchema: GraphQLSchema; + cache: LRUCache; + } | null = null; const reportDataByExecutableSchemaId: { [executableSchemaId: string]: ReportData | undefined; @@ -561,6 +575,7 @@ export function ApolloServerPluginUsageReporting( const { trace } = treeBuilder; let statsReportKey: string | undefined = undefined; + let referencedFieldsByType: ReferencedFieldsByType; if (!requestContext.document) { statsReportKey = `## GraphQLParseFailure\n`; } else if (graphqlValidationFailure) { @@ -577,11 +592,14 @@ export function ApolloServerPluginUsageReporting( trace.unexecutedOperationName = requestContext.request.operationName || ''; } + referencedFieldsByType = Object.create(null); } else { - const signature = getTraceSignature(); - statsReportKey = `# ${ - requestContext.operationName || '-' - }\n${signature}`; + const operationDerivedData = getOperationDerivedData(); + statsReportKey = `# ${requestContext.operationName || '-'}\n${ + operationDerivedData.signature + }`; + referencedFieldsByType = + operationDerivedData.referencedFieldsByType; } const protobufError = Trace.verify(trace); @@ -596,6 +614,7 @@ export function ApolloServerPluginUsageReporting( graphMightSupportTraces && sendOperationAsTrace(trace, statsReportKey), includeTracesContributingToStats, + referencedFieldsByType, }); // If the buffer gets big (according to our estimate), send. @@ -608,36 +627,61 @@ export function ApolloServerPluginUsageReporting( } } - function getTraceSignature(): string { + // Calculates signature and referenced fields for the current document. + // Only call this when the document properly parses and validates and + // the given operation name (if any) is known! + function getOperationDerivedData(): OperationDerivedData { if (!requestContext.document) { // This shouldn't happen: no document means parse failure, which // uses its own special statsReportKey. throw new Error('No document?'); } - const cacheKey = signatureCacheKey( + const cacheKey = operationDerivedDataCacheKey( requestContext.queryHash, requestContext.operationName || '', ); + // Ensure that the cache we have is for the right schema. + if ( + !operationDerivedDataCache || + operationDerivedDataCache.forSchema !== schema + ) { + operationDerivedDataCache = { + forSchema: schema, + cache: createOperationDerivedDataCache({ logger }), + }; + } + // If we didn't have the signature in the cache, we'll resort to // calculating it. - const cachedSignature = signatureCache.get(cacheKey); - - if (cachedSignature) { - return cachedSignature; + const cachedOperationDerivedData = + operationDerivedDataCache.cache.get(cacheKey); + if (cachedOperationDerivedData) { + return cachedOperationDerivedData; } const generatedSignature = ( options.calculateSignature || defaultUsageReportingSignature )(requestContext.document, requestContext.operationName || ''); + const generatedOperationDerivedData: OperationDerivedData = { + signature: generatedSignature, + referencedFieldsByType: calculateReferencedFieldsByType({ + document: requestContext.document, + schema, + resolvedOperationName: requestContext.operationName ?? null, + }), + }; + // Note that this cache is always an in-memory cache. // If we replace it with a more generic async cache, we should // not await the write operation. - signatureCache.set(cacheKey, generatedSignature); - - return generatedSignature; + operationDerivedDataCache.cache.set( + cacheKey, + generatedOperationDerivedData, + ); + return generatedOperationDerivedData; } }, }; diff --git a/packages/apollo-server-core/src/plugin/usageReporting/referencedFields.ts b/packages/apollo-server-core/src/plugin/usageReporting/referencedFields.ts new file mode 100644 index 00000000000..480d5d3052a --- /dev/null +++ b/packages/apollo-server-core/src/plugin/usageReporting/referencedFields.ts @@ -0,0 +1,82 @@ +import { + DocumentNode, + GraphQLSchema, + isInterfaceType, + separateOperations, + TypeInfo, + visit, + visitWithTypeInfo, +} from 'graphql'; +import { ReferencedFieldsForType } from 'apollo-reporting-protobuf'; + +export type ReferencedFieldsByType = Record; + +export function calculateReferencedFieldsByType({ + document, + schema, + resolvedOperationName, +}: { + document: DocumentNode; + resolvedOperationName: string | null; + schema: GraphQLSchema; +}): ReferencedFieldsByType { + // If the document contains multiple operations, we only care about fields + // referenced in the operation we're using and in fragments that are + // (transitively) spread by that operation. (This is because Studio's field + // usage accounting is all by operation, not by document.) This does mean that + // a field can be textually present in a GraphQL document (and need to exist + // for validation) without being represented in the reported referenced fields + // structure, but we'd need to change the data model of Studio to be based on + // documents rather than fields if we wanted to improve that. + const documentSeparatedByOperation = separateOperations(document); + const filteredDocument = + documentSeparatedByOperation[resolvedOperationName ?? '']; + if (!filteredDocument) { + // This shouldn't happen because we only should call this function on + // properly executable documents. + throw Error( + `shouldn't happen: operation '${resolvedOperationName ?? ''}' not found`, + ); + } + const typeInfo = new TypeInfo(schema); + const interfaces = new Set(); + const referencedFieldSetByType: Record> = Object.create( + null, + ); + visit( + filteredDocument, + visitWithTypeInfo(typeInfo, { + Field(field) { + const fieldName = field.name.value; + const parentType = typeInfo.getParentType(); + if (!parentType) { + throw Error( + `shouldn't happen: missing parent type for field ${fieldName}`, + ); + } + const parentTypeName = parentType.name; + if (!referencedFieldSetByType[parentTypeName]) { + referencedFieldSetByType[parentTypeName] = new Set(); + if (isInterfaceType(parentType)) { + interfaces.add(parentTypeName); + } + } + referencedFieldSetByType[parentTypeName].add(fieldName); + }, + }), + ); + + // Convert from initial representation (which uses Sets to avoid quadratic + // behavior) to the protobufjs objects. (We could also use js_use_toArray here + // but that seems a little overkill.) + const referencedFieldsByType = Object.create(null); + for (const [typeName, fieldNames] of Object.entries( + referencedFieldSetByType, + )) { + referencedFieldsByType[typeName] = new ReferencedFieldsForType({ + fieldNames: [...fieldNames], + isInterface: interfaces.has(typeName), + }); + } + return referencedFieldsByType; +} diff --git a/packages/apollo-server-core/src/plugin/usageReporting/signatureCache.ts b/packages/apollo-server-core/src/plugin/usageReporting/signatureCache.ts deleted file mode 100644 index aed88c85630..00000000000 --- a/packages/apollo-server-core/src/plugin/usageReporting/signatureCache.ts +++ /dev/null @@ -1,56 +0,0 @@ -import LRUCache from 'lru-cache'; -import type { Logger } from 'apollo-server-types'; - -export function createSignatureCache({ - logger, -}: { - logger: Logger; -}): LRUCache { - let lastSignatureCacheWarn: Date; - let lastSignatureCacheDisposals: number = 0; - return new LRUCache({ - // Calculate the length of cache objects by the JSON.stringify byteLength. - length(obj) { - return Buffer.byteLength(JSON.stringify(obj), 'utf8'); - }, - // 3MiB limit, very much approximately since we can't be sure how V8 might - // be storing these strings internally. Though this should be enough to - // store a fair amount of operation signatures (~10000?), depending on their - // overall complexity. A future version of this might expose some - // configuration option to grow the cache, but ideally, we could do that - // dynamically based on the resources available to the server, and not add - // more configuration surface area. Hopefully the warning message will allow - // us to evaluate the need with more validated input from those that receive - // it. - max: Math.pow(2, 20) * 3, - dispose() { - // Count the number of disposals between warning messages. - lastSignatureCacheDisposals++; - - // Only show a message warning about the high turnover every 60 seconds. - if ( - !lastSignatureCacheWarn || - new Date().getTime() - lastSignatureCacheWarn.getTime() > 60000 - ) { - // Log the time that we last displayed the message. - lastSignatureCacheWarn = new Date(); - logger.warn( - [ - 'This server is processing a high number of unique operations. ', - `A total of ${lastSignatureCacheDisposals} records have been `, - 'ejected from the ApolloServerPluginUsageReporting signature cache in the past ', - 'interval. If you see this warning frequently, please open an ', - 'issue on the Apollo Server repository.', - ].join(''), - ); - - // Reset the disposal counter for the next message interval. - lastSignatureCacheDisposals = 0; - } - }, - }); -} - -export function signatureCacheKey(queryHash: string, operationName: string) { - return `${queryHash}${operationName && ':' + operationName}`; -} diff --git a/packages/apollo-server-core/src/plugin/usageReporting/stats.ts b/packages/apollo-server-core/src/plugin/usageReporting/stats.ts index 36917ef7d48..05ecfb7bced 100644 --- a/packages/apollo-server-core/src/plugin/usageReporting/stats.ts +++ b/packages/apollo-server-core/src/plugin/usageReporting/stats.ts @@ -13,6 +13,7 @@ import { IReport, } from 'apollo-reporting-protobuf'; import { iterateOverTrace, ResponseNamePath } from './iterateOverTrace'; +import type { ReferencedFieldsByType } from './referencedFields'; // protobuf.js exports both a class and an interface (starting with I) for each // message type. The class is what it produces when it decodes the message; the @@ -54,13 +55,18 @@ export class OurReport implements Required { trace, asTrace, includeTracesContributingToStats, + referencedFieldsByType, }: { statsReportKey: string; trace: Trace; asTrace: boolean; includeTracesContributingToStats: boolean; + referencedFieldsByType: ReferencedFieldsByType; }) { - const tracesAndStats = this.getTracesAndStats(statsReportKey); + const tracesAndStats = this.getTracesAndStats({ + statsReportKey, + referencedFieldsByType, + }); if (asTrace) { const encodedTrace = Trace.encode(trace).finish(); tracesAndStats.trace.push(encodedTrace); @@ -80,17 +86,47 @@ export class OurReport implements Required { } } - private getTracesAndStats(statsReportKey: string) { + private getTracesAndStats({ + statsReportKey, + referencedFieldsByType, + }: { + statsReportKey: string; + referencedFieldsByType: ReferencedFieldsByType; + }) { const existing = this.tracesPerQuery[statsReportKey]; if (existing) { return existing; } this.sizeEstimator.bytes += estimatedBytesForString(statsReportKey); - return (this.tracesPerQuery[statsReportKey] = new OurTracesAndStats()); + + // Update the size estimator for the referenced field structure. + for (const [typeName, referencedFieldsForType] of Object.entries( + referencedFieldsByType, + )) { + // Two bytes each for the map entry and for the ReferencedFieldsForType, + // and for the isInterface bool if it's set. + this.sizeEstimator.bytes += 2 + 2; + if (referencedFieldsForType.isInterface) { + this.sizeEstimator.bytes += 2; + } + this.sizeEstimator.bytes += estimatedBytesForString(typeName); + for (const fieldName of referencedFieldsForType.fieldNames) { + this.sizeEstimator.bytes += estimatedBytesForString(fieldName); + } + } + + // Include the referenced fields map in the report. (In an ideal world we + // could have a slightly more sophisticated protocol and ingestion pipeline + // that allowed us to only have to send this data once for each + // schema/operation pair.) + return (this.tracesPerQuery[statsReportKey] = new OurTracesAndStats( + referencedFieldsByType, + )); } } class OurTracesAndStats implements Required { + constructor(readonly referencedFieldsByType: ReferencedFieldsByType) {} readonly trace: Uint8Array[] = []; readonly statsWithContext = new StatsByContext(); readonly internalTracesContributingToStats: Uint8Array[] = [];