Repository: newrelic/newrelic-node-apollo-server-plugin (js)

Issue:
Capture metrics for every field being requested in a graphql query
## Is your feature request related to a problem? Please describe.
As you maintain a graphql service you'll likely refactor and want to remove older fields.  The Alerts team wants the ability to know every field requested per request so they know if a field is no longer being asked for so they can safely remove.

## Feature Description
At the end of every request report metrics for every unique field being requested within a graphql query.

## Additional context
We currently report scalars as resolve segments but this occurs for every single item in a request.  

## Priority
Please help us better understand this feature request by choosing a priority from the following options:
[Nice to Have, Really Want, Must Have, Blocker]


Pull request description:
feat: added ability to capture both fields and args seen during an Apollo query as metrics
<!--
Thank you for submitting a Pull Request.

This code is leveraged to monitor critical services. Please consider the following:
* Tests are required.
* Performance matters.
* Features that are specific to just your app are unlikely to make it in.

Please fill out the relevant sections as follows:
* Proposed Release Notes: Bulleted list of recommended release notes for the change(s).
* Links: Any relevant links for the change.
* Details: In-depth description of changes, other technical notes, etc.
-->

## Proposed Release Notes
 * Added ability to capture both fields and args seen during an Apollo query as metrics.
   * To enable this feature, update your createPlugin configuration to specify `captureFieldMetrics: true`

## Links
Closes #202 

## Details
These metrics purely track call count unlike the `GraphQL/resolve` and `GraphQL/typedResolve` which track how long it takes for a resolver to execute.  I did this because if a customer just wants to track fields/args requested they wouldn't get them all as scalars are gated behind `captureScalars`. The way we capture scalars is to record a segment and I didn't want to incur this overhead for just tracking fields/args requested.  

Here's a query you can use to query these new metrics, which I added to the docs/metrics.md

```
FROM Metric SELECT count(newrelic.timeslice.value) where appName = '[YOUR APP NAME]' WITH METRIC_FORMAT 'GraphQL/{kind}/ApolloServer/{field}' where kind = 'arg' or kind = 'field' FACET kind, field limit max since 1 day ago 
```



Patch:
diff --git a/README.md b/README.md
index e810961..70094d1 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,7 @@ const plugin = createPlugin({
   captureIntrospectionQueries: true,
   captureServiceDefinitionQueries: true,
   captureHealthCheckQueries: true,
+  captureFieldMetrics: true,
   customResolverAttributes: () => { return { exampleAttribute: 'exampleValue' }},
   customOperationAttributes: () => { return { exampleAttribute: 'exampleValue' }}
 })
