Repository: FormidableLabs/urql
Pull request #325 (2019-06-27 13:05:52)

Add latency info to context
Adds a _latency_ property to a returning operation's context. This is the duration in milliseconds that the request took to complete.


Patch:
diff --git a/src/exchanges/fetch.ts b/src/exchanges/fetch.ts
index b54a6ea7..9208e6eb 100755
--- a/src/exchanges/fetch.ts
+++ b/src/exchanges/fetch.ts
@@ -2,7 +2,7 @@
 import { print } from 'graphql';
 import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
 import { Exchange, Operation, OperationResult } from '../types';
-import { CombinedError } from '../utils/error';
+import { addMetadata, CombinedError } from '../utils';
 
 /** A default exchange for fetching GraphQL requests. */
 export const fetchExchange: Exchange = ({ forward }) => {
@@ -75,9 +75,15 @@ const createFetchSource = (operation: Operation) => {
         abortController !== undefined ? abortController.signal : undefined,
     };
 
+    const startTime = Date.now();
     executeFetch(operation, fetchOptions).then(result => {
       if (result !== undefined) {
-        next(result);
+        next({
+          ...result,
+          operation: addMetadata(result.operation, {
+            networkLatency: Date.now() - startTime,
+          }),
+        });
       }
 
       complete();


Test patch:
diff --git a/src/exchanges/__snapshots__/fetch.test.ts.snap b/src/exchanges/__snapshots__/fetch.test.ts.snap
index ad804f92..13a0174b 100644
--- a/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`returns error data from fetch 1`] = `
+exports[`on error returns error data 1`] = `
 Object {
   "data": undefined,
   "error": [CombinedError: [Network] ],
@@ -9,6 +9,9 @@ Object {
       "fetchOptions": Object {
         "method": "POST",
       },
+      "meta": Object {
+        "networkLatency": 100,
+      },
       "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
     },
@@ -128,7 +131,7 @@ Object {
 }
 `;
 
-exports[`returns response data from fetch 1`] = `
+exports[`on success returns response data 1`] = `
 Object {
   "data": Object {
     "data": Object {
@@ -149,6 +152,9 @@ Object {
           },
         ],
       },
+      "meta": Object {
+        "networkLatency": 100,
+      },
       "requestPolicy": "cache-first",
       "url": "http://localhost:3000/graphql",
     },
diff --git a/src/exchanges/fetch.test.ts b/src/exchanges/fetch.test.ts
index b809ae2f..fc7d30f0 100755
--- a/src/exchanges/fetch.test.ts
+++ b/src/exchanges/fetch.test.ts
@@ -40,69 +40,114 @@ const exchangeArgs = {
   client: {} as Client,
 };
 
-it('returns response data from fetch', async () => {
-  fetch.mockResolvedValue({
-    status: 200,
-    json: jest.fn().mockResolvedValue(response),
+describe('on success', () => {
+  beforeEach(() => {
+    jest
+      .spyOn(Date, 'now')
+      .mockImplementationOnce(() => 100)
+      .mockImplementationOnce(() => 200);
+
+    fetch.mockResolvedValue({
+      status: 200,
+      json: jest.fn().mockResolvedValue(response),
+    });
   });
 
-  const fetchOptions = jest.fn().mockReturnValue({});
-
-  const data = await pipe(
-    fromValue({
-      ...queryOperation,
-      context: {
-        ...queryOperation.context,
-        fetchOptions,
-      },
-    }),
-    fetchExchange(exchangeArgs),
-    toPromise
-  );
-
-  expect(data).toMatchSnapshot();
-  expect(fetchOptions).toHaveBeenCalled();
+  it('returns response data', async () => {
+    const fetchOptions = jest.fn().mockReturnValue({});
+
+    const data = await pipe(
+      fromValue({
+        ...queryOperation,
+        context: {
+          ...queryOperation.context,
+          fetchOptions,
+        },
+      }),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
+    expect(fetchOptions).toHaveBeenCalled();
+  });
+
+  it('adds latency info to the context', async () => {
+    const data = await pipe(
+      fromValue({
+        ...queryOperation,
+        context: {
+          ...queryOperation.context,
+        },
+      }),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
+
+    expect(data.operation.context).toHaveProperty('meta.networkLatency', 100);
+  });
 });
 
-it('returns error data from fetch', async () => {
-  fetch.mockResolvedValue({
-    status: 400,
-    json: jest.fn().mockResolvedValue(response),
+describe('on error', () => {
+  beforeEach(() => {
+    jest
+      .spyOn(Date, 'now')
+      .mockImplementationOnce(() => 100)
+      .mockImplementationOnce(() => 200);
+
+    fetch.mockResolvedValue({
+      status: 400,
+      json: jest.fn().mockResolvedValue(response),
+    });
+  });
+
+  it('returns error data', async () => {
+    const data = await pipe(
+      fromValue(queryOperation),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
+
+    expect(data).toMatchSnapshot();
   });
 
-  const data = await pipe(
-    fromValue(queryOperation),
-    fetchExchange(exchangeArgs),
-    toPromise
-  );
+  it('returns error data', async () => {
+    const data = await pipe(
+      fromValue(queryOperation),
+      fetchExchange(exchangeArgs),
+      toPromise
+    );
 
-  expect(data).toMatchSnapshot();
+    expect(data.operation.context).toHaveProperty('meta.networkLatency', 100);
+  });
 });
 
-it('calls cancel when the Observable is cancelled', () => {
-  fetch.mockReturnValue(Promise.reject(abortError));
+describe('on teardown', () => {
+  it('aborts the outgoing request', () => {
+    fetch.mockReturnValue(Promise.reject(abortError));
 
-  const [unsubscribe] = pipe(
-    fromValue(queryOperation),
-    fetchExchange(exchangeArgs),
-    subscribe(fail)
-  );
+    const [unsubscribe] = pipe(
+      fromValue(queryOperation),
+      fetchExchange(exchangeArgs),
+      subscribe(fail)
+    );
 
-  unsubscribe();
-  expect(fetch).toHaveBeenCalledTimes(1);
-  expect(abort).toHaveBeenCalledTimes(1);
-});
+    unsubscribe();
+    expect(fetch).toHaveBeenCalledTimes(1);
+    expect(abort).toHaveBeenCalledTimes(1);
+  });
 
-it('should not start the operation when teardown gets passed', () => {
-  pipe(
-    fromValue({
-      ...queryOperation,
-      operationName: 'teardown' as OperationType,
-    }),
-    fetchExchange(exchangeArgs),
-    subscribe(fail)
-  );
-
-  expect(fetch).toHaveBeenCalledTimes(0);
-  expect(abort).toHaveBeenCalledTimes(0);
+  it('does not call the query', () => {
+    pipe(
+      fromValue({
+        ...queryOperation,
+        operationName: 'teardown' as OperationType,
+      }),
+      fetchExchange(exchangeArgs),
+      subscribe(fail)
+    );
+
+    expect(fetch).toHaveBeenCalledTimes(0);
+    expect(abort).toHaveBeenCalledTimes(0);
+  });
 });