Repository: FormidableLabs/urql
Pull request #608 (2020-03-12 16:44:45)

Add DebugEvent system to urql client for devtools and debugging
## About

There are a lot of moving parts going on in Urql exchanges, these include:

 - Operation specific events (i.e. “operation deduped”)
 - Exchange state events (i.e. “cache state has been changed”)

Currently we provide a means for communicating *some* operation specific events but not all. There are no means to communicate exchange specific events which are not coupled to an operation.

This proposal is the addition of an event target. It's a pub/sub mechanism for exchanges to publish the aforementioned events and for devtools, a user, or any other third party to consume.

## Usage

Listening to events in the console:

```js
const client = createClient({ ... });
client.debugTarget.addEventListener(console.log);
```

Passing the client to a third party library (such as devtools);

```js
myDevTool(client);
```

## Types

Types only apply if a known key is used. This prevents conflicts with core while also allowing for ambiguous types.

If third parties want to add a type, can use [declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html)


```ts
import { DebugEventTypes } from 'urql'

declare module "urql" {
  export interface DebugEventTypes {
    newType: { dataProp: string };
  }
}
```

## Todo

 - [x] Disable in prod
 - [ ] Add docs on debugging events
 - [x] Add babel plugin to remove debug events in prod bundles (cc @JoviDeCroock)

changeset-bot[bot]: ###  💥  No Changeset

Latest commit: fb91dd473f132482ebfa5b8a35255c96c5427173

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/add-event-support?filename=.changeset/nine-paws-begin.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A---%0A%0AAdd%20event%20support%20to%20urql%20client%0A)


andyrichardson: Cheers for the thoughts @wgolledge 

> I'm guessing this could, down the line, remove the need for the devtools exchange?

Yes totally, instead we could just pass the client to a devtools function.

