diff --git a/meta/classes-global/sstate.bbclass b/meta/classes-global/sstate.bbclass
index b2fa93650ab8..fe14022bc09f 100644
--- a/meta/classes-global/sstate.bbclass
+++ b/meta/classes-global/sstate.bbclass
@@ -101,6 +101,12 @@ SSTATEPREINSTFUNCS = ""
 SSTATEPOSTUNPACKFUNCS = "sstate_hardcode_path_unpack"
 EXTRA_STAGING_FIXMES ?= "HOSTTOOLS_DIR"
 
+# Build-specific paths substituted in replayed task log messages. Keep the
+# order longest-path-first: a shorter value is a prefix of the longer ones
+# (e.g. TOPDIR is a prefix of TMPDIR, HOME of both) and must be substituted
+# after them, or it would match inside their values.
+SSTATE_LOG_VARS ?= "TMPDIR DL_DIR SSTATE_DIR TOPDIR COREBASE HOME"
+
 # Check whether sstate exists for tasks that support sstate and are in the
 # locked signatures file.
 SIGGEN_LOCKEDSIGS_SSTATE_EXISTS_CHECK ?= 'error'
@@ -200,6 +206,54 @@ def sstate_state_fromvars(d, task = None):
     ss['fixmedir'] = fixmedir
     return ss
 
+def sstate_log_strip_paths(d, message):
+    import re
+
+    # Replace paths in log messages by placeholders, so we are not leaking
+    # build paths and can show consistent paths on replay.
+    # Escape literal '@' first.
+    message = message.replace('@', r'\@')
+    for var in d.getVar('SSTATE_LOG_VARS').split():
+        value = (d.getVar(var) or '').rstrip('/')
+        if value:
+            message = re.sub(re.escape(value) + r'(?!\w)', '@%s@' % var,
+                             message)
+
+    return message
+
+def sstate_log_expand_paths(d, message):
+    # Add back paths into the replayed log messages
+    for var in d.getVar('SSTATE_LOG_VARS').split():
+        value = (d.getVar(var) or '').rstrip('/')
+        if value:
+            message = message.replace('@%s@' % var, value)
+
+    message = message.replace(r'\@', '@')
+
+    return message
+
+python sstate_save_task_log () {
+    import json
+
+    # Save the messages logged by this task, so they can be replayed when the
+    # task is restored from sstate. The records are written by bitbake right
+    # after the task body, before the postfuncs.
+    mytask = d.getVar('BB_RUNTASK')
+    recordsfn = os.path.join(d.getVar('T'), 'logrecords.%s.json' % mytask)
+    if not os.path.exists(recordsfn):
+        return
+    with open(recordsfn) as f:
+        records = json.load(f)
+
+    tasks = {mytask: [{'levelno': levelno,
+                       'message': sstate_log_strip_paths(d, message)}
+                      for levelno, message in records]}
+
+    logfn = os.path.join(d.getVar('SSTATE_BUILDDIR'), "sstate-log.json")
+    with open(logfn, 'w') as f:
+        json.dump({'tasks': tasks}, f)
+}
+
 def sstate_add(ss, source, dest, d):
     if not source.endswith("/"):
          source = source + "/"
@@ -387,11 +441,12 @@ def sstate_installpkg(ss, d):
         # All hooks should run in the SSTATE_INSTDIR
         bb.build.exec_func(f, d, (sstateinst,))
 
-    return sstate_installpkgdir(ss, d)
+    return sstate_installpkgdir(ss, d, replay=True)
 
-def sstate_installpkgdir(ss, d):
+def sstate_installpkgdir(ss, d, replay=False):
     import oe.path
     import subprocess
+    import json
 
     sstateinst = d.getVar("SSTATE_INSTDIR")
     d.setVar('SSTATE_FIXMEDIR', ss['fixmedir'])
@@ -402,6 +457,28 @@ def sstate_installpkgdir(ss, d):
 
     sstate_install(ss, d)
 
+    if replay:
+        logfp = os.path.join(sstateinst, "sstate-log.json")
+        if os.path.exists(logfp):
+            with open(logfp, 'r') as logfile:
+                logdata = json.load(logfile)
+            records = logdata.get('tasks', {}).get(d.getVar('BB_RUNTASK').removesuffix('_setscene'), [])
+            for logentry in records:
+                levelno = logentry.get('levelno', 0)
+                if levelno < bb.msg.BBLogFormatter.WARNONCE or "message" not in logentry:
+                    continue
+                message = sstate_log_expand_paths(d, logentry['message'])
+                if levelno >= bb.msg.BBLogFormatter.ERROR:
+                    bb.error(message)
+                elif levelno == bb.msg.BBLogFormatter.ERRORONCE:
+                    bb.erroronce(message)
+                elif levelno == bb.msg.BBLogFormatter.WARNONCE:
+                    bb.warnonce(message)
+                else:
+                    bb.warn(message)
+
+        bb.utils.remove(logfp)
+
     return True
 
 python sstate_hardcode_path_unpack () {
@@ -686,7 +763,7 @@ def sstate_package(ss, d):
     if d.getVar('SSTATE_SKIP_CREATION') == '1':
         return
 
-    sstate_create_package = ['sstate_report_unihash', 'sstate_create_and_sign_package']
+    sstate_create_package = ['sstate_report_unihash', 'sstate_save_task_log', 'sstate_create_and_sign_package']
 
     for f in (d.getVar('SSTATECREATEFUNCS') or '').split() + \
              sstate_create_package + \
