diff --git a/adaptive/runner.py b/adaptive/runner.py
index 3c7e72d5990efedcd3482a6fb1aa14b91b9b7d8d..4ad8f089aea9a706616494212349db91c369b311 100644
--- a/adaptive/runner.py
+++ b/adaptive/runner.py
@@ -17,6 +17,8 @@ class Runner:
     goal : callable, optional
         The end condition for the calculation. This function must take the
         learner as its sole argument, and return True if we should stop.
+    log : bool, default: False
+        If True, record the method calls made to the learner by this runner
     ioloop : asyncio.AbstractEventLoop, optional
         The ioloop in which to run the learning algorithm. If not provided,
         the default event loop is used.
@@ -27,12 +29,17 @@ class Runner:
         The underlying task. May be cancelled to stop the runner.
     learner : Learner
         The underlying learner. May be queried for its state
+    log : list or None
+        Record of the method calls made to the learner, in the format
+        '(method_name, *args)'.
     """
 
-    def __init__(self, learner, executor=None, goal=None, *, ioloop=None):
+    def __init__(self, learner, executor=None, goal=None, *,
+                 log=False, ioloop=None):
         self.ioloop = ioloop if ioloop else asyncio.get_event_loop()
         self.executor = _ensure_async_executor(executor, self.ioloop)
         self.learner = learner
+        self.log = [] if log else None
 
         if goal is None:
             def goal(_):
@@ -50,6 +57,7 @@ class Runner:
         first_completed = asyncio.FIRST_COMPLETED
         xs = dict()
         done = [None] * _get_executor_ncores(self.executor)
+        do_log = self.log is not None
 
         if len(done) == 0:
             raise RuntimeError('Executor has no workers')
@@ -58,6 +66,8 @@ class Runner:
             while not self.goal(self.learner):
                 # Launch tasks to replace the ones that completed
                 # on the last iteration.
+                if do_log:
+                    self.log.append(('choose_points', len(done)))
                 for x in self.learner.choose_points(len(done)):
                     xs[self.executor.submit(self.learner.function, x)] = x
 
@@ -69,6 +79,8 @@ class Runner:
                 for fut in done:
                     x = xs.pop(fut)
                     y = await fut
+                    if do_log:
+                        self.log.append(('add_point', x, y))
                     self.learner.add_point(x, y)
         finally:
             # cancel any outstanding tasks
@@ -77,6 +89,21 @@ class Runner:
                 raise RuntimeError('Some futures remain uncancelled')
 
 
+def replay_log(learner, log):
+    """Apply a sequence of method calls to a learner.
+
+    This is useful for debugging runners.
+
+    Parameters
+    ----------
+    learner : learner.BaseLearner
+    log : list
+        contains tuples: '(method_name, *args)'.
+    """
+    for method, *args in log:
+        getattr(learner, method)(*args)
+
+
 # Internal functionality
 
 class _AsyncExecutor:
diff --git a/learner.ipynb b/learner.ipynb
index 0b487462126c8b6e629cf95d244d4a26104fcd1b..5c99bbac2f057084c0b2f082a09ac5a5a6d8ccc5 100644
--- a/learner.ipynb
+++ b/learner.ipynb
@@ -421,6 +421,60 @@
     "runner.task.result()"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Logging runners"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Runners do their job in the background, which makes introspection quite cumbersome. One way to inspect runners is to instantiate one with `log=True`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "learner = adaptive.learner.Learner1D(f, bounds=(-1, 1))\n",
+    "runner = adaptive.Runner(learner, goal=lambda l: l.loss() < 0.1,\n",
+    "                         log=True)\n",
+    "adaptive.live_plot(runner)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "This gives a the runner a `log` attribute, which is a list of the `learner` methods that were called, as well as their arguments. This is useful because executors typically execute their tasks in a non-deterministic order.\n",
+    "\n",
+    "This can be used with `adaptive.runner.replay_log` to perfom the same set of operations on another runner:\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "reconstructed_learner = adaptive.learner.Learner1D(f, bounds=(-1, 1))\n",
+    "adaptive.runner.replay_log(reconstructed_learner, runner.log)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "learner.plot().opts(style=dict(size=6)) * reconstructed_learner.plot()"
+   ]
+  },
   {
    "cell_type": "markdown",
    "metadata": {},