@@ -140,6 +141,8 @@ const plugin = createPlugin({
 
 * `[captureHealthCheckQueries = false]` Enable capture of timings for a [Health Check query](https://www.apollographql.com/docs/federation/api/apollo-gateway/#servicehealthcheck) received from an Apollo Federated Gateway Server.
 
+* `[captureFieldMetrics = false]` Enable capture of metrics for every field and resolver argument seen for an Apollo query. This is intended to be used to check for any unused fields in your graphql schema. 
+
 There are two configuration options for providing callbacks that can be used to define custom attributes. As their names suggest, `customResolverAttributes` will put custom attributes on the resolver segment and `customOperationAttributes` will use the operation segment. 
 
 The `customResolverAttributes` callback gets passed a `resolverContext` object [corresponding to the attributes given to a resolver](https://www.apollographql.com/docs/apollo-server/data/resolvers/#resolver-arguments) in Apollo server. The `customOperationAttributes` callback gets passed [a `requestContext` argument](https://github.com/apollographql/apollo-server/blob/6b4945935a786d06e7ff904be94c0035fe27aeb1/packages/server/src/externalTypes/graphql.ts#L47). These arguments can be used to imbue more details into custom attributes.  Both callbacks must return an object that will be used to define custom attribute key-value pairs. For example,
diff --git a/docs/metrics.md b/docs/metrics.md
index 550b029..3b22136 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -25,6 +25,21 @@ Resolve metrics capture the duration spent resolving a particular piece of reque
 
 These differ slightly in naming from their segment/span counterparts. To better visualize relationships, the full path to a field is represented in segments/spans (e.g. libraries.books.title). To understand the duration aggregated across all usages and transactions, these metrics use the field name without the full path.
 
+## Field and Argument Metrics
+
+`/GraphQL/field/ApolloServer/[parent-type].[field-name]`
+`/GraphQL/arg/ApolloServer/[parent-type].[field-name]/[arg-name]`
+
+Field metrics are only captured when `config.captureFieldMetrics` is `true`.  Unlike the Field Resolve Metrics, this will capture every time a field or resolver argument is seen.
+The intent of these metrics is to determine if a field in a GraphQL schema is still in use and is safe to remove.
+
+
+### All fields and args that have been requested within the last day
+
+```
+FROM Metric SELECT count(newrelic.timeslice.value) where appName = '[YOUR APP NAME]' WITH METRIC_FORMAT 'GraphQL/{kind}/ApolloServer/{field}' where kind = 'arg' or kind = 'field' FACET kind, field limit max since 1 day ago 
+```
+
 ## Visualizations
 
 Here is a collection of useful queries that leverage these new metrics to better understand the behaviors of your Apollo GraphQL applications.
diff --git a/lib/create-plugin.js b/lib/create-plugin.js
index 87110d2..b5f5f4c 100644
--- a/lib/create-plugin.js
+++ b/lib/create-plugin.js
@@ -17,6 +17,8 @@ const CATEGORY = 'GraphQL'
 const FRAMEWORK = 'ApolloServer'
 const OPERATION_PREFIX = CATEGORY + '/operation/' + FRAMEWORK
 const RESOLVE_PREFIX = CATEGORY + '/resolve/' + FRAMEWORK
+const ARG_PREFIX = `${CATEGORY}/arg/${FRAMEWORK}`
+const FIELD_PREFIX = `${CATEGORY}/field/${FRAMEWORK}`
 const TYPED_RESOLVE_PREFIX = CATEGORY + '/typedResolve/' + FRAMEWORK
 const BATCH_PREFIX = 'batch'
 
@@ -40,16 +42,6 @@ const DESTINATIONS = {
   NONE: 0x00
 }
 
-/**
- * @typedef {object} PluginConfig
- * @property {boolean} [captureScalars=false]
- * Enable capture of timing of fields resolved with the `GraphQLScalarType` return type.
- * This may be desired when performing time intensive calculations to return a scalar
- * value.This is not recommended for queries that return a large number of pre-calculated
- * scalar fields.
- * NOTE: query/mutation resolvers will always be captured even if returning a scalar type.
- */
-
 /* eslint sonarjs/cognitive-complexity: ["error", 57] -- TODO: https://issues.newrelic.com/browse/NEWRELIC-8797 */
 /**
  * Creates an Apollo Server plugin for capturing timing data
@@ -73,6 +65,7 @@ function createPlugin(api, config = {}) {
   config.captureHealthCheckQueries = config.captureHealthCheckQueries || false
   config.customResolverAttributes = config.customResolverAttributes || null
   config.customOperationAttributes = config.customOperationAttributes || null
+  config.captureFieldMetrics = config.captureFieldMetrics || false
 
   logger.debug('Plugin configuration: ', config)
 
@@ -134,6 +127,9 @@ function createPlugin(api, config = {}) {
               const { info, args } = resolverContext
               const pathArray = flattenToArray(info.path)
               const formattedPath = pathArray.reverse().join('.')
+              const flattenedArgs = flattenArgs({ obj: args })
+
+              maybeCaptureFieldMetrics({ operationSegment, info, args: flattenedArgs, config })
 
               if (!config.captureScalars && !isTopLevelField(info) && isScalar(info)) {
                 return null
@@ -175,9 +171,9 @@ function createPlugin(api, config = {}) {
               // the attributes to resolver segments as they help
               // inform performance impacts.
               for (const segment of [operationSegment, resolverSegment]) {
-                for (const [key, value] of Object.entries(args)) {
+                for (const [key, value] of Object.entries(flattenedArgs)) {
                   // Require adding to attribute 'include' configuration
-                  // so as not to accidentally send senstive info to New Relic.
+                  // so as not to accidentally send sensitive info to New Relic.
                   segment.attributes.addAttribute(
                     DESTINATIONS.NONE,
                     `${FIELD_ARGS_ATTR}.${key}`,
@@ -304,6 +300,28 @@ function flattenToArray(fieldPath) {
   return pathArray
 }
 
+/**
+ * Takes a nested object and flattens the key/values
+ * { book: { author: { name: 'George Orwell' }, title: '1984' }}
+ * would flatten to { book.author.name: 'George Orwell', book.title: '1984' }
+ *
+ * @param {object} args
+ * @param {object} result resulting object
+ * @param {string} [prefix=''] prefix of key
+ * @param {object} obj object to flatten
+ */
+function flattenArgs({ result = {}, prefix = '', obj }) {
+  for (const key in obj) {
+    if (typeof obj[key] === 'object') {
+      flattenArgs({ result, prefix: prefix + key + '.', obj: obj[key] })
+    } else {
+      result[prefix + key] = obj[key]
+    }
+  }
+
+  return result
+}
+
 function setTransactionName(transaction, transactionName) {
   const nameState = transaction.nameState
   if (!nameState.graphql) {
@@ -365,7 +383,7 @@ function getDeepestPathAndQueryArguments(definition) {
 
   /**
    * Search each selection path until no-more sub-selections
-   * exist. If the curent path is deeper than deepestPath,
+   * exist. If the current path is deeper than deepestPath,
    * deepestPath is replaced.
    */
   function searchSelection(selection, currentParts) {
@@ -415,6 +433,13 @@ function getDeepestPathAndQueryArguments(definition) {
   }
 }
 
+/**
+ * Creates metrics for resolver fields when transaction is ended.
+ * This will record how long specific resolvers took.
+ *
+ * @param {Object} segment relevant resolver segment
+ * @param {string} scope name of transaction
+ */
 function recordResolveSegment(segment, scope) {
   const duration = segment.getDurationInMillis()
   const exclusive = segment.getExclusiveDurationInMillis()
@@ -570,4 +595,58 @@ function updateOperationSegmentName(context, operationSegment) {
   return false
 }
 
+/**
+ * Captures both field and args of resolvers as metrics.
+ *
+ * This is intended to be used to determine if a field within a graphql schema is still being requested.
+ *
+ * @param {Object} params
+ * @param {Object} params.config plugin config
+ * @param {Object} params.info info key from resolver context
+ * @param {Object} params.operationSegment operation segment
+ * @param {Object} params.args args key from resolver context
+ *
+ */
+function maybeCaptureFieldMetrics({ config, info, operationSegment, args }) {
+  if (config.captureFieldMetrics) {
+    const transaction = operationSegment.transaction
+    const fieldName = info.fieldName
+    const fieldType = info.parentType.toString()
+    captureFieldMetrics({ transaction, args, fieldName, fieldType })
+  }
+}
+
+/**
+ * Used to create metrics that just increment call count.  Intended to be
+ * used to report on when we see args in a resolver
+ *
+ * @param {Object} agent active agent instance
+ * @param {String} name metric name
+ */
+function createCallCountMetric(transaction, name) {
+  const metric = transaction.metrics.getOrCreateMetric(name)
+  metric.incrementCallCount()
+}
+
+/**
+ * Captures both field and args of resolvers as metrics.
+ *
+ * This is intended to be used to determine if a field within a graphql schema is still being requested.
+ *
+ * @param {Object} params
+ * @param {Object} params.transaction active transaction
+ * @param {Object} params.args args key from resolver context
+ * @param {Object} params.fieldType parent type of field
+ * @param {Object} params.fieldName name of field
+ *
+ */
+function captureFieldMetrics({ transaction, args, fieldType, fieldName }) {
+  const fieldMetric = `${FIELD_PREFIX}/${fieldType}.${fieldName}`
+  createCallCountMetric(transaction, fieldMetric)
+  Object.entries(args).forEach(([key]) => {
+    const name = `${ARG_PREFIX}/${fieldType}.${fieldName}/${key}`
+    createCallCountMetric(transaction, name)
+  })
+}
+
 module.exports = createPlugin


Test patch:
diff --git a/tests/data-definitions.js b/tests/data-definitions.js
index 90be52a..1581689 100644
--- a/tests/data-definitions.js
+++ b/tests/data-definitions.js
@@ -42,6 +42,13 @@ const books = [
     author: '10x Developer',
     branch: 'downtown',
     category: 'COOKBOOK'
+  },
+  {
+    title: 'Breaking production for dummies',
+    isbn: 'a-fifth-fake-isbn',
+    author: '10x Developer',
+    branch: 'uptown',
+    category: 'TECH'
   }
 ]
 
@@ -90,6 +97,7 @@ function getTypeDefs(gql) {
     enum BookCategory {
       NOVEL
       COOKBOOK
+      TECH
     }
 
     type Author {
@@ -103,6 +111,7 @@ function getTypeDefs(gql) {
 
     type Query {
       search(contains: String): [SearchResult!]
+      searchByBook(book: BookInput): [Book!]
       books(category: BookCategory): [Book]!
       hello: String
       paramQuery(blah: String!, blee: String): String!
@@ -120,6 +129,15 @@ function getTypeDefs(gql) {
       addThing(name: String!): String!
       addToCollection(title: String!): Item!
     }
+
+    input BookInput {
+      author: AuthorInput
+      title: String
+    }
+
+    input AuthorInput {
+      name: String
+    }
   `
 }
 
@@ -150,6 +168,14 @@ const resolvers = {
     searchCollection: (_, { title }) => {
       const item = collection.filter((coll) => coll.title.includes(title))
       return item[0]
+    },
+    searchByBook: (_, { book: searchBook }) => {
+      const filteredBooks = books.filter((book) => book.author === searchBook.author.name)
+      if (searchBook.title) {
+        return filteredBooks.filter((book) => book.title === searchBook.title)
+      }
+
+      return filteredBooks
     }
   },
   Mutation: {
diff --git a/tests/integration/metrics.test.js b/tests/integration/metrics.test.js
index 39db041..97a62d1 100644
--- a/tests/integration/metrics.test.js
+++ b/tests/integration/metrics.test.js
@@ -5,296 +5,16 @@
 
 'use strict'
 
-const crypto = require('crypto')
-const { executeQuery, executeQueryBatch, makeRequest } = require('../test-client')
-const { setupEnvConfig } = require('../agent-testing')
-
-const ANON_PLACEHOLDER = '<anonymous>'
-const UNKNOWN_OPERATION = '<unknown>'
-
-const OPERATION_PREFIX = 'GraphQL/operation/ApolloServer'
-const RESOLVE_PREFIX = 'GraphQL/resolve/ApolloServer'
-const TYPED_RESOLVE_PREFIX = 'GraphQL/typedResolve/ApolloServer'
-
+const createMetricsTests = require('../metrics-tests')
 const { setupApolloServerTests } = require('./apollo-server-setup')
 
 setupApolloServerTests({
   suiteName: 'metrics',
-  createTests: createMetricsTests
+  createTests: createMetricsTests.bind(null, false)
 })
 
-function createMetricsTests(t) {
-  setupEnvConfig(t)
-
-  t.test('anonymous query, single level metrics', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const query = `query {
-      hello
-    }`
-
-    helper.agent.once('transactionFinished', () => {
-      const operationPart = `query/${ANON_PLACEHOLDER}/hello`
-      t.metrics([`${OPERATION_PREFIX}/${operationPart}`, `${RESOLVE_PREFIX}/hello`])
-    })
-
-    executeQuery(serverUrl, query, (err) => {
-      t.error(err)
-      t.end()
-    })
-  })
-
-  t.test('named query, single level', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const expectedName = 'HeyThere'
-    const query = `query ${expectedName} {
-      hello
-    }`
-
-    helper.agent.once('transactionFinished', () => {
-      const operationPart = `query/${expectedName}/hello`
-      t.metrics([
-        `${OPERATION_PREFIX}/${operationPart}`,
-        `${RESOLVE_PREFIX}/hello`,
-        `${TYPED_RESOLVE_PREFIX}/Query.hello`
-      ])
-    })
-
-    executeQuery(serverUrl, query, (err) => {
-      t.error(err)
-      t.end()
-    })
-  })
-
-  t.test('multi-level, should use field name not path for resolve metrics', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const query = `query {
-      libraries {
-        books {
-          title
-          author {
-            name
-          }
-        }
-      }
-    }`
-
-    const path = 'libraries.books'
-
-    helper.agent.once('transactionFinished', () => {
-      const operationPart = `query/${ANON_PLACEHOLDER}/${path}`
-
-      t.metrics([
-        `${OPERATION_PREFIX}/${operationPart}`,
-        `${RESOLVE_PREFIX}/libraries`,
-        `${RESOLVE_PREFIX}/books`,
-        `${RESOLVE_PREFIX}/author`,
-        `${TYPED_RESOLVE_PREFIX}/Query.libraries`,
-        `${TYPED_RESOLVE_PREFIX}/Library.books`,
-        `${TYPED_RESOLVE_PREFIX}/Book.author`
-      ])
-    })
-
-    executeQuery(serverUrl, query, (err) => {
-      t.error(err)
-      t.end()
-    })
-  })
-
-  t.test('batch query should generate metrics for nested operations', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const expectedName1 = 'GetBookForLibrary'
-    const query1 = `query ${expectedName1} {
-      library(branch: "downtown") {
-        books {
-          title
-          author {
-            name
-          }
-        }
-      }
-    }`
-
-    const query2 = `mutation {
-      addThing(name: "added thing!")
-    }`
-
-    const path1 = 'library.books'
-
-    const queries = [query1, query2]
-
-    helper.agent.once('transactionFinished', () => {
-      const operationPart1 = `query/${expectedName1}/${path1}`
-      const operationPart2 = `mutation/${ANON_PLACEHOLDER}/addThing`
-
-      const operationMetrics1 = [
-        `${OPERATION_PREFIX}/${operationPart1}`,
-        `${RESOLVE_PREFIX}/library`,
-        `${RESOLVE_PREFIX}/books`,
-        `${RESOLVE_PREFIX}/author`,
-        `${TYPED_RESOLVE_PREFIX}/Query.libraries`,
-        `${TYPED_RESOLVE_PREFIX}/Library.books`,
-        `${TYPED_RESOLVE_PREFIX}/Book.author`
-      ]
-
-      const operationMetrics2 = [
-        `${OPERATION_PREFIX}/${operationPart2}`,
-        `${RESOLVE_PREFIX}/addThing`,
-        `${TYPED_RESOLVE_PREFIX}/Mutation.addThing`
-      ]
-
-      t.metrics([...operationMetrics1, ...operationMetrics2])
-    })
-
-    executeQueryBatch(serverUrl, queries, (err) => {
-      t.error(err)
-      t.end()
-    })
-  })
-
-  t.test('should use the context.source when executing a persisted query', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const query = '{ libraries { books { title author { name } } } }'
-    const path = 'libraries.books'
-    const querySha = crypto.createHash('sha256').update(query).digest('hex')
-    const withQuery = `${serverUrl}?extensions={"persistedQuery":{"version":1,
-      "sha256Hash":"${querySha}"}}&query=${query}`
-    const persistedQuery = `${serverUrl}?extensions={"persistedQuery":{"version":1,
-      "sha256Hash":"${querySha}"}}`
-
-    helper.agent.once('transactionFinished', () => {
-      const operationPart = `query/${ANON_PLACEHOLDER}/${path}`
-
-      t.metrics([
-        `${OPERATION_PREFIX}/${operationPart}`,
-        `${RESOLVE_PREFIX}/libraries`,
-        `${RESOLVE_PREFIX}/books`,
-        `${RESOLVE_PREFIX}/author`,
-        `${TYPED_RESOLVE_PREFIX}/Query.libraries`,
-        `${TYPED_RESOLVE_PREFIX}/Library.books`,
-        `${TYPED_RESOLVE_PREFIX}/Book.author`
-      ])
-    })
-
-    // first make a request with persistedQuery extension enabled and the query to persist
-    makeRequest(withQuery, null, (err) => {
-      t.error(err)
-
-      // lastly, make a request with persistedQuery extension enabled
-      // and without the query it should properly pull from query cache
-      makeRequest(persistedQuery, null, (err) => {
-        t.error(err)
-        t.end()
-      })
-    })
-  })
-
-  t.test('when cannot parse, should have unknown placeholder metric', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const invalidQuery = `query {
-      libraries {
-        books {
-          title
-          author {
-            name
-          }
-        }
-      }
-    ` // missing closing }
-
-    helper.agent.once('transactionFinished', () => {
-      t.metrics([`${OPERATION_PREFIX}/${UNKNOWN_OPERATION}`])
-    })
-
-    executeQuery(serverUrl, invalidQuery, (err) => {
-      t.error(err)
-
-      t.end()
-    })
-  })
-
-  t.test('named query with fragment, query first', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const expectedName = 'GetBookForLibrary'
-    const query = `query ${expectedName} {
-      library(branch: "downtown") {
-        books {
-          ... LibraryBook
-        }
-      }
-    }
-    fragment LibraryBook on Book {
-      title
-      author {
-        name
-      }
-    }`
-
-    const path = 'library.books.LibraryBook'
-
-    helper.agent.once('transactionFinished', () => {
-      const operationPart = `query/${expectedName}/${path}`
-
-      t.metrics([
-        `${OPERATION_PREFIX}/${operationPart}`,
-        `${RESOLVE_PREFIX}/library`,
-        `${RESOLVE_PREFIX}/books`,
-        `${RESOLVE_PREFIX}/author`,
-        `${TYPED_RESOLVE_PREFIX}/Query.libraries`,
-        `${TYPED_RESOLVE_PREFIX}/Library.books`,
-        `${TYPED_RESOLVE_PREFIX}/Book.author`
-      ])
-    })
-
-    executeQuery(serverUrl, query, (err) => {
-      t.error(err)
-      t.end()
-    })
-  })
-
-  t.test('named query with fragment, fragment first', (t) => {
-    const { helper, serverUrl } = t.context
-
-    const expectedName = 'GetBookForLibrary'
-    const query = `fragment LibraryBook on Book {
-      title
-      author {
-        name
-      }
-    }
-    query ${expectedName} {
-      library(branch: "downtown") {
-        books {
-          ... LibraryBook
-        }
-      }
-    }`
-
-    const path = 'library.books.LibraryBook'
-
-    helper.agent.once('transactionFinished', () => {
-      const operationPart = `query/${expectedName}/${path}`
-
-      t.metrics([
-        `${OPERATION_PREFIX}/${operationPart}`,
-        `${RESOLVE_PREFIX}/library`,
-        `${RESOLVE_PREFIX}/books`,
-        `${RESOLVE_PREFIX}/author`,
-        `${TYPED_RESOLVE_PREFIX}/Query.libraries`,
-        `${TYPED_RESOLVE_PREFIX}/Library.books`,
-        `${TYPED_RESOLVE_PREFIX}/Book.author`
-      ])
-    })
-
-    executeQuery(serverUrl, query, (err) => {
-      t.error(err)
-      t.end()
-    })
-  })
-}
+setupApolloServerTests({
+  suiteName: 'capture field metrics',
+  createTests: createMetricsTests.bind(null, true),
+  pluginConfig: { captureFieldMetrics: true }
+})
diff --git a/tests/metrics-tests.js b/tests/metrics-tests.js
new file mode 100644
index 0000000..248a7d2
--- /dev/null
+++ b/tests/metrics-tests.js
@@ -0,0 +1,418 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const crypto = require('crypto')
+const { executeQuery, executeQueryBatch, makeRequest } = require('./test-client')
+
+const ANON_PLACEHOLDER = '<anonymous>'
+const UNKNOWN_OPERATION = '<unknown>'
+
+const OPERATION_PREFIX = 'GraphQL/operation/ApolloServer'
+const FIELD_PREFIX = 'GraphQL/field/ApolloServer'
+const RESOLVE_PREFIX = 'GraphQL/resolve/ApolloServer'
+const ARG_PREFIX = 'GraphQL/arg/ApolloServer'
+const TYPED_RESOLVE_PREFIX = 'GraphQL/typedResolve/ApolloServer'
+
+module.exports = createMetricsTests
+
+function createMetricsTests(captureFieldMetrics, t) {
+  t.test('anonymous query, single level metrics', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const query = `query {
+      hello
+    }`
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart = `query/${ANON_PLACEHOLDER}/hello`
+      const metrics = [`${OPERATION_PREFIX}/${operationPart}`, `${RESOLVE_PREFIX}/hello`]
+
+      if (captureFieldMetrics) {
+        metrics.push(`${FIELD_PREFIX}/Query.hello`)
+      }
+
+      t.metrics(metrics)
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
+  t.test('named query, single level', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const expectedName = 'HeyThere'
+    const query = `query ${expectedName} {
+      hello
+    }`
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart = `query/${expectedName}/hello`
+      const metrics = [
+        `${OPERATION_PREFIX}/${operationPart}`,
+        `${RESOLVE_PREFIX}/hello`,
+        `${TYPED_RESOLVE_PREFIX}/Query.hello`
+      ]
+
+      if (captureFieldMetrics) {
+        metrics.push(`${FIELD_PREFIX}/Query.hello`)
+      }
+
+      t.metrics(metrics)
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
+  t.test('multi-level, should use field name not path for resolve metrics', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const query = `query {
+      libraries {
+        books {
+          title
+          author {
+            name
+          }
+        }
+      }
+    }`
+
+    const path = 'libraries.books'
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart = `query/${ANON_PLACEHOLDER}/${path}`
+
+      const metrics = [
+        `${OPERATION_PREFIX}/${operationPart}`,
+        `${RESOLVE_PREFIX}/libraries`,
+        `${RESOLVE_PREFIX}/books`,
+        `${RESOLVE_PREFIX}/author`,
+        `${TYPED_RESOLVE_PREFIX}/Query.libraries`,
+        `${TYPED_RESOLVE_PREFIX}/Library.books`,
+        `${TYPED_RESOLVE_PREFIX}/Book.author`
+      ]
+
+      if (captureFieldMetrics) {
+        const fieldMetrics = [
+          `${FIELD_PREFIX}/Query.libraries`,
+          `${FIELD_PREFIX}/Library.books`,
+          `${FIELD_PREFIX}/Book.title`,
+          `${FIELD_PREFIX}/Book.author`,
+          `${FIELD_PREFIX}/Author.name`
+        ]
+
+        metrics.push(...fieldMetrics)
+      }
+
+      t.metrics(metrics)
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
+  t.test('batch query should generate metrics for nested operations', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const expectedName1 = 'GetBookForLibrary'
+    const query1 = `query ${expectedName1} {
+      library(branch: "downtown") {
+        books {
+          title
+          author {
+            name
+          }
+        }
+      }
+    }`
+
+    const query2 = `mutation {
+      addThing(name: "added thing!")
+    }`
+
+    const path1 = 'library.books'
+
+    const queries = [query1, query2]
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart1 = `query/${expectedName1}/${path1}`
+      const operationPart2 = `mutation/${ANON_PLACEHOLDER}/addThing`
+
+      const operationMetrics1 = [
+        `${OPERATION_PREFIX}/${operationPart1}`,
+        `${RESOLVE_PREFIX}/library`,
+        `${RESOLVE_PREFIX}/books`,
+        `${RESOLVE_PREFIX}/author`,
+        `${TYPED_RESOLVE_PREFIX}/Query.library`,
+        `${TYPED_RESOLVE_PREFIX}/Library.books`,
+        `${TYPED_RESOLVE_PREFIX}/Book.author`
+      ]
+
+      const operationMetrics2 = [
+        `${OPERATION_PREFIX}/${operationPart2}`,
+        `${RESOLVE_PREFIX}/addThing`,
+        `${TYPED_RESOLVE_PREFIX}/Mutation.addThing`
+      ]
+
+      if (captureFieldMetrics) {
+        const fieldMetrics1 = [
+          `${FIELD_PREFIX}/Query.library`,
+          `${ARG_PREFIX}/Query.library/branch`,
+          `${FIELD_PREFIX}/Library.books`,
+          `${FIELD_PREFIX}/Book.title`,
+          `${FIELD_PREFIX}/Book.author`,
+          `${FIELD_PREFIX}/Author.name`
+        ]
+
+        operationMetrics1.push(...fieldMetrics1)
+
+        const fieldMetrics2 = [`${ARG_PREFIX}/Mutation.addThing/name`]
+
+        operationMetrics2.push(...fieldMetrics2)
+      }
+
+      t.metrics([...operationMetrics1, ...operationMetrics2])
+    })
+
+    executeQueryBatch(serverUrl, queries, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
+  t.test('input type query with multiple fields', (t) => {
+    const { helper, serverUrl } = t.context
+    const expectedName = 'FindBooks'
+    const path = 'searchByBook'
+
+    const query = `query ${expectedName} {
+      searchByBook(book: { author: { name: "10x Developer" }  } ) {
+        title
+        isbn
+      }
+    }`
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart = `query/${expectedName}/${path}`
+      const operationMetrics = [
+        `${OPERATION_PREFIX}/${operationPart}`,
+        `${RESOLVE_PREFIX}/${path}`,
+        `${TYPED_RESOLVE_PREFIX}/Query.${path}`
+      ]
+
+      if (captureFieldMetrics) {
+        const fieldMetrics = [
+          `${ARG_PREFIX}/Query.${path}/book.author.name`,
+          `${FIELD_PREFIX}/Book.title`,
+          `${FIELD_PREFIX}/Book.isbn`
+        ]
+        operationMetrics.push(...fieldMetrics)
+      }
+
+      t.metrics(operationMetrics)
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
+  t.test('should use the context.source when executing a persisted query', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const query = '{ libraries { books { title author { name } } } }'
+    const path = 'libraries.books'
+    const querySha = crypto.createHash('sha256').update(query).digest('hex')
+    const withQuery = `${serverUrl}?extensions={"persistedQuery":{"version":1,
+      "sha256Hash":"${querySha}"}}&query=${query}`
+    const persistedQuery = `${serverUrl}?extensions={"persistedQuery":{"version":1,
+      "sha256Hash":"${querySha}"}}`
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart = `query/${ANON_PLACEHOLDER}/${path}`
+
+      const metrics = [
+        `${OPERATION_PREFIX}/${operationPart}`,
+        `${RESOLVE_PREFIX}/libraries`,
+        `${RESOLVE_PREFIX}/books`,
+        `${RESOLVE_PREFIX}/author`,
+        `${TYPED_RESOLVE_PREFIX}/Query.libraries`,
+        `${TYPED_RESOLVE_PREFIX}/Library.books`,
+        `${TYPED_RESOLVE_PREFIX}/Book.author`
+      ]
+
+      if (captureFieldMetrics) {
+        const fieldMetrics = [
+          `${FIELD_PREFIX}/Query.libraries`,
+          `${FIELD_PREFIX}/Library.books`,
+          `${FIELD_PREFIX}/Book.title`,
+          `${FIELD_PREFIX}/Book.author`,
+          `${FIELD_PREFIX}/Author.name`
+        ]
+        metrics.push(...fieldMetrics)
+      }
+
+      t.metrics(metrics)
+    })
+
+    // first make a request with persistedQuery extension enabled and the query to persist
+    makeRequest(withQuery, null, (err) => {
+      t.error(err)
+
+      // lastly, make a request with persistedQuery extension enabled
+      // and without the query it should properly pull from query cache
+      makeRequest(persistedQuery, null, (err) => {
+        t.error(err)
+        t.end()
+      })
+    })
+  })
+
+  t.test('when cannot parse, should have unknown placeholder metric', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const invalidQuery = `query {
+      libraries {
+        books {
+          title
+          author {
+            name
+          }
+        }
+      }
+    ` // missing closing }
+
+    helper.agent.once('transactionFinished', () => {
+      t.metrics([`${OPERATION_PREFIX}/${UNKNOWN_OPERATION}`])
+    })
+
+    executeQuery(serverUrl, invalidQuery, (err) => {
+      t.error(err)
+
+      t.end()
+    })
+  })
+
+  t.test('named query with fragment, query first', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const expectedName = 'GetBookForLibrary'
+    const query = `query ${expectedName} {
+      library(branch: "downtown") {
+        books {
+          ... LibraryBook
+        }
+      }
+    }
+    fragment LibraryBook on Book {
+      title
+      author {
+        name
+      }
+    }`
+
+    const path = 'library.books.LibraryBook'
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart = `query/${expectedName}/${path}`
+
+      const metrics = [
+        `${OPERATION_PREFIX}/${operationPart}`,
+        `${RESOLVE_PREFIX}/library`,
+        `${RESOLVE_PREFIX}/books`,
+        `${RESOLVE_PREFIX}/author`,
+        `${TYPED_RESOLVE_PREFIX}/Query.library`,
+        `${TYPED_RESOLVE_PREFIX}/Library.books`,
+        `${TYPED_RESOLVE_PREFIX}/Book.author`
+      ]
+
+      if (captureFieldMetrics) {
+        const fieldMetrics = [
+          `${FIELD_PREFIX}/Query.library`,
+          `${ARG_PREFIX}/Query.library/branch`,
+          `${FIELD_PREFIX}/Library.books`,
+          `${FIELD_PREFIX}/Book.title`,
+          `${FIELD_PREFIX}/Book.author`,
+          `${FIELD_PREFIX}/Author.name`
+        ]
+
+        metrics.push(...fieldMetrics)
+      }
+
+      t.metrics(metrics)
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
+  t.test('named query with fragment, fragment first', (t) => {
+    const { helper, serverUrl } = t.context
+
+    const expectedName = 'GetBookForLibrary'
+    const query = `fragment LibraryBook on Book {
+      title
+      author {
+        name
+      }
+    }
+    query ${expectedName} {
+      library(branch: "downtown") {
+        books {
+          ... LibraryBook
+        }
+      }
+    }`
+
+    const path = 'library.books.LibraryBook'
+
+    helper.agent.once('transactionFinished', () => {
+      const operationPart = `query/${expectedName}/${path}`
+
+      const metrics = [
+        `${OPERATION_PREFIX}/${operationPart}`,
+        `${RESOLVE_PREFIX}/library`,
+        `${RESOLVE_PREFIX}/books`,
+        `${RESOLVE_PREFIX}/author`,
+        `${TYPED_RESOLVE_PREFIX}/Query.library`,
+        `${TYPED_RESOLVE_PREFIX}/Library.books`,
+        `${TYPED_RESOLVE_PREFIX}/Book.author`
+      ]
+
+      if (captureFieldMetrics) {
+        const fieldMetrics = [
+          `${FIELD_PREFIX}/Query.library`,
+          `${ARG_PREFIX}/Query.library/branch`,
+          `${FIELD_PREFIX}/Library.books`,
+          `${FIELD_PREFIX}/Book.title`,
+          `${FIELD_PREFIX}/Book.author`,
+          `${FIELD_PREFIX}/Author.name`
+        ]
+
+        metrics.push(...fieldMetrics)
+      }
+
+      t.metrics(metrics)
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+}
diff --git a/tests/versioned/apollo-server-express/metrics.test.js b/tests/versioned/apollo-server-express/metrics.test.js
new file mode 100644
index 0000000..62c41e4
--- /dev/null
+++ b/tests/versioned/apollo-server-express/metrics.test.js
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const { setupApolloServerExpressTests } = require('./apollo-server-express-setup')
+const metricsTests = require('../../metrics-tests')
+
+setupApolloServerExpressTests({
+  suiteName: 'metrics',
+  createTests: metricsTests.bind(null, false)
+})
+
+setupApolloServerExpressTests({
+  suiteName: 'capture field metrics',
+  createTests: metricsTests.bind(null, true),
+  pluginConfig: { captureFieldMetrics: true }
+})
diff --git a/tests/versioned/apollo-server-express/package.json b/tests/versioned/apollo-server-express/package.json
index 957bb7b..c9aa009 100644
--- a/tests/versioned/apollo-server-express/package.json
+++ b/tests/versioned/apollo-server-express/package.json
@@ -20,7 +20,8 @@
         "segments.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.tests.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     },
     {
@@ -38,7 +39,8 @@
         "segments.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.tests.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     }
   ],
diff --git a/tests/versioned/apollo-server-express/query-obfuscation.tests.js b/tests/versioned/apollo-server-express/query-obfuscation.test.js
similarity index 100%
rename from tests/versioned/apollo-server-express/query-obfuscation.tests.js
rename to tests/versioned/apollo-server-express/query-obfuscation.test.js
diff --git a/tests/versioned/apollo-server-fastify/metrics.test.js b/tests/versioned/apollo-server-fastify/metrics.test.js
new file mode 100644
index 0000000..ab8ae67
--- /dev/null
+++ b/tests/versioned/apollo-server-fastify/metrics.test.js
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const { setupApolloServerFastifyTests } = require('./apollo-server-fastify-setup')
+const metricsTests = require('../../metrics-tests')
+
+setupApolloServerFastifyTests({
+  suiteName: 'metrics',
+  createTests: metricsTests.bind(null, false)
+})
+
+setupApolloServerFastifyTests({
+  suiteName: 'capture field metrics',
+  createTests: metricsTests.bind(null, true),
+  pluginConfig: { captureFieldMetrics: true }
+})
diff --git a/tests/versioned/apollo-server-fastify/package.json b/tests/versioned/apollo-server-fastify/package.json
index 5f19da9..8d521ac 100644
--- a/tests/versioned/apollo-server-fastify/package.json
+++ b/tests/versioned/apollo-server-fastify/package.json
@@ -17,7 +17,8 @@
         "transaction-naming.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.test.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     }
   ],
diff --git a/tests/versioned/apollo-server-hapi/metrics.test.js b/tests/versioned/apollo-server-hapi/metrics.test.js
new file mode 100644
index 0000000..187c017
--- /dev/null
+++ b/tests/versioned/apollo-server-hapi/metrics.test.js
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const { setupApolloServerHapiTests } = require('./apollo-server-hapi-setup')
+const metricsTests = require('../../metrics-tests')
+
+setupApolloServerHapiTests({
+  suiteName: 'metrics',
+  createTests: metricsTests.bind(null, false)
+})
+
+setupApolloServerHapiTests({
+  suiteName: 'capture field metrics',
+  createTests: metricsTests.bind(null, true),
+  pluginConfig: { captureFieldMetrics: true }
+})
diff --git a/tests/versioned/apollo-server-hapi/package.json b/tests/versioned/apollo-server-hapi/package.json
index 6ecad43..c69a4cf 100644
--- a/tests/versioned/apollo-server-hapi/package.json
+++ b/tests/versioned/apollo-server-hapi/package.json
@@ -17,7 +17,8 @@
         "transaction-naming.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.test.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     },
     {
@@ -33,7 +34,8 @@
         "transaction-naming.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.test.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     }
   ],
diff --git a/tests/versioned/apollo-server-koa/metrics.test.js b/tests/versioned/apollo-server-koa/metrics.test.js
new file mode 100644
index 0000000..f352ce6
--- /dev/null
+++ b/tests/versioned/apollo-server-koa/metrics.test.js
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const { setupApolloServerKoaTests } = require('./apollo-server-koa-setup')
+const metricsTests = require('../../metrics-tests')
+
+setupApolloServerKoaTests({
+  suiteName: 'metrics',
+  createTests: metricsTests.bind(null, false)
+})
+
+setupApolloServerKoaTests({
+  suiteName: 'capture field metrics',
+  createTests: metricsTests.bind(null, true),
+  pluginConfig: { captureFieldMetrics: true }
+})
diff --git a/tests/versioned/apollo-server-koa/package.json b/tests/versioned/apollo-server-koa/package.json
index 25ca4ad..67bf407 100644
--- a/tests/versioned/apollo-server-koa/package.json
+++ b/tests/versioned/apollo-server-koa/package.json
@@ -16,7 +16,8 @@
         "transaction-naming.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.test.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     },
     {
@@ -32,7 +33,8 @@
         "transaction-naming.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.test.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     }
   ],
diff --git a/tests/versioned/apollo-server/metrics.test.js b/tests/versioned/apollo-server/metrics.test.js
new file mode 100644
index 0000000..f1f2312
--- /dev/null
+++ b/tests/versioned/apollo-server/metrics.test.js
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 New Relic Corporation. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+'use strict'
+
+const { setupApolloServerTests } = require('./apollo-server-setup')
+const metricsTests = require('../../metrics-tests')
+
+setupApolloServerTests({
+  suiteName: 'metrics',
+  createTests: metricsTests.bind(null, false)
+})
+
+setupApolloServerTests({
+  suiteName: 'capture field metrics',
+  createTests: metricsTests.bind(null, true),
+  pluginConfig: { captureFieldMetrics: true }
+})
diff --git a/tests/versioned/apollo-server/package.json b/tests/versioned/apollo-server/package.json
index c41113b..118b1f6 100644
--- a/tests/versioned/apollo-server/package.json
+++ b/tests/versioned/apollo-server/package.json
@@ -20,7 +20,8 @@
         "agent-disabled.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.test.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     },
     {
@@ -38,7 +39,8 @@
         "agent-disabled.test.js",
         "errors.test.js",
         "attributes.test.js",
-        "query-obfuscation.test.js"
+        "query-obfuscation.test.js",
+        "metrics.test.js"
       ]
     }
   ],
diff --git a/tests/versioned/attributes-tests.js b/tests/versioned/attributes-tests.js
index 86e4d5a..984cec6 100644
--- a/tests/versioned/attributes-tests.js
+++ b/tests/versioned/attributes-tests.js
@@ -366,6 +366,42 @@ function createAttributesTests(t) {
     })
   })
 
+  t.test('query should capture nested args', (t) => {
+    const { helper, serverUrl } = t.context
+
+    helper.agent.config.attributes.include = ['graphql.field.args.*']
+    helper.agent.config.emit('attributes.include')
+
+    const expectedName = 'BlahQuery'
+    const query = `query ${expectedName} {
+      searchByBook(book: { title: "Breaking production for dummies", author: { name: "10x Developer" }  } ) {
+        title
+        isbn
+      }
+    }`
+
+    helper.agent.once('transactionFinished', (transaction) => {
+      const operationName = `${OPERATION_PREFIX}/query/${expectedName}/searchByBook`
+
+      const operationSegment = findSegmentByName(transaction.trace.root, operationName)
+      const resolveHelloSegment = operationSegment.children[0]
+
+      const expectedArgAttributes = {
+        'graphql.field.args.book.author.name': '10x Developer',
+        'graphql.field.args.book.title': 'Breaking production for dummies'
+      }
+      const resolveAttributes = resolveHelloSegment.attributes.get(SEGMENT_DESTINATION)
+      const operationAttributes = operationSegment.attributes.get(SEGMENT_DESTINATION)
+      t.match(resolveAttributes, expectedArgAttributes)
+      t.match(operationAttributes, expectedArgAttributes)
+    })
+
+    executeQuery(serverUrl, query, (err) => {
+      t.error(err)
+      t.end()
+    })
+  })
+
   t.test('query with variables should capture args when added to include list', (t) => {
     const { helper, serverUrl } = t.context