> One more point on the event dispatching - I wonder whether it could make sense to have an optional dispatch system applied to every exchange that could be invoked (if the exchange desires) with a message and optional data (and the type if it can't be inferred)? It could save having to pass the client through exchanges 🤷‍♂

Initially I thought it would be a good idea to only pass the dispatch function but then realised that we already pass the client (which contains the EventTarget) so it felt pretty redundant.

I think most exchanges will end up with a function like this:

```tsx
const myExchange = ({ client, forward }) => {
  const publishMessage = (m) => client.eventTarget.dispatchEvent({ ...m, source: "myExchange" });
  // ...
}
```
wgolledge: Awesome - yep looks good as far as I can see! Will wait for @kitten and/or @JoviDeCroock to weigh in
andyrichardson: Update - 

A few example of an event we might see:
```js
{
  type: "fetch",
  message: "A network request has been made",
  operation,
}
{
  type: "refetch",
  message: "Attempting to refetch a failed network request",
  operation,
  data: { 
    retries: 1,
  }
}
```

Required attributes are now:
 - type (UID for event)
 - message (human readable description)
 - operation (associated operation)
 - data (optional additional data)


This also means we need to change our devtools implementation so that we no longer listen via an exchange but instead for events. That way we streamline every message to look similar to the above.
kitten: @andyrichardson this seems like the way to go :+1: in what cases do we need to provide additional data? It'd be great if we kept this more on the untyped side, so that adding new debugging events would be easy.

What I basically mean is, if we make this a `log` call (naming-wise as well as in how it _looks_ to contributors), then adding new calls could be useful in Jest / development as well as being very flexible when people go and add custom logging/debugging events in new exchanges.

So for instance, if someone goes and makes a `retryOrAuth` exchange, they may add 

```js
log('auth', 'Refreshing the current access token since it's expired);
```

So for them this would look just like `console.log`. It's also basically the same as what we currently do, we'd just allow for `type` to be raw strings as well.
andyrichardson: Thanks for the response @kitten!

> What I basically mean is, if we make this a log call (naming-wise as well as in how it looks to contributors), then adding new calls could be useful in Jest / development as well as being very flexible when people go and add custom logging/debugging events in new exchanges.

One of the things I want to really make clear with this is that it's an event bus for the purpose of debugging - not for logging events. While making it work like _console.log_ does make things easier to get started, I think it sets a precedent that doesn't really reflect what's going on. 

I wouldn't advise people use this for testing as it exposes implementation details. In a browser however, events could very easily be logged to the console.

```tsx
const client = createClient({ ... });
client.eventTarget.addEventListener(console.log);
```

Every exchange action is associated with an operation, hiding that information when dispatching the event leaves room for ambiguity (not something you want when debugging).

The implementation of the event target is basically a 1-for-1 copy of [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/EventTarget). If you want to trim things down a little, we could pass `dispatchEvent` as an argument to exchanges but I don't see too much benefit for that.

> It'd be great if we kept this more on the untyped side, so that adding new debugging events would be easy.

Have you tried dispatching a custom event? There's nothing stopping you from doing that - the types are there for type safety and to prevent conflicts between third-party exchanges 👍 
kitten: So regardless of this being associated with testing or debugging (those were just other use-cases that came to mind), I believe the `client.eventTarget.dispatchEvent` naming doesn't immediately indicate that this isn't a core part of the package / client's architecture.

So while the terms "info" or "logging" (the latter indeed being inaccurate) may not be what we want here, I think it may make sense to replace the term "event" here, e.g. `client.debugEvents.dispatch` or `client.info.dispatch`, etc.

Overall I believe it should be clear to someone using this or reading our exchanges that this isn't something they need to learn, or something that we have to mention explicitly in the "Concepts" docs to avoid confusion.

To us this is just an addition, but I think using the term "events" in this case without other _hints_ in the code to its purpose could make it be confused with streams, since we use the term "events" frequently to explain what streams/observables are. (ref: https://formidable.com/open-source/urql/docs/concepts/stream-patterns/)
andyrichardson: Sure thing @kitten I wasn't aware that your issue was with the use of the term `eventTarget` and I've renamed it to something less generic - `debugTarget`
andyrichardson: I've found a limitation of the current state notably being the lack of source.

We can either get this merged and add source support once I've had time to flesh it out or can hold this off for now.

Patch:
diff --git a/.changeset/early-camels-live.md b/.changeset/early-camels-live.md
new file mode 100644
index 00000000..8b26f939
--- /dev/null
+++ b/.changeset/early-camels-live.md
@@ -0,0 +1,9 @@
+---
+'@urql/exchange-graphcache': patch
+'@urql/exchange-multipart-fetch': patch
+'@urql/exchange-retry': patch
+'@urql/core': minor
+---
+
+Add debugging events to exchanges that add more detailed information on what is happening
+internally, which will be displayed by devtools like the urql [Chrome / Firefox extension](https://github.com/FormidableLabs/urql-devtools)
diff --git a/.changeset/few-trees-taste.md b/.changeset/few-trees-taste.md
deleted file mode 100644
index a5b9467e..00000000
--- a/.changeset/few-trees-taste.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@urql/exchange-graphcache': patch
-'@urql/exchange-populate': patch
----
-
-Fix @urql/exchange-populate visitWithTypeInfo import by bumping babel-plugin-modular-graphql
diff --git a/.changeset/honest-mangos-rule.md b/.changeset/honest-mangos-rule.md
new file mode 100644
index 00000000..2160984f
--- /dev/null
+++ b/.changeset/honest-mangos-rule.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix persistence using special tab character in serialized keys and add sanitization to persistence key serializer.
diff --git a/.changeset/stale-onions-check.md b/.changeset/stale-onions-check.md
new file mode 100644
index 00000000..733b757a
--- /dev/null
+++ b/.changeset/stale-onions-check.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': patch
+---
+
+Fix stringifyVariables breaking on x.toJSON scalars.
diff --git a/.changeset/young-eyes-call.md b/.changeset/young-eyes-call.md
deleted file mode 100644
index 67cf10be..00000000
--- a/.changeset/young-eyes-call.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@urql/svelte': minor
----
-
-Update `mutate()` API to accept an options argument, instead of separate arguments, to increase consistency
diff --git a/docs/advanced/retry-operations.md b/docs/advanced/retry-operations.md
index df1b46ab..a02fbf39 100644
--- a/docs/advanced/retry-operations.md
+++ b/docs/advanced/retry-operations.md
@@ -77,7 +77,6 @@ const client = createClient({
   exchanges: [
     dedupExchange,
     cacheExchange,
-    fetchExchange,
     retryExchange({
       retryIf: error => {
         if ((error && error.graphQLErrors.length > 0) || error.networkError) {
@@ -85,6 +84,7 @@ const client = createClient({
         }
       },
     }),
+    fetchExchange,
   ],
 });
 ```
diff --git a/docs/advanced/server-side-rendering.md b/docs/advanced/server-side-rendering.md
index f6c7db89..a6680212 100644
--- a/docs/advanced/server-side-rendering.md
+++ b/docs/advanced/server-side-rendering.md
@@ -34,7 +34,7 @@ const ssr = ssrExchange({
   initialState: !isServerSide ? window.__URQL_DATA__ : undefined,
 });
 
-const client createClient({
+const client = createClient({
   exchanges: [
     dedupExchange,
     cacheExchange,
diff --git a/docs/api/core.md b/docs/api/core.md
index 78517e03..a639b846 100644
--- a/docs/api/core.md
+++ b/docs/api/core.md
@@ -73,7 +73,7 @@ be conveniently converted to a promise.
 ```js
 import { pipe, subscribe } from 'wonka';
 
-const { subscribe } = pipe(
+const { unsubscribe } = pipe(
   client.query('{ test }', {
     /* vars */
   }),
@@ -87,6 +87,7 @@ client
   .query('{ test }', {
     /* vars */
   })
+  .toPromise()
   .then(result => {
     console.log(result); // OperationResult
   });
diff --git a/exchanges/graphcache/CHANGELOG.md b/exchanges/graphcache/CHANGELOG.md
index 36c222b7..502eca06 100644
--- a/exchanges/graphcache/CHANGELOG.md
+++ b/exchanges/graphcache/CHANGELOG.md
@@ -1,5 +1,13 @@
 # @urql/exchange-graphcache
 
+## 2.3.3
+
+### Patch Changes
+
+- ⚠️ Fix @urql/exchange-populate visitWithTypeInfo import by bumping babel-plugin-modular-graphql, by [@kitten](https://github.com/kitten) (See [#709](https://github.com/FormidableLabs/urql/pull/709))
+- Updated dependencies (See [#709](https://github.com/FormidableLabs/urql/pull/709))
+  - @urql/exchange-populate@0.1.6
+
 ## 2.3.2
 
 ### Patch Changes
diff --git a/exchanges/graphcache/examples/persisted-store/src/app/index.tsx b/exchanges/graphcache/examples/persisted-store/src/app/index.tsx
index 3512aebb..4754a4fe 100644
--- a/exchanges/graphcache/examples/persisted-store/src/app/index.tsx
+++ b/exchanges/graphcache/examples/persisted-store/src/app/index.tsx
@@ -12,8 +12,16 @@ const storage = {
     db = await openDB('myApplication', 1, {
       upgrade: db => db.createObjectStore('keyval'),
     });
-    const result = (await db.getAll('keyval')) as SerializedEntries;
-    return result;
+
+    return Promise.all([
+      db.getAllKeys("keyval"),
+      db.getAll("keyval"),
+    ]).then(([keys, values]) => {
+      return keys.reduce((acc: SerializedEntries, key, i) => {
+        acc[key] = values[i];
+        return acc;
+      }, {});
+    });
   },
   write: async batch => {
     for (const key in batch) {
diff --git a/exchanges/graphcache/package.json b/exchanges/graphcache/package.json
index 0b91fd17..230c2f82 100644
--- a/exchanges/graphcache/package.json
+++ b/exchanges/graphcache/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/exchange-graphcache",
-  "version": "2.3.2",
+  "version": "2.3.3",
   "description": "A normalized and configurable cache exchange for urql",
   "sideEffects": false,
   "homepage": "https://formidable.com/open-source/urql/docs/graphcache",
@@ -58,7 +58,7 @@
   },
   "dependencies": {
     "@urql/core": ">=1.10.9",
-    "@urql/exchange-populate": ">=0.1.5",
+    "@urql/exchange-populate": ">=0.1.6",
     "wonka": "^4.0.9"
   },
   "peerDependencies": {
diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts
index 88baeec6..0b5b823b 100644
--- a/exchanges/graphcache/src/cacheExchange.ts
+++ b/exchanges/graphcache/src/cacheExchange.ts
@@ -85,6 +85,7 @@ export interface CacheExchangeOpts {
 export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
   forward,
   client,
+  dispatchDebug,
 }) => {
   const store = new Store(opts);
 
@@ -305,7 +306,14 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
           res.operation.context.requestPolicy !== 'cache-only'
         );
       }),
-      map(res => addCacheOutcome(res.operation, 'miss'))
+      map(res => {
+        dispatchDebug({
+          type: 'cacheMiss',
+          message: 'The result could not be retrieved from the cache',
+          operation: res.operation,
+        });
+        return addCacheOutcome(res.operation, 'miss');
+      })
     );
 
     // Resolve OperationResults that the cache was able to assemble completely and trigger
@@ -338,6 +346,15 @@ export const cacheExchange = (opts?: CacheExchangeOpts): Exchange => ({
             );
           }
 
+          dispatchDebug({
+            type: 'cacheHit',
+            message: `A requested operation was found and returned from the cache.`,
+            operation: res.operation,
+            data: {
+              value: result,
+            },
+          });
+
           return result;
         }
       )
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index aac6a40d..92a60819 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -9,10 +9,10 @@ import {
 } from '../types';
 
 import {
+  serializeKeys,
+  deserializeKeyInfo,
   fieldInfoOfKey,
   joinKeys,
-  serializeKeys,
-  indexOfSeparator,
 } from './keys';
 
 import { makeDict } from '../helpers/dict';
@@ -543,18 +543,14 @@ export const persistData = () => {
     const entries: SerializedEntries = makeDict();
     currentIgnoreOptimistic = true;
     currentData!.persist.forEach(key => {
-      const sepIndex = indexOfSeparator(key);
-      if (sepIndex > -1) {
-        const entityKey = key.slice(0, sepIndex);
-        const fieldKey = key.slice(sepIndex + 1);
-        let x: void | Link | EntityField;
-        if ((x = readLink(entityKey, fieldKey)) !== undefined) {
-          entries[key] = `:${stringifyVariables(x)}`;
-        } else if ((x = readRecord(entityKey, fieldKey)) !== undefined) {
-          entries[key] = stringifyVariables(x);
-        } else {
-          entries[key] = undefined;
-        }
+      const { entityKey, fieldKey } = deserializeKeyInfo(key);
+      let x: void | Link | EntityField;
+      if ((x = readLink(entityKey, fieldKey)) !== undefined) {
+        entries[key] = `:${stringifyVariables(x)}`;
+      } else if ((x = readRecord(entityKey, fieldKey)) !== undefined) {
+        entries[key] = stringifyVariables(x);
+      } else {
+        entries[key] = undefined;
       }
     });
 
@@ -573,10 +569,8 @@ export const hydrateData = (
 
   for (const key in entries) {
     const value = entries[key];
-    const sepIndex = indexOfSeparator(key);
-    if (value && sepIndex > -1) {
-      const entityKey = key.slice(0, sepIndex);
-      const fieldKey = key.slice(sepIndex + 1);
+    if (value !== undefined) {
+      const { entityKey, fieldKey } = deserializeKeyInfo(key);
       if (value[0] === ':') {
         writeLink(entityKey, fieldKey, JSON.parse(value.slice(1)));
       } else {
diff --git a/exchanges/graphcache/src/store/keys.ts b/exchanges/graphcache/src/store/keys.ts
index e07ab9c0..bb6942b3 100644
--- a/exchanges/graphcache/src/store/keys.ts
+++ b/exchanges/graphcache/src/store/keys.ts
@@ -1,9 +1,12 @@
 import { stringifyVariables } from '@urql/core';
-import { Variables, FieldInfo } from '../types';
+import { Variables, FieldInfo, KeyInfo } from '../types';
 
 export const keyOfField = (fieldName: string, args?: null | Variables) =>
   args ? `${fieldName}(${stringifyVariables(args)})` : fieldName;
 
+export const joinKeys = (parentKey: string, key: string) =>
+  `${parentKey}.${key}`;
+
 export const fieldInfoOfKey = (fieldKey: string): FieldInfo => {
   const parenIndex = fieldKey.indexOf('(');
   if (parenIndex > -1) {
@@ -21,9 +24,12 @@ export const fieldInfoOfKey = (fieldKey: string): FieldInfo => {
   }
 };
 
-export const joinKeys = (parentKey: string, key: string) =>
-  `${parentKey}.${key}`;
-
 export const serializeKeys = (entityKey: string, fieldKey: string) =>
-  `${entityKey}\t${fieldKey}`;
-export const indexOfSeparator = (key: string) => key.indexOf('\t');
+  `${entityKey.replace(/\./g, '\\.')}.${fieldKey}`;
+
+export const deserializeKeyInfo = (key: string): KeyInfo => {
+  const dotIndex = key.indexOf('.');
+  const entityKey = key.slice(0, dotIndex).replace(/\\\./g, '.');
+  const fieldKey = key.slice(dotIndex + 1);
+  return { entityKey, fieldKey };
+};
diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts
index be57845a..b16fea07 100644
--- a/exchanges/graphcache/src/types.ts
+++ b/exchanges/graphcache/src/types.ts
@@ -45,6 +45,11 @@ export interface FieldInfo {
   arguments: Variables | null;
 }
 
+export interface KeyInfo {
+  entityKey: string;
+  fieldKey: string;
+}
+
 // This is an input operation
 export interface OperationRequest {
   query: DocumentNode;
diff --git a/exchanges/multipart-fetch/src/multipartFetchExchange.ts b/exchanges/multipart-fetch/src/multipartFetchExchange.ts
index 84b1c618..e8b872ce 100644
--- a/exchanges/multipart-fetch/src/multipartFetchExchange.ts
+++ b/exchanges/multipart-fetch/src/multipartFetchExchange.ts
@@ -1,7 +1,9 @@
 import { Kind, DocumentNode, OperationDefinitionNode, print } from 'graphql';
 import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
 import { extractFiles } from 'extract-files';
+
 import {
+  ExchangeInput,
   Exchange,
   Operation,
   OperationResult,
@@ -18,7 +20,10 @@ interface Body {
 const isOperationFetchable = (operation: Operation) =>
   operation.operationName === 'query' || operation.operationName === 'mutation';
 
-export const multipartFetchExchange: Exchange = ({ forward }) => ops$ => {
+export const multipartFetchExchange: Exchange = ({
+  forward,
+  dispatchDebug,
+}) => ops$ => {
   const sharedOps$ = share(ops$);
 
   const fetchResults$ = pipe(
@@ -36,7 +41,8 @@ export const multipartFetchExchange: Exchange = ({ forward }) => ops$ => {
         createFetchSource(
           operation,
           operation.operationName === 'query' &&
-            !!operation.context.preferGetMethod
+            !!operation.context.preferGetMethod,
+          dispatchDebug
         ),
         takeUntil(teardown$)
       );
@@ -62,7 +68,11 @@ const getOperationName = (query: DocumentNode): string | null => {
   return node && node.name ? node.name.value : null;
 };
 
-const createFetchSource = (operation: Operation, shouldUseGet: boolean) => {
+const createFetchSource = (
+  operation: Operation,
+  shouldUseGet: boolean,
+  dispatchDebug: ExchangeInput['dispatchDebug']
+) => {
   if (
     process.env.NODE_ENV !== 'production' &&
     operation.operationName === 'subscription'
@@ -143,7 +153,9 @@ const createFetchSource = (operation: Operation, shouldUseGet: boolean) => {
     let ended = false;
 
     Promise.resolve()
-      .then(() => (ended ? undefined : executeFetch(operation, fetchOptions)))
+      .then(() =>
+        ended ? undefined : executeFetch(operation, fetchOptions, dispatchDebug)
+      )
       .then((result: OperationResult | undefined) => {
         if (!ended) {
           ended = true;
@@ -163,12 +175,23 @@ const createFetchSource = (operation: Operation, shouldUseGet: boolean) => {
 
 const executeFetch = (
   operation: Operation,
-  opts: RequestInit
+  opts: RequestInit,
+  dispatchDebug: ExchangeInput['dispatchDebug']
 ): Promise<OperationResult> => {
   const { url, fetch: fetcher } = operation.context;
   let statusNotOk = false;
   let response: Response;
 
+  dispatchDebug({
+    type: 'fetchRequest',
+    message: 'A fetch request is being executed.',
+    operation,
+    data: {
+      url,
+      fetchOptions: opts,
+    },
+  });
+
   return (fetcher || fetch)(url, opts)
     .then((res: Response) => {
       response = res;
@@ -182,10 +205,34 @@ const executeFetch = (
         throw new Error('No Content');
       }
 
+      dispatchDebug({
+        type: result.errors && !result.data ? 'fetchError' : 'fetchSuccess',
+        message: `A ${
+          result.errors ? 'failed' : 'successful'
+        } fetch response has been returned.`,
+        operation,
+        data: {
+          url,
+          fetchOptions: opts,
+          value: result,
+        },
+      });
+
       return makeResult(operation, result, response);
     })
     .catch((error: Error) => {
       if (error.name !== 'AbortError') {
+        dispatchDebug({
+          type: 'fetchError',
+          message: error.name,
+          operation,
+          data: {
+            url,
+            fetchOptions: opts,
+            value: error,
+          },
+        });
+
         return makeErrorResult(
           operation,
           statusNotOk ? new Error(response.statusText) : error,
diff --git a/exchanges/populate/CHANGELOG.md b/exchanges/populate/CHANGELOG.md
index e64c9b27..9616f584 100644
--- a/exchanges/populate/CHANGELOG.md
+++ b/exchanges/populate/CHANGELOG.md
@@ -1,5 +1,11 @@
 # @urql/exchange-populate
 
+## 0.1.6
+
+### Patch Changes
+
+- ⚠️ Fix @urql/exchange-populate visitWithTypeInfo import by bumping babel-plugin-modular-graphql, by [@kitten](https://github.com/kitten) (See [#709](https://github.com/FormidableLabs/urql/pull/709))
+
 ## 0.1.5
 
 ### Patch Changes
diff --git a/exchanges/populate/package.json b/exchanges/populate/package.json
index 44deab12..0d10cabe 100644
--- a/exchanges/populate/package.json
+++ b/exchanges/populate/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/exchange-populate",
-  "version": "0.1.5",
+  "version": "0.1.6",
   "description": "An exchange that automaticcally populates the mutation selection body",
   "sideEffects": false,
   "homepage": "https://formidable.com/open-source/urql/docs/advanced/auto-populate-mutations",
diff --git a/exchanges/retry/src/retryExchange.ts b/exchanges/retry/src/retryExchange.ts
index 8fceeea4..681d05b2 100644
--- a/exchanges/retry/src/retryExchange.ts
+++ b/exchanges/retry/src/retryExchange.ts
@@ -41,7 +41,7 @@ export const retryExchange = ({
   const retryIf =
     retryIfOption || ((err: CombinedError) => err && err.networkError);
 
-  return ({ forward }) => ops$ => {
+  return ({ forward, dispatchDebug }) => ops$ => {
     const sharedOps$ = pipe(ops$, share);
     const { source: retry$, next: nextRetryOperation } = makeSubject<
       Operation
@@ -51,7 +51,7 @@ export const retryExchange = ({
       retry$,
       mergeMap((op: Operation) => {
         const { key, context } = op;
-        const retryCount = context.retryCount || 0;
+        const retryCount = (context.retryCount || 0) + 1;
         let delayAmount = context.retryDelay || MIN_DELAY;
 
         const backoffFactor = Math.random() + 1.5;
@@ -75,6 +75,15 @@ export const retryExchange = ({
           })
         );
 
+        dispatchDebug({
+          type: 'retryAttempt',
+          message: `The operation has failed and a retry has been triggered (${retryCount} / ${MAX_ATTEMPTS})`,
+          operation: op,
+          data: {
+            retryCount,
+          },
+        });
+
         // Add new retryDelay and retryCount to operation
         return pipe(
           fromValue({
@@ -82,7 +91,7 @@ export const retryExchange = ({
             context: {
               ...op.context,
               retryDelay: delayAmount,
-              retryCount: retryCount + 1,
+              retryCount,
             },
           }),
           delay(delayAmount),
@@ -97,18 +106,30 @@ export const retryExchange = ({
       forward,
       share,
       filter(res => {
-        const maxNumberAttemptsExceeded =
-          (res.operation.context.retryCount || 0) >= MAX_ATTEMPTS - 1;
         // Only retry if the error passes the conditional retryIf function (if passed)
         // or if the error contains a networkError
-        if (res.error && retryIf(res.error) && !maxNumberAttemptsExceeded) {
+        if (!res.error || !retryIf(res.error)) {
+          return true;
+        }
+
+        const maxNumberAttemptsExceeded =
+          (res.operation.context.retryCount || 0) >= MAX_ATTEMPTS - 1;
+
+        if (!maxNumberAttemptsExceeded) {
           // Send failed responses to be retried by calling next on the retry$ subject
           // Exclude operations that have been retried more than the specified max
           nextRetryOperation(res.operation);
           return false;
-        } else {
-          return true;
         }
+
+        dispatchDebug({
+          type: 'retryExhausted',
+          message:
+            'Maximum number of retries has been reached. No further retries will be performed.',
+          operation: res.operation,
+        });
+
+        return true;
       })
     ) as sourceT<OperationResult>;
 
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index c7b46c4a..e29e2dee 100755
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -17,16 +17,15 @@ import {
   publish,
   subscribe,
   map,
+  Subscription,
 } from 'wonka';
 
-import {
-  composeExchanges,
-  defaultExchanges,
-  fallbackExchangeIO,
-} from './exchanges';
+import { composeExchanges, defaultExchanges } from './exchanges';
+import { fallbackExchange } from './exchanges/fallback';
 
 import {
   Exchange,
+  ExchangeInput,
   GraphQLRequest,
   Operation,
   OperationContext,
@@ -34,6 +33,7 @@ import {
   OperationType,
   RequestPolicy,
   PromisifiedSource,
+  DebugEvent,
 } from './types';
 
 import {
@@ -41,6 +41,7 @@ import {
   toSuspenseSource,
   withPromise,
   maskTypename,
+  noop,
 } from './utils';
 
 import { DocumentNode } from 'graphql';
@@ -73,6 +74,9 @@ export const createClient = (opts: ClientOptions) => new Client(opts);
 
 /** The URQL application-wide client library. Each execute method starts a GraphQL request and returns a stream of results. */
 export class Client {
+  // Event target for monitoring
+  subscribeToDebugTarget?: (onEvent: (e: DebugEvent) => void) => Subscription;
+
   // These are variables derived from ClientOptions
   url: string;
   fetch?: typeof fetch;
@@ -94,6 +98,14 @@ export class Client {
       throw new Error('You are creating an urql-client without a url.');
     }
 
+    let dispatchDebug: ExchangeInput['dispatchDebug'] = noop;
+    if (process.env.NODE_ENV !== 'production') {
+      const { next, source } = makeSubject<DebugEvent>();
+      this.subscribeToDebugTarget = (onEvent: (e: DebugEvent) => void) =>
+        pipe(source, subscribe(onEvent));
+      dispatchDebug = next as ExchangeInput['dispatchDebug'];
+    }
+
     this.url = opts.url;
     this.fetchOptions = opts.fetchOptions;
     this.fetch = opts.fetch;
@@ -124,16 +136,19 @@ export class Client {
 
     const exchanges =
       opts.exchanges !== undefined ? opts.exchanges : defaultExchanges;
+
     // All exchange are composed into a single one and are called using the constructed client
     // and the fallback exchange stream
-    const exchange = composeExchanges(exchanges);
+    const composedExchange = composeExchanges(exchanges);
 
-    // All operations run through the exchanges in a pipeline-like fashion
-    // and this observable then combines all their results
+    // All exchanges receive inputs using which they can forward operations to the next exchange
+    // and receive a stream of results in return, access the client, or dispatch debugging events
+    // All operations then run through the Exchange IOs in a pipeline-like fashion
     this.results$ = share(
-      exchange({
+      composedExchange({
         client: this,
-        forward: fallbackExchangeIO,
+        dispatchDebug,
+        forward: fallbackExchange({ dispatchDebug }),
       })(this.operations$)
     );
 
diff --git a/packages/core/src/exchanges/cache.ts b/packages/core/src/exchanges/cache.ts
index fe5f0cf3..8df20c2e 100755
--- a/packages/core/src/exchanges/cache.ts
+++ b/packages/core/src/exchanges/cache.ts
@@ -2,7 +2,7 @@
 import { filter, map, merge, pipe, share, tap } from 'wonka';
 
 import { Client } from '../client';
-import { Exchange, Operation, OperationResult } from '../types';
+import { Exchange, Operation, OperationResult, ExchangeInput } from '../types';
 import {
   addMetadata,
   collectTypesFromResponse,
@@ -18,7 +18,7 @@ interface OperationCache {
 const shouldSkip = ({ operationName }: Operation) =>
   operationName !== 'mutation' && operationName !== 'query';
 
-export const cacheExchange: Exchange = ({ forward, client }) => {
+export const cacheExchange: Exchange = ({ forward, client, dispatchDebug }) => {
   const resultCache = new Map() as ResultCache;
   const operationCache = Object.create(null) as OperationCache;
 
@@ -31,7 +31,8 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
   const handleAfterMutation = afterMutation(
     resultCache,
     operationCache,
-    client
+    client,
+    dispatchDebug
   );
 
   const handleAfterQuery = afterQuery(resultCache, operationCache);
@@ -57,6 +58,20 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
       filter(op => !shouldSkip(op) && isOperationCached(op)),
       map(operation => {
         const cachedResult = resultCache.get(operation.key);
+
+        dispatchDebug({
+          operation,
+          ...(cachedResult
+            ? {
+                type: 'cacheHit',
+                message: 'The result was successfully retried from the cache',
+              }
+            : {
+                type: 'cacheMiss',
+                message: 'The result could not be retrieved from the cache',
+              }),
+        });
+
         const result: OperationResult = {
           ...cachedResult,
           operation: addMetadata(operation, {
@@ -126,15 +141,25 @@ const reexecuteOperation = (client: Client, operation: Operation) => {
 export const afterMutation = (
   resultCache: ResultCache,
   operationCache: OperationCache,
-  client: Client
+  client: Client,
+  dispatchDebug: ExchangeInput['dispatchDebug']
 ) => (response: OperationResult) => {
   const pendingOperations = new Set<number>();
   const { additionalTypenames } = response.operation.context;
 
-  [
+  const typenames = [
     ...collectTypesFromResponse(response.data),
     ...(additionalTypenames || []),
-  ].forEach(typeName => {
+  ];
+
+  dispatchDebug({
+    type: 'cacheInvalidation',
+    message: `The following typenames have been invalidated: ${typenames}`,
+    operation: response.operation,
+    data: { typenames, response },
+  });
+
+  typenames.forEach(typeName => {
     const operations =
       operationCache[typeName] || (operationCache[typeName] = new Set());
     operations.forEach(key => {
diff --git a/packages/core/src/exchanges/compose.ts b/packages/core/src/exchanges/compose.ts
index de6112ea..ef7626b7 100644
--- a/packages/core/src/exchanges/compose.ts
+++ b/packages/core/src/exchanges/compose.ts
@@ -1,14 +1,23 @@
-import { Exchange } from '../types';
+import { Exchange, ExchangeInput } from '../types';
 
 /** This composes an array of Exchanges into a single ExchangeIO function */
-export const composeExchanges = (exchanges: Exchange[]): Exchange => {
-  if (exchanges.length === 1) {
-    return exchanges[0];
-  }
-
-  return payload => {
-    return exchanges.reduceRight((forward, exchange) => {
-      return exchange({ client: payload.client, forward });
-    }, payload.forward);
-  };
-};
+export const composeExchanges = (exchanges: Exchange[]) => ({
+  client,
+  forward,
+  dispatchDebug,
+}: ExchangeInput) =>
+  exchanges.reduceRight(
+    (forward, exchange) =>
+      exchange({
+        client,
+        forward,
+        dispatchDebug(event) {
+          dispatchDebug({
+            ...event,
+            timestamp: Date.now(),
+            source: exchange.name,
+          });
+        },
+      }),
+    forward
+  );
diff --git a/packages/core/src/exchanges/dedup.ts b/packages/core/src/exchanges/dedup.ts
index 9db1a61c..f55ad44b 100644
--- a/packages/core/src/exchanges/dedup.ts
+++ b/packages/core/src/exchanges/dedup.ts
@@ -2,7 +2,7 @@ import { filter, pipe, tap } from 'wonka';
 import { Exchange, Operation, OperationResult } from '../types';
 
 /** A default exchange for debouncing GraphQL requests. */
-export const dedupExchange: Exchange = ({ forward }) => {
+export const dedupExchange: Exchange = ({ forward, dispatchDebug }) => {
   const inFlightKeys = new Set<number>();
 
   const filterIncomingOperation = (operation: Operation) => {
@@ -10,12 +10,23 @@ export const dedupExchange: Exchange = ({ forward }) => {
     if (operationName === 'teardown') {
       inFlightKeys.delete(key);
       return true;
-    } else if (operationName !== 'query' && operationName !== 'subscription') {
+    }
+
+    if (operationName !== 'query' && operationName !== 'subscription') {
       return true;
     }
 
     const isInFlight = inFlightKeys.has(key);
     inFlightKeys.add(key);
+
+    if (isInFlight) {
+      dispatchDebug({
+        type: 'dedup',
+        message: 'An operation has been deduped.',
+        operation,
+      });
+    }
+
     return !isInFlight;
   };
 
diff --git a/packages/core/src/exchanges/fallback.ts b/packages/core/src/exchanges/fallback.ts
index 7aa790cd..8be1c2b9 100644
--- a/packages/core/src/exchanges/fallback.ts
+++ b/packages/core/src/exchanges/fallback.ts
@@ -1,20 +1,34 @@
 import { filter, pipe, tap } from 'wonka';
-import { ExchangeIO, Operation } from '../types';
+import { Operation, ExchangeIO, ExchangeInput } from '../types';
+import { noop } from '../utils';
 
 /** This is always the last exchange in the chain; No operation should ever reach it */
-export const fallbackExchangeIO: ExchangeIO = ops$ =>
+export const fallbackExchange: ({
+  dispatchDebug,
+}: Pick<ExchangeInput, 'dispatchDebug'>) => ExchangeIO = ({
+  dispatchDebug,
+}) => ops$ =>
   pipe(
     ops$,
-    tap<Operation>(({ operationName }) => {
+    tap<Operation>(operation => {
       if (
-        operationName !== 'teardown' &&
+        operation.operationName !== 'teardown' &&
         process.env.NODE_ENV !== 'production'
       ) {
-        console.warn(
-          `No exchange has handled operations of type "${operationName}". Check whether you've added an exchange responsible for these operations.`
-        );
+        const message = `No exchange has handled operations of type "${operation.operationName}". Check whether you've added an exchange responsible for these operations.`;
+
+        dispatchDebug({
+          type: 'fallbackCatch',
+          message,
+          operation,
+        });
+        console.warn(message);
       }
     }),
     /* All operations that skipped through the entire exchange chain should be filtered from the output */
     filter<any>(() => false)
   );
+
+export const fallbackExchangeIO: ExchangeIO = fallbackExchange({
+  dispatchDebug: noop,
+});
diff --git a/packages/core/src/exchanges/fetch.ts b/packages/core/src/exchanges/fetch.ts
index dca0f4f6..e0181ce1 100755
--- a/packages/core/src/exchanges/fetch.ts
+++ b/packages/core/src/exchanges/fetch.ts
@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/no-use-before-define */
 import { Kind, DocumentNode, OperationDefinitionNode, print } from 'graphql';
 import { filter, make, merge, mergeMap, pipe, share, takeUntil } from 'wonka';
-import { Exchange, Operation, OperationResult } from '../types';
+import { Exchange, Operation, OperationResult, ExchangeInput } from '../types';
 import { makeResult, makeErrorResult } from '../utils';
 
 interface Body {
@@ -11,7 +11,7 @@ interface Body {
 }
 
 /** A default exchange for fetching GraphQL requests. */
-export const fetchExchange: Exchange = ({ forward }) => {
+export const fetchExchange: Exchange = ({ forward, dispatchDebug }) => {
   return ops$ => {
     const sharedOps$ = share(ops$);
     const fetchResults$ = pipe(
@@ -33,7 +33,8 @@ export const fetchExchange: Exchange = ({ forward }) => {
           createFetchSource(
             operation,
             operation.operationName === 'query' &&
-              !!operation.context.preferGetMethod
+              !!operation.context.preferGetMethod,
+            dispatchDebug
           ),
           takeUntil(teardown$)
         );
@@ -65,7 +66,11 @@ const getOperationName = (query: DocumentNode): string | null => {
   return node ? node.name!.value : null;
 };
 
-const createFetchSource = (operation: Operation, shouldUseGet: boolean) => {
+const createFetchSource = (
+  operation: Operation,
+  shouldUseGet: boolean,
+  dispatchDebug: ExchangeInput['dispatchDebug']
+) => {
   if (
     process.env.NODE_ENV !== 'production' &&
     operation.operationName === 'subscription'
@@ -118,7 +123,9 @@ const createFetchSource = (operation: Operation, shouldUseGet: boolean) => {
     let ended = false;
 
     Promise.resolve()
-      .then(() => (ended ? undefined : executeFetch(operation, fetchOptions)))
+      .then(() =>
+        ended ? undefined : executeFetch(operation, fetchOptions, dispatchDebug)
+      )
       .then((result: OperationResult | undefined) => {
         if (!ended) {
           ended = true;
@@ -138,12 +145,23 @@ const createFetchSource = (operation: Operation, shouldUseGet: boolean) => {
 
 const executeFetch = (
   operation: Operation,
-  opts: RequestInit
+  opts: RequestInit,
+  dispatchDebug: ExchangeInput['dispatchDebug']
 ): Promise<OperationResult> => {
   const { url, fetch: fetcher } = operation.context;
   let statusNotOk = false;
   let response: Response;
 
+  dispatchDebug({
+    type: 'fetchRequest',
+    message: 'A fetch request is being executed.',
+    operation,
+    data: {
+      url,
+      fetchOptions: opts,
+    },
+  });
+
   return (fetcher || fetch)(url, opts)
     .then((res: Response) => {
       response = res;
@@ -157,10 +175,34 @@ const executeFetch = (
         throw new Error('No Content');
       }
 
+      dispatchDebug({
+        type: result.errors && !result.data ? 'fetchError' : 'fetchSuccess',
+        message: `A ${
+          result.errors ? 'failed' : 'successful'
+        } fetch response has been returned.`,
+        operation,
+        data: {
+          url,
+          fetchOptions: opts,
+          value: result,
+        },
+      });
+
       return makeResult(operation, result, response);
     })
     .catch((error: Error) => {
       if (error.name !== 'AbortError') {
+        dispatchDebug({
+          type: 'fetchError',
+          message: error.name,
+          operation,
+          data: {
+            url,
+            fetchOptions: opts,
+            value: error,
+          },
+        });
+
         return makeErrorResult(
           operation,
           statusNotOk ? new Error(response.statusText) : error,
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 8bc087f2..ed8a851d 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -5,9 +5,6 @@ import { CombinedError } from './utils/error';
 
 export { ExecutionResult } from 'graphql';
 
-/** Utility type to Omit keys from an interface/object type */
-export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
-
 export type PromisifiedSource<T = any> = Source<T> & {
   toPromise: () => Promise<T>;
 };
@@ -76,8 +73,11 @@ export interface OperationResult<Data = any> {
 
 /** Input parameters for to an Exchange factory function. */
 export interface ExchangeInput {
-  forward: ExchangeIO;
   client: Client;
+  forward: ExchangeIO;
+  dispatchDebug: <T extends keyof DebugEventTypes | string>(
+    t: DebugEventArg<T>
+  ) => void;
 }
 
 /** Function responsible for listening for streamed [operations]{@link Operation}. */
@@ -85,3 +85,47 @@ export type Exchange = (input: ExchangeInput) => ExchangeIO;
 
 /** Function responsible for receiving an observable [operation]{@link Operation} and returning a [result]{@link OperationResult}. */
 export type ExchangeIO = (ops$: Source<Operation>) => Source<OperationResult>;
+
+/** Debug event types (interfaced for declaration merging). */
+export interface DebugEventTypes {
+  // Cache exchange
+  cacheHit: { value: any };
+  cacheInvalidation: {
+    typenames: string[];
+    response: OperationResult;
+  };
+  // Fetch exchange
+  fetchRequest: {
+    url: string;
+    fetchOptions: RequestInit;
+  };
+  fetchSuccess: {
+    url: string;
+    fetchOptions: RequestInit;
+    value: object;
+  };
+  fetchError: {
+    url: string;
+    fetchOptions: RequestInit;
+    value: Error;
+  };
+  // Retry exchange
+  retryRetrying: {
+    retryCount: number;
+  };
+}
+
+export type DebugEventArg<T extends keyof DebugEventTypes | string> = {
+  type: T;
+  message: string;
+  operation: Operation;
+} & (T extends keyof DebugEventTypes
+  ? { data: DebugEventTypes[T] }
+  : { data?: any });
+
+export type DebugEvent<
+  T extends keyof DebugEventTypes | string = string
+> = DebugEventArg<T> & {
+  timestamp: number;
+  source: string;
+};
diff --git a/packages/core/src/utils/stringifyVariables.ts b/packages/core/src/utils/stringifyVariables.ts
index 493cb42c..d8ce554e 100644
--- a/packages/core/src/utils/stringifyVariables.ts
+++ b/packages/core/src/utils/stringifyVariables.ts
@@ -7,7 +7,7 @@ const stringify = (x: any): string => {
   } else if (typeof x !== 'object') {
     return JSON.stringify(x) || '';
   } else if (x.toJSON) {
-    return x.toJSON();
+    return stringify(x.toJSON());
   } else if (Array.isArray(x)) {
     let out = '[';
     for (let i = 0, l = x.length; i < l; i++) {
diff --git a/packages/svelte-urql/CHANGELOG.md b/packages/svelte-urql/CHANGELOG.md
index d8dea1c3..9307eb0a 100644
--- a/packages/svelte-urql/CHANGELOG.md
+++ b/packages/svelte-urql/CHANGELOG.md
@@ -1,5 +1,11 @@
 # @urql/svelte
 
+## 0.2.0
+
+### Minor Changes
+
+- Update `mutate()` API to accept an options argument, instead of separate arguments, to increase consistency, by [@kitten](https://github.com/kitten) (See [#705](https://github.com/FormidableLabs/urql/pull/705))
+
 ## 0.1.3
 
 ### Patch Changes
diff --git a/packages/svelte-urql/package.json b/packages/svelte-urql/package.json
index c861f1f4..7bb4ff7d 100644
--- a/packages/svelte-urql/package.json
+++ b/packages/svelte-urql/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@urql/svelte",
-  "version": "0.1.3",
+  "version": "0.2.0",
   "description": "A highly customizable and versatile GraphQL client for Svelte",
   "sideEffects": false,
   "homepage": "https://formidable.com/open-source/urql/docs/",
diff --git a/scripts/babel/transform-debug-target.js b/scripts/babel/transform-debug-target.js
new file mode 100644
index 00000000..1301b64e
--- /dev/null
+++ b/scripts/babel/transform-debug-target.js
@@ -0,0 +1,30 @@
+const dispatchProperty = 'dispatchDebug';
+const visited = 'visitedByDebugTargetTransformer';
+
+const warningDevCheckTemplate = `
+  process.env.NODE_ENV !== 'production' ? NODE : undefined
+`.trim();
+
+const plugin = ({ template, types: t }) => {
+  const wrapWithDevCheck = template.expression(
+    warningDevCheckTemplate,
+    { placeholderPattern: /^NODE$/ }
+  );
+
+  return {
+    visitor: {
+      CallExpression(path) {
+        if (
+          !path.node[visited] &&
+          path.node.callee &&
+          path.node.callee.name === dispatchProperty
+        ) {
+          path.node[visited] = true;
+          path.replaceWith(wrapWithDevCheck({ NODE: path.node }));
+        }
+      }
+    }
+  };
+};
+
+export default plugin;
diff --git a/scripts/rollup/plugins.js b/scripts/rollup/plugins.js
index b4e607ec..c12ba34d 100644
--- a/scripts/rollup/plugins.js
+++ b/scripts/rollup/plugins.js
@@ -13,6 +13,7 @@ import { terser } from 'rollup-plugin-terser';
 
 import babelPluginTransformPipe from '../babel/transform-pipe';
 import babelPluginTransformInvariant from '../babel/transform-invariant-warning';
+import babelPluginTransformDebugTarget from '../babel/transform-debug-target';
 
 import * as settings from './settings';
 
@@ -69,6 +70,7 @@ export const makePlugins = ({ isProduction } = {}) => [
     exclude: 'node_modules/**',
     presets: [],
     plugins: [
+      babelPluginTransformDebugTarget,
       babelPluginTransformPipe,
       babelPluginTransformInvariant,
       'babel-plugin-modular-graphql',


Test patch:
diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts
index b3f10849..8bfc0550 100644
--- a/exchanges/graphcache/src/cacheExchange.test.ts
+++ b/exchanges/graphcache/src/cacheExchange.test.ts
@@ -38,6 +38,8 @@ const queryOneData = {
   },
 };
 
+const dispatchDebug = jest.fn();
+
 describe('data dependencies', () => {
   it('writes queries to the cache', () => {
     const client = createClient({ url: 'http://0.0.0.0' });
@@ -57,7 +59,11 @@ describe('data dependencies', () => {
     const result = jest.fn();
     const forward: ExchangeIO = ops$ => pipe(ops$, map(response));
 
-    pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish);
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
 
     next(op);
     next(op);
@@ -98,7 +104,11 @@ describe('data dependencies', () => {
     const result = jest.fn();
     const forward: ExchangeIO = ops$ => pipe(ops$, map(response));
 
-    pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish);
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
 
     next(op);
     expect(response).toHaveBeenCalledTimes(0);
@@ -165,7 +175,11 @@ describe('data dependencies', () => {
     const forward: ExchangeIO = ops$ => pipe(ops$, map(response));
     const result = jest.fn();
 
-    pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish);
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
 
     next(opOne);
     expect(response).toHaveBeenCalledTimes(1);
@@ -308,7 +322,9 @@ describe('data dependencies', () => {
     };
 
     pipe(
-      cacheExchange({ updates, keys })({ forward, client })(ops$),
+      cacheExchange({ updates, keys })({ forward, client, dispatchDebug })(
+        ops$
+      ),
       tap(result),
       publish
     );
@@ -385,7 +401,11 @@ describe('data dependencies', () => {
     const forward: ExchangeIO = ops$ => pipe(ops$, map(response));
     const result = jest.fn();
 
-    pipe(cacheExchange({})({ forward, client })(ops$), tap(result), publish);
+    pipe(
+      cacheExchange({})({ forward, client, dispatchDebug })(ops$),
+      tap(result),
+      publish
+    );
 
     next(opOne);
     expect(response).toHaveBeenCalledTimes(1);
@@ -441,7 +461,7 @@ describe('data dependencies', () => {
     };
 
     pipe(
-      cacheExchange({ updates })({ forward, client })(ops$),
+      cacheExchange({ updates })({ forward, client, dispatchDebug })(ops$),
       tap(result),
       publish
     );
@@ -501,7 +521,11 @@ describe('data dependencies', () => {
     };
 
     pipe(
-      cacheExchange({ updates, optimistic })({ forward, client })(ops$),
+      cacheExchange({ updates, optimistic })({
+        forward,
+        client,
+        dispatchDebug,
+      })(ops$),
       tap(result),
       publish
     );
@@ -562,7 +586,11 @@ describe('data dependencies', () => {
     };
 
     pipe(
-      cacheExchange({ updates, optimistic })({ forward, client })(ops$),
+      cacheExchange({ updates, optimistic })({
+        forward,
+        client,
+        dispatchDebug,
+      })(ops$),
       tap(result),
       publish
     );
@@ -649,7 +677,7 @@ describe('optimistic updates', () => {
     };
 
     pipe(
-      cacheExchange({ optimistic })({ forward, client })(ops$),
+      cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$),
       tap(result),
       publish
     );
@@ -761,7 +789,11 @@ describe('optimistic updates', () => {
     };
 
     pipe(
-      cacheExchange({ optimistic, updates })({ forward, client })(ops$),
+      cacheExchange({ optimistic, updates })({
+        forward,
+        client,
+        dispatchDebug,
+      })(ops$),
       tap(result),
       publish
     );
@@ -818,7 +850,7 @@ describe('custom resolvers', () => {
             },
           },
         },
-      })({ forward, client })(ops$),
+      })({ forward, client, dispatchDebug })(ops$),
       tap(result),
       publish
     );
@@ -899,7 +931,7 @@ describe('custom resolvers', () => {
             },
           },
         },
-      })({ forward, client })(ops$),
+      })({ forward, client, dispatchDebug })(ops$),
       tap(result),
       publish
     );
@@ -1049,7 +1081,7 @@ describe('custom resolvers', () => {
             },
           },
         },
-      })({ forward, client })(ops$),
+      })({ forward, client, dispatchDebug })(ops$),
       tap(result),
       publish
     );
@@ -1180,7 +1212,7 @@ describe('schema awareness', () => {
       cacheExchange({
         // eslint-disable-next-line
         schema: require('./test-utils/simple_schema.json'),
-      })({ forward, client })(ops$),
+      })({ forward, client, dispatchDebug })(ops$),
       tap(result),
       publish
     );
@@ -1282,7 +1314,11 @@ describe('commutativity', () => {
         mergeMap(result)
       );
 
-    pipe(cacheExchange()({ forward, client })(ops$), tap(output), publish);
+    pipe(
+      cacheExchange()({ forward, client, dispatchDebug })(ops$),
+      tap(output),
+      publish
+    );
 
     next(client.createRequestOperation('query', { key: 1, query }));
     next(client.createRequestOperation('query', { key: 2, query }));
@@ -1351,7 +1387,7 @@ describe('commutativity', () => {
     };
 
     pipe(
-      cacheExchange({ optimistic })({ forward, client })(ops$),
+      cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$),
       tap(result => {
         if (result.operation.operationName === 'query') {
           data = result.data;
@@ -1445,7 +1481,7 @@ describe('commutativity', () => {
       ]);
 
     pipe(
-      cacheExchange()({ forward, client })(ops$),
+      cacheExchange()({ forward, client, dispatchDebug })(ops$),
       tap(result => {
         if (result.operation.operationName === 'query') {
           data = result.data;
@@ -1557,7 +1593,7 @@ describe('commutativity', () => {
     };
 
     pipe(
-      cacheExchange({ optimistic })({ forward, client })(ops$),
+      cacheExchange({ optimistic })({ forward, client, dispatchDebug })(ops$),
       tap(result => {
         if (result.operation.operationName === 'query') {
           data = result.data;
@@ -1642,7 +1678,7 @@ describe('commutativity', () => {
       ]);
 
     pipe(
-      cacheExchange()({ forward, client })(ops$),
+      cacheExchange()({ forward, client, dispatchDebug })(ops$),
       tap(result => {
         if (result.operation.operationName === 'query') {
           data = result.data;
@@ -1733,7 +1769,7 @@ describe('commutativity', () => {
       ]);
 
     pipe(
-      cacheExchange()({ forward, client })(ops$),
+      cacheExchange()({ forward, client, dispatchDebug })(ops$),
       tap(result => {
         if (result.operation.operationName === 'query') {
           data = result.data;
diff --git a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
index 9fdebaf7..414ef8dc 100644
--- a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
+++ b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
@@ -2,9 +2,9 @@
 
 exports[`Store with storage should be able to store and rehydrate data 1`] = `
 Object {
-  "Appointment:1	__typename": "\\"Appointment\\"",
-  "Appointment:1	id": "\\"1\\"",
-  "Appointment:1	info": "\\"urql meeting\\"",
-  "Query	appointment({\\"id\\":\\"1\\"})": ":\\"Appointment:1\\"",
+  "Appointment:1.__typename": "\\"Appointment\\"",
+  "Appointment:1.id": "\\"1\\"",
+  "Appointment:1.info": "\\"urql meeting\\"",
+  "Query.appointment({\\"id\\":\\"1\\"})": ":\\"Appointment:1\\"",
 }
 `;
diff --git a/exchanges/graphcache/src/store/store.test.ts b/exchanges/graphcache/src/store/store.test.ts
index f95ada5c..6f221e28 100644
--- a/exchanges/graphcache/src/store/store.test.ts
+++ b/exchanges/graphcache/src/store/store.test.ts
@@ -568,7 +568,7 @@ describe('Store with storage', () => {
     const serialisedStore = (storage.write as any).mock.calls[0][0];
 
     expect(serialisedStore).toEqual({
-      'Query\tbase': 'true',
+      'Query.base': 'true',
     });
 
     store = new Store();
diff --git a/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts b/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
index d25b2f9f..3b188b68 100644
--- a/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
+++ b/exchanges/multipart-fetch/src/multipartFetchExchange.test.ts
@@ -44,6 +44,7 @@ const response = {
 const exchangeArgs = {
   forward: () => empty as Source<OperationResult>,
   client: {} as Client,
+  dispatchDebug: jest.fn(),
 };
 
 describe('on success', () => {
diff --git a/exchanges/populate/src/populateExchange.test.ts b/exchanges/populate/src/populateExchange.test.ts
index aef29e11..ad1bf977 100644
--- a/exchanges/populate/src/populateExchange.test.ts
+++ b/exchanges/populate/src/populateExchange.test.ts
@@ -66,6 +66,7 @@ beforeEach(jest.clearAllMocks);
 const exchangeArgs = {
   forward: a => a as any,
   client: {} as Client,
+  dispatchDebug: jest.fn(),
 };
 
 describe('on mutation', () => {
diff --git a/exchanges/retry/src/retryExchange.test.ts b/exchanges/retry/src/retryExchange.test.ts
index 1218885c..449c7425 100644
--- a/exchanges/retry/src/retryExchange.test.ts
+++ b/exchanges/retry/src/retryExchange.test.ts
@@ -10,6 +10,8 @@ import {
 } from '@urql/core';
 import { retryExchange } from './retryExchange';
 
+const dispatchDebug = jest.fn();
+
 beforeEach(() => {
   jest.useFakeTimers();
 });
@@ -105,6 +107,7 @@ it(`retries if it hits an error and works for multiple concurrent operations`, (
     })({
       forward,
       client,
+      dispatchDebug,
     })(ops$),
     tap(result),
     publish
@@ -163,6 +166,7 @@ it('should retry x number of times and then return the successful result', () =>
     })({
       forward,
       client,
+      dispatchDebug,
     })(ops$),
     tap(result),
     publish
@@ -207,6 +211,7 @@ it(`should still retry if retryIf undefined but there is a networkError`, () =>
     })({
       forward,
       client,
+      dispatchDebug,
     })(ops$),
     tap(result),
     publish
diff --git a/exchanges/suspense/src/suspenseExchange.test.ts b/exchanges/suspense/src/suspenseExchange.test.ts
index 70af7927..666496ef 100644
--- a/exchanges/suspense/src/suspenseExchange.test.ts
+++ b/exchanges/suspense/src/suspenseExchange.test.ts
@@ -12,6 +12,8 @@ import {
 import { createClient, Operation, OperationResult } from 'urql';
 import { suspenseExchange } from './suspenseExchange';
 
+const dispatchDebug = jest.fn();
+
 beforeEach(() => {
   jest.useFakeTimers();
 });
@@ -28,7 +30,7 @@ it('logs a warning if suspense mode is not activated', () => {
   const forward = jest.fn(() => fromArray([]));
   const ops = fromArray([]);
 
-  suspenseExchange({ client, forward })(ops);
+  suspenseExchange({ client, forward, dispatchDebug })(ops);
   expect(forward).toHaveBeenCalledWith(ops);
   expect(warn).toHaveBeenCalled();
   warn.mockRestore();
@@ -47,7 +49,7 @@ it('forwards skipped operations', () => {
     );
 
   const res = pipe(
-    suspenseExchange({ client, forward })(fromValue(operation)),
+    suspenseExchange({ client, forward, dispatchDebug })(fromValue(operation)),
     toArray
   );
 
@@ -69,7 +71,7 @@ it('resolves synchronous results immediately', () => {
   const { source: ops, next: dispatch } = makeSubject<Operation>();
 
   pipe(
-    suspenseExchange({ client, forward })(ops),
+    suspenseExchange({ client, forward, dispatchDebug })(ops),
     forEach(result => (prevResult = result))
   );
 
@@ -99,7 +101,7 @@ it('caches asynchronous results once for suspense', () => {
   const { source: ops, next: dispatch } = makeSubject<Operation>();
 
   pipe(
-    suspenseExchange({ client, forward })(ops),
+    suspenseExchange({ client, forward, dispatchDebug })(ops),
     forEach(result => (prevResult = result))
   );
 
diff --git a/packages/core/src/__snapshots__/client.test.ts.snap b/packages/core/src/__snapshots__/client.test.ts.snap
index e8d83e74..88745fc4 100644
--- a/packages/core/src/__snapshots__/client.test.ts.snap
+++ b/packages/core/src/__snapshots__/client.test.ts.snap
@@ -18,6 +18,7 @@ Client {
   "reexecuteOperation": [Function],
   "requestPolicy": "cache-first",
   "results$": [Function],
+  "subscribeToDebugTarget": [Function],
   "suspense": false,
   "url": "https://hostname.com",
 }
diff --git a/packages/core/src/exchanges/cache.test.ts b/packages/core/src/exchanges/cache.test.ts
index 1257e944..8715ee22 100755
--- a/packages/core/src/exchanges/cache.test.ts
+++ b/packages/core/src/exchanges/cache.test.ts
@@ -19,19 +19,22 @@ import {
   subscriptionResult,
   undefinedQueryResponse,
 } from '../test-utils';
-import { Operation, OperationResult } from '../types';
+import { Operation, OperationResult, ExchangeInput } from '../types';
 import { afterMutation, cacheExchange } from './cache';
 
+const reexecuteOperation = jest.fn();
+const dispatchDebug = jest.fn();
+
 let response;
-let exchangeArgs;
+let exchangeArgs: ExchangeInput;
 let forwardedOperations: Operation[];
-let reexecuteOperation;
 let input: Subject<Operation>;
 
+beforeEach(jest.clearAllMocks);
+
 beforeEach(() => {
   response = queryResponse;
   forwardedOperations = [];
-  reexecuteOperation = jest.fn();
   input = makeSubject<Operation>();
 
   // Collect all forwarded operations
@@ -49,7 +52,7 @@ beforeEach(() => {
     reexecuteOperation: reexecuteOperation as any,
   } as Client;
 
-  exchangeArgs = { forward, client };
+  exchangeArgs = { forward, client, dispatchDebug };
 });
 
 describe('on query', () => {
@@ -182,7 +185,8 @@ describe('on mutation', () => {
     afterMutation(
       resultCache,
       operationCache,
-      exchangeArgs.client
+      exchangeArgs.client,
+      dispatchDebug
     )({
       ...mutationResponse,
       data: {
@@ -206,7 +210,8 @@ describe('on mutation', () => {
     afterMutation(
       resultCache,
       operationCache,
-      exchangeArgs.client
+      exchangeArgs.client,
+      dispatchDebug
     )({
       ...mutationResponse,
       operation: {
@@ -250,7 +255,6 @@ describe('on empty query response', () => {
   beforeEach(() => {
     response = undefinedQueryResponse;
     forwardedOperations = [];
-    reexecuteOperation = jest.fn();
     input = makeSubject<Operation>();
 
     // Collect all forwarded operations
@@ -268,7 +272,7 @@ describe('on empty query response', () => {
       reexecuteOperation: reexecuteOperation as any,
     } as Client;
 
-    exchangeArgs = { forward, client };
+    exchangeArgs = { forward, client, dispatchDebug };
   });
 
   it('does not cache response', () => {
diff --git a/packages/core/src/exchanges/compose.test.ts b/packages/core/src/exchanges/compose.test.ts
index 8666c962..e02e251a 100644
--- a/packages/core/src/exchanges/compose.test.ts
+++ b/packages/core/src/exchanges/compose.test.ts
@@ -1,13 +1,15 @@
 import { empty, Source } from 'wonka';
-import { Client } from '../client';
 import { Exchange } from '../types';
 import { composeExchanges } from './compose';
+import { noop } from '../utils';
 
-const mockClient = {} as Client;
+const mockClient = {} as any;
+
+const forward = jest.fn();
 const noopExchange: Exchange = ({ forward }) => ops$ => forward(ops$);
 
-it('returns the first exchange if it is the only input', () => {
-  expect(composeExchanges([noopExchange])).toBe(noopExchange);
+beforeEach(() => {
+  jest.spyOn(Date, 'now').mockReturnValue(1234);
 });
 
 it('composes exchanges correctly', () => {
@@ -36,7 +38,37 @@ it('composes exchanges correctly', () => {
   const exchange = composeExchanges([firstExchange, secondExchange]);
   const outerFw = jest.fn(() => noopExchange) as any;
 
-  exchange({ client: mockClient, forward: outerFw })(empty as Source<any>);
+  exchange({ client: mockClient, forward: outerFw, dispatchDebug: noop })(
+    empty as Source<any>
+  );
   expect(outerFw).toHaveBeenCalled();
   expect(counter).toBe(4);
 });
+
+describe('on dispatchDebug', () => {
+  it('dispatches debug event with exchange source name', () => {
+    const dispatchDebug = jest.fn();
+    const debugArgs = {
+      type: 'test',
+      message: 'Hello',
+    } as any;
+
+    const testExchange: Exchange = ({ dispatchDebug }) => {
+      dispatchDebug(debugArgs);
+      return () => empty as Source<any>;
+    };
+
+    composeExchanges([testExchange])({
+      client: mockClient,
+      forward,
+      dispatchDebug,
+    });
+
+    expect(dispatchDebug).toBeCalledTimes(1);
+    expect(dispatchDebug).toBeCalledWith({
+      ...debugArgs,
+      timestamp: Date.now(),
+      source: 'testExchange',
+    });
+  });
+});
diff --git a/packages/core/src/exchanges/dedup.test.ts b/packages/core/src/exchanges/dedup.test.ts
index 0532e549..c59606bb 100644
--- a/packages/core/src/exchanges/dedup.test.ts
+++ b/packages/core/src/exchanges/dedup.test.ts
@@ -7,7 +7,6 @@ import {
   Source,
   Subject,
 } from 'wonka';
-import { Client } from '../client';
 import {
   mutationOperation,
   queryOperation,
@@ -16,6 +15,7 @@ import {
 import { Operation } from '../types';
 import { dedupExchange } from './dedup';
 
+const dispatchDebug = jest.fn();
 let shouldRespond = false;
 let exchangeArgs;
 let forwardedOperations: Operation[];
@@ -38,7 +38,7 @@ beforeEach(() => {
     );
   };
 
-  exchangeArgs = { forward, subject: {} as Client };
+  exchangeArgs = { forward, client: {}, dispatchDebug };
 });
 
 it('forwards query operations correctly', async () => {
diff --git a/packages/core/src/exchanges/fallback.test.ts b/packages/core/src/exchanges/fallback.test.ts
index 68197296..fba29db1 100644
--- a/packages/core/src/exchanges/fallback.test.ts
+++ b/packages/core/src/exchanges/fallback.test.ts
@@ -1,9 +1,11 @@
 import { forEach, fromValue, pipe } from 'wonka';
 import { queryOperation, teardownOperation } from '../test-utils';
-import { fallbackExchangeIO } from './fallback';
+import { fallbackExchange } from './fallback';
 
 const consoleWarn = console.warn;
 
+const dispatchDebug = jest.fn();
+
 beforeEach(() => {
   console.warn = jest.fn();
 });
@@ -16,7 +18,7 @@ it('filters all results and warns about input', () => {
   const res: any[] = [];
 
   pipe(
-    fallbackExchangeIO(fromValue(queryOperation)),
+    fallbackExchange({ dispatchDebug })(fromValue(queryOperation)),
     forEach(x => res.push(x))
   );
 
@@ -28,7 +30,7 @@ it('filters all results and warns about input', () => {
   const res: any[] = [];
 
   pipe(
-    fallbackExchangeIO(fromValue(teardownOperation)),
+    fallbackExchange({ dispatchDebug })(fromValue(teardownOperation)),
     forEach(x => res.push(x))
   );
 
diff --git a/packages/core/src/exchanges/fetch.test.ts b/packages/core/src/exchanges/fetch.test.ts
index 6d3394f1..d4e2e6f7 100755
--- a/packages/core/src/exchanges/fetch.test.ts
+++ b/packages/core/src/exchanges/fetch.test.ts
@@ -39,8 +39,13 @@ const response = {
 };
 
 const exchangeArgs = {
+  dispatchDebug: jest.fn(),
   forward: () => empty as Source<OperationResult>,
-  client: {} as Client,
+  client: ({
+    debugTarget: {
+      dispatchEvent: jest.fn(),
+    },
+  } as any) as Client,
 };
 
 describe('on success', () => {
diff --git a/packages/core/src/exchanges/subscription.test.ts b/packages/core/src/exchanges/subscription.test.ts
index e18c9a3c..45cf824a 100644
--- a/packages/core/src/exchanges/subscription.test.ts
+++ b/packages/core/src/exchanges/subscription.test.ts
@@ -15,6 +15,7 @@ import { subscriptionExchange, SubscriptionForwarder } from './subscription';
 
 it('should return response data from forwardSubscription observable', async () => {
   const exchangeArgs = {
+    dispatchDebug: jest.fn(),
     forward: () => empty as Source<OperationResult>,
     client: {} as Client,
   };
@@ -52,6 +53,7 @@ it('should tear down the operation if the source subscription ends', async () =>
   const unsubscribe = jest.fn();
 
   const exchangeArgs = {
+    dispatchDebug: jest.fn(),
     forward: () => empty as Source<OperationResult>,
     client: { reexecuteOperation: reexecuteOperation as any } as Client,
   };
diff --git a/packages/core/src/utils/stringifyVariables.test.ts b/packages/core/src/utils/stringifyVariables.test.ts
index 6e6c6ae4..2d592e4e 100644
--- a/packages/core/src/utils/stringifyVariables.test.ts
+++ b/packages/core/src/utils/stringifyVariables.test.ts
@@ -29,7 +29,7 @@ it('returns null for circular structures', () => {
 
 it('stringifies dates correctly', () => {
   const date = new Date('2019-12-11T04:20:00');
-  expect(stringifyVariables(date)).toBe(date.toJSON());
+  expect(stringifyVariables(date)).toBe(`"${date.toJSON()}"`);
 });
 
 it('stringifies dictionaries (Object.create(null)) correctly', () => {
diff --git a/packages/next-urql/src/__tests__/with-urql-client.spec.tsx b/packages/next-urql/src/__tests__/with-urql-client.spec.tsx
index 3fffc44d..85f8ce39 100644
--- a/packages/next-urql/src/__tests__/with-urql-client.spec.tsx
+++ b/packages/next-urql/src/__tests__/with-urql-client.spec.tsx
@@ -6,6 +6,8 @@ import { Client, defaultExchanges } from 'urql';
 import { withUrqlClient, NextUrqlPageContext } from '..';
 import * as init from '../init-urql-client';
 
+beforeEach(jest.clearAllMocks);
+
 const MockApp: React.FC<any> = () => {
   return <div />;
 };
@@ -23,11 +25,6 @@ describe('withUrqlClient', () => {
     configure({ adapter: new Adapter() });
   });
 
-  afterEach(() => {
-    spyInitUrqlClient.mockClear();
-    mockMergeExchanges.mockClear();
-  });
-
   describe('with client options', () => {
     beforeEach(() => {
       Component = withUrqlClient({ url: 'http://localhost:3000' })(MockApp);
@@ -108,19 +105,38 @@ describe('withUrqlClient', () => {
   });
 
   describe('with mergeExchanges provided', () => {
+    const exchange = jest.fn(() => op => op);
+
     beforeEach(() => {
+      mockMergeExchanges.mockImplementation(() => [exchange] as any[]);
       Component = withUrqlClient(
         { url: 'http://localhost:3000' },
         mockMergeExchanges
       )(MockApp);
     });
 
-    it('should call the user-supplied mergeExchanges function', () => {
+    it('calls the user-supplied mergeExchanges function', () => {
       const tree = shallow(<Component />);
       const app = tree.find(MockApp);
 
-      expect(app.props().urqlClient).toBeInstanceOf(Client);
+      const client = app.props().urqlClient;
+      expect(client).toBeInstanceOf(Client);
       expect(mockMergeExchanges).toHaveBeenCalledTimes(1);
     });
+
+    it('uses exchanges returned from mergeExchanges', () => {
+      const tree = shallow(<Component />);
+      const app = tree.find(MockApp);
+
+      const client = app.props().urqlClient;
+      client.query(`
+        {
+          users {
+            id
+          }
+        }
+      `);
+      expect(exchange).toBeCalledTimes(1);
+    });
   });
 });