diff --git a/README.md b/README.md
index 0c4d82a1768f6184170ec6cc5712ca66c919ac1c..f8f19d403be02c7cbafc05a7869f3beebd3d06fa 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 ## Dependencies
-The runner script requires the Python packages `PyYaml` and `click`.
+The runner script requires the Python packages `PyYaml`, `structlog`, and `click`.
 
 On Debian they can be install via the package manager as follows.
 ```
-# apt install python3-yaml python3-click
+# apt install python3-yaml python3-structlog python3-click
 ```
diff --git a/run-exp.py b/run-exp.py
index 6eda8734591930306b37e129f245dfb01fa8df8a..9b1a1b0e825c07576b91e0e955b4ee6324ae1530 100755
--- a/run-exp.py
+++ b/run-exp.py
@@ -1,15 +1,19 @@
 #!/usr/bin/env python3
 
-import itertools
+import json
 import pathlib
 import subprocess
 
 from abc import ABC, abstractmethod
 
 import click
+import structlog
 import yaml
 
 
+logger = structlog.get_logger()
+
+
 class Variable(ABC):
     def __init__(self, name):
         self._name = name
@@ -87,7 +91,19 @@ class Task:
     def work_dir(self):
         return self._work_dir
 
+    def __repr__(self):
+        return json.dumps({
+            'name': self._name,
+            'work-dir': str(self._work_dir),
+            'base-command': self._base_command,
+            'parameters': self._parameters,
+            'timeout': self._timeout,
+        })
+
     def run(self, variables, global_timeout, dry_run):
+        log = logger.bind(task = self, variables = variables)
+        log.info("Preparing task")
+
         if dry_run:
             args = ["echo"]
         else:
@@ -115,6 +131,7 @@ class Task:
 
             args.append(p["value"].format(**variables))
 
+        log = log.bind(args = args)
         # print()
         # print(f"pushd {self._work_dir}")
         # print(" ".join(args))
@@ -125,6 +142,9 @@ class Task:
         elif global_timeout is not None:
             timeout = global_timeout
 
+        log = log.bind(timeout = timeout)
+        log.info("Running task")
+
         try:
             subprocess.run(
                 " ".join(args),
@@ -135,9 +155,7 @@ class Task:
 
             return False
         except subprocess.TimeoutExpired:
-            print()
-            print("Task timed out!")
-            print()
+            log.error("Task timed out!")
 
             return True
 
@@ -185,6 +203,14 @@ class Config:
     def tasks(self):
         return self._tasks
 
+    def __repr__(self):
+        return json.dumps({
+            'name': self._name,
+            'repetitions': self._repetitions,
+            'variables': [repr(v) for v in self._variables],
+            'tasks': [repr(t) for t in self._tasks],
+        })
+
     @staticmethod
     def parse_var_definition(name, value_config):
         if isinstance(value_config, list):
@@ -235,11 +261,10 @@ class Config:
 def main(config_file, global_timeout, dry_run):
     c = Config.from_file(config_file)
 
-    print(f"> Running experiment \"{c.name}\"")
-    print(f">    with {len(c.variables)} variables")
-    print(f">     and {len(c.tasks)} tasks")
-    print(f">     and {c.repetitions} repetitions")
-    print()
+    logger.info(
+        f"Running experiment \"{c.name}\"",
+        config = c,
+    )
 
     # we precompute all variable values to be able to abort early in case of a problem,
     # for instance, if a file does not exist
@@ -259,19 +284,12 @@ def main(config_file, global_timeout, dry_run):
         variable_maps = updated_variable_maps
 
     for variables in variable_maps:
-        print()
-        print("Running tasks with variable map: ")
-        print(variables)
-        print()
-
         for rep in range(c.repetitions):
-            print(f"Repetition {rep}")
+            # print(f"Repetition {rep}")
             timed_out = False
 
             for task in c.tasks:
-                print(f"Running task {task.name}")
                 timed_out = task.run(variables, global_timeout, dry_run)
-            print()
 
             if timed_out:  # repetitions are useless if tasks time out
                 break