Repository: kopia/kopia
Pull request #3262 (2023-09-02 21:12:04)

feat(server): fixed server logging to file & console
Previously some logs from a running server were only kept in memory (including storage activity logs) which was confusing to many folks.

This changes the behavior so that logs are sent to their regular (console/file) file locations in addition to the UI tasks.

Old behavior can be restored by adding `--no-persistent-logs` to server.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Patch coverage: **`100.00%`** and project coverage change: **`-0.01%`** :warning:
> Comparison is base [(`1320f0c`)](https://app.codecov.io/gh/kopia/kopia/commit/1320f0c8e856ec856ad0b82c52ceed5483a5196d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.52% compared to head [(`e25964f`)](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.52%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3262      +/-   ##
==========================================
- Coverage   75.52%   75.52%   -0.01%     
==========================================
  Files         460      460              
  Lines       36641    36660      +19     
==========================================
+ Hits        27674    27687      +13     
- Misses       7050     7053       +3     
- Partials     1917     1920       +3     
```


| [Files Changed](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Coverage Δ | |
|---|---|---|
| [cli/command\_server\_start.go](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-Y2xpL2NvbW1hbmRfc2VydmVyX3N0YXJ0Lmdv) | `86.86% <100.00%> (+0.11%)` | :arrow_up: |
| [internal/server/server.go](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-aW50ZXJuYWwvc2VydmVyL3NlcnZlci5nbw==) | `73.69% <100.00%> (ø)` | |
| [internal/uitask/uitask\_manager.go](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-aW50ZXJuYWwvdWl0YXNrL3VpdGFza19tYW5hZ2VyLmdv) | `92.39% <100.00%> (+0.27%)` | :arrow_up: |
| [repo/content/committed\_read\_manager.go](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9jb250ZW50L2NvbW1pdHRlZF9yZWFkX21hbmFnZXIuZ28=) | `71.46% <100.00%> (+0.14%)` | :arrow_up: |
| [repo/logging/broadcast.go](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9sb2dnaW5nL2Jyb2FkY2FzdC5nbw==) | `100.00% <100.00%> (ø)` | |
| [repo/logging/ctx.go](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-cmVwby9sb2dnaW5nL2N0eC5nbw==) | `88.00% <100.00%> (ø)` | |

... and [6 files with indirect coverage changes](https://app.codecov.io/gh/kopia/kopia/pull/3262/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/3262?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).


Patch:
diff --git a/app/package-lock.json b/app/package-lock.json
index 1002c362..d9cdddff 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -19,13 +19,13 @@
       },
       "devDependencies": {
         "@electron/notarize": "^2.1.0",
-        "@playwright/test": "^1.36.2",
+        "@playwright/test": "^1.37.1",
         "asar": "^3.2.0",
-        "concurrently": "^8.2.0",
+        "concurrently": "^8.2.1",
         "dotenv": "^16.3.1",
-        "electron": "^25.3.2",
+        "electron": "^26.1.0",
         "electron-builder": "^24.6.3",
-        "playwright": "^1.36.2",
+        "playwright": "^1.37.1",
         "playwright-core": "^1.35.1"
       }
     },
@@ -352,13 +352,13 @@
       }
     },
     "node_modules/@playwright/test": {
-      "version": "1.36.2",
-      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.2.tgz",
-      "integrity": "sha512-2rVZeyPRjxfPH6J0oGJqE8YxiM1IBRyM8hyrXYK7eSiAqmbNhxwcLa7dZ7fy9Kj26V7FYia5fh9XJRq4Dqme+g==",
+      "version": "1.37.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz",
+      "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==",
       "dev": true,
       "dependencies": {
         "@types/node": "*",
-        "playwright-core": "1.36.2"
+        "playwright-core": "1.37.1"
       },
       "bin": {
         "playwright": "cli.js"
@@ -1176,9 +1176,9 @@
       "dev": true
     },
     "node_modules/concurrently": {
-      "version": "8.2.0",
-      "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.0.tgz",
-      "integrity": "sha512-nnLMxO2LU492mTUj9qX/az/lESonSZu81UznYDoXtz1IQf996ixVqPAgHXwvHiHCAef/7S8HIK+fTFK7Ifk8YA==",
+      "version": "8.2.1",
+      "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.1.tgz",
+      "integrity": "sha512-nVraf3aXOpIcNud5pB9M82p1tynmZkrSGQ1p6X/VY8cJ+2LMVqAgXsJxYYefACSHbTYlm92O1xuhdGTjwoEvbQ==",
       "dev": true,
       "dependencies": {
         "chalk": "^4.1.2",
@@ -1490,9 +1490,9 @@
       }
     },
     "node_modules/electron": {
-      "version": "25.3.2",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-25.3.2.tgz",
-      "integrity": "sha512-xiktJvXraaE/ARf2OVHFyTze1TksSbsbJgOaBtdIiBvUduez6ipATEPIec8Msz1n6eQ+xqYb6YF8tDuIZtJSPw==",
+      "version": "26.1.0",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-26.1.0.tgz",
+      "integrity": "sha512-qEh19H09Pysn3ibms5nZ0haIh5pFoOd7/5Ww7gzmAwDQOulRi8Sa2naeueOyIb1GKpf+6L4ix3iceYRAuA5r5Q==",
       "dev": true,
       "hasInstallScript": true,
       "dependencies": {
@@ -2537,13 +2537,13 @@
       "dev": true
     },
     "node_modules/playwright": {
-      "version": "1.36.2",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.36.2.tgz",
-      "integrity": "sha512-4Fmlq3KWsl85Bl4InJw1NC21aeQV0iSZuFvTDcy1F8zVmXmgQRe89GxF8zMSRt/KIS+2tUolak7EXVl9aC+JdA==",
+      "version": "1.37.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.37.1.tgz",
+      "integrity": "sha512-bgUXRrQKhT48zHdxDYQTpf//0xDfDd5hLeEhjuSw8rXEGoT9YeElpfvs/izonTNY21IQZ7d3s22jLxYaAnubbQ==",
       "dev": true,
       "hasInstallScript": true,
       "dependencies": {
-        "playwright-core": "1.36.2"
+        "playwright-core": "1.37.1"
       },
       "bin": {
         "playwright": "cli.js"
@@ -2553,9 +2553,9 @@
       }
     },
     "node_modules/playwright-core": {
-      "version": "1.36.2",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.2.tgz",
-      "integrity": "sha512-sQYZt31dwkqxOrP7xy2ggDfEzUxM1lodjhsQ3NMMv5uGTRDsLxU0e4xf4wwMkF2gplIxf17QMBCodSFgm6bFVQ==",
+      "version": "1.37.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz",
+      "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==",
       "dev": true,
       "bin": {
         "playwright-core": "cli.js"
@@ -3518,14 +3518,14 @@
       }
     },
     "@playwright/test": {
-      "version": "1.36.2",
-      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.36.2.tgz",
-      "integrity": "sha512-2rVZeyPRjxfPH6J0oGJqE8YxiM1IBRyM8hyrXYK7eSiAqmbNhxwcLa7dZ7fy9Kj26V7FYia5fh9XJRq4Dqme+g==",
+      "version": "1.37.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz",
+      "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==",
       "dev": true,
       "requires": {
         "@types/node": "*",
         "fsevents": "2.3.2",
-        "playwright-core": "1.36.2"
+        "playwright-core": "1.37.1"
       }
     },
     "@sindresorhus/is": {
@@ -4168,9 +4168,9 @@
       "dev": true
     },
     "concurrently": {
-      "version": "8.2.0",
-      "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.0.tgz",
-      "integrity": "sha512-nnLMxO2LU492mTUj9qX/az/lESonSZu81UznYDoXtz1IQf996ixVqPAgHXwvHiHCAef/7S8HIK+fTFK7Ifk8YA==",
+      "version": "8.2.1",
+      "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.1.tgz",
+      "integrity": "sha512-nVraf3aXOpIcNud5pB9M82p1tynmZkrSGQ1p6X/VY8cJ+2LMVqAgXsJxYYefACSHbTYlm92O1xuhdGTjwoEvbQ==",
       "dev": true,
       "requires": {
         "chalk": "^4.1.2",
@@ -4396,9 +4396,9 @@
       }
     },
     "electron": {
-      "version": "25.3.2",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-25.3.2.tgz",
-      "integrity": "sha512-xiktJvXraaE/ARf2OVHFyTze1TksSbsbJgOaBtdIiBvUduez6ipATEPIec8Msz1n6eQ+xqYb6YF8tDuIZtJSPw==",
+      "version": "26.1.0",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-26.1.0.tgz",
+      "integrity": "sha512-qEh19H09Pysn3ibms5nZ0haIh5pFoOd7/5Ww7gzmAwDQOulRi8Sa2naeueOyIb1GKpf+6L4ix3iceYRAuA5r5Q==",
       "dev": true,
       "requires": {
         "@electron/get": "^2.0.0",
@@ -5202,18 +5202,18 @@
       "dev": true
     },
     "playwright": {
-      "version": "1.36.2",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.36.2.tgz",
-      "integrity": "sha512-4Fmlq3KWsl85Bl4InJw1NC21aeQV0iSZuFvTDcy1F8zVmXmgQRe89GxF8zMSRt/KIS+2tUolak7EXVl9aC+JdA==",
+      "version": "1.37.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.37.1.tgz",
+      "integrity": "sha512-bgUXRrQKhT48zHdxDYQTpf//0xDfDd5hLeEhjuSw8rXEGoT9YeElpfvs/izonTNY21IQZ7d3s22jLxYaAnubbQ==",
       "dev": true,
       "requires": {
-        "playwright-core": "1.36.2"
+        "playwright-core": "1.37.1"
       }
     },
     "playwright-core": {
-      "version": "1.36.2",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.36.2.tgz",
-      "integrity": "sha512-sQYZt31dwkqxOrP7xy2ggDfEzUxM1lodjhsQ3NMMv5uGTRDsLxU0e4xf4wwMkF2gplIxf17QMBCodSFgm6bFVQ==",
+      "version": "1.37.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz",
+      "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==",
       "dev": true
     },
     "plist": {
diff --git a/app/package.json b/app/package.json
index 3943fc16..422fdef2 100644
--- a/app/package.json
+++ b/app/package.json
@@ -111,13 +111,13 @@
   },
   "devDependencies": {
     "@electron/notarize": "^2.1.0",
-    "@playwright/test": "^1.36.2",
+    "@playwright/test": "^1.37.1",
     "asar": "^3.2.0",
-    "concurrently": "^8.2.0",
+    "concurrently": "^8.2.1",
     "dotenv": "^16.3.1",
-    "electron": "^25.3.2",
+    "electron": "^26.1.0",
     "electron-builder": "^24.6.3",
-    "playwright": "^1.36.2",
+    "playwright": "^1.37.1",
     "playwright-core": "^1.35.1"
   },
   "homepage": "./",
diff --git a/cli/command_server_start.go b/cli/command_server_start.go
index 7b6d1ceb..72c5841c 100644
--- a/cli/command_server_start.go
+++ b/cli/command_server_start.go
@@ -62,6 +62,7 @@ type commandServerStart struct {
 	uiTitlePrefix                       string
 	uiPreferencesFile                   string
 	asyncRepoConnect                    bool
+	persistentLogs                      bool
 
 	shutdownGracePeriod time.Duration
 
@@ -108,6 +109,7 @@ func (c *commandServerStart) setup(svc advancedAppServices, parent commandParent
 	cmd.Flag("tls-print-server-cert", "Print server certificate").Hidden().BoolVar(&c.serverStartTLSPrintFullServerCert)
 
 	cmd.Flag("async-repo-connect", "Connect to repository asynchronously").Hidden().BoolVar(&c.asyncRepoConnect)
+	cmd.Flag("persistent-logs", "Persist logs in a file").Default("true").BoolVar(&c.persistentLogs)
 	cmd.Flag("ui-title-prefix", "UI title prefix").Hidden().Envar(svc.EnvName("KOPIA_UI_TITLE_PREFIX")).StringVar(&c.uiTitlePrefix)
 	cmd.Flag("ui-preferences-file", "Path to JSON file storing UI preferences").StringVar(&c.uiPreferencesFile)
 
@@ -149,6 +151,7 @@ func (c *commandServerStart) serverStartOptions(ctx context.Context) (*server.Op
 		PasswordPersist:      c.svc.passwordPersistenceStrategy(),
 		UIPreferencesFile:    uiPreferencesFile,
 		UITitlePrefix:        c.uiTitlePrefix,
+		PersistentLogs:       c.persistentLogs,
 
 		DisableCSRFTokenChecks: c.disableCSRFTokenChecks,
 	}, nil
diff --git a/internal/server/server.go b/internal/server/server.go
index d0729133..dd6ddc53 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -865,6 +865,7 @@ type Options struct {
 	UIPreferencesFile      string // name of the JSON file storing UI preferences
 	ServerControlUser      string // name of the user allowed to access the server control API
 	DisableCSRFTokenChecks bool
+	PersistentLogs         bool
 	UITitlePrefix          string
 }
 
@@ -1047,7 +1048,7 @@ func New(ctx context.Context, options *Options) (*Server, error) {
 		grpcServerState:      makeGRPCServerState(options.MaxConcurrency),
 		authenticator:        options.Authenticator,
 		authorizer:           options.Authorizer,
-		taskmgr:              uitask.NewManager(),
+		taskmgr:              uitask.NewManager(options.PersistentLogs),
 		mounts:               map[object.ID]mount.Controller{},
 		authCookieSigningKey: []byte(options.AuthCookieSigningKey),
 	}
diff --git a/internal/uitask/uitask_manager.go b/internal/uitask/uitask_manager.go
index 17bafac4..3a05ee4e 100644
--- a/internal/uitask/uitask_manager.go
+++ b/internal/uitask/uitask_manager.go
@@ -32,6 +32,8 @@ type Manager struct {
 
 	MaxFinishedTasks      int // +checklocksignore
 	MaxLogMessagesPerTask int // +checklocksignore
+
+	persistentLogs bool // +checklocksignore
 }
 
 // Controller allows the task to communicate with task manager and receive signals.
@@ -56,7 +58,13 @@ func (m *Manager) Run(ctx context.Context, kind, description string, task TaskFu
 		maxLogMessages: m.MaxLogMessagesPerTask,
 	}
 
-	ctx = logging.WithLogger(ctx, r.loggerForModule)
+	if m.persistentLogs {
+		// log to regular file logger in addition to in-memory buffers.
+		ctx = logging.WithAdditionalLogger(ctx, r.loggerForModule)
+	} else {
+		// only log to in-memory buffers.
+		ctx = logging.WithLogger(ctx, r.loggerForModule)
+	}
 
 	m.startTask(r)
 
@@ -251,12 +259,13 @@ func (m *Manager) completeTask(r *runningTaskInfo, err error) {
 }
 
 // NewManager creates new UI Task Manager.
-func NewManager() *Manager {
+func NewManager(alsoLogToFile bool) *Manager {
 	return &Manager{
 		running:  map[string]*runningTaskInfo{},
 		finished: map[string]*Info{},
 
 		MaxLogMessagesPerTask: maxLogMessagesPerTask,
 		MaxFinishedTasks:      maxFinishedTasks,
+		persistentLogs:        alsoLogToFile,
 	}
 }
diff --git a/repo/content/committed_read_manager.go b/repo/content/committed_read_manager.go
index 97d6aaa7..04baec19 100644
--- a/repo/content/committed_read_manager.go
+++ b/repo/content/committed_read_manager.go
@@ -593,7 +593,9 @@ func (sm *SharedManager) CloseShared(ctx context.Context) error {
 func (sm *SharedManager) AlsoLogToContentLog(ctx context.Context) context.Context {
 	sm.repoLogManager.Enable()
 
-	return logging.AlsoLogTo(ctx, sm.log)
+	return logging.WithAdditionalLogger(ctx, func(module string) logging.Logger {
+		return sm.log
+	})
 }
 
 func (sm *SharedManager) shouldRefreshIndexes() bool {
diff --git a/repo/logging/broadcast.go b/repo/logging/broadcast.go
index b6b79c21..af2504da 100644
--- a/repo/logging/broadcast.go
+++ b/repo/logging/broadcast.go
@@ -9,9 +9,21 @@
 func Broadcast(logger ...Logger) Logger {
 	var cores []zapcore.Core
 
+	var singleName string
+
 	for _, l := range logger {
-		cores = append(cores, l.Desugar().Core())
+		dl := l.Desugar()
+
+		if singleName == "" {
+			singleName = dl.Name()
+		}
+
+		if dl.Name() != singleName {
+			singleName = "-"
+		}
+
+		cores = append(cores, dl.Core())
 	}
 
-	return zap.New(zapcore.NewTee(cores...)).Sugar()
+	return zap.New(zapcore.NewTee(cores...)).Sugar().Named(singleName)
 }
diff --git a/repo/logging/ctx.go b/repo/logging/ctx.go
index da341acb..2c21b67f 100644
--- a/repo/logging/ctx.go
+++ b/repo/logging/ctx.go
@@ -34,6 +34,15 @@ func WithLogger(ctx context.Context, l LoggerFactory) context.Context {
 	})
 }
 
+// WithAdditionalLogger returns a context where all logging is emitted the original output plus the provided logger factory.
+func WithAdditionalLogger(ctx context.Context, fact LoggerFactory) context.Context {
+	originalLogFactory := loggerFactoryFromContext(ctx)
+
+	return WithLogger(ctx, func(module string) Logger {
+		return Broadcast(originalLogFactory(module), fact(module))
+	})
+}
+
 // loggerFactoryFromContext returns a LoggerFactory associated with current context.
 func loggerFactoryFromContext(ctx context.Context) LoggerFactory {
 	v := ctx.Value(loggerCacheKey)
@@ -43,12 +52,3 @@ func loggerFactoryFromContext(ctx context.Context) LoggerFactory {
 
 	return v.(*loggerCache).getLogger //nolint:forcetypeassert
 }
-
-// AlsoLogTo returns a context where all logging is emitted the original output plus the provided loggers.
-func AlsoLogTo(ctx context.Context, loggers ...Logger) context.Context {
-	originalLogFactory := loggerFactoryFromContext(ctx)
-
-	return WithLogger(ctx, func(module string) Logger {
-		return Broadcast(append([]Logger{originalLogFactory(module)}, loggers...)...)
-	})
-}


Test patch:
diff --git a/internal/parallelwork/parallel_work_queue_test.go b/internal/parallelwork/parallel_work_queue_test.go
index 9b98f307..408e6fe7 100644
--- a/internal/parallelwork/parallel_work_queue_test.go
+++ b/internal/parallelwork/parallel_work_queue_test.go
@@ -224,18 +224,23 @@ func TestOnNthCompletion(t *testing.T) {
 		// callback must be called exactly 1 time
 		require.Equal(t, callbackInvoked.Load(), int32(1))
 
-		var cnt int
+		var (
+			errCalledCount int
+			noErrorCount   int
+		)
+
 		for result := range results {
-			cnt++
-			switch cnt {
-			// n-th invocation must run and return an expected error
-			case n:
-				require.Error(t, result)
-				require.ErrorIs(t, result, errCalled)
-			// other invocations must not run and return any error
-			default:
-				require.NoError(t, result)
+			if result == nil {
+				noErrorCount++
+				continue
 			}
+
+			errCalledCount++
+
+			require.ErrorIs(t, result, errCalled)
 		}
+
+		require.Equal(t, errCalledCount, 1)
+		require.Equal(t, noErrorCount, n)
 	})
 }
diff --git a/internal/uitask/uitask_test.go b/internal/uitask/uitask_test.go
index 07833f09..8eed8515 100644
--- a/internal/uitask/uitask_test.go
+++ b/internal/uitask/uitask_test.go
@@ -1,6 +1,7 @@
 package uitask_test
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"strings"
@@ -10,6 +11,7 @@
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/uuid"
 	"github.com/pkg/errors"
+	"github.com/stretchr/testify/require"
 
 	"github.com/kopia/kopia/internal/uitask"
 	"github.com/kopia/kopia/repo/content"
@@ -21,11 +23,29 @@
 	ignoredLog = logging.Module(content.FormatLogModule)
 )
 
-func TestUITask(t *testing.T) {
-	t.Parallel()
+func TestUITask_withoutPersistentLogging(t *testing.T) {
+	var logBuf bytes.Buffer
 
-	ctx := context.Background()
-	m := uitask.NewManager()
+	ctx := logging.WithLogger(context.Background(), logging.ToWriter(&logBuf))
+
+	m := uitask.NewManager(false)
+	testUITaskInternal(t, ctx, m)
+	require.Equal(t, "", logBuf.String())
+}
+
+func TestUITask_withPersistentLogging(t *testing.T) {
+	var logBuf bytes.Buffer
+
+	ctx := logging.WithLogger(context.Background(), logging.ToWriter(&logBuf))
+
+	m := uitask.NewManager(true)
+	testUITaskInternal(t, ctx, m)
+	require.Equal(t, "first\nthis is ignored\niii\nwww\neee\n", logBuf.String())
+}
+
+//nolint:thelper
+func testUITaskInternal(t *testing.T, ctx context.Context, m *uitask.Manager) {
+	t.Parallel()
 
 	m.MaxLogMessagesPerTask = 3
 	m.MaxFinishedTasks = 3
@@ -225,7 +245,7 @@ func verifyTaskList(t *testing.T, m *uitask.Manager, wantStatuses map[string]uit
 func TestUITaskCancel_NonExistent(t *testing.T) {
 	t.Parallel()
 
-	m := uitask.NewManager()
+	m := uitask.NewManager(false)
 	m.CancelTask("no-such-task")
 }
 
@@ -233,7 +253,7 @@ func TestUITaskCancel_AfterOnCancel(t *testing.T) {
 	t.Parallel()
 
 	ctx := context.Background()
-	m := uitask.NewManager()
+	m := uitask.NewManager(false)
 
 	ch := make(chan string)
 
@@ -277,7 +297,7 @@ func TestUITaskCancel_BeforeOnCancel(t *testing.T) {
 	t.Parallel()
 
 	ctx := context.Background()
-	m := uitask.NewManager()
+	m := uitask.NewManager(false)
 
 	ch := make(chan string)
 
@@ -331,7 +351,7 @@ func verifyTaskLog(t *testing.T, m *uitask.Manager, taskID string, want []string
 	t.Helper()
 
 	if got, want := logText(m.TaskLog(taskID)), strings.Join(want, "\n"); got != want {
-		t.Fatalf("invalid task log %v, want %v", got, want)
+		t.Fatalf("invalid task log: got `%v`, want `%v`", got, want)
 	}
 }
 
diff --git a/repo/logging/logging_test.go b/repo/logging/logging_test.go
index 31c1be6e..0d436e7f 100644
--- a/repo/logging/logging_test.go
+++ b/repo/logging/logging_test.go
@@ -82,6 +82,23 @@ func TestNonNullWriterModule(t *testing.T) {
 	require.Equal(t, "A\nS\t{\"b\":123}\nB\nC\nW\n", buf.String())
 }
 
+func TestWithAdditionalLogger(t *testing.T) {
+	var buf, buf2 bytes.Buffer
+
+	ctx := logging.WithLogger(context.Background(), logging.ToWriter(&buf))
+	ctx = logging.WithAdditionalLogger(ctx, logging.ToWriter(&buf2))
+	l := logging.Module("mod1")(ctx)
+
+	l.Debugf("A")
+	l.Debugw("S", "b", 123)
+	l.Infof("B")
+	l.Errorf("C")
+	l.Warnf("W")
+
+	require.Equal(t, "A\nS\t{\"b\":123}\nB\nC\nW\n", buf.String())
+	require.Equal(t, "A\nS\t{\"b\":123}\nB\nC\nW\n", buf2.String())
+}
+
 func BenchmarkLogger(b *testing.B) {
 	mod1 := logging.Module("mod1")
 	ctx := logging.WithLogger(context.Background(), testlogging.PrintfFactory(b.Logf))