Просмотр исходного кода

feat: switch logging framework (#10220)

This updates our logging framework from legacy freetext strings using
the `log` package to structured log entries using `log/slog`. I have
updated all INFO or higher level entries, but not yet DEBUG (😓)... So,
at a high level:

There is a slight change in log levels, effectively adding a new warning
level:

- DEBUG is still debug (ideally not for users but developers, though
this is something we need to work on)
- INFO is still info, though I've added more data here, effectively
making Syncthing more verbose by default (more on this below)
- WARNING is a new log level that is different from the _old_ WARNING
(more below)
- ERROR is what was WARNING before -- problems that must be dealt with,
and also bubbled as a popup in the GUI.

A new feature is that the logging level can be set per package to
something other than just debug or info, and hence I feel that we can
add a bit more things into INFO while moving some (in fact, most)
current INFO level warnings into WARNING. For example, I think it's
justified to get a log of synced files in INFO and sync failures in
WARNING. These are things that have historically been tricky to debug
properly, and having more information by default will be useful to many,
while still making it possible get close to told level of inscrutability
by setting the log level to WARNING. I'd like to get to a stage where
DEBUG is never necessary to just figure out what's going on, as opposed
to trying to narrow down a likely bug.

Code wise:

- Our logging object, generally known as `l` in each package, is now a
new adapter object that provides the old API on top of the newer one.
(This should go away once all old log entries are migrated.) This is
only for `l.Debugln` and `l.Debugf`.
- There is a new level tracker that keeps the log level for each
package.
- There is a nested setup of handlers, since the structure mandated by
`log/slog` is slightly convoluted (imho). We do this because we need to
do formatting at a "medium" level internally so we can buffer log lines
in text format but with separate timestamp and log level for the API/GUI
to consume.
- The `debug` API call becomes a `loglevels` API call, which can set the
log level to `DEBUG`, `INFO`, `WARNING` or `ERROR` per package. The GUI
is updated to handle this.
- Our custom `sync` package provided some debugging of mutexes quite
strongly integrated into the old logging framework, only turned on when
`STTRACE` was set to certain values at startup, etc. It's been a long
time since this has been useful; I removed it.
- The `STTRACE` env var remains and can be used the same way as before,
while additionally permitting specific log levels to be specified,
`STTRACE=model:WARN,scanner:DEBUG`.
- There is a new command line option `--log-level=INFO` to set the
default log level.
- The command line options `--log-flags` and `--verbose` go away, but
are currently retained as hidden & ignored options since we set them by
default in some of our startup examples and Syncthing would otherwise
fail to start.

Sample format messages:

```
2009-02-13 23:31:30 INF A basic info line (attr1="val with spaces" attr2=2 attr3="val\"quote" a=a log.pkg=slogutil)
2009-02-13 23:31:30 INF An info line with grouped values (attr1=val1 foo.attr2=2 foo.bar.attr3=3 a=a log.pkg=slogutil)
2009-02-13 23:31:30 INF An info line with grouped values via logger (foo.attr1=val1 foo.attr2=2 a=a log.pkg=slogutil)
2009-02-13 23:31:30 INF An info line with nested grouped values via logger (bar.foo.attr1=val1 bar.foo.attr2=2 a=a log.pkg=slogutil)
2009-02-13 23:31:30 WRN A warning entry (a=a log.pkg=slogutil)
2009-02-13 23:31:30 ERR An error (a=a log.pkg=slogutil)
```

---------

Co-authored-by: Ross Smith II <[email protected]>
Jakob Borg 2 месяцев назад
Родитель
Сommit
836045ee87
100 измененных файлов с 1305 добавлено и 1523 удалено
  1. 10 0
      .golangci.yml
  2. 2 2
      cmd/infra/strelaypoolsrv/main.go
  3. 0 3
      cmd/infra/strelaypoolsrv/main_test.go
  4. 2 2
      cmd/infra/strelaypoolsrv/stats.go
  5. 6 5
      cmd/infra/stupgrades/main.go
  6. 13 12
      cmd/infra/ursrv/serve/serve.go
  7. 5 2
      cmd/syncthing/blockprof.go
  8. 0 9
      cmd/syncthing/cli/utils.go
  9. 6 3
      cmd/syncthing/crash_reporting.go
  10. 2 4
      cmd/syncthing/debug.go
  11. 10 10
      cmd/syncthing/generate/generate.go
  12. 5 2
      cmd/syncthing/heapprof.go
  13. 71 94
      cmd/syncthing/main.go
  14. 22 39
      cmd/syncthing/monitor.go
  15. 1 1
      etc/linux-systemd/system/[email protected]
  16. 9 4
      gui/default/syncthing/core/logViewerModalView.html
  17. 7 18
      gui/default/syncthing/core/syncthingController.js
  18. 1 2
      internal/db/olddb/smallindex.go
  19. 2 1
      internal/db/sqlite/db_folderdb.go
  20. 3 1
      internal/db/sqlite/db_open.go
  21. 28 15
      internal/db/sqlite/db_service.go
  22. 2 6
      internal/db/sqlite/debug.go
  23. 7 5
      internal/db/sqlite/folderdb_update.go
  24. 25 0
      internal/slogutil/expensive.go
  25. 187 0
      internal/slogutil/formatting.go
  26. 51 0
      internal/slogutil/formatting_test.go
  27. 104 0
      internal/slogutil/leveler.go
  28. 61 0
      internal/slogutil/line.go
  29. 59 0
      internal/slogutil/recorder.go
  30. 71 0
      internal/slogutil/slogadapter.go
  31. 47 0
      internal/slogutil/sloginit.go
  32. 40 0
      internal/slogutil/slogvalues.go
  33. 40 49
      lib/api/api.go
  34. 12 10
      lib/api/api_auth.go
  35. 1 2
      lib/api/api_statics.go
  36. 5 16
      lib/api/api_test.go
  37. 5 3
      lib/api/confighandler.go
  38. 2 4
      lib/api/debug.go
  39. 1 2
      lib/api/tokenmanager.go
  40. 1 1
      lib/beacon/beacon.go
  41. 4 2
      lib/beacon/broadcast.go
  42. 2 4
      lib/beacon/debug.go
  43. 3 2
      lib/beacon/multicast.go
  44. 6 5
      lib/config/config.go
  45. 2 4
      lib/config/debug.go
  46. 3 2
      lib/config/deviceconfiguration.go
  47. 8 0
      lib/config/folderconfiguration.go
  48. 3 1
      lib/config/migrations.go
  49. 1 3
      lib/config/optionsconfiguration.go
  50. 5 4
      lib/config/wrapper.go
  51. 2 2
      lib/connections/connections_test.go
  52. 2 4
      lib/connections/debug.go
  53. 6 8
      lib/connections/limiter.go
  54. 12 10
      lib/connections/quic_listen.go
  55. 1 2
      lib/connections/registry/registry.go
  56. 9 7
      lib/connections/relay_listen.go
  57. 32 72
      lib/connections/service.go
  58. 5 0
      lib/connections/structs.go
  59. 11 7
      lib/connections/tcp_listen.go
  60. 3 2
      lib/dialer/control_unix.go
  61. 2 12
      lib/dialer/debug.go
  62. 4 3
      lib/dialer/internal.go
  63. 2 2
      lib/discover/cache.go
  64. 2 4
      lib/discover/debug.go
  65. 13 11
      lib/discover/global.go
  66. 8 6
      lib/discover/local.go
  67. 14 16
      lib/discover/manager.go
  68. 2 2
      lib/events/debug.go
  69. 4 5
      lib/events/events.go
  70. 2 1
      lib/fs/basicfs.go
  71. 0 4
      lib/fs/casefs_test.go
  72. 2 9
      lib/fs/debug.go
  73. 2 0
      lib/fs/fakefs.go
  74. 2 3
      lib/fs/filesystem_copy_range.go
  75. 1 2
      lib/ignore/ignore.go
  76. 0 19
      lib/logger/LICENSE
  77. 0 407
      lib/logger/logger.go
  78. 0 209
      lib/logger/logger_test.go
  79. 0 142
      lib/logger/mocks/logger.go
  80. 2 8
      lib/model/debug.go
  81. 2 2
      lib/model/deviceactivity.go
  82. 1 3
      lib/model/devicedownloadstate.go
  83. 21 22
      lib/model/folder.go
  84. 1 1
      lib/model/folder_recvenc.go
  85. 3 2
      lib/model/folder_recvonly.go
  86. 2 2
      lib/model/folder_sendonly.go
  87. 62 32
      lib/model/folder_sendrecv.go
  88. 4 4
      lib/model/folder_sendrecv_test.go
  89. 2 2
      lib/model/folder_sendrecv_windows.go
  90. 2 3
      lib/model/folder_summary.go
  91. 1 1
      lib/model/folder_test.go
  92. 3 8
      lib/model/folderstate.go
  93. 6 5
      lib/model/indexhandler.go
  94. 76 79
      lib/model/model.go
  95. 1 1
      lib/model/model_test.go
  96. 6 6
      lib/model/progressemitter.go
  97. 0 10
      lib/model/progressemitter_test.go
  98. 2 5
      lib/model/queue.go
  99. 1 1
      lib/model/requests_test.go
  100. 1 1
      lib/model/service_map.go

+ 10 - 0
.golangci.yml

@@ -60,6 +60,16 @@ linters:
       - builtin$
       - examples$
       - _test\.go$
+    rules:
+      # relax the slog rules for debug lines, for now
+      - linters: [sloglint]
+        source: Debug
+  settings:
+    sloglint:
+      context: "scope"
+      static-msg: true
+      msg-style: capitalized
+      key-naming-case: camel
 formatters:
   enable:
     - gofumpt

+ 2 - 2
cmd/infra/strelaypoolsrv/main.go

@@ -17,6 +17,7 @@ import (
 	"path/filepath"
 	"strconv"
 	"strings"
+	"sync"
 	"sync/atomic"
 	"time"
 
@@ -31,7 +32,6 @@ import (
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/rand"
 	"github.com/syncthing/syncthing/lib/relay/client"
-	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/tlsutil"
 )
 
@@ -115,7 +115,7 @@ var (
 
 	requests chan request
 
-	mut             = sync.NewRWMutex()
+	mut             sync.RWMutex
 	knownRelays     = make([]*relay, 0)
 	permanentRelays = make([]*relay, 0)
 	evictionTimers  = make(map[string]*time.Timer)

+ 0 - 3
cmd/infra/strelaypoolsrv/main_test.go

@@ -13,7 +13,6 @@ import (
 	"net/http/httptest"
 	"net/url"
 	"strings"
-	"sync"
 	"testing"
 )
 
@@ -28,8 +27,6 @@ func init() {
 		{URL: "known2"},
 		{URL: "known3"},
 	}
-
-	mut = new(sync.RWMutex)
 }
 
 // Regression test: handleGetRequest should not modify permanentRelays.

+ 2 - 2
cmd/infra/strelaypoolsrv/stats.go

@@ -6,10 +6,10 @@ import (
 	"encoding/json"
 	"net"
 	"net/http"
+	"sync"
 	"time"
 
 	"github.com/prometheus/client_golang/prometheus"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 var (
@@ -104,7 +104,7 @@ func refreshStats() {
 	mut.RUnlock()
 
 	now := time.Now()
-	wg := sync.NewWaitGroup()
+	var wg sync.WaitGroup
 
 	results := make(chan statsFetchResult, len(relays))
 	for _, rel := range relays {

+ 6 - 5
cmd/infra/stupgrades/main.go

@@ -24,6 +24,7 @@ import (
 
 	"github.com/alecthomas/kong"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	_ "github.com/syncthing/syncthing/lib/automaxprocs"
 	"github.com/syncthing/syncthing/lib/httpcache"
 	"github.com/syncthing/syncthing/lib/upgrade"
@@ -58,10 +59,10 @@ func server(params *cli) error {
 		if err != nil {
 			return fmt.Errorf("metrics: %w", err)
 		}
-		slog.Info("Metrics listener started", "addr", params.MetricsListen)
+		slog.Info("Metrics listener started", slogutil.Address(params.MetricsListen))
 		go func() {
 			if err := http.Serve(metricsListen, mux); err != nil {
-				slog.Warn("Metrics server returned", "error", err)
+				slog.Warn("Metrics server returned", slogutil.Error(err))
 			}
 		}()
 	}
@@ -75,9 +76,9 @@ func server(params *cli) error {
 
 	go func() {
 		for range time.NewTicker(params.CacheTime).C {
-			slog.Info("Refreshing cached releases", "url", params.URL)
+			slog.Info("Refreshing cached releases", slogutil.URI(params.URL))
 			if err := cache.Update(context.Background()); err != nil {
-				slog.Error("Failed to refresh cached releases", "url", params.URL, "error", err)
+				slog.Error("Failed to refresh cached releases", slogutil.URI(params.URL), slogutil.Error(err))
 			}
 		}
 	}()
@@ -109,7 +110,7 @@ func server(params *cli) error {
 	if err != nil {
 		return fmt.Errorf("listen: %w", err)
 	}
-	slog.Info("Main listener started", "addr", params.Listen)
+	slog.Info("Main listener started", slogutil.Address(params.Listen))
 
 	return srv.Serve(srvListener)
 }

+ 13 - 12
cmd/infra/ursrv/serve/serve.go

@@ -29,6 +29,7 @@ import (
 	"github.com/syncthing/syncthing/internal/blob"
 	"github.com/syncthing/syncthing/internal/blob/azureblob"
 	"github.com/syncthing/syncthing/internal/blob/s3"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/geoip"
 	"github.com/syncthing/syncthing/lib/ur/contract"
@@ -104,23 +105,23 @@ func (cli *CLI) Run() error {
 
 	urListener, err := net.Listen("tcp", cli.Listen)
 	if err != nil {
-		slog.Error("Failed to listen (usage reports)", "error", err)
+		slog.Error("Failed to listen (usage reports)", slogutil.Error(err))
 		return err
 	}
-	slog.Info("Listening (usage reports)", "address", urListener.Addr())
+	slog.Info("Listening (usage reports)", slogutil.Address(urListener.Addr()))
 
 	internalListener, err := net.Listen("tcp", cli.ListenInternal)
 	if err != nil {
-		slog.Error("Failed to listen (internal)", "error", err)
+		slog.Error("Failed to listen (internal)", slogutil.Error(err))
 		return err
 	}
-	slog.Info("Listening (internal)", "address", internalListener.Addr())
+	slog.Info("Listening (internal)", slogutil.Address(internalListener.Addr()))
 
 	var geo *geoip.Provider
 	if cli.GeoIPAccountID != 0 && cli.GeoIPLicenseKey != "" {
 		geo, err = geoip.NewGeoLite2CityProvider(context.Background(), cli.GeoIPAccountID, cli.GeoIPLicenseKey, os.TempDir())
 		if err != nil {
-			slog.Error("Failed to load GeoIP", "error", err)
+			slog.Error("Failed to load GeoIP", slogutil.Error(err))
 			return err
 		}
 		go geo.Serve(context.TODO())
@@ -132,20 +133,20 @@ func (cli *CLI) Run() error {
 	if cli.S3Endpoint != "" {
 		blobs, err = s3.NewSession(cli.S3Endpoint, cli.S3Region, cli.S3Bucket, cli.S3AccessKeyID, cli.S3SecretKey)
 		if err != nil {
-			slog.Error("Failed to create S3 session", "error", err)
+			slog.Error("Failed to create S3 session", slogutil.Error(err))
 			return err
 		}
 	} else if cli.AzureBlobAccount != "" {
 		blobs, err = azureblob.NewBlobStore(cli.AzureBlobAccount, cli.AzureBlobKey, cli.AzureBlobContainer)
 		if err != nil {
-			slog.Error("Failed to create Azure blob store", "error", err)
+			slog.Error("Failed to create Azure blob store", slogutil.Error(err))
 			return err
 		}
 	}
 
 	if _, err := os.Stat(cli.DumpFile); err != nil && blobs != nil {
 		if err := cli.downloadDumpFile(blobs); err != nil {
-			slog.Error("Failed to download dump file", "error", err)
+			slog.Error("Failed to download dump file", slogutil.Error(err))
 		}
 	}
 
@@ -167,7 +168,7 @@ func (cli *CLI) Run() error {
 	go func() {
 		for range time.Tick(cli.DumpInterval) {
 			if err := cli.saveDumpFile(srv, blobs); err != nil {
-				slog.Error("Failed to write dump file", "error", err)
+				slog.Error("Failed to write dump file", slogutil.Error(err))
 			}
 		}
 	}()
@@ -307,7 +308,7 @@ func (s *server) handleNewData(w http.ResponseWriter, r *http.Request) {
 	lr := &io.LimitedReader{R: r.Body, N: 40 * 1024}
 	bs, _ := io.ReadAll(lr)
 	if err := json.Unmarshal(bs, &rep); err != nil {
-		log.Error("Failed to decode JSON", "error", err)
+		log.Error("Failed to decode JSON", slogutil.Error(err))
 		http.Error(w, "JSON Decode Error", http.StatusInternalServerError)
 		return
 	}
@@ -317,7 +318,7 @@ func (s *server) handleNewData(w http.ResponseWriter, r *http.Request) {
 	rep.Address = addr
 
 	if err := rep.Validate(); err != nil {
-		log.Error("Failed to validate report", "error", err)
+		log.Error("Failed to validate report", slogutil.Error(err))
 		http.Error(w, "Validation Error", http.StatusInternalServerError)
 		return
 	}
@@ -394,7 +395,7 @@ func (s *server) load(r io.Reader) {
 		if err := dec.Decode(&rep); errors.Is(err, io.EOF) {
 			break
 		} else if err != nil {
-			slog.Error("Failed to load record", "error", err)
+			slog.Error("Failed to load record", slogutil.Error(err))
 			break
 		}
 		s.addReport(&rep)

+ 5 - 2
cmd/syncthing/blockprof.go

@@ -8,11 +8,14 @@ package main
 
 import (
 	"fmt"
+	"log/slog"
 	"os"
 	"runtime"
 	"runtime/pprof"
 	"syscall"
 	"time"
+
+	"github.com/syncthing/syncthing/internal/slogutil"
 )
 
 func startBlockProfiler() {
@@ -20,10 +23,10 @@ func startBlockProfiler() {
 	if profiler == nil {
 		panic("Couldn't find block profiler")
 	}
-	l.Debugln("Starting block profiling")
+	slog.Debug("Starting block profiling")
 	go func() {
 		err := saveBlockingProfiles(profiler) // Only returns on error
-		l.Warnln("Block profiler failed:", err)
+		slog.Error("Block profiler failed", slogutil.Error(err))
 		panic("Block profiler failed")
 	}()
 }

+ 0 - 9
cmd/syncthing/cli/utils.go

@@ -131,15 +131,6 @@ func prettyPrintResponse(response *http.Response) error {
 	return prettyPrintJSON(data)
 }
 
-func nulString(bs []byte) string {
-	for i := range bs {
-		if bs[i] == 0 {
-			return string(bs[:i])
-		}
-	}
-	return string(bs)
-}
-
 func normalizePath(path string) string {
 	return filepath.ToSlash(filepath.Clean(path))
 }

+ 6 - 3
cmd/syncthing/crash_reporting.go

@@ -11,12 +11,15 @@ import (
 	"context"
 	"crypto/sha256"
 	"fmt"
+	"log/slog"
 	"net/http"
 	"os"
 	"path/filepath"
 	"slices"
 	"strings"
 	"time"
+
+	"github.com/syncthing/syncthing/internal/slogutil"
 )
 
 const (
@@ -33,7 +36,7 @@ const (
 func uploadPanicLogs(ctx context.Context, urlBase, dir string) {
 	files, err := filepath.Glob(filepath.Join(dir, "panic-*.log"))
 	if err != nil {
-		l.Warnln("Failed to list panic logs:", err)
+		slog.ErrorContext(ctx, "Failed to list panic logs", slogutil.Error(err))
 		return
 	}
 
@@ -48,7 +51,7 @@ func uploadPanicLogs(ctx context.Context, urlBase, dir string) {
 		}
 
 		if err := uploadPanicLog(ctx, urlBase, file); err != nil {
-			l.Warnln("Reporting crash:", err)
+			slog.ErrorContext(ctx, "Reporting crash", slogutil.Error(err))
 		} else {
 			// Rename the log so we don't have to try to report it again. This
 			// succeeds, or it does not. There is no point complaining about it.
@@ -71,7 +74,7 @@ func uploadPanicLog(ctx context.Context, urlBase, file string) error {
 	data = filterLogLines(data)
 
 	hash := fmt.Sprintf("%x", sha256.Sum256(data))
-	l.Infof("Reporting crash found in %s (report ID %s) ...\n", filepath.Base(file), hash[:8])
+	slog.InfoContext(ctx, "Reporting crash", slogutil.FilePath(filepath.Base(file)), slog.String("id", hash[:8]))
 
 	url := fmt.Sprintf("%s/%s", urlBase, hash)
 	headReq, err := http.NewRequest(http.MethodHead, url, nil)

+ 2 - 4
cmd/syncthing/debug.go

@@ -6,8 +6,6 @@
 
 package main
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("main", "Main package")
+func init() { slogutil.RegisterPackage("Main package") }

+ 10 - 10
cmd/syncthing/generate/generate.go

@@ -18,9 +18,9 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/locations"
-	"github.com/syncthing/syncthing/lib/logger"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/syncthing"
+	"golang.org/x/exp/slog"
 )
 
 type CLI struct {
@@ -29,7 +29,7 @@ type CLI struct {
 	NoPortProbing bool   `help:"Don't try to find free ports for GUI and listen addresses on first startup" env:"STNOPORTPROBING"`
 }
 
-func (c *CLI) Run(l logger.Logger) error {
+func (c *CLI) Run() error {
 	// Support reading the password from a pipe or similar
 	if c.GUIPassword == "-" {
 		reader := bufio.NewReader(os.Stdin)
@@ -40,13 +40,13 @@ func (c *CLI) Run(l logger.Logger) error {
 		c.GUIPassword = string(password)
 	}
 
-	if err := Generate(l, locations.GetBaseDir(locations.ConfigBaseDir), c.GUIUser, c.GUIPassword, c.NoPortProbing); err != nil {
+	if err := Generate(locations.GetBaseDir(locations.ConfigBaseDir), c.GUIUser, c.GUIPassword, c.NoPortProbing); err != nil {
 		return fmt.Errorf("failed to generate config and keys: %w", err)
 	}
 	return nil
 }
 
-func Generate(l logger.Logger, confDir, guiUser, guiPassword string, skipPortProbing bool) error {
+func Generate(confDir, guiUser, guiPassword string, skipPortProbing bool) error {
 	dir, err := fs.ExpandTilde(confDir)
 	if err != nil {
 		return err
@@ -61,7 +61,7 @@ func Generate(l logger.Logger, confDir, guiUser, guiPassword string, skipPortPro
 	certFile, keyFile := locations.Get(locations.CertFile), locations.Get(locations.KeyFile)
 	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
 	if err == nil {
-		l.Warnln("Key exists; will not overwrite.")
+		slog.Warn("Key exists; will not overwrite.")
 	} else {
 		cert, err = syncthing.GenerateCertificate(certFile, keyFile)
 		if err != nil {
@@ -69,7 +69,7 @@ func Generate(l logger.Logger, confDir, guiUser, guiPassword string, skipPortPro
 		}
 	}
 	myID = protocol.NewDeviceID(cert.Certificate[0])
-	l.Infoln("Device ID:", myID)
+	slog.Info("Genereated new keypair", myID.LogAttr())
 
 	cfgFile := locations.Get(locations.ConfigFile)
 	cfg, _, err := config.Load(cfgFile, myID, events.NoopLogger)
@@ -87,7 +87,7 @@ func Generate(l logger.Logger, confDir, guiUser, guiPassword string, skipPortPro
 
 	var updateErr error
 	waiter, err := cfg.Modify(func(cfg *config.Configuration) {
-		updateErr = updateGUIAuthentication(l, &cfg.GUI, guiUser, guiPassword)
+		updateErr = updateGUIAuthentication(&cfg.GUI, guiUser, guiPassword)
 	})
 	if err != nil {
 		return fmt.Errorf("modify config: %w", err)
@@ -103,17 +103,17 @@ func Generate(l logger.Logger, confDir, guiUser, guiPassword string, skipPortPro
 	return nil
 }
 
-func updateGUIAuthentication(l logger.Logger, guiCfg *config.GUIConfiguration, guiUser, guiPassword string) error {
+func updateGUIAuthentication(guiCfg *config.GUIConfiguration, guiUser, guiPassword string) error {
 	if guiUser != "" && guiCfg.User != guiUser {
 		guiCfg.User = guiUser
-		l.Infoln("Updated GUI authentication user name:", guiUser)
+		slog.Info("Updated GUI authentication user", "name", guiUser)
 	}
 
 	if guiPassword != "" && guiCfg.Password != guiPassword {
 		if err := guiCfg.SetPassword(guiPassword); err != nil {
 			return fmt.Errorf("failed to set GUI authentication password: %w", err)
 		}
-		l.Infoln("Updated GUI authentication password.")
+		slog.Info("Updated GUI authentication password")
 	}
 	return nil
 }

+ 5 - 2
cmd/syncthing/heapprof.go

@@ -8,18 +8,21 @@ package main
 
 import (
 	"fmt"
+	"log/slog"
 	"os"
 	"runtime"
 	"runtime/pprof"
 	"syscall"
 	"time"
+
+	"github.com/syncthing/syncthing/internal/slogutil"
 )
 
 func startHeapProfiler() {
-	l.Debugln("Starting heap profiling")
+	slog.Debug("Starting heap profiling")
 	go func() {
 		err := saveHeapProfiles(1) // Only returns on error
-		l.Warnln("Heap profiler failed:", err)
+		slog.Error("Heap profiler failed", slogutil.Error(err))
 		panic("Heap profiler failed")
 	}()
 }

+ 71 - 94
cmd/syncthing/main.go

@@ -14,7 +14,8 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"log"
+	"log/slog"
+	"maps"
 	"net/http"
 	_ "net/http/pprof" // Need to import this to support STPROFILER.
 	"net/url"
@@ -40,6 +41,7 @@ import (
 	"github.com/syncthing/syncthing/cmd/syncthing/generate"
 	"github.com/syncthing/syncthing/internal/db"
 	"github.com/syncthing/syncthing/internal/db/sqlite"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	_ "github.com/syncthing/syncthing/lib/automaxprocs"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
@@ -47,7 +49,6 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/locations"
-	"github.com/syncthing/syncthing/lib/logger"
 	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/svcutil"
@@ -61,20 +62,8 @@ const (
 
 const (
 	extraUsage = `
-The --logflags value is a sum of the following:
-
-   1  Date
-   2  Time
-   4  Microsecond time
-   8  Long filename
-  16  Short filename
-
-I.e. to prefix each log line with time and filename, set --logflags=18 (2 + 16
-from above). The value 0 is used to disable all of the above. The default is
-to show date and time (3).
-
 Logging always happens to the command line (stdout) and optionally to the
-file at the path specified by --logfile=path. In addition to an path, the special
+file at the path specified by --log-file=path. In addition to an path, the special
 values "default" and "-" may be used. The former logs to DATADIR/syncthing.log
 (see --data), which is the default on Windows, and the latter only to stdout,
 no file, which is the default anywhere else.
@@ -87,11 +76,10 @@ The following environment variables modify Syncthing's behavior in ways that
 are mostly useful for developers. Use with care. See also the --debug-* options
 above.
 
- STTRACE           A comma separated string of facilities to trace. The valid
-                   facility strings are listed below.
-
- STLOCKTHRESHOLD   Used for debugging internal deadlocks; sets debug
-                   sensitivity.  Use only under direction of a developer.
+ STTRACE           A comma separated string of packages to trace or change log
+                   level for. The valid package strings are listed below. A log
+                   level (DEBUG, INFO, WARN or ERROR) can be added after each
+                   package, separated by a colon. Ex: "model:WARN,nat:DEBUG".
 
  STVERSIONEXTRA    Add extra information to the version string in logs and the
                    version line in the GUI. Can be set to the name of a wrapper
@@ -106,8 +94,8 @@ above.
                    of CPU usage (i.e. performance).
 
 
-Debugging Facilities
---------------------
+Logging Facilities
+------------------
 
 The following are valid values for the STTRACE variable:
 
@@ -170,8 +158,9 @@ type serveCmd struct {
 	DBDeleteRetentionInterval time.Duration `help:"Database deleted item retention interval" default:"4320h" env:"STDBDELETERETENTIONINTERVAL"`
 	GUIAddress                string        `name:"gui-address" help:"Override GUI address (e.g. \"http://192.0.2.42:8443\")" placeholder:"URL" env:"STGUIADDRESS"`
 	GUIAPIKey                 string        `name:"gui-apikey" help:"Override GUI API key" placeholder:"API-KEY" env:"STGUIAPIKEY"`
-	LogFile                   string        `name:"logfile" help:"Log file name (see below)" default:"${logFile}" placeholder:"PATH" env:"STLOGFILE"`
-	LogFlags                  int           `name:"logflags" help:"Select information in log line prefix (see below)" default:"${logFlags}" placeholder:"BITS" env:"STLOGFLAGS"`
+	LogFile                   string        `name:"log-file" aliases:"logfile" help:"Log file name (see below)" default:"${logFile}" placeholder:"PATH" env:"STLOGFILE"`
+	LogFlags                  int           `name:"logflags" help:"Deprecated option that does nothing, kept for compatibility" hidden:""`
+	LogLevel                  slog.Level    `help:"Log level for all packages (DEBUG,INFO,WARN,ERROR)" env:"STLOGLEVEL" default:"INFO"`
 	LogMaxFiles               int           `name:"log-max-old-files" help:"Number of old files to keep (zero to keep only current)" default:"${logMaxFiles}" placeholder:"N" env:"STLOGMAXOLDFILES"`
 	LogMaxSize                int           `help:"Maximum size of any file (zero to disable log rotation)" default:"${logMaxSize}" placeholder:"BYTES" env:"STLOGMAXSIZE"`
 	NoBrowser                 bool          `help:"Do not start browser" env:"STNOBROWSER"`
@@ -180,7 +169,6 @@ type serveCmd struct {
 	NoUpgrade                 bool          `help:"Disable automatic upgrades" env:"STNOUPGRADE"`
 	Paused                    bool          `help:"Start with all devices and folders paused" env:"STPAUSED"`
 	Unpaused                  bool          `help:"Start with all devices and folders unpaused" env:"STUNPAUSED"`
-	Verbose                   bool          `help:"Print verbose log output" env:"STVERBOSE"`
 
 	// Debug options below
 	DebugGUIAssetsDir   string `help:"Directory to load GUI assets from" placeholder:"PATH" env:"STGUIASSETS"`
@@ -199,14 +187,9 @@ type serveCmd struct {
 func defaultVars() kong.Vars {
 	vars := kong.Vars{}
 
-	vars["logFlags"] = strconv.Itoa(logger.DefaultFlags)
 	vars["logMaxSize"] = strconv.Itoa(10 << 20) // 10 MiB
 	vars["logMaxFiles"] = "3"                   // plus the current one
 
-	if os.Getenv("STTRACE") != "" {
-		vars["logFlags"] = strconv.Itoa(logger.DebugFlags)
-	}
-
 	// On non-Windows, we explicitly default to "-" which means stdout. On
 	// Windows, the "default" options.logFile will later be replaced with the
 	// default path, unless the user has manually specified "-" or
@@ -234,13 +217,13 @@ func main() {
 		defaultVars(),
 	)
 	if err != nil {
-		log.Fatal(err)
+		slog.Error("Parsing startup", slogutil.Error(err))
+		os.Exit(svcutil.ExitError.AsInt())
 	}
 
 	kongplete.Complete(parser)
 	ctx, err := parser.Parse(os.Args[1:])
 	parser.FatalIfErrorf(err)
-	ctx.BindTo(l, (*logger.Logger)(nil)) // main logger available to subcommands
 	err = ctx.Run()
 	parser.FatalIfErrorf(err)
 }
@@ -252,15 +235,13 @@ func helpHandler(options kong.HelpOptions, ctx *kong.Context) error {
 	if ctx.Command() == "serve" {
 		// Help was requested for `syncthing serve`, so we add our extra
 		// usage info afte the normal options output.
-		fmt.Printf(extraUsage, debugFacilities())
+		fmt.Printf(extraUsage, logPackages())
 	}
 	return nil
 }
 
 // serveCmd.Run() is the entrypoint for `syncthing serve`
 func (c *serveCmd) Run() error {
-	l.SetFlags(c.LogFlags)
-
 	if c.GUIAddress != "" {
 		// The config picks this up from the environment.
 		os.Setenv("STGUIADDRESS", c.GUIAddress)
@@ -274,6 +255,9 @@ func (c *serveCmd) Run() error {
 		osutil.HideConsole()
 	}
 
+	// The default log level for all packages
+	slogutil.SetDefaultLevel(c.LogLevel)
+
 	// Treat an explicitly empty log file name as no log file
 	if c.LogFile == "" {
 		c.LogFile = "-"
@@ -281,7 +265,7 @@ func (c *serveCmd) Run() error {
 	if c.LogFile != "default" {
 		// We must set this *after* expandLocations above.
 		if err := locations.Set(locations.LogFile, c.LogFile); err != nil {
-			l.Warnln("Setting log file path:", err)
+			slog.Error("Failed to set log file path", slogutil.Error(err))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 	}
@@ -290,7 +274,7 @@ func (c *serveCmd) Run() error {
 		// The asset dir is blank if STGUIASSETS wasn't set, in which case we
 		// should look for extra assets in the default place.
 		if err := locations.Set(locations.GUIAssets, c.DebugGUIAssetsDir); err != nil {
-			l.Warnln("Setting GUI assets path:", err)
+			slog.Error("Failed to set GUI assets path", slogutil.Error(err))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 	}
@@ -298,7 +282,7 @@ func (c *serveCmd) Run() error {
 	// Ensure that our config and data directories exist.
 	for _, loc := range []locations.BaseDirEnum{locations.ConfigBaseDir, locations.DataBaseDir} {
 		if err := syncthing.EnsureDir(locations.GetBaseDir(loc), 0o700); err != nil {
-			l.Warnln("Failed to ensure directory exists:", err)
+			slog.Error("Failed to ensure directory exists", slogutil.Error(err))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 	}
@@ -321,29 +305,27 @@ func openGUI() error {
 			return err
 		}
 	} else {
-		l.Warnln("Browser: GUI is currently disabled")
+		slog.Error("Browser: GUI is currently disabled")
 	}
 	return nil
 }
 
-func debugFacilities() string {
-	facilities := l.Facilities()
+func logPackages() string {
+	packages := slogutil.PackageDescrs()
 
 	// Get a sorted list of names
-	var names []string
+	names := slices.Sorted(maps.Keys(packages))
 	maxLen := 0
-	for name := range facilities {
-		names = append(names, name)
+	for _, name := range names {
 		if len(name) > maxLen {
 			maxLen = len(name)
 		}
 	}
-	slices.Sort(names)
 
 	// Format the choices
 	b := new(bytes.Buffer)
 	for _, name := range names {
-		fmt.Fprintf(b, " %-*s - %s\n", maxLen, name, facilities[name])
+		fmt.Fprintf(b, " %-*s - %s\n", maxLen, name, packages[name])
 	}
 	return b.String()
 }
@@ -371,7 +353,7 @@ func checkUpgrade() (upgrade.Release, error) {
 		return upgrade.Release{}, &errNoUpgrade{build.Version, release.Tag}
 	}
 
-	l.Infof("Upgrade available (current %q < latest %q)", build.Version, release.Tag)
+	slog.Info("Upgrade available", "current", build.Version, "latest", release.Tag)
 	return release, nil
 }
 
@@ -428,13 +410,9 @@ func (c *serveCmd) syncthingMain() {
 		startPerfStats()
 	}
 
-	// Set a log prefix similar to the ID we will have later on, or early log
-	// lines look ugly.
-	l.SetPrefix("[start] ")
-
 	// Print our version information up front, so any crash that happens
 	// early etc. will have it available.
-	l.Infoln(build.LongVersion)
+	slog.Info(build.LongVersion) //nolint:sloglint
 
 	// Ensure that we have a certificate and key.
 	cert, err := syncthing.LoadOrGenerateCertificate(
@@ -442,7 +420,7 @@ func (c *serveCmd) syncthingMain() {
 		locations.Get(locations.KeyFile),
 	)
 	if err != nil {
-		l.Warnln("Failed to load/generate certificate:", err)
+		slog.Error("Failed to load/generate certificate", slogutil.Error(err))
 		os.Exit(1)
 	}
 
@@ -450,10 +428,10 @@ func (c *serveCmd) syncthingMain() {
 	lf := flock.New(locations.Get(locations.LockFile))
 	locked, err := lf.TryLock()
 	if err != nil {
-		l.Warnln("Failed to acquire lock:", err)
+		slog.Error("Failed to acquire lock", slogutil.Error(err))
 		os.Exit(1)
 	} else if !locked {
-		l.Warnln("Failed to acquire lock: is another Syncthing instance already running?")
+		slog.Error("Failed to acquire lock: is another Syncthing instance already running?")
 		os.Exit(1)
 	}
 
@@ -462,7 +440,7 @@ func (c *serveCmd) syncthingMain() {
 
 	// earlyService is a supervisor that runs the services needed for or
 	// before app startup; the event logger, and the config service.
-	spec := svcutil.SpecWithDebugLogger(l)
+	spec := svcutil.SpecWithDebugLogger()
 	earlyService := suture.New("early", spec)
 	earlyService.ServeBackground(ctx)
 
@@ -471,7 +449,7 @@ func (c *serveCmd) syncthingMain() {
 
 	cfgWrapper, err := syncthing.LoadConfigAtStartup(locations.Get(locations.ConfigFile), cert, evLogger, c.AllowNewerConfig, c.NoPortProbing)
 	if err != nil {
-		l.Warnln("Failed to initialize config:", err)
+		slog.Error("Failed to initialize config", slogutil.Error(err))
 		os.Exit(svcutil.ExitError.AsInt())
 	}
 	earlyService.Add(cfgWrapper)
@@ -483,7 +461,7 @@ func (c *serveCmd) syncthingMain() {
 
 	if build.IsCandidate && !upgrade.DisabledByCompilation && !c.NoUpgrade {
 		cfgWrapper.Modify(func(cfg *config.Configuration) {
-			l.Infoln("Automatic upgrade is always enabled for candidate releases.")
+			slog.Info("Automatic upgrade is always enabled for candidate releases")
 			if cfg.Options.AutoUpgradeIntervalH == 0 || cfg.Options.AutoUpgradeIntervalH > 24 {
 				cfg.Options.AutoUpgradeIntervalH = 12
 				// Set the option into the config as well, as the auto upgrade
@@ -495,13 +473,13 @@ func (c *serveCmd) syncthingMain() {
 	}
 
 	if err := syncthing.TryMigrateDatabase(c.DBDeleteRetentionInterval); err != nil {
-		l.Warnln("Failed to migrate old-style database:", err)
+		slog.Error("Failed to migrate old-style database", slogutil.Error(err))
 		os.Exit(1)
 	}
 
 	sdb, err := syncthing.OpenDatabase(locations.Get(locations.Database), c.DBDeleteRetentionInterval)
 	if err != nil {
-		l.Warnln("Error opening database:", err)
+		slog.Error("Error opening database", slogutil.Error(err))
 		os.Exit(1)
 	}
 
@@ -518,12 +496,12 @@ func (c *serveCmd) syncthingMain() {
 		}
 		if err != nil {
 			if _, ok := err.(*errNoUpgrade); ok || err == errTooEarlyUpgradeCheck || err == errTooEarlyUpgrade {
-				l.Debugln("Initial automatic upgrade:", err)
+				slog.Debug("Initial automatic upgrade", slogutil.Error(err))
 			} else {
-				l.Infoln("Initial automatic upgrade:", err)
+				slog.Info("Initial automatic upgrade", slogutil.Error(err))
 			}
 		} else {
-			l.Infof("Upgraded to %q, should exit now.", release.Tag)
+			slog.Info("Upgraded, should exit now", "newVersion", release.Tag)
 			os.Exit(svcutil.ExitUpgrade.AsInt())
 		}
 	}
@@ -538,18 +516,17 @@ func (c *serveCmd) syncthingMain() {
 		NoUpgrade:             c.NoUpgrade,
 		ProfilerAddr:          c.DebugProfilerListen,
 		ResetDeltaIdxs:        c.DebugResetDeltaIdxs,
-		Verbose:               c.Verbose,
 		DBMaintenanceInterval: c.DBMaintenanceInterval,
 	}
 
 	if c.Audit || cfgWrapper.Options().AuditEnabled {
-		l.Infoln("Auditing is enabled.")
+		slog.Info("Auditing is enabled")
 
 		auditFile := cfgWrapper.Options().AuditFile
 
 		// Ignore config option if command-line option is set
 		if c.AuditFile != "" {
-			l.Debugln("Using the audit file from the command-line parameter.")
+			slog.Debug("Using the audit file from the command-line parameter", slogutil.FilePath(c.AuditFile))
 			auditFile = c.AuditFile
 		}
 
@@ -558,7 +535,7 @@ func (c *serveCmd) syncthingMain() {
 
 	app, err := syncthing.New(cfgWrapper, sdb, evLogger, cert, appOpts)
 	if err != nil {
-		l.Warnln("Failed to start Syncthing:", err)
+		slog.Error("Failed to start Syncthing", slogutil.Error(err))
 		os.Exit(svcutil.ExitError.AsInt())
 	}
 
@@ -571,11 +548,11 @@ func (c *serveCmd) syncthingMain() {
 	if c.DebugProfileCPU {
 		f, err := os.Create(fmt.Sprintf("cpu-%d.pprof", os.Getpid()))
 		if err != nil {
-			l.Warnln("Creating profile:", err)
+			slog.Error("Failed to create profile", slogutil.Error(err))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 		if err := pprof.StartCPUProfile(f); err != nil {
-			l.Warnln("Starting profile:", err)
+			slog.Error("Failed to start profile", slogutil.Error(err))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 	}
@@ -595,7 +572,7 @@ func (c *serveCmd) syncthingMain() {
 	status := app.Wait()
 
 	if status == svcutil.ExitError {
-		l.Warnln("Syncthing stopped with error:", app.Error())
+		slog.Error("Syncthing stopped with error", slogutil.Error(app.Error()))
 	}
 
 	if c.DebugProfileCPU {
@@ -663,13 +640,13 @@ func auditWriter(auditFile string) io.Writer {
 		}
 		fd, err = os.OpenFile(auditFile, auditFlags, 0o600)
 		if err != nil {
-			l.Warnln("Audit:", err)
+			slog.Error("Failed to open audit file", slogutil.Error(err))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 		auditDest = auditFile
 	}
 
-	l.Infoln("Audit log in", auditDest)
+	slog.Info("Writing audit log", slogutil.FilePath(auditDest))
 
 	return fd
 }
@@ -679,7 +656,7 @@ func (c *serveCmd) autoUpgradePossible() bool {
 		return false
 	}
 	if c.NoUpgrade {
-		l.Infof("No automatic upgrades; STNOUPGRADE environment variable defined.")
+		slog.Info("No automatic upgrades; STNOUPGRADE environment variable defined")
 		return false
 	}
 	return true
@@ -696,7 +673,7 @@ func autoUpgrade(cfg config.Wrapper, app *syncthing.App, evLogger events.Logger)
 				continue
 			}
 			if cfg.Options().AutoUpgradeEnabled() {
-				l.Infof("Connected to device %s with a newer version (current %q < remote %q). Checking for upgrades.", data["id"], build.Version, data["clientVersion"])
+				slog.Info("Connected to device with a newer version; checking for upgrades", slog.String("device", data["id"]), slog.String("ourVersion", build.Version), slog.String("theirVersion", data["clientVersion"]))
 			}
 		case <-timer.C:
 		}
@@ -716,7 +693,7 @@ func autoUpgrade(cfg config.Wrapper, app *syncthing.App, evLogger events.Logger)
 		if err != nil {
 			// Don't complain too loudly here; we might simply not have
 			// internet connectivity, or the upgrade server might be down.
-			l.Infoln("Automatic upgrade:", err)
+			slog.Info("Automatic upgrade", slogutil.Error(err))
 			timer.Reset(checkInterval)
 			continue
 		}
@@ -727,15 +704,15 @@ func autoUpgrade(cfg config.Wrapper, app *syncthing.App, evLogger events.Logger)
 			continue
 		}
 
-		l.Infof("Automatic upgrade (current %q < latest %q)", build.Version, rel.Tag)
+		slog.Info("Automatic upgrade", "current", build.Version, "latest", rel.Tag)
 		err = upgrade.To(rel)
 		if err != nil {
-			l.Warnln("Automatic upgrade:", err)
+			slog.Error("Automatic upgrade failed", slogutil.Error(err))
 			timer.Reset(checkInterval)
 			continue
 		}
 		sub.Unsubscribe()
-		l.Warnf("Automatically upgraded to version %q. Restarting in 1 minute.", rel.Tag)
+		slog.Error("Automatically upgraded, restarting in 1 minute", slog.String("newVersion", rel.Tag))
 		time.Sleep(time.Minute)
 		app.Stop(svcutil.ExitUpgrade)
 		return
@@ -788,22 +765,22 @@ func cleanConfigDirectory() {
 		fs := fs.NewFilesystem(fs.FilesystemTypeBasic, locations.GetBaseDir(locations.ConfigBaseDir))
 		files, err := fs.Glob(pat)
 		if err != nil {
-			l.Infoln("Cleaning:", err)
+			slog.Warn("Failed to clean config directory", slogutil.Error(err))
 			continue
 		}
 
 		for _, file := range files {
 			info, err := fs.Lstat(file)
 			if err != nil {
-				l.Infoln("Cleaning:", err)
+				slog.Warn("Failed to clean config directory", slogutil.Error(err))
 				continue
 			}
 
 			if time.Since(info.ModTime()) > dur {
 				if err = fs.RemoveAll(file); err != nil {
-					l.Infoln("Cleaning:", err)
+					slog.Warn("Failed to clean config directory", slogutil.Error(err))
 				} else {
-					l.Infoln("Cleaned away old file", filepath.Base(file))
+					slog.Warn("Cleaned away old file", slogutil.FilePath(filepath.Base(file)))
 				}
 			}
 		}
@@ -820,7 +797,7 @@ func setPauseState(cfgWrapper config.Wrapper, paused bool) {
 		}
 	})
 	if err != nil {
-		l.Warnln("Cannot adjust paused state:", err)
+		slog.Error("Cannot adjust paused state", slogutil.Error(err))
 		os.Exit(svcutil.ExitError.AsInt())
 	}
 }
@@ -847,7 +824,7 @@ func (deviceIDCmd) Run() error {
 		locations.Get(locations.KeyFile),
 	)
 	if err != nil {
-		l.Warnln("Error reading device ID:", err)
+		slog.Error("Failed to read device ID", slogutil.Error(err))
 		os.Exit(svcutil.ExitError.AsInt())
 	}
 
@@ -870,7 +847,7 @@ type upgradeCmd struct {
 func (u upgradeCmd) Run() error {
 	if u.CheckOnly {
 		if _, err := checkUpgrade(); err != nil {
-			l.Warnln("Checking for upgrade:", err)
+			slog.Error("Failed to check for upgrade", slogutil.Error(err))
 			os.Exit(exitCodeForUpgrade(err))
 		}
 		return nil
@@ -879,10 +856,10 @@ func (u upgradeCmd) Run() error {
 	if u.From != "" {
 		err := upgrade.ToURL(u.From)
 		if err != nil {
-			l.Warnln("Error while Upgrading:", err)
+			slog.Error("Failed to upgrade", slogutil.Error(err))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
-		l.Infoln("Upgraded from", u.From)
+		slog.Info("Upgraded", "from", u.From)
 		return nil
 	}
 
@@ -892,7 +869,7 @@ func (u upgradeCmd) Run() error {
 		var locked bool
 		locked, err = lf.TryLock()
 		if err != nil {
-			l.Warnln("Upgrade:", err)
+			slog.Error("Failed to lock for upgrade", slogutil.Error(err))
 			os.Exit(1)
 		} else if locked {
 			err = upgradeViaRest()
@@ -901,10 +878,10 @@ func (u upgradeCmd) Run() error {
 		}
 	}
 	if err != nil {
-		l.Warnln("Upgrade:", err)
+		slog.Error("Failed to check for upgrade", slogutil.Error(err))
 		os.Exit(exitCodeForUpgrade(err))
 	}
-	l.Infof("Upgraded to %q", release.Tag)
+	slog.Info("Upgraded", "to", release.Tag)
 	os.Exit(svcutil.ExitUpgrade.AsInt())
 	return nil
 }
@@ -913,7 +890,7 @@ type browserCmd struct{}
 
 func (browserCmd) Run() error {
 	if err := openGUI(); err != nil {
-		l.Warnln("Failed to open web UI:", err)
+		slog.Error("Failed to open web UI", slogutil.Error(err))
 		os.Exit(svcutil.ExitError.AsInt())
 	}
 	return nil
@@ -929,12 +906,12 @@ type debugCmd struct {
 type resetDatabaseCmd struct{}
 
 func (resetDatabaseCmd) Run() error {
-	l.Infoln("Removing database in", locations.Get(locations.Database))
+	slog.Info("Removing database", slogutil.FilePath(locations.Get(locations.Database)))
 	if err := os.RemoveAll(locations.Get(locations.Database)); err != nil {
-		l.Warnln("Resetting database:", err)
+		slog.Error("Failed to reset database", slogutil.Error(err))
 		os.Exit(svcutil.ExitError.AsInt())
 	}
-	l.Infoln("Successfully reset database - it will be rebuilt after next start.")
+	slog.Info("Reset database - it will be rebuilt after next start")
 	return nil
 }
 

+ 22 - 39
cmd/syncthing/monitor.go

@@ -11,26 +11,28 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"log/slog"
 	"os"
 	"os/exec"
 	"os/signal"
 	"path/filepath"
 	"strings"
+	"sync"
 	"syscall"
 	"time"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/locations"
 	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 var (
 	stdoutFirstLines []string // The first 10 lines of stdout
 	stdoutLastLines  []string // The last 50 lines of stdout
-	stdoutMut        = sync.NewMutex()
+	stdoutMut        sync.Mutex
 )
 
 const (
@@ -44,8 +46,6 @@ const (
 )
 
 func (c *serveCmd) monitorMain() {
-	l.SetPrefix("[monitor] ")
-
 	var dst io.Writer = os.Stdout
 
 	logFile := locations.Get(locations.LogFile)
@@ -64,7 +64,7 @@ func (c *serveCmd) monitorMain() {
 			fileDst, err = open(logFile)
 		}
 		if err != nil {
-			l.Warnln("Failed to set up logging to file, proceeding with logging to stdout only:", err)
+			slog.Error("Failed to set up logging to file, proceeding with logging to stdout only", slogutil.Error(err))
 		} else {
 			if build.IsWindows {
 				// Translate line breaks to Windows standard
@@ -78,14 +78,14 @@ func (c *serveCmd) monitorMain() {
 			// Log to both stdout and file.
 			dst = io.MultiWriter(dst, fileDst)
 
-			l.Infof(`Log output saved to file "%s"`, logFile)
+			slog.Info("Saved log output", slogutil.FilePath(logFile))
 		}
 	}
 
 	args := os.Args
 	binary, err := getBinary(args[0])
 	if err != nil {
-		l.Warnln("Error starting the main Syncthing process:", err)
+		slog.Error("Failed to start the main Syncthing process", slogutil.Error(err))
 		panic("Error starting the main Syncthing process")
 	}
 	var restarts [restartCounts]time.Time
@@ -102,7 +102,7 @@ func (c *serveCmd) monitorMain() {
 		maybeReportPanics()
 
 		if t := time.Since(restarts[0]); t < restartLoopThreshold {
-			l.Warnf("%d restarts in %v; not retrying further", restartCounts, t)
+			slog.Error("Too many restarts; not retrying further", slog.Int("count", restartCounts), slog.Any("interval", t))
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 
@@ -122,10 +122,10 @@ func (c *serveCmd) monitorMain() {
 			panic(err)
 		}
 
-		l.Debugln("Starting syncthing")
+		slog.Debug("Starting syncthing")
 		err = cmd.Start()
 		if err != nil {
-			l.Warnln("Error starting the main Syncthing process:", err)
+			slog.Error("Failed to start the main Syncthing process", slogutil.Error(err))
 			panic("Error starting the main Syncthing process")
 		}
 
@@ -134,7 +134,7 @@ func (c *serveCmd) monitorMain() {
 		stdoutLastLines = make([]string, 0, 50)
 		stdoutMut.Unlock()
 
-		wg := sync.NewWaitGroup()
+		var wg sync.WaitGroup
 
 		wg.Add(1)
 		go func() {
@@ -158,13 +158,13 @@ func (c *serveCmd) monitorMain() {
 		stopped := false
 		select {
 		case s := <-stopSign:
-			l.Infof("Signal %d received; exiting", s)
+			slog.Info("Received signal; exiting", "signal", s)
 			cmd.Process.Signal(sigTerm)
 			err = <-exit
 			stopped = true
 
 		case s := <-restartSign:
-			l.Infof("Signal %d received; restarting", s)
+			slog.Info("Received signal; restarting", "signal", s)
 			cmd.Process.Signal(sigHup)
 			err = <-exit
 
@@ -184,9 +184,9 @@ func (c *serveCmd) monitorMain() {
 			if exitCode == svcutil.ExitUpgrade.AsInt() {
 				// Restart the monitor process to release the .old
 				// binary as part of the upgrade process.
-				l.Infoln("Restarting monitor...")
+				slog.Info("Restarting monitor...")
 				if err = restartMonitor(binary, args); err != nil {
-					l.Warnln("Restart:", err)
+					slog.Error("Failed to restart monitor", slogutil.Error(err))
 				}
 				os.Exit(exitCode)
 			}
@@ -196,7 +196,7 @@ func (c *serveCmd) monitorMain() {
 			os.Exit(svcutil.ExitError.AsInt())
 		}
 
-		l.Infoln("Syncthing exited:", err)
+		slog.Info("Syncthing exited", slogutil.Error(err))
 		time.Sleep(restartPause)
 
 		if first {
@@ -243,29 +243,13 @@ func copyStderr(stderr io.Reader, dst io.Writer) {
 		if panicFd == nil && (strings.HasPrefix(line, "panic:") || strings.HasPrefix(line, "fatal error:")) {
 			panicFd, err = os.Create(locations.GetTimestamped(locations.PanicLog))
 			if err != nil {
-				l.Warnln("Create panic log:", err)
+				slog.Error("Failed to create panic log", slogutil.Error(err))
 				continue
 			}
 
-			l.Warnf("Panic detected, writing to \"%s\"", panicFd.Name())
-			if strings.Contains(line, "leveldb") && strings.Contains(line, "corrupt") {
-				l.Warnln(`
-*********************************************************************************
-* Crash due to corrupt database.                                                *
-*                                                                               *
-* This crash usually occurs due to one of the following reasons:                *
-*  - Syncthing being stopped abruptly (killed/loss of power)                    *
-*  - Bad hardware (memory/disk issues)                                          *
-*  - Software that affects disk writes (SSD caching software and similar)       *
-*                                                                               *
-* Please see the following URL for instructions on how to recover:              *
-*   https://docs.syncthing.net/users/faq.html#my-syncthing-database-is-corrupt  *
-*********************************************************************************
-`)
-			} else {
-				l.Warnln("Please check for existing issues with similar panic message at https://github.com/syncthing/syncthing/issues/")
-				l.Warnln("If no issue with similar panic message exists, please create a new issue with the panic log attached")
-			}
+			slog.Error("Panic detected, writing to file", slogutil.FilePath(panicFd.Name()))
+			slog.Info("Please check for existing issues with similar panic message at https://github.com/syncthing/syncthing/issues/")
+			slog.Info("If no issue with similar panic message exists, please create a new issue with the panic log attached")
 
 			stdoutMut.Lock()
 			for _, line := range stdoutFirstLines {
@@ -446,7 +430,6 @@ func newAutoclosedFile(name string, closeDelay, maxOpenTime time.Duration) (*aut
 		name:        name,
 		closeDelay:  closeDelay,
 		maxOpenTime: maxOpenTime,
-		mut:         sync.NewMutex(),
 		closed:      make(chan struct{}),
 		closeTimer:  time.NewTimer(time.Minute),
 	}
@@ -554,7 +537,7 @@ func maybeReportPanics() {
 	// Try to get a config to see if/where panics should be reported.
 	cfg, err := loadOrDefaultConfig()
 	if err != nil {
-		l.Warnln("Couldn't load config; not reporting crash")
+		slog.Error("Couldn't load config; not reporting crash")
 		return
 	}
 
@@ -574,7 +557,7 @@ func maybeReportPanics() {
 		case <-ctx.Done():
 			return
 		case <-time.After(panicUploadNoticeWait):
-			l.Warnln("Uploading crash reports is taking a while, please wait...")
+			slog.Warn("Uploading crash reports is taking a while, please wait")
 		}
 	}()
 

+ 1 - 1
etc/linux-systemd/system/[email protected]

@@ -7,7 +7,7 @@ StartLimitBurst=4
 
 [Service]
 User=%i
-ExecStart=/usr/bin/syncthing serve --no-browser --no-restart --logflags=0
+ExecStart=/usr/bin/syncthing serve --no-browser --no-restart
 Restart=on-failure
 RestartSec=1
 SuccessExitStatus=3 4

+ 9 - 4
gui/default/syncthing/core/logViewerModalView.html

@@ -16,11 +16,16 @@
         <label translate>Available debug logging facilities:</label>
         <table class="table table-condensed table-striped">
           <tbody>
-            <tr ng-repeat="(name, data) in logging.facilities">
-              <td>
-                <input type="checkbox" ng-model="data.enabled" ng-change="logging.onFacilityChange(name)" ng-disabled="data.enabled == null"> <span>{{ name }}</span>
+            <tr ng-repeat="(key, level) in logging.facilities.levels">
+              <td>{{ logging.facilities.packages[key] }} (<code>{{ key }}</code>)</td>
+              <td class="form-group">
+                <select class="form-control" ng-model="logging.facilities.levels[key]" ng-change="logging.onFacilityChange()" ng-disabled="logging.facilities.updating">
+                  <option value="DEBUG" translate>Debug</option>
+                  <option value="INFO" translate>Info</option>
+                  <option value="WARN" translate>Warning</option>
+                  <option value="ERROR" translate>Error</option>
+                </select>
               </td>
-              <td>{{ data.description }}</td>
             </tr>
           </tbody>
         </table>

+ 7 - 18
gui/default/syncthing/core/syncthingController.js

@@ -1568,16 +1568,8 @@ angular.module('syncthing.core')
         $scope.logging = {
             facilities: {},
             refreshFacilities: function () {
-                $http.get(urlbase + '/system/debug').success(function (data) {
-                    var facilities = {};
-                    data.enabled = data.enabled || [];
-                    $.each(data.facilities, function (key, value) {
-                        facilities[key] = {
-                            description: value,
-                            enabled: data.enabled.indexOf(key) > -1
-                        }
-                    })
-                    $scope.logging.facilities = facilities;
+                $http.get(urlbase + '/system/loglevels').success(function (data) {
+                    $scope.logging.facilities = data;
                 }).error($scope.emitHTTPError);
             },
             show: function () {
@@ -1597,13 +1589,10 @@ angular.module('syncthing.core')
                 });
                 showModal('#logViewer');
             },
-            onFacilityChange: function (facility) {
-                var enabled = $scope.logging.facilities[facility].enabled;
-                // Disable checkboxes while we're in flight.
-                $.each($scope.logging.facilities, function (key) {
-                    $scope.logging.facilities[key].enabled = null;
-                })
-                $http.post(urlbase + '/system/debug?' + (enabled ? 'enable=' : 'disable=') + facility)
+            onFacilityChange: function () {
+                // Disable editing while we're in flight.
+                $scope.logging.facilities.updating = true;
+                $http.post(urlbase + '/system/loglevels', $scope.logging.facilities.levels)
                     .success($scope.logging.refreshFacilities)
                     .error($scope.emitHTTPError);
             },
@@ -1626,7 +1615,7 @@ angular.module('syncthing.core')
             content: function () {
                 var content = "";
                 $.each($scope.logging.entries, function (idx, entry) {
-                    content += entry.when.split('.')[0].replace('T', ' ') + ' ' + entry.message + "\n";
+                    content += entry.when.split('.')[0].replace('T', ' ') + ' ' + entry.level + ' ' + entry.message + "\n";
                 });
                 return content;
             },

+ 1 - 2
internal/db/olddb/smallindex.go

@@ -9,9 +9,9 @@ package olddb
 import (
 	"encoding/binary"
 	"slices"
+	"sync"
 
 	"github.com/syncthing/syncthing/internal/db/olddb/backend"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 // A smallIndex is an in memory bidirectional []byte to uint32 map. It gives
@@ -32,7 +32,6 @@ func newSmallIndex(db backend.Backend, prefix []byte) *smallIndex {
 		prefix: prefix,
 		id2val: make(map[uint32]string),
 		val2id: make(map[string]uint32),
-		mut:    sync.NewMutex(),
 	}
 	idx.load()
 	return idx

+ 2 - 1
internal/db/sqlite/db_folderdb.go

@@ -12,6 +12,7 @@ import (
 	"fmt"
 	"io"
 	"iter"
+	"log/slog"
 	"path/filepath"
 	"strings"
 	"time"
@@ -78,7 +79,7 @@ func (s *DB) getFolderDB(folder string, create bool) (*folderDB, error) {
 		}
 	}
 
-	l.Debugf("Folder %s in database %s", folder, dbName)
+	slog.Debug("Folder database opened", "folder", folder, "db", dbName)
 	path := dbName
 	if !filepath.IsAbs(path) {
 		path = filepath.Join(s.pathBase, dbName)

+ 3 - 1
internal/db/sqlite/db_open.go

@@ -7,12 +7,14 @@
 package sqlite
 
 import (
+	"log/slog"
 	"os"
 	"path/filepath"
 	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/internal/db"
+	"github.com/syncthing/syncthing/internal/slogutil"
 )
 
 const maxDBConns = 16
@@ -128,7 +130,7 @@ func OpenTemp() (*DB, error) {
 		return nil, wrap(err)
 	}
 	path := filepath.Join(dir, "db")
-	l.Debugln("Test DB in", path)
+	slog.Debug("Test DB", slogutil.FilePath(path))
 	return Open(path)
 }
 

+ 28 - 15
internal/db/sqlite/db_service.go

@@ -9,10 +9,12 @@ package sqlite
 import (
 	"context"
 	"fmt"
+	"log/slog"
 	"time"
 
 	"github.com/jmoiron/sqlx"
 	"github.com/syncthing/syncthing/internal/db"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/thejerf/suture/v4"
 )
 
@@ -56,7 +58,7 @@ func (s *Service) Serve(ctx context.Context) error {
 	if wait < 0 {
 		wait = time.Minute
 	}
-	l.Debugln("Next periodic run in", wait)
+	slog.DebugContext(ctx, "Next periodic run due", "after", wait)
 
 	timer := time.NewTimer(wait)
 	for {
@@ -71,17 +73,17 @@ func (s *Service) Serve(ctx context.Context) error {
 		}
 
 		timer.Reset(s.maintenanceInterval)
-		l.Debugln("Next periodic run in", s.maintenanceInterval)
+		slog.DebugContext(ctx, "Next periodic run due", "after", s.maintenanceInterval)
 		_ = s.internalMeta.PutTime(lastMaintKey, time.Now())
 	}
 }
 
 func (s *Service) periodic(ctx context.Context) error {
 	t0 := time.Now()
-	l.Debugln("Periodic start")
+	slog.DebugContext(ctx, "Periodic start")
 
 	t1 := time.Now()
-	defer func() { l.Debugln("Periodic done in", time.Since(t1), "+", t1.Sub(t0)) }()
+	defer func() { slog.DebugContext(ctx, "Periodic done in", "t1", time.Since(t1), "t0t1", t1.Sub(t0)) }()
 
 	s.sdb.updateLock.Lock()
 	err := tidy(ctx, s.sdb.sql)
@@ -94,7 +96,7 @@ func (s *Service) periodic(ctx context.Context) error {
 		fdb.updateLock.Lock()
 		defer fdb.updateLock.Unlock()
 
-		if err := garbageCollectOldDeletedLocked(fdb); err != nil {
+		if err := garbageCollectOldDeletedLocked(ctx, fdb); err != nil {
 			return wrap(err)
 		}
 		if err := garbageCollectBlocklistsAndBlocksLocked(ctx, fdb); err != nil {
@@ -118,15 +120,16 @@ func tidy(ctx context.Context, db *sqlx.DB) error {
 	return nil
 }
 
-func garbageCollectOldDeletedLocked(fdb *folderDB) error {
+func garbageCollectOldDeletedLocked(ctx context.Context, fdb *folderDB) error {
+	l := slog.With("fdb", fdb.baseDB)
 	if fdb.deleteRetention <= 0 {
-		l.Debugln(fdb.baseName, "delete retention is infinite, skipping cleanup")
+		slog.DebugContext(ctx, "Delete retention is infinite, skipping cleanup")
 		return nil
 	}
 
 	// Remove deleted files that are marked as not needed (we have processed
 	// them) and they were deleted more than MaxDeletedFileAge ago.
-	l.Debugln(fdb.baseName, "forgetting deleted files older than", fdb.deleteRetention)
+	l.DebugContext(ctx, "Forgetting deleted files", "retention", fdb.deleteRetention)
 	res, err := fdb.stmt(`
 		DELETE FROM files
 		WHERE deleted AND modified < ? AND local_flags & {{.FlagLocalNeeded}} == 0
@@ -135,7 +138,7 @@ func garbageCollectOldDeletedLocked(fdb *folderDB) error {
 		return wrap(err)
 	}
 	if aff, err := res.RowsAffected(); err == nil {
-		l.Debugln(fdb.baseName, "removed old deleted file records:", aff)
+		l.DebugContext(ctx, "Removed old deleted file records", "affected", aff)
 	}
 	return nil
 }
@@ -176,9 +179,14 @@ func garbageCollectBlocklistsAndBlocksLocked(ctx context.Context, fdb *folderDB)
 			SELECT 1 FROM files WHERE files.blocklist_hash = blocklists.blocklist_hash
 		)`); err != nil {
 		return wrap(err, "delete blocklists")
-	} else if shouldDebug() {
-		rows, err := res.RowsAffected()
-		l.Debugln(fdb.baseName, "blocklist GC:", rows, err)
+	} else {
+		slog.DebugContext(ctx, "Blocklist GC", "fdb", fdb.baseName, "result", slogutil.Expensive(func() any {
+			rows, err := res.RowsAffected()
+			if err != nil {
+				return slogutil.Error(err)
+			}
+			return slog.Int64("rows", rows)
+		}))
 	}
 
 	if res, err := tx.ExecContext(ctx, `
@@ -187,9 +195,14 @@ func garbageCollectBlocklistsAndBlocksLocked(ctx context.Context, fdb *folderDB)
 			SELECT 1 FROM blocklists WHERE blocklists.blocklist_hash = blocks.blocklist_hash
 		)`); err != nil {
 		return wrap(err, "delete blocks")
-	} else if shouldDebug() {
-		rows, err := res.RowsAffected()
-		l.Debugln(fdb.baseName, "blocks GC:", rows, err)
+	} else {
+		slog.DebugContext(ctx, "Blocks GC", "fdb", fdb.baseName, "result", slogutil.Expensive(func() any {
+			rows, err := res.RowsAffected()
+			if err != nil {
+				return slogutil.Error(err)
+			}
+			return slog.Int64("rows", rows)
+		}))
 	}
 
 	return wrap(tx.Commit())

+ 2 - 6
internal/db/sqlite/debug.go

@@ -6,10 +6,6 @@
 
 package sqlite
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("sqlite", "SQLite database")
-
-func shouldDebug() bool { return l.ShouldDebug("sqlite") }
+func init() { slogutil.RegisterPackage("SQLite database") }

+ 7 - 5
internal/db/sqlite/folderdb_update.go

@@ -10,11 +10,13 @@ import (
 	"cmp"
 	"context"
 	"fmt"
+	"log/slog"
 	"slices"
 
 	"github.com/jmoiron/sqlx"
 	"github.com/syncthing/syncthing/internal/gen/dbproto"
 	"github.com/syncthing/syncthing/internal/itererr"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/sliceutil"
@@ -486,12 +488,12 @@ func (s *folderDB) periodicCheckpointLocked(fs []protocol.FileInfo) {
 	if s.updatePoints > updatePointsThreshold {
 		conn, err := s.sql.Conn(context.Background())
 		if err != nil {
-			l.Debugln(s.baseName, "conn:", err)
+			slog.Debug("Connection error", slog.String("db", s.baseName), slogutil.Error(err))
 			return
 		}
 		defer conn.Close()
 		if _, err := conn.ExecContext(context.Background(), `PRAGMA journal_size_limit = 8388608`); err != nil {
-			l.Debugln(s.baseName, "PRAGMA journal_size_limit:", err)
+			slog.Debug("PRAGMA journal_size_limit error", slog.String("db", s.baseName), slogutil.Error(err))
 		}
 
 		// Every 50th checkpoint becomes a truncate, in an effort to bring
@@ -505,11 +507,11 @@ func (s *folderDB) periodicCheckpointLocked(fs []protocol.FileInfo) {
 
 		var res, modified, moved int
 		if row.Err() != nil {
-			l.Debugln(s.baseName, cmd+":", err)
+			slog.Debug("Command error", slog.String("db", s.baseName), slog.String("cmd", cmd), slogutil.Error(err))
 		} else if err := row.Scan(&res, &modified, &moved); err != nil {
-			l.Debugln(s.baseName, cmd+" (scan):", err)
+			slog.Debug("Command scan error", slog.String("db", s.baseName), slog.String("cmd", cmd), slogutil.Error(err))
 		} else {
-			l.Debugln(s.baseName, cmd, s.checkpointsCount, "at", s.updatePoints, "returned", res, modified, moved)
+			slog.Debug("Checkpoint result", "db", s.baseName, "checkpointscount", s.checkpointsCount, "updatepoints", s.updatePoints, "res", res, "modified", modified, "moved", moved)
 		}
 
 		// Reset the truncate counter when a truncate succeeded. If it

+ 25 - 0
internal/slogutil/expensive.go

@@ -0,0 +1,25 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"log/slog"
+)
+
+// Expensive wraps a log value that is expensive to compute and should only
+// be called if the log line is actually emitted.
+func Expensive(fn func() any) expensive {
+	return expensive{fn}
+}
+
+type expensive struct {
+	fn func() any
+}
+
+func (e expensive) LogValue() slog.Value {
+	return slog.AnyValue(e.fn())
+}

+ 187 - 0
internal/slogutil/formatting.go

@@ -0,0 +1,187 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"cmp"
+	"context"
+	"io"
+	"log/slog"
+	"path"
+	"runtime"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type formattingHandler struct {
+	attrs        []slog.Attr
+	groups       []string
+	out          io.Writer
+	recs         []*lineRecorder
+	timeOverride time.Time
+}
+
+var _ slog.Handler = (*formattingHandler)(nil)
+
+func (h *formattingHandler) Enabled(context.Context, slog.Level) bool {
+	return true
+}
+
+func (h *formattingHandler) Handle(_ context.Context, rec slog.Record) error {
+	fr := runtime.CallersFrames([]uintptr{rec.PC})
+	var logAttrs []any
+	if fram, _ := fr.Next(); fram.Function != "" {
+		pkgName, typeName := funcNameToPkg(fram.Function)
+		lvl := globalLevels.Get(pkgName)
+		if lvl > rec.Level {
+			// Logging not enabled at the record's level
+			return nil
+		}
+		logAttrs = append(logAttrs, slog.String("pkg", pkgName))
+		if lvl <= slog.LevelDebug {
+			// We are debugging, add additional source line data
+			if typeName != "" {
+				logAttrs = append(logAttrs, slog.String("type", typeName))
+			}
+			logAttrs = append(logAttrs, slog.Group("src", slog.String("file", path.Base(fram.File)), slog.Int("line", fram.Line)))
+		}
+	}
+
+	var prefix string
+	if len(h.groups) > 0 {
+		prefix = strings.Join(h.groups, ".") + "."
+	}
+
+	// Build the message string.
+	var sb strings.Builder
+	sb.WriteString(rec.Message)
+
+	// Collect all the attributes, adding the handler prefix.
+	attrs := make([]slog.Attr, 0, rec.NumAttrs()+len(h.attrs)+1)
+	rec.Attrs(func(attr slog.Attr) bool {
+		attr.Key = prefix + attr.Key
+		attrs = append(attrs, attr)
+		return true
+	})
+	attrs = append(attrs, h.attrs...)
+	attrs = append(attrs, slog.Group("log", logAttrs...))
+
+	// Expand and format attributes
+	var attrCount int
+	for _, attr := range attrs {
+		for _, attr := range expandAttrs("", attr) {
+			appendAttr(&sb, "", attr, &attrCount)
+		}
+	}
+	if attrCount > 0 {
+		sb.WriteRune(')')
+	}
+
+	line := Line{
+		When:    cmp.Or(h.timeOverride, rec.Time),
+		Message: sb.String(),
+		Level:   rec.Level,
+	}
+
+	// If there is a recorder, record the line.
+	for _, rec := range h.recs {
+		rec.record(line)
+	}
+
+	// If there's an output, print the line.
+	if h.out != nil {
+		_, _ = line.WriteTo(h.out)
+	}
+	return nil
+}
+
+func expandAttrs(prefix string, a slog.Attr) []slog.Attr {
+	if prefix != "" {
+		a.Key = prefix + "." + a.Key
+	}
+	val := a.Value.Resolve()
+	if val.Kind() != slog.KindGroup {
+		return []slog.Attr{a}
+	}
+	var attrs []slog.Attr
+	for _, attr := range val.Group() {
+		attrs = append(attrs, expandAttrs(a.Key, attr)...)
+	}
+	return attrs
+}
+
+func appendAttr(sb *strings.Builder, prefix string, a slog.Attr, attrCount *int) {
+	if a.Key == "" {
+		return
+	}
+	sb.WriteRune(' ')
+	if *attrCount == 0 {
+		sb.WriteRune('(')
+	}
+	sb.WriteString(prefix)
+	sb.WriteString(a.Key)
+	sb.WriteRune('=')
+	v := a.Value.Resolve().String()
+	if strings.ContainsAny(v, ` "`) {
+		v = strconv.Quote(v)
+	}
+	sb.WriteString(v)
+	*attrCount++
+}
+
+func (h *formattingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+	if len(h.groups) > 0 {
+		prefix := strings.Join(h.groups, ".") + "."
+		for i := range attrs {
+			attrs[i].Key = prefix + attrs[i].Key
+		}
+	}
+	return &formattingHandler{
+		attrs:        append(h.attrs, attrs...),
+		groups:       h.groups,
+		recs:         h.recs,
+		out:          h.out,
+		timeOverride: h.timeOverride,
+	}
+}
+
+func (h *formattingHandler) WithGroup(name string) slog.Handler {
+	if name == "" {
+		return h
+	}
+	return &formattingHandler{
+		attrs:        h.attrs,
+		groups:       append([]string{name}, h.groups...),
+		recs:         h.recs,
+		out:          h.out,
+		timeOverride: h.timeOverride,
+	}
+}
+
+func funcNameToPkg(fn string) (string, string) {
+	fn = strings.ToLower(fn)
+	fn = strings.TrimPrefix(fn, "github.com/syncthing/syncthing/lib/")
+	fn = strings.TrimPrefix(fn, "github.com/syncthing/syncthing/internal/")
+
+	pkgTypFn := strings.Split(fn, ".") // [package, type, method] or [package, function]
+	if len(pkgTypFn) <= 2 {
+		return pkgTypFn[0], ""
+	}
+
+	pkg := pkgTypFn[0]
+	// Remove parenthesis and asterisk from the type name
+	typ := strings.TrimLeft(strings.TrimRight(pkgTypFn[1], ")"), "(*")
+	// Skip certain type names that add no value
+	typ = strings.TrimSuffix(typ, "service")
+	switch typ {
+	case pkg, "", "serveparams":
+		return pkg, ""
+	default:
+		return pkg, typ
+	}
+}

+ 51 - 0
internal/slogutil/formatting_test.go

@@ -0,0 +1,51 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"bytes"
+	"log/slog"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestFormattingHandler(t *testing.T) {
+	buf := new(bytes.Buffer)
+	h := &formattingHandler{
+		out:          buf,
+		timeOverride: time.Unix(1234567890, 0).In(time.UTC),
+	}
+
+	l := slog.New(h).With("a", "a")
+	l.Info("A basic info line", "attr1", "val with spaces", "attr2", 2, "attr3", `val"quote`)
+	l.Info("An info line with grouped values", "attr1", "val1", slog.Group("foo", "attr2", 2, slog.Group("bar", "attr3", "3")))
+
+	l2 := l.WithGroup("foo")
+	l2.Info("An info line with grouped values via logger", "attr1", "val1", "attr2", 2)
+
+	l3 := l2.WithGroup("bar")
+	l3.Info("An info line with nested grouped values via logger", "attr1", "val1", "attr2", 2)
+
+	l3.Debug("A debug entry")
+	l3.Warn("A warning entry")
+	l3.Error("An error")
+
+	exp := `
+2009-02-13 23:31:30 INF A basic info line (attr1="val with spaces" attr2=2 attr3="val\"quote" a=a log.pkg=slogutil)
+2009-02-13 23:31:30 INF An info line with grouped values (attr1=val1 foo.attr2=2 foo.bar.attr3=3 a=a log.pkg=slogutil)
+2009-02-13 23:31:30 INF An info line with grouped values via logger (foo.attr1=val1 foo.attr2=2 a=a log.pkg=slogutil)
+2009-02-13 23:31:30 INF An info line with nested grouped values via logger (bar.foo.attr1=val1 bar.foo.attr2=2 a=a log.pkg=slogutil)
+2009-02-13 23:31:30 WRN A warning entry (a=a log.pkg=slogutil)
+2009-02-13 23:31:30 ERR An error (a=a log.pkg=slogutil)`
+
+	if strings.TrimSpace(buf.String()) != strings.TrimSpace(exp) {
+		t.Log(buf.String())
+		t.Log(exp)
+		t.Error("mismatch")
+	}
+}

+ 104 - 0
internal/slogutil/leveler.go

@@ -0,0 +1,104 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"log/slog"
+	"maps"
+	"sync"
+)
+
+// A levelTracker keeps track of log level per package. This enables the
+// traditional STTRACE variable to set certain packages to debug level, but
+// also allows setting packages to other levels such as WARN to silence
+// INFO-level messages.
+//
+// The STTRACE environment variable is one way of controlling this, where
+// mentioning a package makes it DEBUG level:
+//     STTRACE="model,protocol"  # model and protocol are at DEBUG level
+// however you can also give specific levels after a colon:
+//     STTRACE="model:WARNING,protocol:DEBUG"
+
+func PackageDescrs() map[string]string {
+	return globalLevels.Descrs()
+}
+
+func PackageLevels() map[string]slog.Level {
+	return globalLevels.Levels()
+}
+
+func SetPackageLevel(pkg string, level slog.Level) {
+	globalLevels.Set(pkg, level)
+}
+
+func SetDefaultLevel(level slog.Level) {
+	globalLevels.SetDefault(level)
+}
+
+type levelTracker struct {
+	mut      sync.RWMutex
+	defLevel slog.Level
+	descrs   map[string]string     // package name to description
+	levels   map[string]slog.Level // package name to level
+}
+
+func (t *levelTracker) Get(pkg string) slog.Level {
+	t.mut.RLock()
+	defer t.mut.RUnlock()
+	if level, ok := t.levels[pkg]; ok {
+		return level
+	}
+	return t.defLevel
+}
+
+func (t *levelTracker) Set(pkg string, level slog.Level) {
+	t.mut.Lock()
+	changed := t.levels[pkg] != level
+	t.levels[pkg] = level
+	t.mut.Unlock()
+	if changed {
+		slog.Info("Changed package log level", "package", pkg, "level", level)
+	}
+}
+
+func (t *levelTracker) SetDefault(level slog.Level) {
+	t.mut.Lock()
+	changed := t.defLevel != level
+	t.defLevel = level
+	t.mut.Unlock()
+	if changed {
+		slog.Info("Changed default log level", "level", level)
+	}
+}
+
+func (t *levelTracker) SetDescr(pkg, descr string) {
+	t.mut.Lock()
+	t.descrs[pkg] = descr
+	t.mut.Unlock()
+}
+
+func (t *levelTracker) Descrs() map[string]string {
+	t.mut.RLock()
+	defer t.mut.RUnlock()
+	m := make(map[string]string, len(t.descrs))
+	maps.Copy(m, t.descrs)
+	return m
+}
+
+func (t *levelTracker) Levels() map[string]slog.Level {
+	t.mut.RLock()
+	defer t.mut.RUnlock()
+	m := make(map[string]slog.Level, len(t.descrs))
+	for pkg := range t.descrs {
+		if level, ok := t.levels[pkg]; ok {
+			m[pkg] = level
+		} else {
+			m[pkg] = t.defLevel
+		}
+	}
+	return m
+}

+ 61 - 0
internal/slogutil/line.go

@@ -0,0 +1,61 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"log/slog"
+	"time"
+)
+
+// A Line is our internal representation of a formatted log line. This is
+// what we present in the API and what we buffer internally.
+type Line struct {
+	When    time.Time  `json:"when"`
+	Message string     `json:"message"`
+	Level   slog.Level `json:"level"`
+}
+
+func (l *Line) WriteTo(w io.Writer) (int64, error) {
+	n, err := fmt.Fprintf(w, "%s %s %s\n", l.timeStr(), l.levelStr(), l.Message)
+	return int64(n), err
+}
+
+func (l *Line) timeStr() string {
+	return l.When.Format("2006-01-02 15:04:05")
+}
+
+func (l *Line) levelStr() string {
+	str := func(base string, val slog.Level) string {
+		if val == 0 {
+			return base
+		}
+		return fmt.Sprintf("%s%+d", base, val)
+	}
+
+	switch {
+	case l.Level < slog.LevelInfo:
+		return str("DBG", l.Level-slog.LevelDebug)
+	case l.Level < slog.LevelWarn:
+		return str("INF", l.Level-slog.LevelInfo)
+	case l.Level < slog.LevelError:
+		return str("WRN", l.Level-slog.LevelWarn)
+	default:
+		return str("ERR", l.Level-slog.LevelError)
+	}
+}
+
+func (l *Line) MarshalJSON() ([]byte, error) {
+	// Custom marshal to get short level strings instead of default JSON serialisation
+	return json.Marshal(map[string]any{
+		"when":    l.When,
+		"message": l.Message,
+		"level":   l.levelStr(),
+	})
+}

+ 59 - 0
internal/slogutil/recorder.go

@@ -0,0 +1,59 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"log/slog"
+	"sync"
+	"time"
+)
+
+const maxLogLines = 1000
+
+type Recorder interface {
+	Since(t time.Time) []Line
+	Clear()
+}
+
+func NewRecorder(level slog.Level) Recorder {
+	return &lineRecorder{level: level}
+}
+
+type lineRecorder struct {
+	level slog.Level
+	mut   sync.Mutex
+	lines []Line
+}
+
+func (r *lineRecorder) record(line Line) {
+	if line.Level < r.level {
+		return
+	}
+	r.mut.Lock()
+	r.lines = append(r.lines, line)
+	if len(r.lines) > maxLogLines {
+		r.lines = r.lines[len(r.lines)-maxLogLines:]
+	}
+	r.mut.Unlock()
+}
+
+func (r *lineRecorder) Clear() {
+	r.mut.Lock()
+	r.lines = nil
+	r.mut.Unlock()
+}
+
+func (r *lineRecorder) Since(t time.Time) []Line {
+	r.mut.Lock()
+	defer r.mut.Unlock()
+	for i := range r.lines {
+		if r.lines[i].When.After(t) {
+			return r.lines[i:]
+		}
+	}
+	return nil
+}

+ 71 - 0
internal/slogutil/slogadapter.go

@@ -0,0 +1,71 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"runtime"
+	"strings"
+	"time"
+)
+
+// Log levels:
+// - DEBUG: programmers only (not user troubleshooting)
+// - INFO: most stuff, files syncing properly
+// - WARN: errors that can be ignored or will be retried (e.g., sync failures)
+// - ERROR: errors that need handling, shown in the GUI
+
+func RegisterPackage(descr string) {
+	registerPackage(descr, 2)
+}
+
+func NewAdapter(descr string) *adapter {
+	registerPackage(descr, 2)
+	return &adapter{slogDef}
+}
+
+func registerPackage(descr string, frames int) {
+	var pcs [1]uintptr
+	runtime.Callers(1+frames, pcs[:])
+	pc := pcs[0]
+	fr := runtime.CallersFrames([]uintptr{pc})
+	if fram, _ := fr.Next(); fram.Function != "" {
+		pkgName, _ := funcNameToPkg(fram.Function)
+		globalLevels.SetDescr(pkgName, descr)
+	}
+}
+
+type adapter struct {
+	l *slog.Logger
+}
+
+func (a adapter) Debugln(vals ...interface{}) {
+	a.log(strings.TrimSpace(fmt.Sprintln(vals...)), slog.LevelDebug)
+}
+
+func (a adapter) Debugf(format string, vals ...interface{}) {
+	a.log(fmt.Sprintf(format, vals...), slog.LevelDebug)
+}
+
+func (a adapter) log(msg string, level slog.Level) {
+	h := a.l.Handler()
+	if !h.Enabled(context.Background(), level) {
+		return
+	}
+	var pcs [1]uintptr
+	// skip [runtime.Callers, this function, this function's caller]
+	runtime.Callers(3, pcs[:])
+	pc := pcs[0]
+	r := slog.NewRecord(time.Now(), level, msg, pc)
+	_ = h.Handle(context.Background(), r)
+}
+
+func (a adapter) ShouldDebug(facility string) bool {
+	return globalLevels.Get(facility) <= slog.LevelDebug
+}

+ 47 - 0
internal/slogutil/sloginit.go

@@ -0,0 +1,47 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"log/slog"
+	"os"
+	"strings"
+)
+
+var (
+	GlobalRecorder = &lineRecorder{level: -1000}
+	ErrorRecorder  = &lineRecorder{level: slog.LevelError}
+	globalLevels   = &levelTracker{
+		levels: make(map[string]slog.Level),
+		descrs: make(map[string]string),
+	}
+	slogDef = slog.New(&formattingHandler{
+		recs: []*lineRecorder{GlobalRecorder, ErrorRecorder},
+		out:  os.Stdout,
+	})
+)
+
+func init() {
+	slog.SetDefault(slogDef)
+
+	// Handle legacy STTRACE var
+	pkgs := strings.Split(os.Getenv("STTRACE"), ",")
+	for _, pkg := range pkgs {
+		pkg = strings.TrimSpace(pkg)
+		if pkg == "" {
+			continue
+		}
+		level := slog.LevelDebug
+		if cutPkg, levelStr, ok := strings.Cut(pkg, ":"); ok {
+			pkg = cutPkg
+			if err := level.UnmarshalText([]byte(levelStr)); err != nil {
+				slog.Warn("Bad log level requested in STTRACE", slog.String("pkg", pkg), slog.String("level", levelStr), Error(err))
+			}
+		}
+		globalLevels.Set(pkg, level)
+	}
+}

+ 40 - 0
internal/slogutil/slogvalues.go

@@ -0,0 +1,40 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package slogutil
+
+import (
+	"log/slog"
+	"maps"
+	"slices"
+)
+
+func Address(v any) slog.Attr {
+	return slog.Any("address", v)
+}
+
+func Error(err any) slog.Attr {
+	if err == nil {
+		return slog.Attr{}
+	}
+	return slog.Any("error", err)
+}
+
+func FilePath(path string) slog.Attr {
+	return slog.String("path", path)
+}
+
+func URI(v any) slog.Attr {
+	return slog.Any("uri", v)
+}
+
+func Map[T any](m map[string]T) []any {
+	var attrs []any
+	for _, key := range slices.Sorted(maps.Keys(m)) {
+		attrs = append(attrs, slog.Any(key, m[key]))
+	}
+	return attrs
+}

+ 40 - 49
lib/api/api.go

@@ -17,6 +17,7 @@ import (
 	"fmt"
 	"io"
 	"log"
+	"log/slog"
 	"net"
 	"net/http"
 	"net/url"
@@ -28,6 +29,7 @@ import (
 	"slices"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 	"unicode"
 
@@ -42,6 +44,7 @@ import (
 	"golang.org/x/text/unicode/norm"
 
 	"github.com/syncthing/syncthing/internal/db"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections"
@@ -49,12 +52,10 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/locations"
-	"github.com/syncthing/syncthing/lib/logger"
 	"github.com/syncthing/syncthing/lib/model"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/rand"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/tlsutil"
 	"github.com/syncthing/syncthing/lib/upgrade"
 	"github.com/syncthing/syncthing/lib/ur"
@@ -95,8 +96,8 @@ type service struct {
 	miscDB               *db.Typed
 	shutdownTimeout      time.Duration
 
-	guiErrors logger.Recorder
-	systemLog logger.Recorder
+	guiErrors slogutil.Recorder
+	systemLog slogutil.Recorder
 }
 
 var _ config.Verifier = &service{}
@@ -107,7 +108,7 @@ type Service interface {
 	WaitForStart() error
 }
 
-func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonName string, m model.Model, defaultSub, diskSub events.BufferedSubscription, evLogger events.Logger, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, fss model.FolderSummaryService, errors, systemLog logger.Recorder, noUpgrade bool, miscDB *db.Typed) Service {
+func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonName string, m model.Model, defaultSub, diskSub events.BufferedSubscription, evLogger events.Logger, discoverer discover.Manager, connectionsService connections.Service, urService *ur.Service, fss model.FolderSummaryService, errors, systemLog slogutil.Recorder, noUpgrade bool, miscDB *db.Typed) Service {
 	return &service{
 		id:      id,
 		cfg:     cfg,
@@ -117,7 +118,6 @@ func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonNam
 			DefaultEventMask: defaultSub,
 			DiskEventMask:    diskSub,
 		},
-		eventSubsMut:         sync.NewMutex(),
 		evLogger:             evLogger,
 		discoverer:           discoverer,
 		connectionsService:   connectionsService,
@@ -151,8 +151,10 @@ func (s *service) getListener(guiCfg config.GUIConfiguration) (net.Listener, err
 		err = shouldRegenerateCertificate(cert)
 	}
 	if err != nil {
-		l.Infoln("Loading HTTPS certificate:", err)
-		l.Infoln("Creating new HTTPS certificate")
+		if !os.IsNotExist(err) {
+			slog.Warn("Failed to load HTTPS certificate", slogutil.Error(err))
+		}
+		slog.Info("Creating new HTTPS certificate")
 
 		// When generating the HTTPS certificate, use the system host name per
 		// default. If that isn't available, use the "syncthing" default.
@@ -222,7 +224,7 @@ func (s *service) Serve(ctx context.Context) error {
 		case <-s.startedOnce:
 			// We let this be a loud user-visible warning as it may be the only
 			// indication they get that the GUI won't be available.
-			l.Warnln("Starting API/GUI:", err)
+			slog.ErrorContext(ctx, "Failed to start API/GUI", slogutil.Error(err))
 
 		default:
 			// This is during initialization. A failure here should be fatal
@@ -280,7 +282,7 @@ func (s *service) Serve(ctx context.Context) error {
 	restMux.HandlerFunc(http.MethodGet, "/rest/system/status", s.getSystemStatus)             // -
 	restMux.HandlerFunc(http.MethodGet, "/rest/system/upgrade", s.getSystemUpgrade)           // -
 	restMux.HandlerFunc(http.MethodGet, "/rest/system/version", s.getSystemVersion)           // -
-	restMux.HandlerFunc(http.MethodGet, "/rest/system/debug", s.getSystemDebug)               // -
+	restMux.HandlerFunc(http.MethodGet, "/rest/system/loglevels", s.getSystemDebug)           // -
 	restMux.HandlerFunc(http.MethodGet, "/rest/system/log", s.getSystemLog)                   // [since]
 	restMux.HandlerFunc(http.MethodGet, "/rest/system/log.txt", s.getSystemLogTxt)            // [since]
 
@@ -300,7 +302,7 @@ func (s *service) Serve(ctx context.Context) error {
 	restMux.HandlerFunc(http.MethodPost, "/rest/system/upgrade", s.postSystemUpgrade)            // -
 	restMux.HandlerFunc(http.MethodPost, "/rest/system/pause", s.makeDevicePauseHandler(true))   // [device]
 	restMux.HandlerFunc(http.MethodPost, "/rest/system/resume", s.makeDevicePauseHandler(false)) // [device]
-	restMux.HandlerFunc(http.MethodPost, "/rest/system/debug", s.postSystemDebug)                // [enable] [disable]
+	restMux.HandlerFunc(http.MethodPost, "/rest/system/loglevels", s.postSystemDebug)            // [enable] [disable]
 
 	// The DELETE handlers
 	restMux.HandlerFunc(http.MethodDelete, "/rest/cluster/pending/devices", s.deletePendingDevices) // device
@@ -409,8 +411,8 @@ func (s *service) Serve(ctx context.Context) error {
 		srv.ErrorLog = log.Default()
 	}
 
-	l.Infoln("GUI and API listening on", listener.Addr())
-	l.Infoln("Access the GUI via the following URL:", guiCfg.URL())
+	slog.InfoContext(ctx, "GUI and API listening", slogutil.Address(listener.Addr()))
+	slog.InfoContext(ctx, "Access the GUI via the following URL: "+guiCfg.URL()) //nolint:sloglint
 	if s.started != nil {
 		// only set when run by the tests
 		select {
@@ -443,14 +445,14 @@ func (s *service) Serve(ctx context.Context) error {
 	select {
 	case <-ctx.Done():
 		// Shutting down permanently
-		l.Debugln("shutting down (stop)")
+		slog.DebugContext(ctx, "Shutting down (stop)")
 	case <-s.configChanged:
 		// Soft restart due to configuration change
-		l.Debugln("restarting (config changed)")
+		slog.DebugContext(ctx, "Restarting (config changed)")
 	case err = <-s.exitChan:
 	case err = <-serveError:
 		// Restart due to listen/serve failure
-		l.Warnln("GUI/API:", err, "(restarting)")
+		slog.ErrorContext(ctx, "GUI/API error (restarting)", slogutil.Error(err))
 	}
 	// Give it a moment to shut down gracefully, e.g. if we are restarting
 	// due to a config change through the API, let that finish successfully.
@@ -749,31 +751,20 @@ func (*service) getSystemVersion(w http.ResponseWriter, _ *http.Request) {
 }
 
 func (*service) getSystemDebug(w http.ResponseWriter, _ *http.Request) {
-	names := l.Facilities()
-	enabled := l.FacilityDebugging()
-	slices.Sort(enabled)
-	sendJSON(w, map[string]interface{}{
-		"facilities": names,
-		"enabled":    enabled,
+	sendJSON(w, map[string]any{
+		"packages": slogutil.PackageDescrs(),
+		"levels":   slogutil.PackageLevels(),
 	})
 }
 
 func (*service) postSystemDebug(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-Type", "application/json; charset=utf-8")
-	q := r.URL.Query()
-	for _, f := range strings.Split(q.Get("enable"), ",") {
-		if f == "" || l.ShouldDebug(f) {
-			continue
-		}
-		l.SetDebug(f, true)
-		l.Infof("Enabled debug data for %q", f)
+	var levelRequest map[string]slog.Level
+	if err := json.NewDecoder(r.Body).Decode(&levelRequest); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
 	}
-	for _, f := range strings.Split(q.Get("disable"), ",") {
-		if f == "" || !l.ShouldDebug(f) {
-			continue
-		}
-		l.SetDebug(f, false)
-		l.Infof("Disabled debug data for %q", f)
+	for pkg, level := range levelRequest {
+		slogutil.SetPackageLevel(pkg, level)
 	}
 }
 
@@ -1106,7 +1097,7 @@ func (s *service) getSystemStatus(w http.ResponseWriter, _ *http.Request) {
 }
 
 func (s *service) getSystemError(w http.ResponseWriter, _ *http.Request) {
-	sendJSON(w, map[string][]logger.Line{
+	sendJSON(w, map[string][]slogutil.Line{
 		"errors": s.guiErrors.Since(time.Time{}),
 	})
 }
@@ -1114,7 +1105,7 @@ func (s *service) getSystemError(w http.ResponseWriter, _ *http.Request) {
 func (*service) postSystemError(_ http.ResponseWriter, r *http.Request) {
 	bs, _ := io.ReadAll(r.Body)
 	r.Body.Close()
-	l.Warnln(string(bs))
+	slog.Error("External error report", slogutil.Error(string(bs)))
 }
 
 func (s *service) postSystemErrorClear(_ http.ResponseWriter, _ *http.Request) {
@@ -1127,7 +1118,7 @@ func (s *service) getSystemLog(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		l.Debugln(err)
 	}
-	sendJSON(w, map[string][]logger.Line{
+	sendJSON(w, map[string][]slogutil.Line{
 		"messages": s.systemLog.Since(since),
 	})
 }
@@ -1156,7 +1147,7 @@ func (s *service) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 
 	// Redacted configuration as a JSON
 	if jsonConfig, err := json.MarshalIndent(getRedactedConfig(s), "", "  "); err != nil {
-		l.Warnln("Support bundle: failed to create config.json:", err)
+		slog.Warn("Failed to create config.json in support bundle", slogutil.Error(err))
 	} else {
 		files = append(files, fileEntry{name: "config.json.txt", data: jsonConfig})
 	}
@@ -1171,7 +1162,7 @@ func (s *service) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 	// Errors as a JSON
 	if errs := s.guiErrors.Since(time.Time{}); len(errs) > 0 {
 		if jsonError, err := json.MarshalIndent(errs, "", "  "); err != nil {
-			l.Warnln("Support bundle: failed to create errors.json:", err)
+			slog.Warn("Failed to create errors.json in support bundle", slogutil.Error(err))
 		} else {
 			files = append(files, fileEntry{name: "errors.json.txt", data: jsonError})
 		}
@@ -1181,7 +1172,7 @@ func (s *service) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 	if panicFiles, err := filepath.Glob(filepath.Join(locations.GetBaseDir(locations.ConfigBaseDir), "panic*")); err == nil {
 		for _, f := range panicFiles {
 			if panicFile, err := os.ReadFile(f); err != nil {
-				l.Warnf("Support bundle: failed to load %s: %s", filepath.Base(f), err)
+				slog.Warn("Failed to load panic file for support bundle", slogutil.FilePath(filepath.Base(f)), slogutil.Error(err))
 			} else {
 				files = append(files, fileEntry{name: filepath.Base(f), data: panicFile})
 			}
@@ -1204,15 +1195,15 @@ func (s *service) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 	}, "", "  "); err == nil {
 		files = append(files, fileEntry{name: "version-platform.json.txt", data: versionPlatform})
 	} else {
-		l.Warnln("Failed to create versionPlatform.json: ", err)
+		slog.Warn("Failed to create versionPlatform.json in support bundle", slogutil.Error(err))
 	}
 
 	// Report Data as a JSON
 	if r, err := s.urService.ReportDataPreview(r.Context(), ur.Version); err != nil {
-		l.Warnln("Support bundle: failed to create usage-reporting.json.txt:", err)
+		slog.Warn("Failed to create usage-reporting.json.txt in support bundle", slogutil.Error(err))
 	} else {
 		if usageReportingData, err := json.MarshalIndent(r, "", "  "); err != nil {
-			l.Warnln("Support bundle: failed to serialize usage-reporting.json.txt", err)
+			slog.Warn("Failed to serialize usage-reporting.json.txt in support bundle", slogutil.Error(err))
 		} else {
 			files = append(files, fileEntry{name: "usage-reporting.json.txt", data: usageReportingData})
 		}
@@ -1227,7 +1218,7 @@ func (s *service) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 	// Connection data as JSON
 	connStats := s.model.ConnectionStats()
 	if connStatsJSON, err := json.MarshalIndent(connStats, "", "  "); err != nil {
-		l.Warnln("Support bundle: failed to serialize connection-stats.json.txt", err)
+		slog.Warn("Failed to serialize connection-stats.json.txt in support bundle", slogutil.Error(err))
 	} else {
 		files = append(files, fileEntry{name: "connection-stats.json.txt", data: connStatsJSON})
 	}
@@ -1273,7 +1264,7 @@ func (s *service) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 	// Add buffer files to buffer zip
 	var zipFilesBuffer bytes.Buffer
 	if err := writeZip(&zipFilesBuffer, files); err != nil {
-		l.Warnln("Support bundle: failed to create support bundle zip:", err)
+		slog.Warn("Failed to create support bundle zip (buffer)", slogutil.Error(err))
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -1284,7 +1275,7 @@ func (s *service) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 
 	// Write buffer zip to local zip file (back up)
 	if err := os.WriteFile(zipFilePath, zipFilesBuffer.Bytes(), 0o600); err != nil {
-		l.Warnln("Support bundle: support bundle zip could not be created:", err)
+		slog.Warn("Failed to create support bundle zip (file)", slogutil.FilePath(zipFilePath), slogutil.Error(err))
 	}
 
 	// Serve the buffer zip to client for download
@@ -1534,7 +1525,7 @@ func (s *service) postSystemUpgrade(w http.ResponseWriter, _ *http.Request) {
 	if upgrade.CompareVersions(rel.Tag, build.Version) > upgrade.Equal {
 		err = upgrade.To(rel)
 		if err != nil {
-			l.Warnln("upgrading:", err)
+			slog.Error("Failed to upgrade", slogutil.Error(err))
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}

+ 12 - 10
lib/api/api_auth.go

@@ -9,6 +9,7 @@ package api
 import (
 	"crypto/tls"
 	"fmt"
+	"log/slog"
 	"net"
 	"net/http"
 	"slices"
@@ -16,6 +17,7 @@ import (
 	"time"
 
 	ldap "github.com/go-ldap/ldap/v3"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/osutil"
@@ -43,11 +45,11 @@ func emitLoginAttempt(success bool, username string, r *http.Request, evLogger e
 	if success {
 		return
 	}
+	l := slog.Default().With(slogutil.Address(remoteAddress), slog.String("username", username))
 	if proxy != "" {
-		l.Infof("Wrong credentials supplied during API authorization from %s proxied by %s", remoteAddress, proxy)
-	} else {
-		l.Infof("Wrong credentials supplied during API authorization from %s", remoteAddress)
+		l = l.With("proxy", proxy)
 	}
+	l.Warn("Bad credentials supplied during API authorization")
 }
 
 func remoteAddress(r *http.Request) (remoteAddr, proxy string) {
@@ -203,7 +205,7 @@ func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg c
 		return "", false
 	}
 
-	l.Debugln("Sessionless HTTP request with authentication; this is expensive.")
+	slog.Debug("Sessionless HTTP request with authentication; this is expensive.")
 
 	if auth(username, password, guiCfg, ldapCfg) {
 		return username, true
@@ -254,14 +256,14 @@ func authLDAP(username string, password string, cfg config.LDAPConfiguration) bo
 	}
 
 	if err != nil {
-		l.Warnln("LDAP Dial:", err)
+		slog.Error("Failed to dial LDAP server", slogutil.Error(err))
 		return false
 	}
 
 	if cfg.Transport == config.LDAPTransportStartTLS {
 		err = connection.StartTLS(&tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify})
 		if err != nil {
-			l.Warnln("LDAP Start TLS:", err)
+			slog.Error("Failed to handshake start TLS With LDAP server", slogutil.Error(err))
 			return false
 		}
 	}
@@ -271,7 +273,7 @@ func authLDAP(username string, password string, cfg config.LDAPConfiguration) bo
 	bindDN := formatOptionalPercentS(cfg.BindDN, escapeForLDAPDN(username))
 	err = connection.Bind(bindDN, password)
 	if err != nil {
-		l.Warnln("LDAP Bind:", err)
+		slog.Error("Failed to bind with LDAP server", slogutil.Error(err))
 		return false
 	}
 
@@ -281,7 +283,7 @@ func authLDAP(username string, password string, cfg config.LDAPConfiguration) bo
 	}
 
 	if cfg.SearchFilter == "" || cfg.SearchBaseDN == "" {
-		l.Warnln("LDAP configuration: both searchFilter and searchBaseDN must be set, or neither.")
+		slog.Error("Bad LDAP configuration: both searchFilter and searchBaseDN must be set, or neither")
 		return false
 	}
 
@@ -296,11 +298,11 @@ func authLDAP(username string, password string, cfg config.LDAPConfiguration) bo
 
 	res, err := connection.Search(searchReq)
 	if err != nil {
-		l.Warnln("LDAP Search:", err)
+		slog.Warn("Failed LDAP search", slogutil.Error(err))
 		return false
 	}
 	if len(res.Entries) != 1 {
-		l.Infof("Wrong number of LDAP search results, %d != 1", len(res.Entries))
+		slog.Warn("Incorrect number of LDAP search results (expected one)", slog.Int("results", len(res.Entries)))
 		return false
 	}
 

+ 1 - 2
lib/api/api_statics.go

@@ -12,12 +12,12 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/lib/api/auto"
 	"github.com/syncthing/syncthing/lib/assets"
 	"github.com/syncthing/syncthing/lib/config"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 const themePrefix = "theme-assets/"
@@ -36,7 +36,6 @@ func newStaticsServer(theme, assetDir string) *staticsServer {
 	s := &staticsServer{
 		assetDir:        assetDir,
 		assets:          auto.Assets(),
-		mut:             sync.NewRWMutex(),
 		theme:           theme,
 		lastThemeChange: time.Now().UTC(),
 	}

+ 5 - 16
lib/api/api_test.go

@@ -29,6 +29,7 @@ import (
 
 	"github.com/syncthing/syncthing/internal/db"
 	"github.com/syncthing/syncthing/internal/db/sqlite"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/assets"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
@@ -38,14 +39,11 @@ import (
 	eventmocks "github.com/syncthing/syncthing/lib/events/mocks"
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/locations"
-	"github.com/syncthing/syncthing/lib/logger"
-	loggermocks "github.com/syncthing/syncthing/lib/logger/mocks"
 	"github.com/syncthing/syncthing/lib/model"
 	modelmocks "github.com/syncthing/syncthing/lib/model/mocks"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/rand"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/tlsutil"
 	"github.com/syncthing/syncthing/lib/ur"
 )
@@ -96,7 +94,7 @@ func TestStopAfterBrokenConfig(t *testing.T) {
 
 	srv.started = make(chan string)
 
-	sup := suture.New("test", svcutil.SpecWithDebugLogger(l))
+	sup := suture.New("test", svcutil.SpecWithDebugLogger())
 	sup.Add(srv)
 	ctx, cancel := context.WithCancel(context.Background())
 	sup.ServeBackground(ctx)
@@ -150,7 +148,6 @@ func TestAssetsDir(t *testing.T) {
 
 	e := &staticsServer{
 		theme:    "foo",
-		mut:      sync.NewRWMutex(),
 		assetDir: "testdata",
 		assets: map[string]assets.Asset{
 			"foo/a":     foo, // overridden in foo/a
@@ -360,7 +357,7 @@ func TestAPIServiceRequests(t *testing.T) {
 			Prefix: "{",
 		},
 		{
-			URL:    "/rest/system/debug",
+			URL:    "/rest/system/loglevels",
 			Code:   200,
 			Type:   "application/json",
 			Prefix: "{",
@@ -1044,16 +1041,8 @@ func startHTTPWithShutdownTimeout(t *testing.T, cfg config.Wrapper, shutdownTime
 	diskEventSub := new(eventmocks.BufferedSubscription)
 	discoverer := new(discovermocks.Manager)
 	connections := new(connmocks.Service)
-	errorLog := new(loggermocks.Recorder)
-	systemLog := new(loggermocks.Recorder)
-	for _, l := range []*loggermocks.Recorder{errorLog, systemLog} {
-		l.SinceReturns([]logger.Line{
-			{
-				When:    time.Now(),
-				Message: "Test message",
-			},
-		})
-	}
+	errorLog := slogutil.NewRecorder(0)
+	systemLog := slogutil.NewRecorder(0)
 	addrChan := make(chan string)
 	mockedSummary := &modelmocks.FolderSummaryService{}
 	mockedSummary.SummaryReturns(new(model.FolderSummary), nil)

+ 5 - 3
lib/api/confighandler.go

@@ -9,10 +9,12 @@ package api
 import (
 	"encoding/json"
 	"io"
+	"log/slog"
 	"net/http"
 
 	"github.com/julienschmidt/httprouter"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/structutil"
@@ -311,7 +313,7 @@ func (c *configMuxBuilder) adjustConfig(w http.ResponseWriter, r *http.Request)
 	to, err := config.ReadJSON(r.Body, c.id)
 	r.Body.Close()
 	if err != nil {
-		l.Warnln("Decoding posted config:", err)
+		slog.Error("Failed to decode posted config", slogutil.Error(err))
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
@@ -415,7 +417,7 @@ func (c *configMuxBuilder) adjustGUI(w http.ResponseWriter, r *http.Request, gui
 func (c *configMuxBuilder) postAdjustGui(from *config.GUIConfiguration, to *config.GUIConfiguration) error {
 	if to.Password != from.Password {
 		if err := to.SetPassword(to.Password); err != nil {
-			l.Warnln("hashing password:", err)
+			slog.Error("Failed to hash password", slogutil.Error(err))
 			return err
 		}
 	}
@@ -456,7 +458,7 @@ func unmarshalToRawMessages(body io.ReadCloser) ([]json.RawMessage, error) {
 func (c *configMuxBuilder) finish(w http.ResponseWriter, waiter config.Waiter) {
 	waiter.Wait()
 	if err := c.cfg.Save(); err != nil {
-		l.Warnln("Saving config:", err)
+		slog.Error("Failed to save config", slogutil.Error(err))
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 }

+ 2 - 4
lib/api/debug.go

@@ -6,11 +6,9 @@
 
 package api
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("api", "REST API")
+var l = slogutil.NewAdapter("REST API")
 
 func shouldDebugHTTP() bool {
 	return l.ShouldDebug("api")

+ 1 - 2
lib/api/tokenmanager.go

@@ -10,6 +10,7 @@ import (
 	"net/http"
 	"slices"
 	"strings"
+	"sync"
 	"time"
 
 	"google.golang.org/protobuf/proto"
@@ -19,7 +20,6 @@ import (
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/rand"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type tokenManager struct {
@@ -49,7 +49,6 @@ func newTokenManager(key string, miscDB *db.Typed, lifetime time.Duration, maxIt
 		lifetime: lifetime,
 		maxItems: maxItems,
 		timeNow:  time.Now,
-		mut:      sync.NewMutex(),
 		tokens:   &tokens,
 	}
 }

+ 1 - 1
lib/beacon/beacon.go

@@ -45,7 +45,7 @@ type cast struct {
 // methods to get a functional implementation of Interface.
 func newCast(name string) *cast {
 	// Only log restarts in debug mode.
-	spec := svcutil.SpecWithDebugLogger(l)
+	spec := svcutil.SpecWithDebugLogger()
 	// Don't retry too frenetically: an error to open a socket or
 	// whatever is usually something that is either permanent or takes
 	// a while to get solved...

+ 4 - 2
lib/beacon/broadcast.go

@@ -8,9 +8,11 @@ package beacon
 
 import (
 	"context"
+	"log/slog"
 	"net"
 	"time"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/netutil"
 )
 
@@ -109,7 +111,7 @@ func writeBroadcasts(ctx context.Context, inbox <-chan []byte, port int) error {
 		}
 
 		if success == 0 {
-			l.Debugln("couldn't send any broadcasts")
+			slog.DebugContext(ctx, "Couldn't send any broadcasts", slogutil.Error(err))
 			return err
 		}
 	}
@@ -146,7 +148,7 @@ func readBroadcasts(ctx context.Context, outbox chan<- recv, port int) error {
 		case <-doneCtx.Done():
 			return doneCtx.Err()
 		default:
-			l.Debugln("dropping message")
+			slog.DebugContext(ctx, "Dropping message")
 		}
 	}
 }

+ 2 - 4
lib/beacon/debug.go

@@ -6,8 +6,6 @@
 
 package beacon
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("beacon", "Multicast and broadcast discovery")
+var l = slogutil.NewAdapter("Multicast and broadcast discovery")

+ 3 - 2
lib/beacon/multicast.go

@@ -9,6 +9,7 @@ package beacon
 import (
 	"context"
 	"errors"
+	"log/slog"
 	"net"
 	"time"
 
@@ -138,7 +139,7 @@ func readMulticasts(ctx context.Context, outbox chan<- recv, addr string) error
 	}
 
 	if joined == 0 {
-		l.Debugln("no multicast interfaces available")
+		slog.DebugContext(ctx, "No multicast interfaces available")
 		return errors.New("no multicast interfaces available")
 	}
 
@@ -161,7 +162,7 @@ func readMulticasts(ctx context.Context, outbox chan<- recv, addr string) error
 		select {
 		case outbox <- recv{c, addr}:
 		default:
-			l.Debugln("dropping message")
+			slog.DebugContext(ctx, "Dropping message")
 		}
 	}
 }

+ 6 - 5
lib/config/config.go

@@ -13,6 +13,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"log/slog"
 	"net"
 	"net/url"
 	"os"
@@ -21,6 +22,7 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/netutil"
@@ -121,8 +123,7 @@ func New(myID protocol.DeviceID) Configuration {
 
 	// Can't happen.
 	if err := cfg.prepare(myID); err != nil {
-		l.Warnln("bug: error in preparing new folder:", err)
-		panic("error in preparing new folder")
+		panic("bug: error in preparing new folder")
 	}
 
 	return cfg
@@ -418,7 +419,7 @@ func (cfg *Configuration) removeDeprecatedProtocols() {
 
 func (cfg *Configuration) applyMigrations() {
 	if cfg.Version > 0 && cfg.Version < OldestHandledVersion {
-		l.Warnf("Configuration version %d is deprecated. Attempting best effort conversion, but please verify manually.", cfg.Version)
+		slog.Warn("Loaded deprecated configuration version; attempting best effort conversion, but please verify manually", "version", cfg.Version)
 	}
 
 	// Upgrade configuration versions as appropriate
@@ -591,7 +592,7 @@ func ensureNoUntrustedTrustingSharing(f *FolderConfiguration, devices []FolderDe
 			continue
 		}
 		if devCfg := existingDevices[dev.DeviceID]; devCfg.Untrusted {
-			l.Warnf("Folder %s (%s) is shared in trusted mode with untrusted device %s (%s); unsharing.", f.ID, f.Label, dev.DeviceID.Short(), devCfg.Name)
+			slog.Error("Folder is shared in trusted mode with untrusted device; unsharing", dev.DeviceID.LogAttr(), f.LogAttr())
 			devices = sliceutil.RemoveAndZero(devices, i)
 			i--
 		}
@@ -611,7 +612,7 @@ func cleanSymlinks(filesystem fs.Filesystem, dir string) {
 			return err
 		}
 		if info.IsSymlink() {
-			l.Infoln("Removing incorrectly versioned symlink", path)
+			slog.Warn("Removing incorrectly versioned symlink", slogutil.FilePath(path))
 			filesystem.Remove(path)
 			return fs.SkipDir
 		}

+ 2 - 4
lib/config/debug.go

@@ -6,8 +6,6 @@
 
 package config
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("config", "Configuration loading and saving")
+var l = slogutil.NewAdapter("Configuration loading and saving")

+ 3 - 2
lib/config/deviceconfiguration.go

@@ -8,6 +8,7 @@ package config
 
 import (
 	"fmt"
+	"log/slog"
 	"slices"
 
 	"github.com/syncthing/syncthing/lib/protocol"
@@ -65,11 +66,11 @@ func (cfg *DeviceConfiguration) prepare(sharedFolders []string) {
 	// auto accept folders.
 	if cfg.Untrusted {
 		if cfg.Introducer {
-			l.Warnf("Device %s (%s) is both untrusted and an introducer, removing introducer flag", cfg.DeviceID.Short(), cfg.Name)
+			slog.Warn("Device is both untrusted and an introducer, removing introducer flag", cfg.DeviceID.LogAttr())
 			cfg.Introducer = false
 		}
 		if cfg.AutoAcceptFolders {
-			l.Warnf("Device %s (%s) is both untrusted and auto-accepting folders, removing auto-accept flag", cfg.DeviceID.Short(), cfg.Name)
+			slog.Warn("Device is both untrusted and auto-accepting folders, removing auto-accept flag", cfg.DeviceID.LogAttr())
 			cfg.AutoAcceptFolders = false
 		}
 	}

+ 8 - 0
lib/config/folderconfiguration.go

@@ -13,6 +13,7 @@ import (
 	"encoding/xml"
 	"errors"
 	"fmt"
+	"log/slog"
 	"path"
 	"path/filepath"
 	"slices"
@@ -268,6 +269,13 @@ func (f FolderConfiguration) Description() string {
 	return fmt.Sprintf("%q (%s)", f.Label, f.ID)
 }
 
+func (f FolderConfiguration) LogAttr() slog.Attr {
+	if f.Label == "" || f.Label == f.ID {
+		return slog.Group("folder", slog.String("id", f.ID), slog.String("type", f.Type.String()))
+	}
+	return slog.Group("folder", slog.String("label", f.Label), slog.String("id", f.ID), slog.String("type", f.Type.String()))
+}
+
 func (f *FolderConfiguration) DeviceIDs() []protocol.DeviceID {
 	deviceIDs := make([]protocol.DeviceID, len(f.Devices))
 	for i, n := range f.Devices {

+ 3 - 1
lib/config/migrations.go

@@ -8,6 +8,7 @@ package config
 
 import (
 	"cmp"
+	"log/slog"
 	"net/url"
 	"os"
 	"path"
@@ -16,6 +17,7 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/netutil"
@@ -239,7 +241,7 @@ func migrateToConfigV23(cfg *Configuration) {
 				fs.Hide(DefaultMarkerName) // ignore error
 			}
 			if err != nil {
-				l.Infoln("Failed to upgrade folder marker:", err)
+				slog.Warn("Failed to upgrade folder marker", slogutil.Error(err))
 			}
 		}
 	}

+ 1 - 3
lib/config/optionsconfiguration.go

@@ -134,11 +134,9 @@ func (opts *OptionsConfiguration) prepare(guiPWIsSet bool) {
 	}
 
 	if opts.ConnectionPriorityQUICWAN <= opts.ConnectionPriorityQUICLAN {
-		l.Warnln("Connection priority number for QUIC over WAN must be worse (higher) than QUIC over LAN. Correcting.")
 		opts.ConnectionPriorityQUICWAN = opts.ConnectionPriorityQUICLAN + 1
 	}
 	if opts.ConnectionPriorityTCPWAN <= opts.ConnectionPriorityTCPLAN {
-		l.Warnln("Connection priority number for TCP over WAN must be worse (higher) than TCP over LAN. Correcting.")
 		opts.ConnectionPriorityTCPWAN = opts.ConnectionPriorityTCPLAN + 1
 	}
 
@@ -186,7 +184,7 @@ func (opts OptionsConfiguration) StunServers() []string {
 		case "default":
 			_, records, err := net.LookupSRV("stun", "udp", "syncthing.net")
 			if err != nil {
-				l.Debugf("Unable to resolve primary STUN servers via DNS:", err)
+				l.Debugln("Unable to resolve primary STUN servers via DNS:", err)
 			}
 
 			for _, record := range records {

+ 5 - 4
lib/config/wrapper.go

@@ -12,18 +12,20 @@ package config
 import (
 	"context"
 	"errors"
+	"log/slog"
 	"os"
 	"reflect"
+	"sync"
 	"sync/atomic"
 	"time"
 
 	"github.com/thejerf/suture/v4"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/sliceutil"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 const (
@@ -151,7 +153,6 @@ func Wrap(path string, cfg Configuration, myID protocol.DeviceID, evLogger event
 		myID:     myID,
 		queue:    make(chan modifyEntry, maxModifications),
 		waiter:   noopWaiter{}, // Noop until first config change
-		mut:      sync.NewMutex(),
 	}
 	return w
 }
@@ -297,7 +298,7 @@ func (w *wrapper) serveSave() {
 		return
 	}
 	if err := w.Save(); err != nil {
-		l.Warnln("Failed to save config:", err)
+		slog.Error("Failed to save config", slogutil.Error(err))
 	}
 }
 
@@ -328,7 +329,7 @@ func (w *wrapper) replaceLocked(to Configuration) (Waiter, error) {
 }
 
 func (w *wrapper) notifyListeners(from, to Configuration) Waiter {
-	wg := sync.NewWaitGroup()
+	wg := new(sync.WaitGroup)
 	wg.Add(len(w.subs))
 	for _, sub := range w.subs {
 		go func(committer Committer) {

+ 2 - 2
lib/connections/connections_test.go

@@ -17,6 +17,7 @@ import (
 	"net"
 	"net/url"
 	"strings"
+	"sync"
 	"testing"
 	"time"
 
@@ -27,7 +28,6 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/nat"
 	"github.com/syncthing/syncthing/lib/protocol"
-	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/tlsutil"
 )
 
@@ -336,7 +336,7 @@ func BenchmarkConnections(b *testing.B) {
 						total := 0
 						b.ResetTimer()
 						for i := 0; i < b.N; i++ {
-							wg := sync.NewWaitGroup()
+							var wg sync.WaitGroup
 							wg.Add(2)
 							errC := make(chan error, 2)
 							go func() {

+ 2 - 4
lib/connections/debug.go

@@ -6,8 +6,6 @@
 
 package connections
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("connections", "Connection handling")
+var l = slogutil.NewAdapter("Connection handling")

+ 6 - 8
lib/connections/limiter.go

@@ -10,13 +10,14 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"log/slog"
+	"sync"
 	"sync/atomic"
 
 	"golang.org/x/time/rate"
 
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/protocol"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 // limiter manages a read and write rate limit, reacting to config changes
@@ -46,7 +47,6 @@ func newLimiter(myId protocol.DeviceID, cfg config.Wrapper) *limiter {
 		myID:                myId,
 		write:               rate.NewLimiter(rate.Inf, limiterBurstSize),
 		read:                rate.NewLimiter(rate.Inf, limiterBurstSize),
-		mu:                  sync.NewMutex(),
 		deviceReadLimiters:  make(map[protocol.DeviceID]*rate.Limiter),
 		deviceWriteLimiters: make(map[protocol.DeviceID]*rate.Limiter),
 	}
@@ -107,15 +107,13 @@ func (lim *limiter) processDevicesConfigurationLocked(from, to config.Configurat
 				writeLimitStr = fmt.Sprintf("limit is %d KiB/s", dev.MaxSendKbps)
 			}
 
-			l.Infof("Device %s send rate %s, receive rate %s", dev.DeviceID, writeLimitStr, readLimitStr)
+			slog.Info("Device is rate limited", dev.DeviceID.LogAttr(), slog.String("send", writeLimitStr), slog.String("recv", readLimitStr))
 		}
 	}
 
 	// Delete remote devices which were removed in new configuration
 	for _, dev := range from.Devices {
 		if _, ok := seen[dev.DeviceID]; !ok {
-			l.Debugf("deviceID: %s should be removed", dev.DeviceID)
-
 			delete(lim.deviceWriteLimiters, dev.DeviceID)
 			delete(lim.deviceReadLimiters, dev.DeviceID)
 		}
@@ -160,13 +158,13 @@ func (lim *limiter) CommitConfiguration(from, to config.Configuration) bool {
 
 	lim.limitsLAN.Store(to.Options.LimitBandwidthInLan)
 
-	l.Infof("Overall send rate %s, receive rate %s", sendLimitStr, recvLimitStr)
+	slog.Info("Overall rate limit in use", "send", sendLimitStr, "recv", recvLimitStr)
 
 	if limited {
 		if to.Options.LimitBandwidthInLan {
-			l.Infoln("Rate limits apply to LAN connections")
+			slog.Info("Rate limits apply to LAN connections")
 		} else {
-			l.Infoln("Rate limits do not apply to LAN connections")
+			slog.Info("Rate limits do not apply to LAN connections")
 		}
 	}
 

+ 12 - 10
lib/connections/quic_listen.go

@@ -13,6 +13,7 @@ import (
 	"context"
 	"crypto/tls"
 	"errors"
+	"log/slog"
 	"net"
 	"net/url"
 	"sync"
@@ -21,6 +22,7 @@ import (
 
 	"github.com/quic-go/quic-go"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections/registry"
 	"github.com/syncthing/syncthing/lib/nat"
@@ -58,7 +60,7 @@ type quicListener struct {
 
 func (t *quicListener) OnNATTypeChanged(natType stun.NATType) {
 	if natType != stun.NATUnknown {
-		l.Infof("%s detected NAT type: %s", t.uri, natType)
+		slog.Info("Detected NAT type", slogutil.URI(t.uri), slog.Any("type", natType))
 	}
 	t.nat.Store(uint64(natType))
 }
@@ -77,7 +79,7 @@ func (t *quicListener) OnExternalAddressChanged(address *stun.Host, via string)
 	t.mut.Unlock()
 
 	if uri != nil && (existingAddress == nil || existingAddress.String() != uri.String()) {
-		l.Infof("%s resolved external address %s (via %s)", t.uri, uri.String(), via)
+		slog.Info("Resolved external address", slogutil.URI(t.uri), slogutil.Address(uri.String()), slog.String("via", via))
 		t.notifyAddressesChanged(t)
 	} else if uri == nil && existingAddress != nil {
 		t.notifyAddressesChanged(t)
@@ -89,13 +91,13 @@ func (t *quicListener) serve(ctx context.Context) error {
 
 	udpAddr, err := net.ResolveUDPAddr(network, t.uri.Host)
 	if err != nil {
-		l.Infoln("Listen (BEP/quic):", err)
+		slog.WarnContext(ctx, "Failed to listen (QUIC)", slogutil.Error(err))
 		return err
 	}
 
 	udpConn, err := net.ListenUDP(network, udpAddr)
 	if err != nil {
-		l.Infoln("Listen (BEP/quic):", err)
+		slog.WarnContext(ctx, "Failed to listen (QUIC)", slogutil.Error(err))
 		return err
 	}
 	defer udpConn.Close()
@@ -117,7 +119,7 @@ func (t *quicListener) serve(ctx context.Context) error {
 
 	listener, err := quicTransport.Listen(t.tlsCfg, quicConfig)
 	if err != nil {
-		l.Infoln("Listen (BEP/quic):", err)
+		slog.WarnContext(ctx, "Failed to listen (QUIC)", slogutil.Error(err))
 		return err
 	}
 	defer listener.Close()
@@ -125,8 +127,8 @@ func (t *quicListener) serve(ctx context.Context) error {
 	t.notifyAddressesChanged(t)
 	defer t.clearAddresses(t)
 
-	l.Infof("QUIC listener (%v) starting", udpConn.LocalAddr())
-	defer l.Infof("QUIC listener (%v) shutting down", udpConn.LocalAddr())
+	slog.InfoContext(ctx, "QUIC listener starting", slogutil.Address(udpConn.LocalAddr()))
+	defer slog.InfoContext(ctx, "QUIC listener shutting down", slogutil.Address(udpConn.LocalAddr()))
 
 	var ipVersion nat.IPVersion
 	switch t.uri.Scheme {
@@ -168,7 +170,7 @@ func (t *quicListener) serve(ctx context.Context) error {
 		if errors.Is(err, context.Canceled) {
 			return nil
 		} else if err != nil {
-			l.Infoln("Listen (BEP/quic): Accepting connection:", err)
+			slog.WarnContext(ctx, "Failed to accept QUIC connection", slogutil.Error(err))
 
 			acceptFailures++
 			if acceptFailures > maxAcceptFailures {
@@ -185,13 +187,13 @@ func (t *quicListener) serve(ctx context.Context) error {
 
 		acceptFailures = 0
 
-		l.Debugln("connect from", session.RemoteAddr())
+		slog.DebugContext(ctx, "Incoming connection", "from", session.RemoteAddr())
 
 		streamCtx, cancel := context.WithTimeout(ctx, quicOperationTimeout)
 		stream, err := session.AcceptStream(streamCtx)
 		cancel()
 		if err != nil {
-			l.Debugf("failed to accept stream from %s: %v", session.RemoteAddr(), err)
+			slog.DebugContext(ctx, "Failed to accept stream", slogutil.Address(session.RemoteAddr()), slogutil.Error(err))
 			_ = session.CloseWithError(1, err.Error())
 			continue
 		}

+ 1 - 2
lib/connections/registry/registry.go

@@ -11,9 +11,9 @@ package registry
 
 import (
 	"strings"
+	"sync"
 
 	"github.com/syncthing/syncthing/lib/sliceutil"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type Registry struct {
@@ -23,7 +23,6 @@ type Registry struct {
 
 func New() *Registry {
 	return &Registry{
-		mut:       sync.NewMutex(),
 		available: make(map[string][]interface{}),
 	}
 }

+ 9 - 7
lib/connections/relay_listen.go

@@ -10,10 +10,12 @@ import (
 	"context"
 	"crypto/tls"
 	"errors"
+	"log/slog"
 	"net/url"
 	"sync"
 	"time"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections/registry"
 	"github.com/syncthing/syncthing/lib/dialer"
@@ -46,7 +48,7 @@ type relayListener struct {
 func (t *relayListener) serve(ctx context.Context) error {
 	clnt, err := client.NewClient(t.uri, t.tlsCfg.Certificates, 10*time.Second)
 	if err != nil {
-		l.Infoln("Listen (BEP/relay):", err)
+		slog.WarnContext(ctx, "Failed to listen (relay)", slogutil.Error(err))
 		return err
 	}
 
@@ -54,8 +56,8 @@ func (t *relayListener) serve(ctx context.Context) error {
 	t.client = clnt
 	t.mut.Unlock()
 
-	l.Infof("Relay listener (%v) starting", t)
-	defer l.Infof("Relay listener (%v) shutting down", t)
+	slog.InfoContext(ctx, "Relay listener starting", "id", t.String())
+	defer slog.InfoContext(ctx, "Relay listener shutting down", "id", t.String())
 	defer t.clearAddresses(t)
 
 	invitationCtx, cancel := context.WithCancel(ctx)
@@ -77,19 +79,19 @@ func (t *relayListener) handleInvitations(ctx context.Context, clnt client.Relay
 			conn, err := client.JoinSession(ctx, inv)
 			if err != nil {
 				if !errors.Is(err, context.Canceled) {
-					l.Infoln("Listen (BEP/relay): joining session:", err)
+					slog.InfoContext(ctx, "Failed to join session", slogutil.Error(err))
 				}
 				continue
 			}
 
 			err = dialer.SetTCPOptions(conn)
 			if err != nil {
-				l.Debugln("Listen (BEP/relay): setting tcp options:", err)
+				slog.DebugContext(ctx, "Failed to set TCP options", slogutil.Error(err))
 			}
 
 			err = dialer.SetTrafficClass(conn, t.cfg.Options().TrafficClass)
 			if err != nil {
-				l.Debugln("Listen (BEP/relay): setting traffic class:", err)
+				slog.DebugContext(ctx, "Failed to set traffic class", slogutil.Error(err))
 			}
 
 			var tc *tls.Conn
@@ -102,7 +104,7 @@ func (t *relayListener) handleInvitations(ctx context.Context, clnt client.Relay
 			err = tlsTimedHandshake(tc)
 			if err != nil {
 				tc.Close()
-				l.Infoln("Listen (BEP/relay): TLS handshake:", err)
+				slog.WarnContext(ctx, "Failed TLS handshake", slogutil.Error(err))
 				continue
 			}
 

+ 32 - 72
lib/connections/service.go

@@ -19,17 +19,18 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"log/slog"
 	"math"
 	"net"
 	"net/url"
 	"slices"
 	"strings"
-	stdsync "sync"
+	"sync"
 	"time"
 
 	"github.com/thejerf/suture/v4"
-	"golang.org/x/time/rate"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections/registry"
@@ -42,7 +43,6 @@ import (
 	"github.com/syncthing/syncthing/lib/sliceutil"
 	"github.com/syncthing/syncthing/lib/stringutil"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 
 	// Registers NAT service providers
 	_ "github.com/syncthing/syncthing/lib/pmp"
@@ -185,7 +185,7 @@ type service struct {
 }
 
 func NewService(cfg config.Wrapper, myID protocol.DeviceID, mdl Model, tlsCfg *tls.Config, discoverer discover.Finder, bepProtocolName string, tlsDefaultCommonName string, evLogger events.Logger, registry *registry.Registry, keyGen *protocol.KeyGenerator) Service {
-	spec := svcutil.SpecWithInfoLogger(l)
+	spec := svcutil.SpecWithInfoLogger()
 	service := &service{
 		Supervisor:              suture.New("connections.Service", spec),
 		connectionStatusHandler: newConnectionStatusHandler(),
@@ -206,11 +206,9 @@ func NewService(cfg config.Wrapper, myID protocol.DeviceID, mdl Model, tlsCfg *t
 		keyGen:               keyGen,
 		lanChecker:           &lanChecker{cfg},
 
-		dialNowDevicesMut: sync.NewMutex(),
-		dialNow:           make(chan struct{}, 1),
-		dialNowDevices:    make(map[protocol.DeviceID]struct{}),
+		dialNow:        make(chan struct{}, 1),
+		dialNowDevices: make(map[protocol.DeviceID]struct{}),
 
-		listenersMut:   sync.NewRWMutex(),
 		listeners:      make(map[string]genericListener),
 		listenerTokens: make(map[string]suture.ServiceToken),
 	}
@@ -257,7 +255,7 @@ func (s *service) handleConns(ctx context.Context) error {
 		// because there are implementations out there that don't support
 		// protocol negotiation (iOS for one...).
 		if cs.NegotiatedProtocol != s.bepProtocolName {
-			l.Infof("Peer at %s did not negotiate bep/1.0", c)
+			slog.WarnContext(ctx, "Peer at did not negotiate bep/1.0", slogutil.Address(c.RemoteAddr()))
 		}
 
 		// We should have received exactly one certificate from the other
@@ -265,7 +263,7 @@ func (s *service) handleConns(ctx context.Context) error {
 		// connection.
 		certs := cs.PeerCertificates
 		if cl := len(certs); cl != 1 {
-			l.Infof("Got peer certificate list of length %d != 1 from peer at %s; protocol error", cl, c)
+			slog.WarnContext(ctx, "Got peer certificate list of incorrect length", slog.Int("length", cl), slogutil.Address(c.RemoteAddr()))
 			c.Close()
 			continue
 		}
@@ -276,17 +274,13 @@ func (s *service) handleConns(ctx context.Context) error {
 		// though, especially in the presence of NAT hairpinning, multiple
 		// clients between the same NAT gateway, and global discovery.
 		if remoteID == s.myID {
-			l.Debugf("Connected to myself (%s) at %s", remoteID, c)
+			slog.DebugContext(ctx, "Connected to myself", "id", remoteID, "addr", c)
 			c.Close()
 			continue
 		}
 
 		if err := s.connectionCheckEarly(remoteID, c); err != nil {
-			if errors.Is(err, errDeviceAlreadyConnected) {
-				l.Debugf("Connection from %s at %s (%s) rejected: %v", remoteID, c.RemoteAddr(), c.Type(), err)
-			} else {
-				l.Infof("Connection from %s at %s (%s) rejected: %v", remoteID, c.RemoteAddr(), c.Type(), err)
-			}
+			slog.DebugContext(ctx, "Connection rejected", remoteID.LogAttr(), slogutil.Address(c.RemoteAddr()), slog.String("type", c.Type()), slogutil.Error(err))
 			c.Close()
 			continue
 		}
@@ -382,22 +376,10 @@ func (s *service) handleHellos(ctx context.Context) error {
 
 		if err != nil {
 			if protocol.IsVersionMismatch(err) {
-				// The error will be a relatively user friendly description
-				// of what's wrong with the version compatibility. By
-				// default identify the other side by device ID and IP.
-				remote := fmt.Sprintf("%v (%v)", remoteID, c.RemoteAddr())
-				if hello.DeviceName != "" {
-					// If the name was set in the hello return, use that to
-					// give the user more info about which device is the
-					// affected one. It probably says more than the remote
-					// IP.
-					remote = fmt.Sprintf("%q (%s %s, %v)", hello.DeviceName, hello.ClientName, hello.ClientVersion, remoteID)
-				}
-				msg := fmt.Sprintf("Connecting to %s: %s", remote, err)
-				warningFor(remoteID, msg)
+				slog.WarnContext(ctx, "Remote device is too old", remoteID.LogAttr(), slogutil.Address(c.RemoteAddr()), slogutil.Error(err))
 			} else {
 				// It's something else - connection reset or whatever
-				l.Infof("Failed to exchange Hello messages with %s at %s: %s", remoteID, c, err)
+				slog.WarnContext(ctx, "Failed to exchange Hello messages", remoteID.LogAttr(), slogutil.Address(c.RemoteAddr()), slogutil.Error(err))
 			}
 			c.Close()
 			continue
@@ -407,14 +389,14 @@ func (s *service) handleHellos(ctx context.Context) error {
 		// The Model will return an error for devices that we don't want to
 		// have a connection with for whatever reason, for example unknown devices.
 		if err := s.model.OnHello(remoteID, c.RemoteAddr(), hello); err != nil {
-			l.Infof("Connection from %s at %s (%s) rejected: %v", remoteID, c.RemoteAddr(), c.Type(), err)
+			slog.WarnContext(ctx, "Connection rejected", remoteID.LogAttr(), slogutil.Address(c.RemoteAddr()), slog.Any("type", c.Type()), slogutil.Error(err))
 			c.Close()
 			continue
 		}
 
 		deviceCfg, ok := s.cfg.Device(remoteID)
 		if !ok {
-			l.Infof("Device %s removed from config during connection attempt at %s", remoteID, c)
+			slog.WarnContext(ctx, "Device removed from config during connection attempt", remoteID.LogAttr(), slogutil.Address(c.RemoteAddr()))
 			c.Close()
 			continue
 		}
@@ -434,7 +416,7 @@ func (s *service) handleHellos(ctx context.Context) error {
 			// Incorrect certificate name is something the user most
 			// likely wants to know about, since it's an advanced
 			// config. Warn instead of Info.
-			l.Warnf("Bad certificate from %s at %s: %v", remoteID, c, err)
+			slog.ErrorContext(ctx, "Bad certificate from remote", remoteID.LogAttr(), slogutil.Address(c.RemoteAddr()), slogutil.Error(err))
 			c.Close()
 			continue
 		}
@@ -455,7 +437,7 @@ func (s *service) handleHellos(ctx context.Context) error {
 			s.dialNowDevicesMut.Unlock()
 		}()
 
-		l.Infof("Established secure connection to %s at %s", remoteID.Short(), c)
+		slog.InfoContext(ctx, "Established secure connection", remoteID.LogAttr(), slog.Any("connection", c))
 
 		s.model.AddConnection(protoConn, hello)
 		continue
@@ -477,9 +459,9 @@ func (s *service) connect(ctx context.Context) error {
 		bestDialerPriority := s.bestDialerPriority(cfg)
 		isInitialRampup := initialRampup < stdConnectionLoopSleep
 
-		l.Debugln("Connection loop")
+		slog.DebugContext(ctx, "Connection loop")
 		if isInitialRampup {
-			l.Debugln("Connection loop in initial rampup")
+			slog.DebugContext(ctx, "Connection loop in initial rampup")
 		}
 
 		// Used for consistency throughout this loop run, as time passes
@@ -616,9 +598,9 @@ func (s *service) dialDevices(ctx context.Context, now time.Time, cfg config.Con
 	// Perform dials according to the queue, stopping when we've reached the
 	// allowed additional number of connections (if limited).
 	numConns := 0
-	var numConnsMut stdsync.Mutex
+	var numConnsMut sync.Mutex
 	dialSemaphore := semaphore.New(dialMaxParallel)
-	dialWG := new(stdsync.WaitGroup)
+	dialWG := new(sync.WaitGroup)
 	dialCtx, dialCancel := context.WithCancel(ctx)
 	defer func() {
 		dialWG.Wait()
@@ -675,7 +657,7 @@ func (s *service) resolveDialTargets(ctx context.Context, now time.Time, cfg con
 		uri, err := url.Parse(addr)
 		if err != nil {
 			s.setConnectionStatus(addr, err)
-			l.Infof("Parsing dialer address %s: %v", addr, err)
+			slog.WarnContext(ctx, "Failed to parse dialer address", slogutil.Address(addr), slogutil.Error(err))
 			continue
 		}
 
@@ -695,7 +677,7 @@ func (s *service) resolveDialTargets(ctx context.Context, now time.Time, cfg con
 			l.Debugf("Dialer for %v: %v", uri, err)
 			continue
 		} else if err != nil {
-			l.Infof("Dialer for %v: %v", uri, err)
+			slog.WarnContext(ctx, "Failed to get dialer", slogutil.URI(uri), slogutil.Error(err))
 			continue
 		}
 
@@ -711,7 +693,7 @@ func (s *service) resolveDialTargets(ctx context.Context, now time.Time, cfg con
 			continue
 		}
 		if currentConns >= s.desiredConnectionsToDevice(deviceCfg.DeviceID) && priority == priorityCutoff {
-			l.Debugf("Not dialing %s at %s using %s as priority is equal and we already have %d/%d connections", deviceID.Short(), addr, dialerFactory, currentConns, deviceCfg.NumConnections)
+			l.Debugf("Not dialing %s at %s using %s as priority is equal and we already have %d/%d connections", deviceID.Short(), addr, dialerFactory, currentConns, deviceCfg.NumConnections())
 			continue
 		}
 
@@ -818,7 +800,7 @@ func (s *lanChecker) isLAN(addr net.Addr) bool {
 func (s *service) createListener(factory listenerFactory, uri *url.URL) bool {
 	// must be called with listenerMut held
 
-	l.Debugln("Starting listener", uri)
+	slog.Debug("Starting listener", "uri", uri)
 
 	listener := factory.New(uri, s.cfg, s.tlsCfg, s.conns, s.natService, s.registry, s.lanChecker)
 	listener.OnAddressesChanged(s.logListenAddressesChangedEvent)
@@ -826,7 +808,7 @@ func (s *service) createListener(factory listenerFactory, uri *url.URL) bool {
 	// Retrying a listener many times in rapid succession is unlikely to help,
 	// thus back off quickly. A listener may soon be functional again, e.g. due
 	// to a network interface coming back online - retry every minute.
-	spec := svcutil.SpecWithInfoLogger(l)
+	spec := svcutil.SpecWithInfoLogger()
 	spec.FailureThreshold = 2
 	spec.FailureBackoff = time.Minute
 	sup := suture.New(fmt.Sprintf("listenerSupervisor@%v", listener), spec)
@@ -854,9 +836,6 @@ func (s *service) CommitConfiguration(from, to config.Configuration) bool {
 
 	for _, dev := range from.Devices {
 		if !newDevices[dev.DeviceID] {
-			warningLimitersMut.Lock()
-			delete(warningLimiters, dev.DeviceID)
-			warningLimitersMut.Unlock()
 			metricDeviceActiveConnections.DeleteLabelValues(dev.DeviceID.String())
 		}
 	}
@@ -875,7 +854,7 @@ func (s *service) CommitConfiguration(from, to config.Configuration) bool {
 
 		uri, err := url.Parse(addr)
 		if err != nil {
-			l.Warnf("Skipping malformed listener URL %q: %v", addr, err)
+			slog.Error("Skipping malformed listener URL", slogutil.URI(addr), slogutil.Error(err))
 			continue
 		}
 
@@ -886,7 +865,7 @@ func (s *service) CommitConfiguration(from, to config.Configuration) bool {
 		// mean something entirely different to the computer (e.g.,
 		// tcp:/127.0.0.1:22000 in fact being equivalent to tcp://:22000).
 		if canonical := uri.String(); canonical != addr {
-			l.Warnf("Skipping malformed listener URL %q (not canonical)", addr)
+			slog.Error("Skipping malformed listener URL (not canonical)", slogutil.URI(addr))
 			continue
 		}
 
@@ -900,7 +879,7 @@ func (s *service) CommitConfiguration(from, to config.Configuration) bool {
 			l.Debugf("Listener for %v: %v", uri, err)
 			continue
 		} else if err != nil {
-			l.Infof("Listener for %v: %v", uri, err)
+			slog.Warn("Failed to get listener", slogutil.URI(uri), slogutil.Error(err))
 			continue
 		}
 
@@ -1007,8 +986,7 @@ type connectionStatusHandler struct {
 
 func newConnectionStatusHandler() connectionStatusHandler {
 	return connectionStatusHandler{
-		connectionStatusMut: sync.NewRWMutex(),
-		connectionStatus:    make(map[string]ConnectionStatusEntry),
+		connectionStatus: make(map[string]ConnectionStatusEntry),
 	}
 }
 
@@ -1082,24 +1060,6 @@ func urlsToStrings(urls []*url.URL) []string {
 	return strings
 }
 
-var (
-	warningLimiters    = make(map[protocol.DeviceID]*rate.Limiter)
-	warningLimitersMut = sync.NewMutex()
-)
-
-func warningFor(dev protocol.DeviceID, msg string) {
-	warningLimitersMut.Lock()
-	defer warningLimitersMut.Unlock()
-	lim, ok := warningLimiters[dev]
-	if !ok {
-		lim = rate.NewLimiter(rate.Every(perDeviceWarningIntv), 1)
-		warningLimiters[dev] = lim
-	}
-	if lim.Allow() {
-		l.Warnln(msg)
-	}
-}
-
 func tlsTimedHandshake(tc *tls.Conn) error {
 	tc.SetDeadline(time.Now().Add(tlsHandshakeTimeout))
 	defer tc.SetDeadline(time.Time{})
@@ -1156,7 +1116,7 @@ func (s *service) dialParallel(ctx context.Context, deviceID protocol.DeviceID,
 	for _, prio := range priorities {
 		tgts := dialTargetBuckets[prio]
 		res := make(chan internalConn, len(tgts))
-		wg := stdsync.WaitGroup{}
+		wg := sync.WaitGroup{}
 		for _, tgt := range tgts {
 			sema.Take(1)
 			wg.Add(1)
@@ -1215,7 +1175,7 @@ func (s *service) validateIdentity(c internalConn, expectedID protocol.DeviceID)
 	// connection.
 	certs := cs.PeerCertificates
 	if cl := len(certs); cl != 1 {
-		l.Infof("Got peer certificate list of length %d != 1 from peer at %s; protocol error", cl, c)
+		slog.Warn("Got peer certificate list of incorrect length", slog.Int("length", cl), slogutil.Address(c.RemoteAddr()))
 		c.Close()
 		return fmt.Errorf("expected 1 certificate, got %d", cl)
 	}
@@ -1364,7 +1324,7 @@ func (s *service) desiredConnectionsToDevice(deviceID protocol.DeviceID) int {
 // connected to and how many connections we have to each device. It also
 // tracks how many connections they are willing to use.
 type deviceConnectionTracker struct {
-	connectionsMut  stdsync.Mutex
+	connectionsMut  sync.Mutex
 	connections     map[protocol.DeviceID][]protocol.Connection // current connections
 	wantConnections map[protocol.DeviceID]int                   // number of connections they want
 }

+ 5 - 0
lib/connections/structs.go

@@ -11,6 +11,7 @@ import (
 	"crypto/tls"
 	"fmt"
 	"io"
+	"log/slog"
 	"net"
 	"net/url"
 	"time"
@@ -152,6 +153,10 @@ func (c internalConn) String() string {
 	return fmt.Sprintf("%s-%s/%s/%s/%s-P%d-%s", c.LocalAddr(), c.RemoteAddr(), c.Type(), c.Crypto(), t, c.Priority(), c.connectionID)
 }
 
+func (c internalConn) LogValue() slog.Value {
+	return slog.GroupValue(slog.String("local", c.LocalAddr().String()), slog.String("remote", c.RemoteAddr().String()), slog.String("type", c.Type()), slog.Bool("lan", c.isLocal), slog.String("crypto", c.Crypto()), slog.Int("prio", c.priority), slog.String("id", c.ConnectionID()))
+}
+
 type dialerFactory interface {
 	New(config.OptionsConfiguration, *tls.Config, *registry.Registry, *lanChecker) genericDialer
 	AlwaysWAN() bool

+ 11 - 7
lib/connections/tcp_listen.go

@@ -9,11 +9,14 @@ package connections
 import (
 	"context"
 	"crypto/tls"
+	"errors"
+	"log/slog"
 	"net"
 	"net/url"
 	"sync"
 	"time"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections/registry"
 	"github.com/syncthing/syncthing/lib/dialer"
@@ -50,7 +53,7 @@ type tcpListener struct {
 func (t *tcpListener) serve(ctx context.Context) error {
 	tcaddr, err := net.ResolveTCPAddr(t.uri.Scheme, t.uri.Host)
 	if err != nil {
-		l.Infoln("Listen (BEP/tcp):", err)
+		slog.WarnContext(ctx, "Failed to listen (TCP)", slogutil.Error(err))
 		return err
 	}
 
@@ -60,7 +63,7 @@ func (t *tcpListener) serve(ctx context.Context) error {
 
 	listener, err := lc.Listen(context.TODO(), t.uri.Scheme, tcaddr.String())
 	if err != nil {
-		l.Infoln("Listen (BEP/tcp):", err)
+		slog.WarnContext(ctx, "Failed to listen (TCP)", slogutil.Error(err))
 		return err
 	}
 	defer listener.Close()
@@ -74,8 +77,8 @@ func (t *tcpListener) serve(ctx context.Context) error {
 	t.registry.Register(t.uri.Scheme, tcaddr)
 	defer t.registry.Unregister(t.uri.Scheme, tcaddr)
 
-	l.Infof("TCP listener (%v) starting", tcaddr)
-	defer l.Infof("TCP listener (%v) shutting down", tcaddr)
+	slog.InfoContext(ctx, "TCP listener starting", slogutil.Address(tcaddr))
+	defer slog.InfoContext(ctx, "TCP listener shutting down", slogutil.Address(tcaddr))
 
 	var ipVersion nat.IPVersion
 	if t.uri.Scheme == "tcp4" {
@@ -121,8 +124,9 @@ func (t *tcpListener) serve(ctx context.Context) error {
 		default:
 		}
 		if err != nil {
-			if err, ok := err.(*net.OpError); !ok || !err.Timeout() {
-				l.Warnln("Listen (BEP/tcp): Accepting connection:", err)
+			var ne *net.OpError
+			if ok := errors.As(err, &ne); !ok || !ne.Timeout() {
+				slog.WarnContext(ctx, "Failed to accept TCP connection", slogutil.Error(err))
 
 				acceptFailures++
 				if acceptFailures > maxAcceptFailures {
@@ -152,7 +156,7 @@ func (t *tcpListener) serve(ctx context.Context) error {
 
 		tc := tls.Server(conn, t.tlsCfg)
 		if err := tlsTimedHandshake(tc); err != nil {
-			l.Infoln("Listen (BEP/tcp): TLS handshake:", err)
+			slog.WarnContext(ctx, "Failed TLS handshake", slogutil.Address(tc.RemoteAddr()), slogutil.Error(err))
 			tc.Close()
 			continue
 		}

+ 3 - 2
lib/dialer/control_unix.go

@@ -10,6 +10,7 @@
 package dialer
 
 import (
+	"log/slog"
 	"syscall"
 
 	"golang.org/x/sys/unix"
@@ -28,11 +29,11 @@ func init() {
 	err = unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
 	switch {
 	case err == unix.ENOPROTOOPT || err == unix.EINVAL:
-		l.Debugln("SO_REUSEPORT not supported")
+		slog.Debug("SO_REUSEPORT not supported")
 	case err != nil:
 		l.Debugln("Unknown error when determining SO_REUSEPORT support", err)
 	default:
-		l.Debugln("SO_REUSEPORT supported")
+		slog.Debug("SO_REUSEPORT supported")
 		SupportsReusePort = true
 	}
 }

+ 2 - 12
lib/dialer/debug.go

@@ -7,17 +7,7 @@
 package dialer
 
 import (
-	"os"
-	"strings"
-
-	"github.com/syncthing/syncthing/lib/logger"
+	"github.com/syncthing/syncthing/internal/slogutil"
 )
 
-var (
-	l = logger.DefaultLogger.NewFacility("dialer", "Dialing connections")
-	// To run before init() of other files that log on init.
-	_ = func() error {
-		l.SetDebug("dialer", strings.Contains(os.Getenv("STTRACE"), "dialer") || os.Getenv("STTRACE") == "all")
-		return nil
-	}()
-)
+var l = slogutil.NewAdapter("Dialing connections")

+ 4 - 3
lib/dialer/internal.go

@@ -7,6 +7,7 @@
 package dialer
 
 import (
+	"log/slog"
 	"net"
 	"net/http"
 	"net/url"
@@ -31,15 +32,15 @@ func init() {
 		// Defer this, so that logging gets set up.
 		go func() {
 			time.Sleep(500 * time.Millisecond)
-			l.Infoln("Proxy settings detected")
+			slog.Info("Proxy settings detected")
 			if noFallback {
-				l.Infoln("Proxy fallback disabled")
+				slog.Info("Proxy fallback disabled")
 			}
 		}()
 	} else {
 		go func() {
 			time.Sleep(500 * time.Millisecond)
-			l.Debugln("Dialer logging disabled, as no proxy was detected")
+			slog.Debug("Dialer logging disabled, as no proxy was detected")
 		}()
 	}
 }

+ 2 - 2
lib/discover/cache.go

@@ -7,7 +7,7 @@
 package discover
 
 import (
-	stdsync "sync"
+	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/lib/protocol"
@@ -34,7 +34,7 @@ type cachedError interface {
 
 type cache struct {
 	entries map[protocol.DeviceID]CacheEntry
-	mut     stdsync.Mutex
+	mut     sync.Mutex
 }
 
 func newCache() *cache {

+ 2 - 4
lib/discover/debug.go

@@ -6,8 +6,6 @@
 
 package discover
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("discover", "Remote device discovery")
+var l = slogutil.NewAdapter("Remote device discovery")

+ 13 - 11
lib/discover/global.go

@@ -14,15 +14,17 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"log/slog"
 	"net"
 	"net/http"
 	"net/url"
 	"strconv"
-	stdsync "sync"
+	"sync"
 	"time"
 
 	"golang.org/x/net/http2"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/connections/registry"
 	"github.com/syncthing/syncthing/lib/dialer"
 	"github.com/syncthing/syncthing/lib/events"
@@ -181,12 +183,12 @@ func (c *globalClient) Lookup(ctx context.Context, device protocol.DeviceID) (ad
 
 	resp, err := c.queryClient.Get(ctx, qURL.String())
 	if err != nil {
-		l.Debugln("globalClient.Lookup", qURL, err)
+		slog.DebugContext(ctx, "globalClient.Lookup", "url", qURL, slogutil.Error(err))
 		return nil, err
 	}
 	if resp.StatusCode != http.StatusOK {
 		resp.Body.Close()
-		l.Debugln("globalClient.Lookup", qURL, resp.Status)
+		slog.DebugContext(ctx, "globalClient.Lookup", "url", qURL, "status", resp.Status)
 		err := errors.New(resp.Status)
 		if secs, atoiErr := strconv.Atoi(resp.Header.Get("Retry-After")); atoiErr == nil && secs > 0 {
 			err = &lookupError{
@@ -238,7 +240,7 @@ func (c *globalClient) Serve(ctx context.Context) error {
 			} else if timerResetCount == maxAddressChangesBetweenAnnouncements {
 				// Yet only do it if we haven't had to reset maxAddressChangesBetweenAnnouncements times in a row,
 				// so if something is flip-flopping within 2 seconds, we don't end up in a permanent reset loop.
-				l.Warnf("Detected a flip-flopping listener")
+				slog.ErrorContext(ctx, "Detected a flip-flopping listener", slog.String("server", c.server))
 				c.setError(errors.New("flip flopping listener"))
 				// Incrementing the count above 10 will prevent us from warning or setting the error again
 				// It will also suppress event based resets until we've had a proper round after announceErrorRetryInterval
@@ -273,27 +275,27 @@ func (c *globalClient) sendAnnouncement(ctx context.Context, timer *time.Timer)
 	// The marshal doesn't fail, I promise.
 	postData, _ := json.Marshal(ann)
 
-	l.Debugf("%s Announcement: %v", c, ann)
+	slog.DebugContext(ctx, "send announcement", "server", c.server, "announcement", ann)
 
 	resp, err := c.announceClient.Post(ctx, c.server, "application/json", bytes.NewReader(postData))
 	if err != nil {
-		l.Debugln(c, "announce POST:", err)
+		slog.DebugContext(ctx, "announce POST", "server", c.server, slogutil.Error(err))
 		c.setError(err)
 		timer.Reset(announceErrorRetryInterval)
 		return
 	}
-	l.Debugln(c, "announce POST:", resp.Status)
+	slog.DebugContext(ctx, "announce POST", "server", c.server, "status", resp.Status)
 	resp.Body.Close()
 
 	if resp.StatusCode < 200 || resp.StatusCode > 299 {
-		l.Debugln(c, "announce POST:", resp.Status)
+		slog.DebugContext(ctx, "announce POST", "server", c.server, "status", resp.Status)
 		c.setError(errors.New(resp.Status))
 
 		if h := resp.Header.Get("Retry-After"); h != "" {
 			// The server has a recommendation on when we should
 			// retry. Follow it.
 			if secs, err := strconv.Atoi(h); err == nil && secs > 0 {
-				l.Debugln(c, "announce Retry-After:", secs, err)
+				slog.DebugContext(ctx, "server sets retry-after", "server", c.server, "seconds", secs)
 				timer.Reset(time.Duration(secs) * time.Second)
 				return
 			}
@@ -309,7 +311,7 @@ func (c *globalClient) sendAnnouncement(ctx context.Context, timer *time.Timer)
 		// The server has a recommendation on when we should
 		// reannounce. Follow it.
 		if secs, err := strconv.Atoi(h); err == nil && secs > 0 {
-			l.Debugln(c, "announce Reannounce-After:", secs, err)
+			slog.DebugContext(ctx, "announce sets reannounce-after", "server", c.server, "seconds", secs)
 			timer.Reset(time.Duration(secs) * time.Second)
 			return
 		}
@@ -424,7 +426,7 @@ func (c *idCheckingHTTPClient) Post(ctx context.Context, url, ctype string, data
 
 type errorHolder struct {
 	err error
-	mut stdsync.Mutex // uses stdlib sync as I want this to be trivially embeddable, and there is no risk of blocking
+	mut sync.Mutex // uses stdlib sync as I want this to be trivially embeddable, and there is no risk of blocking
 }
 
 func (e *errorHolder) setError(err error) {

+ 8 - 6
lib/discover/local.go

@@ -14,6 +14,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"log/slog"
 	"net"
 	"net/url"
 	"strconv"
@@ -23,6 +24,7 @@ import (
 	"google.golang.org/protobuf/proto"
 
 	"github.com/syncthing/syncthing/internal/gen/discoproto"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/beacon"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/protocol"
@@ -54,7 +56,7 @@ const (
 
 func NewLocal(id protocol.DeviceID, addr string, addrList AddressLister, evLogger events.Logger) (FinderService, error) {
 	c := &localClient{
-		Supervisor:      suture.New("local", svcutil.SpecWithDebugLogger(l)),
+		Supervisor:      suture.New("local", svcutil.SpecWithDebugLogger()),
 		myID:            id,
 		addrList:        addrList,
 		evLogger:        evLogger,
@@ -176,7 +178,7 @@ func (c *localClient) recvAnnouncements(ctx context.Context) error {
 			continue
 		}
 		if len(buf) < 4 {
-			l.Debugf("discover: short packet from %s", addr.String())
+			slog.DebugContext(ctx, "received short packet", "address", addr.String())
 			continue
 		}
 
@@ -188,25 +190,25 @@ func (c *localClient) recvAnnouncements(ctx context.Context) error {
 		case v13Magic:
 			// Old version
 			if !warnedAbout[addr.String()] {
-				l.Warnf("Incompatible (v0.13) local discovery packet from %v - upgrade that device to connect", addr)
+				slog.ErrorContext(ctx, "Incompatible (v0.13) local discovery packet - upgrade that device to connect", slogutil.Address(addr))
 				warnedAbout[addr.String()] = true
 			}
 			continue
 
 		default:
-			l.Debugf("discover: Incorrect magic %x from %s", magic, addr)
+			slog.DebugContext(ctx, "Incorrect magic", "magic", magic, "address", addr)
 			continue
 		}
 
 		var pkt discoproto.Announce
 		err := proto.Unmarshal(buf[4:], &pkt)
 		if err != nil && !errors.Is(err, io.EOF) {
-			l.Debugf("discover: Failed to unmarshal local announcement from %s (%s):\n%s", addr, err, hex.Dump(buf[4:]))
+			slog.DebugContext(ctx, "Failed to unmarshal local announcement", "address", addr, slogutil.Error(err), "packet", hex.Dump(buf[4:]))
 			continue
 		}
 
 		id, _ := protocol.DeviceIDFromBytes(pkt.Id)
-		l.Debugf("discover: Received local announcement from %s for %s", addr, id)
+		slog.DebugContext(ctx, "Received local announcement", "address", addr, "device", id)
 
 		var newDevice bool
 		if !bytes.Equal(pkt.Id, c.myID[:]) {

+ 14 - 16
lib/discover/manager.go

@@ -13,18 +13,20 @@ import (
 	"context"
 	"crypto/tls"
 	"fmt"
+	"log/slog"
 	"slices"
+	"sync"
 	"time"
 
 	"github.com/thejerf/suture/v4"
 
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections/registry"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/stringutil"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 // The Manager aggregates results from multiple Finders. Each Finder has
@@ -53,7 +55,7 @@ type manager struct {
 
 func NewManager(myID protocol.DeviceID, cfg config.Wrapper, cert tls.Certificate, evLogger events.Logger, lister AddressLister, registry *registry.Registry) Manager {
 	m := &manager{
-		Supervisor:    suture.New("discover.Manager", svcutil.SpecWithDebugLogger(l)),
+		Supervisor:    suture.New("discover.Manager", svcutil.SpecWithDebugLogger()),
 		myID:          myID,
 		cfg:           cfg,
 		cert:          cert,
@@ -62,7 +64,6 @@ func NewManager(myID protocol.DeviceID, cfg config.Wrapper, cert tls.Certificate
 		registry:      registry,
 
 		finders: make(map[string]cachedFinder),
-		mut:     sync.NewRWMutex(),
 	}
 	m.Add(svcutil.AsService(m.serve, m.String()))
 	return m
@@ -89,7 +90,7 @@ func (m *manager) addLocked(identity string, finder Finder, cacheTime, negCacheT
 		entry.token = &token
 	}
 	m.finders[identity] = entry
-	l.Infoln("Using discovery mechanism:", identity)
+	slog.Info("Using discovery mechanism", "identity", identity)
 }
 
 func (m *manager) removeLocked(identity string) {
@@ -100,11 +101,11 @@ func (m *manager) removeLocked(identity string) {
 	if entry.token != nil {
 		err := m.Supervisor.Remove(*entry.token)
 		if err != nil {
-			l.Warnf("removing discovery %s: %s", identity, err)
+			slog.Warn("Failed to remove discovery mechanism", slog.String("identity", identity), slogutil.Error(err))
 		}
 	}
 	delete(m.finders, identity)
-	l.Infoln("Stopped using discovery mechanism: ", identity)
+	slog.Info("Stopped using discovery mechanism", "identity", identity)
 }
 
 // Lookup attempts to resolve the device ID using any of the added Finders,
@@ -117,8 +118,7 @@ func (m *manager) Lookup(ctx context.Context, deviceID protocol.DeviceID) (addre
 
 			if cacheEntry.found && time.Since(cacheEntry.when) < finder.cacheTime {
 				// It's a positive, valid entry. Use it.
-				l.Debugln("cached discovery entry for", deviceID, "at", finder)
-				l.Debugln("  cache:", cacheEntry)
+				slog.DebugContext(ctx, "Found cached discovery entry", "device", deviceID, "finder", finder, "entry", cacheEntry)
 				addresses = append(addresses, cacheEntry.Addresses...)
 				continue
 			}
@@ -127,7 +127,7 @@ func (m *manager) Lookup(ctx context.Context, deviceID protocol.DeviceID) (addre
 			if !cacheEntry.found && valid {
 				// It's a negative, valid entry. We should not make another
 				// attempt right now.
-				l.Debugln("negative cache entry for", deviceID, "at", finder, "valid until", cacheEntry.when.Add(finder.negCacheTime), "or", cacheEntry.validUntil)
+				slog.DebugContext(ctx, "Negative cache entry", "device", deviceID, "finder", finder, "until1", cacheEntry.when.Add(finder.negCacheTime), "until2", cacheEntry.validUntil)
 				continue
 			}
 
@@ -136,8 +136,7 @@ func (m *manager) Lookup(ctx context.Context, deviceID protocol.DeviceID) (addre
 
 		// Perform the actual lookup and cache the result.
 		if addrs, err := finder.Lookup(ctx, deviceID); err == nil {
-			l.Debugln("lookup for", deviceID, "at", finder)
-			l.Debugln("  addresses:", addrs)
+			slog.DebugContext(ctx, "Got finder result", "device", deviceID, "finder", finder, "address", addrs)
 			addresses = append(addresses, addrs...)
 			finder.cache.Set(deviceID, CacheEntry{
 				Addresses: addrs,
@@ -161,8 +160,7 @@ func (m *manager) Lookup(ctx context.Context, deviceID protocol.DeviceID) (addre
 	addresses = stringutil.UniqueTrimmedStrings(addresses)
 	slices.Sort(addresses)
 
-	l.Debugln("lookup results for", deviceID)
-	l.Debugln("  addresses: ", addresses)
+	slog.DebugContext(ctx, "Final lookup results", "device", deviceID, "addresses", addresses)
 
 	return addresses, nil
 }
@@ -262,7 +260,7 @@ func (m *manager) CommitConfiguration(_, to config.Configuration) (handled bool)
 			}
 			gd, err := NewGlobal(srv, m.cert, m.addressLister, m.evLogger, m.registry)
 			if err != nil {
-				l.Warnln("Global discovery:", err)
+				slog.Warn("Failed to initialize global discovery", slogutil.Error(err))
 				continue
 			}
 
@@ -279,7 +277,7 @@ func (m *manager) CommitConfiguration(_, to config.Configuration) (handled bool)
 		if _, ok := m.finders[v4Identity]; !ok {
 			bcd, err := NewLocal(m.myID, fmt.Sprintf(":%d", to.Options.LocalAnnPort), m.addressLister, m.evLogger)
 			if err != nil {
-				l.Warnln("IPv4 local discovery:", err)
+				slog.Warn("Failed to initialize IPv4 local discovery", slogutil.Error(err))
 			} else {
 				m.addLocked(v4Identity, bcd, 0, 0)
 			}
@@ -290,7 +288,7 @@ func (m *manager) CommitConfiguration(_, to config.Configuration) (handled bool)
 		if _, ok := m.finders[v6Identity]; !ok {
 			mcd, err := NewLocal(m.myID, to.Options.LocalAnnMCAddr, m.addressLister, m.evLogger)
 			if err != nil {
-				l.Warnln("IPv6 local discovery:", err)
+				slog.Warn("Failed to initialize IPv6 local discovery", slogutil.Error(err))
 			} else {
 				m.addLocked(v6Identity, mcd, 0, 0)
 			}

+ 2 - 2
lib/events/debug.go

@@ -7,7 +7,7 @@
 package events
 
 import (
-	liblogger "github.com/syncthing/syncthing/lib/logger"
+	"github.com/syncthing/syncthing/internal/slogutil"
 )
 
-var dl = liblogger.DefaultLogger.NewFacility("events", "Event generation and logging")
+var dl = slogutil.NewAdapter("Event generation and logging")

+ 4 - 5
lib/events/events.go

@@ -16,11 +16,11 @@ import (
 	"errors"
 	"fmt"
 	"runtime"
+	"sync"
 	"time"
 
+	"github.com/syncthing/syncthing/lib/syncutil"
 	"github.com/thejerf/suture/v4"
-
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type EventType int64
@@ -474,7 +474,7 @@ type bufferedSubscription struct {
 	next int
 	cur  int // Current SubscriptionID
 	mut  sync.Mutex
-	cond *sync.TimeoutCond
+	cond *syncutil.TimeoutCond
 }
 
 type BufferedSubscription interface {
@@ -486,9 +486,8 @@ func NewBufferedSubscription(s Subscription, size int) BufferedSubscription {
 	bs := &bufferedSubscription{
 		sub: s,
 		buf: make([]Event, size),
-		mut: sync.NewMutex(),
 	}
-	bs.cond = sync.NewTimeoutCond(bs.mut)
+	bs.cond = syncutil.NewTimeoutCond(&bs.mut)
 	go bs.pollingLoop()
 	return bs
 }

+ 2 - 1
lib/fs/basicfs.go

@@ -9,6 +9,7 @@ package fs
 import (
 	"errors"
 	"fmt"
+	"log/slog"
 	"os"
 	"os/user"
 	"path/filepath"
@@ -32,7 +33,7 @@ type OptionJunctionsAsDirs struct{}
 
 func (*OptionJunctionsAsDirs) apply(fs Filesystem) Filesystem {
 	if basic, ok := fs.(*BasicFilesystem); !ok {
-		l.Warnln("WithJunctionsAsDirs must only be used with FilesystemTypeBasic")
+		slog.Warn("WithJunctionsAsDirs must only be used with FilesystemTypeBasic")
 	} else {
 		basic.junctionsAsDirs = true
 	}

+ 0 - 4
lib/fs/casefs_test.go

@@ -300,12 +300,10 @@ func doubleWalkFSWithOtherOps(fsys Filesystem, paths []string, otherOpEvery int,
 	if err := fsys.Walk("/", func(path string, info FileInfo, err error) error {
 		i++
 		if otherOpEvery != 0 && i%otherOpEvery == 0 {
-			// l.Infoln("AAA", otherOpPath)
 			if _, err := fsys.Lstat(otherOpPath); err != nil {
 				return err
 			}
 		}
-		// l.Infoln("CCC", path)
 		return err
 	}); err != nil {
 		return err
@@ -316,11 +314,9 @@ func doubleWalkFSWithOtherOps(fsys Filesystem, paths []string, otherOpEvery int,
 			i++
 			if otherOpEvery != 0 && i%otherOpEvery == 0 {
 				if _, err := fsys.Lstat(otherOpPath); err != nil {
-					// l.Infoln("AAA", otherOpPath)
 					return err
 				}
 			}
-			// l.Infoln("CCC", p)
 			if _, err := fsys.Lstat(p); err != nil {
 				return err
 			}

+ 2 - 9
lib/fs/debug.go

@@ -7,14 +7,7 @@
 package fs
 
 import (
-	"github.com/syncthing/syncthing/lib/logger"
+	"github.com/syncthing/syncthing/internal/slogutil"
 )
 
-var l = logger.DefaultLogger.NewFacility("fs", "Filesystem access")
-
-func init() {
-	logger.DefaultLogger.NewFacility("walkfs", "Filesystem access while walking")
-	if logger.DefaultLogger.ShouldDebug("walkfs") {
-		l.SetDebug("fs", true)
-	}
-}
+var l = slogutil.NewAdapter("Filesystem access")

+ 2 - 0
lib/fs/fakefs.go

@@ -376,6 +376,8 @@ func (fs *fakeFS) Lstat(name string) (FileInfo, error) {
 	}
 
 	info := &fakeFileInfo{*entry}
+	info.content = nil
+	info.children = nil
 	if fs.insens {
 		info.name = filepath.Base(name)
 	}

+ 2 - 3
lib/fs/filesystem_copy_range.go

@@ -7,14 +7,13 @@
 package fs
 
 import (
+	"sync"
 	"syscall"
-
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 var (
 	copyRangeMethods = make(map[CopyRangeMethod]copyRangeImplementation)
-	mut              = sync.NewMutex()
+	mut              sync.Mutex
 )
 
 type copyRangeImplementation func(src, dst File, srcOffset, dstOffset, size int64) error

+ 1 - 2
lib/ignore/ignore.go

@@ -16,6 +16,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"sync"
 	"time"
 	"unicode/utf8"
 
@@ -26,7 +27,6 @@ import (
 	"github.com/syncthing/syncthing/lib/fs"
 	"github.com/syncthing/syncthing/lib/ignore/ignoreresult"
 	"github.com/syncthing/syncthing/lib/osutil"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 const escapePrefix = "#escape"
@@ -153,7 +153,6 @@ func New(fs fs.Filesystem, opts ...Option) *Matcher {
 	m := &Matcher{
 		fs:   fs,
 		stop: make(chan struct{}),
-		mut:  sync.NewMutex(),
 	}
 	for _, opt := range opts {
 		opt(m)

+ 0 - 19
lib/logger/LICENSE

@@ -1,19 +0,0 @@
-Copyright (C) 2013 Jakob Borg
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-- The above copyright notice and this permission notice shall be included in
-  all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.

+ 0 - 407
lib/logger/logger.go

@@ -1,407 +0,0 @@
-// Copyright (C) 2014 Jakob Borg. All rights reserved. Use of this source code
-// is governed by an MIT-style license that can be found in the LICENSE file.
-
-//go:generate -command counterfeiter go run github.com/maxbrunsfeld/counterfeiter/v6
-//go:generate counterfeiter -o mocks/logger.go --fake-name Recorder . Recorder
-
-// Package logger implements a standardized logger with callback functionality
-package logger
-
-import (
-	"fmt"
-	"io"
-	"log"
-	"os"
-	"slices"
-	"strings"
-	"sync"
-	"time"
-)
-
-// This package uses stdlib sync as it may be used to debug syncthing/lib/sync
-// and that would cause an implosion of the universe.
-
-type LogLevel int
-
-const (
-	LevelDebug LogLevel = iota
-	LevelVerbose
-	LevelInfo
-	LevelWarn
-	NumLevels
-)
-
-const (
-	DefaultFlags = log.Ltime | log.Ldate
-	DebugFlags   = log.Ltime | log.Ldate | log.Lmicroseconds | log.Lshortfile
-)
-
-// A MessageHandler is called with the log level and message text.
-type MessageHandler func(l LogLevel, msg string)
-
-type Logger interface {
-	AddHandler(level LogLevel, h MessageHandler)
-	SetFlags(flag int)
-	SetPrefix(prefix string)
-	Debugln(vals ...interface{})
-	Debugf(format string, vals ...interface{})
-	Verboseln(vals ...interface{})
-	Verbosef(format string, vals ...interface{})
-	Infoln(vals ...interface{})
-	Infof(format string, vals ...interface{})
-	Warnln(vals ...interface{})
-	Warnf(format string, vals ...interface{})
-	ShouldDebug(facility string) bool
-	SetDebug(facility string, enabled bool)
-	Facilities() map[string]string
-	FacilityDebugging() []string
-	NewFacility(facility, description string) Logger
-}
-
-type logger struct {
-	logger     *log.Logger
-	handlers   [NumLevels][]MessageHandler
-	facilities map[string]string   // facility name => description
-	debug      map[string]struct{} // only facility names with debugging enabled
-	traces     []string
-	mut        sync.Mutex
-}
-
-// DefaultLogger logs to standard output with a time prefix.
-var DefaultLogger = New()
-
-func New() Logger {
-	if os.Getenv("LOGGER_DISCARD") != "" {
-		// Hack to completely disable logging, for example when running
-		// benchmarks.
-		return newLogger(io.Discard)
-	}
-	return newLogger(controlStripper{os.Stdout})
-}
-
-func newLogger(w io.Writer) Logger {
-	traces := strings.FieldsFunc(os.Getenv("STTRACE"), func(r rune) bool {
-		return strings.ContainsRune(",; ", r)
-	})
-
-	if len(traces) > 0 {
-		if slices.Contains(traces, "all") {
-			traces = []string{"all"}
-		} else {
-			slices.Sort(traces)
-		}
-	}
-
-	return &logger{
-		logger:     log.New(w, "", DefaultFlags),
-		traces:     traces,
-		facilities: make(map[string]string),
-		debug:      make(map[string]struct{}),
-	}
-}
-
-// AddHandler registers a new MessageHandler to receive messages with the
-// specified log level or above.
-func (l *logger) AddHandler(level LogLevel, h MessageHandler) {
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.handlers[level] = append(l.handlers[level], h)
-}
-
-// See log.SetFlags
-func (l *logger) SetFlags(flag int) {
-	l.logger.SetFlags(flag)
-}
-
-// See log.SetPrefix
-func (l *logger) SetPrefix(prefix string) {
-	l.logger.SetPrefix(prefix)
-}
-
-func (l *logger) callHandlers(level LogLevel, s string) {
-	for ll := LevelDebug; ll <= level; ll++ {
-		for _, h := range l.handlers[ll] {
-			h(level, strings.TrimSpace(s))
-		}
-	}
-}
-
-// Debugln logs a line with a DEBUG prefix.
-func (l *logger) Debugln(vals ...interface{}) {
-	l.debugln(3, vals...)
-}
-
-func (l *logger) debugln(level int, vals ...interface{}) {
-	s := fmt.Sprintln(vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(level, "DEBUG: "+s)
-	l.callHandlers(LevelDebug, s)
-}
-
-// Debugf logs a formatted line with a DEBUG prefix.
-func (l *logger) Debugf(format string, vals ...interface{}) {
-	l.debugf(3, format, vals...)
-}
-
-func (l *logger) debugf(level int, format string, vals ...interface{}) {
-	s := fmt.Sprintf(format, vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(level, "DEBUG: "+s)
-	l.callHandlers(LevelDebug, s)
-}
-
-// Infoln logs a line with a VERBOSE prefix.
-func (l *logger) Verboseln(vals ...interface{}) {
-	s := fmt.Sprintln(vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(2, "VERBOSE: "+s)
-	l.callHandlers(LevelVerbose, s)
-}
-
-// Infof logs a formatted line with a VERBOSE prefix.
-func (l *logger) Verbosef(format string, vals ...interface{}) {
-	s := fmt.Sprintf(format, vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(2, "VERBOSE: "+s)
-	l.callHandlers(LevelVerbose, s)
-}
-
-// Infoln logs a line with an INFO prefix.
-func (l *logger) Infoln(vals ...interface{}) {
-	s := fmt.Sprintln(vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(2, "INFO: "+s)
-	l.callHandlers(LevelInfo, s)
-}
-
-// Infof logs a formatted line with an INFO prefix.
-func (l *logger) Infof(format string, vals ...interface{}) {
-	s := fmt.Sprintf(format, vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(2, "INFO: "+s)
-	l.callHandlers(LevelInfo, s)
-}
-
-// Warnln logs a formatted line with a WARNING prefix.
-func (l *logger) Warnln(vals ...interface{}) {
-	s := fmt.Sprintln(vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(2, "WARNING: "+s)
-	l.callHandlers(LevelWarn, s)
-}
-
-// Warnf logs a formatted line with a WARNING prefix.
-func (l *logger) Warnf(format string, vals ...interface{}) {
-	s := fmt.Sprintf(format, vals...)
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	l.logger.Output(2, "WARNING: "+s)
-	l.callHandlers(LevelWarn, s)
-}
-
-// ShouldDebug returns true if the given facility has debugging enabled.
-func (l *logger) ShouldDebug(facility string) bool {
-	l.mut.Lock()
-	_, res := l.debug[facility]
-	l.mut.Unlock()
-	return res
-}
-
-// SetDebug enabled or disables debugging for the given facility name.
-func (l *logger) SetDebug(facility string, enabled bool) {
-	l.mut.Lock()
-	defer l.mut.Unlock()
-	if _, ok := l.debug[facility]; enabled && !ok {
-		l.SetFlags(DebugFlags)
-		l.debug[facility] = struct{}{}
-	} else if !enabled && ok {
-		delete(l.debug, facility)
-		if len(l.debug) == 0 {
-			l.SetFlags(DefaultFlags)
-		}
-	}
-}
-
-// isTraced returns whether the facility name is contained in STTRACE.
-func (l *logger) isTraced(facility string) bool {
-	if len(l.traces) > 0 {
-		if l.traces[0] == "all" {
-			return true
-		}
-
-		_, found := slices.BinarySearch(l.traces, facility)
-		return found
-	}
-
-	return false
-}
-
-// FacilityDebugging returns the set of facilities that have debugging
-// enabled.
-func (l *logger) FacilityDebugging() []string {
-	enabled := make([]string, 0, len(l.debug))
-	l.mut.Lock()
-	for facility := range l.debug {
-		enabled = append(enabled, facility)
-	}
-	l.mut.Unlock()
-	return enabled
-}
-
-// Facilities returns the currently known set of facilities and their
-// descriptions.
-func (l *logger) Facilities() map[string]string {
-	l.mut.Lock()
-	res := make(map[string]string, len(l.facilities))
-	for facility, descr := range l.facilities {
-		res[facility] = descr
-	}
-	l.mut.Unlock()
-	return res
-}
-
-// NewFacility returns a new logger bound to the named facility.
-func (l *logger) NewFacility(facility, description string) Logger {
-	l.SetDebug(facility, l.isTraced(facility))
-
-	l.mut.Lock()
-	l.facilities[facility] = description
-	l.mut.Unlock()
-
-	return &facilityLogger{
-		logger:   l,
-		facility: facility,
-	}
-}
-
-// A facilityLogger is a regular logger but bound to a facility name. The
-// Debugln and Debugf methods are no-ops unless debugging has been enabled for
-// this facility on the parent logger.
-type facilityLogger struct {
-	*logger
-	facility string
-}
-
-// Debugln logs a line with a DEBUG prefix.
-func (l *facilityLogger) Debugln(vals ...interface{}) {
-	if !l.ShouldDebug(l.facility) {
-		return
-	}
-	l.logger.debugln(3, vals...)
-}
-
-// Debugf logs a formatted line with a DEBUG prefix.
-func (l *facilityLogger) Debugf(format string, vals ...interface{}) {
-	if !l.ShouldDebug(l.facility) {
-		return
-	}
-	l.logger.debugf(3, format, vals...)
-}
-
-// A Recorder keeps a size limited record of log events.
-type Recorder interface {
-	Since(t time.Time) []Line
-	Clear()
-}
-
-type recorder struct {
-	lines   []Line
-	initial int
-	mut     sync.Mutex
-}
-
-// A Line represents a single log entry.
-type Line struct {
-	When    time.Time `json:"when"`
-	Message string    `json:"message"`
-	Level   LogLevel  `json:"level"`
-}
-
-func NewRecorder(l Logger, level LogLevel, size, initial int) Recorder {
-	r := &recorder{
-		lines:   make([]Line, 0, size),
-		initial: initial,
-	}
-	l.AddHandler(level, r.append)
-	return r
-}
-
-func (r *recorder) Since(t time.Time) []Line {
-	r.mut.Lock()
-	defer r.mut.Unlock()
-
-	res := r.lines
-
-	for i := 0; i < len(res); i++ {
-		if res[i].When.After(t) {
-			// We must copy the result as r.lines can be mutated as soon as the lock
-			// is released.
-			res = res[i:]
-			cp := make([]Line, len(res))
-			copy(cp, res)
-			return cp
-		}
-	}
-	return nil
-}
-
-func (r *recorder) Clear() {
-	r.mut.Lock()
-	r.lines = r.lines[:0]
-	r.mut.Unlock()
-}
-
-func (r *recorder) append(l LogLevel, msg string) {
-	line := Line{
-		When:    time.Now(), // intentionally high precision
-		Message: msg,
-		Level:   l,
-	}
-
-	r.mut.Lock()
-	defer r.mut.Unlock()
-
-	if len(r.lines) == cap(r.lines) {
-		if r.initial > 0 {
-			// Shift all lines one step to the left, keeping the "initial" first intact.
-			copy(r.lines[r.initial+1:], r.lines[r.initial+2:])
-		} else {
-			copy(r.lines, r.lines[1:])
-		}
-		// Add the new one at the end
-		r.lines[len(r.lines)-1] = line
-		return
-	}
-
-	r.lines = append(r.lines, line)
-	if len(r.lines) == r.initial {
-		r.lines = append(r.lines, Line{time.Now(), "...", l})
-	}
-}
-
-// controlStripper is a Writer that replaces control characters
-// with spaces.
-type controlStripper struct {
-	io.Writer
-}
-
-func (s controlStripper) Write(data []byte) (int, error) {
-	for i, b := range data {
-		if b == '\n' || b == '\r' {
-			// Newlines are OK
-			continue
-		}
-		if b < 32 {
-			// Characters below 32 are control characters
-			data[i] = ' '
-		}
-	}
-	return s.Writer.Write(data)
-}

+ 0 - 209
lib/logger/logger_test.go

@@ -1,209 +0,0 @@
-// Copyright (C) 2014 Jakob Borg. All rights reserved. Use of this source code
-// is governed by an MIT-style license that can be found in the LICENSE file.
-
-package logger
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"log"
-	"strings"
-	"testing"
-	"time"
-)
-
-func TestAPI(t *testing.T) {
-	l := New()
-	l.SetFlags(0)
-	l.SetPrefix("testing")
-
-	debug := 0
-	l.AddHandler(LevelDebug, checkFunc(t, LevelDebug, &debug))
-	info := 0
-	l.AddHandler(LevelInfo, checkFunc(t, LevelInfo, &info))
-	warn := 0
-	l.AddHandler(LevelWarn, checkFunc(t, LevelWarn, &warn))
-
-	l.Debugf("test %d", 0)
-	l.Debugln("test", 0)
-	l.Infof("test %d", 1)
-	l.Infoln("test", 1)
-	l.Warnf("test %d", 3)
-	l.Warnln("test", 3)
-
-	if debug != 6 {
-		t.Errorf("Debug handler called %d != 8 times", debug)
-	}
-	if info != 4 {
-		t.Errorf("Info handler called %d != 6 times", info)
-	}
-	if warn != 2 {
-		t.Errorf("Warn handler called %d != 2 times", warn)
-	}
-}
-
-func checkFunc(t *testing.T, expectl LogLevel, counter *int) func(LogLevel, string) {
-	return func(l LogLevel, msg string) {
-		*counter++
-		if l < expectl {
-			t.Errorf("Incorrect message level %d < %d", l, expectl)
-		}
-	}
-}
-
-func TestFacilityDebugging(t *testing.T) {
-	l := New()
-	l.SetFlags(0)
-
-	msgs := 0
-	l.AddHandler(LevelDebug, func(l LogLevel, msg string) {
-		msgs++
-		if strings.Contains(msg, "f1") {
-			t.Fatal("Should not get message for facility f1")
-		}
-	})
-
-	f0 := l.NewFacility("f0", "foo#0")
-	f1 := l.NewFacility("f1", "foo#1")
-
-	l.SetDebug("f0", true)
-	l.SetDebug("f1", false)
-
-	f0.Debugln("Debug line from f0")
-	f1.Debugln("Debug line from f1")
-
-	if msgs != 1 {
-		t.Fatalf("Incorrect number of messages, %d != 1", msgs)
-	}
-}
-
-func TestRecorder(t *testing.T) {
-	l := New()
-	l.SetFlags(0)
-
-	// Keep the last five warnings or higher, no special initial handling.
-	r0 := NewRecorder(l, LevelWarn, 5, 0)
-	// Keep the last ten infos or higher, with the first three being permanent.
-	r1 := NewRecorder(l, LevelInfo, 10, 3)
-
-	// Log a bunch of messages.
-	for i := 0; i < 15; i++ {
-		l.Debugf("Debug#%d", i)
-		l.Infof("Info#%d", i)
-		l.Warnf("Warn#%d", i)
-	}
-
-	// r0 should contain the last five warnings
-	lines := r0.Since(time.Time{})
-	if len(lines) != 5 {
-		t.Fatalf("Incorrect length %d != 5", len(lines))
-	}
-	for i := 0; i < 5; i++ {
-		expected := fmt.Sprintf("Warn#%d", i+10)
-		if lines[i].Message != expected {
-			t.Error("Incorrect warning in r0:", lines[i].Message, "!=", expected)
-		}
-	}
-
-	// r0 should contain:
-	// - The first three messages
-	// - A "..." marker
-	// - The last six messages
-	// (totalling ten)
-	lines = r1.Since(time.Time{})
-	if len(lines) != 10 {
-		t.Fatalf("Incorrect length %d != 10", len(lines))
-	}
-	expected := []string{
-		"Info#0",
-		"Warn#0",
-		"Info#1",
-		"...",
-		"Info#12",
-		"Warn#12",
-		"Info#13",
-		"Warn#13",
-		"Info#14",
-		"Warn#14",
-	}
-	for i := 0; i < 10; i++ {
-		if lines[i].Message != expected[i] {
-			t.Error("Incorrect warning in r0:", lines[i].Message, "!=", expected[i])
-		}
-	}
-
-	// Check that since works
-	now := time.Now()
-
-	time.Sleep(time.Millisecond)
-
-	lines = r1.Since(now)
-	if len(lines) != 0 {
-		t.Error("unexpected lines")
-	}
-
-	l.Infoln("hah")
-
-	lines = r1.Since(now)
-	if len(lines) != 1 {
-		t.Fatalf("unexpected line count: %d", len(lines))
-	}
-	if lines[0].Message != "hah" {
-		t.Errorf("incorrect line: %s", lines[0].Message)
-	}
-}
-
-func TestStackLevel(t *testing.T) {
-	b := new(bytes.Buffer)
-	l := newLogger(b)
-
-	l.SetFlags(log.Lshortfile)
-	l.Infoln("testing")
-	res := b.String()
-
-	if !strings.Contains(res, "logger_test.go:") {
-		t.Logf("%q", res)
-		t.Error("Should identify this file as the source (bad level?)")
-	}
-}
-
-func TestControlStripper(t *testing.T) {
-	b := new(bytes.Buffer)
-	l := newLogger(controlStripper{b})
-
-	l.Infoln("testing\x07testing\ntesting")
-	res := b.String()
-
-	if !strings.Contains(res, "testing testing\ntesting") {
-		t.Logf("%q", res)
-		t.Error("Control character should become space")
-	}
-	if strings.Contains(res, "\x07") {
-		t.Logf("%q", res)
-		t.Error("Control character should be removed")
-	}
-}
-
-func BenchmarkLog(b *testing.B) {
-	l := newLogger(controlStripper{io.Discard})
-	benchmarkLogger(b, l)
-}
-
-func BenchmarkLogNoStripper(b *testing.B) {
-	l := newLogger(io.Discard)
-	benchmarkLogger(b, l)
-}
-
-func benchmarkLogger(b *testing.B, l Logger) {
-	l.SetFlags(log.Lshortfile | log.Lmicroseconds)
-	l.SetPrefix("ABCDEFG")
-
-	for i := 0; i < b.N; i++ {
-		l.Infoln("This is a somewhat representative log line")
-		l.Infof("This is a log line with a couple of formatted things: %d %q", 42, "a file name maybe, who knows?")
-	}
-
-	b.ReportAllocs()
-	b.SetBytes(2) // log entries per iteration
-}

+ 0 - 142
lib/logger/mocks/logger.go

@@ -1,142 +0,0 @@
-// Code generated by counterfeiter. DO NOT EDIT.
-package mocks
-
-import (
-	"sync"
-	"time"
-
-	"github.com/syncthing/syncthing/lib/logger"
-)
-
-type Recorder struct {
-	ClearStub        func()
-	clearMutex       sync.RWMutex
-	clearArgsForCall []struct {
-	}
-	SinceStub        func(time.Time) []logger.Line
-	sinceMutex       sync.RWMutex
-	sinceArgsForCall []struct {
-		arg1 time.Time
-	}
-	sinceReturns struct {
-		result1 []logger.Line
-	}
-	sinceReturnsOnCall map[int]struct {
-		result1 []logger.Line
-	}
-	invocations      map[string][][]interface{}
-	invocationsMutex sync.RWMutex
-}
-
-func (fake *Recorder) Clear() {
-	fake.clearMutex.Lock()
-	fake.clearArgsForCall = append(fake.clearArgsForCall, struct {
-	}{})
-	stub := fake.ClearStub
-	fake.recordInvocation("Clear", []interface{}{})
-	fake.clearMutex.Unlock()
-	if stub != nil {
-		fake.ClearStub()
-	}
-}
-
-func (fake *Recorder) ClearCallCount() int {
-	fake.clearMutex.RLock()
-	defer fake.clearMutex.RUnlock()
-	return len(fake.clearArgsForCall)
-}
-
-func (fake *Recorder) ClearCalls(stub func()) {
-	fake.clearMutex.Lock()
-	defer fake.clearMutex.Unlock()
-	fake.ClearStub = stub
-}
-
-func (fake *Recorder) Since(arg1 time.Time) []logger.Line {
-	fake.sinceMutex.Lock()
-	ret, specificReturn := fake.sinceReturnsOnCall[len(fake.sinceArgsForCall)]
-	fake.sinceArgsForCall = append(fake.sinceArgsForCall, struct {
-		arg1 time.Time
-	}{arg1})
-	stub := fake.SinceStub
-	fakeReturns := fake.sinceReturns
-	fake.recordInvocation("Since", []interface{}{arg1})
-	fake.sinceMutex.Unlock()
-	if stub != nil {
-		return stub(arg1)
-	}
-	if specificReturn {
-		return ret.result1
-	}
-	return fakeReturns.result1
-}
-
-func (fake *Recorder) SinceCallCount() int {
-	fake.sinceMutex.RLock()
-	defer fake.sinceMutex.RUnlock()
-	return len(fake.sinceArgsForCall)
-}
-
-func (fake *Recorder) SinceCalls(stub func(time.Time) []logger.Line) {
-	fake.sinceMutex.Lock()
-	defer fake.sinceMutex.Unlock()
-	fake.SinceStub = stub
-}
-
-func (fake *Recorder) SinceArgsForCall(i int) time.Time {
-	fake.sinceMutex.RLock()
-	defer fake.sinceMutex.RUnlock()
-	argsForCall := fake.sinceArgsForCall[i]
-	return argsForCall.arg1
-}
-
-func (fake *Recorder) SinceReturns(result1 []logger.Line) {
-	fake.sinceMutex.Lock()
-	defer fake.sinceMutex.Unlock()
-	fake.SinceStub = nil
-	fake.sinceReturns = struct {
-		result1 []logger.Line
-	}{result1}
-}
-
-func (fake *Recorder) SinceReturnsOnCall(i int, result1 []logger.Line) {
-	fake.sinceMutex.Lock()
-	defer fake.sinceMutex.Unlock()
-	fake.SinceStub = nil
-	if fake.sinceReturnsOnCall == nil {
-		fake.sinceReturnsOnCall = make(map[int]struct {
-			result1 []logger.Line
-		})
-	}
-	fake.sinceReturnsOnCall[i] = struct {
-		result1 []logger.Line
-	}{result1}
-}
-
-func (fake *Recorder) Invocations() map[string][][]interface{} {
-	fake.invocationsMutex.RLock()
-	defer fake.invocationsMutex.RUnlock()
-	fake.clearMutex.RLock()
-	defer fake.clearMutex.RUnlock()
-	fake.sinceMutex.RLock()
-	defer fake.sinceMutex.RUnlock()
-	copiedInvocations := map[string][][]interface{}{}
-	for key, value := range fake.invocations {
-		copiedInvocations[key] = value
-	}
-	return copiedInvocations
-}
-
-func (fake *Recorder) recordInvocation(key string, args []interface{}) {
-	fake.invocationsMutex.Lock()
-	defer fake.invocationsMutex.Unlock()
-	if fake.invocations == nil {
-		fake.invocations = map[string][][]interface{}{}
-	}
-	if fake.invocations[key] == nil {
-		fake.invocations[key] = [][]interface{}{}
-	}
-	fake.invocations[key] = append(fake.invocations[key], args)
-}
-
-var _ logger.Recorder = new(Recorder)

+ 2 - 8
lib/model/debug.go

@@ -6,12 +6,6 @@
 
 package model
 
-import (
-	"github.com/syncthing/syncthing/lib/logger"
-)
+import "github.com/syncthing/syncthing/internal/slogutil"
 
-var l = logger.DefaultLogger.NewFacility("model", "The root hub")
-
-func shouldDebug() bool {
-	return l.ShouldDebug("model")
-}
+var l = slogutil.NewAdapter("The root hub")

+ 2 - 2
lib/model/deviceactivity.go

@@ -7,8 +7,9 @@
 package model
 
 import (
+	"sync"
+
 	"github.com/syncthing/syncthing/lib/protocol"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 // deviceActivity tracks the number of outstanding requests per device and can
@@ -22,7 +23,6 @@ type deviceActivity struct {
 func newDeviceActivity() *deviceActivity {
 	return &deviceActivity{
 		act: make(map[protocol.DeviceID]int),
-		mut: sync.NewMutex(),
 	}
 }
 

+ 1 - 3
lib/model/devicedownloadstate.go

@@ -8,9 +8,9 @@ package model
 
 import (
 	"slices"
+	"sync"
 
 	"github.com/syncthing/syncthing/lib/protocol"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 // deviceFolderFileDownloadState holds current download state of a file that
@@ -122,7 +122,6 @@ func (t *deviceDownloadState) Update(folder string, updates []protocol.FileDownl
 
 	if !ok {
 		f = &deviceFolderDownloadState{
-			mut:   sync.NewRWMutex(),
 			files: make(map[string]deviceFolderFileDownloadState),
 		}
 		t.mut.Lock()
@@ -186,7 +185,6 @@ func (t *deviceDownloadState) BytesDownloaded(folder string) int64 {
 
 func newDeviceDownloadState() *deviceDownloadState {
 	return &deviceDownloadState{
-		mut:     sync.NewRWMutex(),
 		folders: make(map[string]*deviceFolderDownloadState),
 	}
 }

+ 21 - 22
lib/model/folder.go

@@ -10,14 +10,17 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"log/slog"
 	"math/rand"
 	"path/filepath"
 	"slices"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/internal/db"
 	"github.com/syncthing/syncthing/internal/itererr"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/fs"
@@ -30,7 +33,6 @@ import (
 	"github.com/syncthing/syncthing/lib/stats"
 	"github.com/syncthing/syncthing/lib/stringutil"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/versioner"
 	"github.com/syncthing/syncthing/lib/watchaggregator"
 )
@@ -54,6 +56,7 @@ type folder struct {
 	modTimeWindow time.Duration
 	ctx           context.Context //nolint:containedctx // used internally, only accessible on serve lifetime
 	done          chan struct{}   // used externally, accessible regardless of serve
+	sl            *slog.Logger
 
 	scanInterval           time.Duration
 	scanTimer              *time.Timer
@@ -98,7 +101,7 @@ type puller interface {
 	pull() (bool, error) // true when successful and should not be retried
 }
 
-func newFolder(model *model, ignores *ignore.Matcher, cfg config.FolderConfiguration, evLogger events.Logger, ioLimiter *semaphore.Semaphore, ver versioner.Versioner) folder {
+func newFolder(model *model, ignores *ignore.Matcher, cfg config.FolderConfiguration, evLogger events.Logger, ioLimiter *semaphore.Semaphore, ver versioner.Versioner) *folder {
 	f := folder{
 		stateTracker:              newStateTracker(cfg.ID, evLogger),
 		FolderConfiguration:       cfg,
@@ -112,6 +115,7 @@ func newFolder(model *model, ignores *ignore.Matcher, cfg config.FolderConfigura
 		mtimefs:       cfg.Filesystem(fs.NewMtimeOption(model.sdb, cfg.ID)),
 		modTimeWindow: cfg.ModTimeWindow(),
 		done:          make(chan struct{}),
+		sl:            slog.Default().With(cfg.LogAttr()),
 
 		scanInterval:           time.Duration(cfg.RescanIntervalS) * time.Second,
 		scanTimer:              time.NewTimer(0), // The first scan should be done immediately.
@@ -123,17 +127,13 @@ func newFolder(model *model, ignores *ignore.Matcher, cfg config.FolderConfigura
 
 		pullScheduled: make(chan struct{}, 1), // This needs to be 1-buffered so that we queue a pull if we're busy when it comes.
 
-		errorsMut: sync.NewMutex(),
-
 		doInSyncChan: make(chan syncRequest),
 
 		forcedRescanRequested: make(chan struct{}, 1),
 		forcedRescanPaths:     make(map[string]struct{}),
-		forcedRescanPathsMut:  sync.NewMutex(),
 
 		watchCancel:      func() {},
 		restartWatchChan: make(chan struct{}, 1),
-		watchMut:         sync.NewMutex(),
 
 		versioner: ver,
 	}
@@ -143,7 +143,7 @@ func newFolder(model *model, ignores *ignore.Matcher, cfg config.FolderConfigura
 
 	registerFolderMetrics(f.ID)
 
-	return f
+	return &f
 }
 
 func (f *folder) Serve(ctx context.Context) error {
@@ -440,7 +440,7 @@ func (f *folder) pull() (success bool, err error) {
 
 	// Pulling failed, try again later.
 	delay := f.pullPause + time.Since(startTime)
-	l.Infof("Folder %v isn't making sync progress - retrying in %v.", f.Description(), stringutil.NiceDurationString(delay))
+	f.sl.Info("Folder failed to sync, will be retried", slog.String("wait", stringutil.NiceDurationString(delay)))
 	f.pullFailTimer.Reset(delay)
 
 	return false, err
@@ -948,11 +948,11 @@ func (f *folder) scanTimerFired() error {
 	select {
 	case <-f.initialScanFinished:
 	default:
-		status := "Completed"
 		if err != nil {
-			status = "Failed"
+			f.sl.Error("Failed initial scan", slogutil.Error(err))
+		} else {
+			f.sl.Info("Competed initial scan")
 		}
-		l.Infoln(status, "initial scan of", f.Type.String(), "folder", f.Description())
 		close(f.initialScanFinished)
 	}
 
@@ -973,7 +973,7 @@ func (f *folder) versionCleanupTimerFired() {
 	f.setState(FolderCleaning)
 
 	if err := f.versioner.Clean(f.ctx); err != nil {
-		l.Infoln("Failed to clean versions in %s: %v", f.Description(), err)
+		f.sl.Warn("Failed to clean versions", slogutil.Error(err))
 	}
 
 	f.versionCleanupTimer.Reset(f.versionCleanupInterval)
@@ -1084,7 +1084,7 @@ func (f *folder) monitorWatch(ctx context.Context) {
 			var errOutside *fs.WatchEventOutsideRootError
 			if errors.As(err, &errOutside) {
 				if !warnedOutside {
-					l.Warnln(err)
+					slog.WarnContext(ctx, err.Error()) //nolint:sloglint
 					warnedOutside = true
 				}
 				f.evLogger.Log(events.Failure, "watching for changes encountered an event outside of the filesystem root")
@@ -1099,7 +1099,7 @@ func (f *folder) monitorWatch(ctx context.Context) {
 				f.warnedKqueue = true
 				summarySub.Unsubscribe()
 				summaryChan = nil
-				l.Warnf("Filesystem watching (kqueue) is enabled on %v with a lot of files/directories, and that requires a lot of resources and might slow down your system significantly", f.Description())
+				slog.WarnContext(ctx, "Filesystem watching (kqueue) is enabled with a lot of files/directories, which requires a lot of resources and might slow down your system significantly", f.LogAttr())
 			}
 		case <-ctx.Done():
 			aggrCancel() // for good measure and keeping the linters happy
@@ -1130,12 +1130,11 @@ func (f *folder) setWatchError(err error, nextTryIn time.Duration) {
 	if err == nil {
 		return
 	}
-	msg := fmt.Sprintf("Error while trying to start filesystem watcher for folder %s, trying again in %v: %v", f.Description(), nextTryIn, err)
 	if prevErr != err { //nolint:errorlint
-		l.Infof(msg)
-		return
+		f.sl.Warn("Failed to start filesystem watcher", slog.String("wait", nextTryIn.String()), slogutil.Error(err))
+	} else {
+		f.sl.Debug("Failed to start filesystem watcher", slog.String("wait", nextTryIn.String()), slogutil.Error(err))
 	}
-	l.Debugf(msg)
 }
 
 // scanOnWatchErr schedules a full scan immediately if an error occurred while watching.
@@ -1162,12 +1161,12 @@ func (f *folder) setError(err error) {
 
 	if err != nil {
 		if oldErr == nil {
-			l.Warnf("Error on folder %s: %v", f.Description(), err)
+			f.sl.Warn("Error on folder", slogutil.Error(err))
 		} else {
-			l.Infof("Error on folder %s changed: %q -> %q", f.Description(), oldErr, err)
+			f.sl.Info("Folder error changed", slogutil.Error(err), slog.Any("previously", oldErr))
 		}
 	} else {
-		l.Infoln("Cleared error on folder", f.Description())
+		f.sl.Info("Folder error cleared")
 		f.SchedulePull()
 	}
 
@@ -1195,7 +1194,7 @@ func (f *folder) String() string {
 
 func (f *folder) newScanError(path string, err error) {
 	f.errorsMut.Lock()
-	l.Infof("Scanner (folder %s, item %q): %v", f.Description(), path, err)
+	f.sl.Warn("Failed to scan", slogutil.FilePath(path), slogutil.Error(err))
 	f.scanErrors = append(f.scanErrors, FileError{
 		Err:  err.Error(),
 		Path: path,

+ 1 - 1
lib/model/folder_recvenc.go

@@ -40,7 +40,7 @@ func (f *receiveEncryptedFolder) Revert() {
 }
 
 func (f *receiveEncryptedFolder) revert() error {
-	l.Infof("Reverting unexpected items in folder %v (receive-encrypted)", f.Description())
+	f.sl.Info("Reverting unexpected items")
 
 	f.setState(FolderScanning)
 	defer f.setState(FolderIdle)

+ 3 - 2
lib/model/folder_recvonly.go

@@ -12,6 +12,7 @@ import (
 	"time"
 
 	"github.com/syncthing/syncthing/internal/itererr"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/ignore"
@@ -69,7 +70,7 @@ func (f *receiveOnlyFolder) Revert() {
 }
 
 func (f *receiveOnlyFolder) revert() error {
-	l.Infof("Reverting folder %v", f.Description())
+	f.sl.Info("Reverting folder")
 
 	f.setState(FolderScanning)
 	defer f.setState(FolderIdle)
@@ -154,7 +155,7 @@ func (f *receiveOnlyFolder) revert() error {
 	// Handle any queued directories
 	deleted, err := delQueue.flush()
 	if err != nil {
-		l.Infoln("Revert:", err)
+		f.sl.Warn("Failed to revert directories", slogutil.Error(err))
 	}
 	now := time.Now()
 	for _, dir := range deleted {

+ 2 - 2
lib/model/folder_sendonly.go

@@ -21,7 +21,7 @@ func init() {
 }
 
 type sendOnlyFolder struct {
-	folder
+	*folder
 }
 
 func newSendOnlyFolder(model *model, ignores *ignore.Matcher, cfg config.FolderConfiguration, _ versioner.Versioner, evLogger events.Logger, ioLimiter *semaphore.Semaphore) service {
@@ -93,7 +93,7 @@ func (f *sendOnlyFolder) Override() {
 }
 
 func (f *sendOnlyFolder) override() error {
-	l.Infoln("Overriding global state on folder", f.Description())
+	f.sl.Info("Overriding global state ")
 
 	f.setState(FolderScanning)
 	defer f.setState(FolderIdle)

+ 62 - 32
lib/model/folder_sendrecv.go

@@ -13,13 +13,16 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"log/slog"
 	"path/filepath"
 	"slices"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/internal/itererr"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
@@ -29,13 +32,12 @@ import (
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/scanner"
 	"github.com/syncthing/syncthing/lib/semaphore"
-	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/versioner"
 )
 
 var (
 	blockStats    = make(map[string]int)
-	blockStatsMut = sync.NewMutex()
+	blockStatsMut sync.Mutex
 )
 
 func init() {
@@ -120,7 +122,7 @@ type dbUpdateJob struct {
 }
 
 type sendReceiveFolder struct {
-	folder
+	*folder
 
 	queue              *jobQueue
 	blockPullReorderer blockPullReorderer
@@ -210,7 +212,7 @@ func (f *sendReceiveFolder) pull() (bool, error) {
 	if pullErrNum > 0 {
 		f.pullErrors = make([]FileError, 0, len(f.tempPullErrors))
 		for path, err := range f.tempPullErrors {
-			l.Infof("Puller (folder %s, item %q): %v", f.Description(), path, err)
+			f.sl.Warn("Failed to sync", slogutil.FilePath(path), slogutil.Error(err))
 			f.pullErrors = append(f.pullErrors, FileError{
 				Err:  err,
 				Path: path,
@@ -221,7 +223,6 @@ func (f *sendReceiveFolder) pull() (bool, error) {
 	f.errorsMut.Unlock()
 
 	if pullErrNum > 0 {
-		l.Infof("%v: Failed to sync %v items", f.Description(), pullErrNum)
 		f.evLogger.Log(events.FolderErrors, map[string]interface{}{
 			"folder": f.folderID,
 			"errors": f.Errors(),
@@ -245,10 +246,10 @@ func (f *sendReceiveFolder) pullerIteration(scanChan chan<- string) (int, error)
 	finisherChan := make(chan *sharedPullerState)
 	dbUpdateChan := make(chan dbUpdateJob)
 
-	pullWg := sync.NewWaitGroup()
-	copyWg := sync.NewWaitGroup()
-	doneWg := sync.NewWaitGroup()
-	updateWg := sync.NewWaitGroup()
+	var pullWg sync.WaitGroup
+	var copyWg sync.WaitGroup
+	var doneWg sync.WaitGroup
+	var updateWg sync.WaitGroup
 
 	l.Debugln(f, "copiers:", f.Copiers, "pullerPendingKiB:", f.PullerMaxPendingKiB)
 
@@ -422,7 +423,6 @@ loop:
 			}
 
 		default:
-			l.Warnln(file)
 			panic("unhandleable item type, can't happen")
 		}
 	}
@@ -554,6 +554,7 @@ func (f *sendReceiveFolder) handleDir(file protocol.FileInfo, dbUpdateChan chan<
 	})
 
 	defer func() {
+		slog.Info("Created or updated directory", f.LogAttr(), file.LogAttr())
 		f.evLogger.Log(events.ItemFinished, map[string]interface{}{
 			"folder": f.folderID,
 			"item":   file.Name,
@@ -568,11 +569,10 @@ func (f *sendReceiveFolder) handleDir(file protocol.FileInfo, dbUpdateChan chan<
 		mode = 0o777
 	}
 
-	if shouldDebug() {
+	f.sl.Debug("Need dir", "file", file, "cur", slogutil.Expensive(func() any {
 		curFile, _, _ := f.model.sdb.GetDeviceFile(f.folderID, protocol.LocalDeviceID, file.Name)
-		l.Debugf("need dir\n\t%v\n\t%v", file, curFile)
-	}
-
+		return curFile
+	}))
 	info, err := f.mtimefs.Lstat(file.Name)
 	switch {
 	// There is already something under that name, we need to handle that.
@@ -723,6 +723,11 @@ func (f *sendReceiveFolder) handleSymlink(file protocol.FileInfo, dbUpdateChan c
 	})
 
 	defer func() {
+		if err != nil {
+			slog.Warn("Failed to handle symlink", f.LogAttr(), file.LogAttr(), slogutil.Error(err))
+		} else {
+			slog.Info("Created or updated symlink", f.LogAttr(), file.LogAttr())
+		}
 		f.evLogger.Log(events.ItemFinished, map[string]interface{}{
 			"folder": f.folderID,
 			"item":   file.Name,
@@ -732,10 +737,10 @@ func (f *sendReceiveFolder) handleSymlink(file protocol.FileInfo, dbUpdateChan c
 		})
 	}()
 
-	if shouldDebug() {
-		curFile, ok, _ := f.model.sdb.GetDeviceFile(f.folderID, protocol.LocalDeviceID, file.Name)
-		l.Debugf("need symlink\n\t%v\n\t%v", file, curFile, ok)
-	}
+	f.sl.Debug("Need symlink", slogutil.FilePath(file.Name), slog.Any("cur", slogutil.Expensive(func() any {
+		curFile, _, _ := f.model.sdb.GetDeviceFile(f.folderID, protocol.LocalDeviceID, file.Name)
+		return curFile
+	})))
 
 	if len(file.SymlinkTarget) == 0 {
 		// Index entry from a Syncthing predating the support for including
@@ -814,6 +819,9 @@ func (f *sendReceiveFolder) deleteDir(file protocol.FileInfo, dbUpdateChan chan<
 	defer func() {
 		if err != nil {
 			f.newPullError(file.Name, fmt.Errorf("delete dir: %w", err))
+			slog.Info("Failed to delete directory", f.LogAttr(), file.LogAttr(), slogutil.Error(err))
+		} else {
+			slog.Info("Deleted directory", f.LogAttr(), file.LogAttr())
 		}
 		f.evLogger.Log(events.ItemFinished, map[string]interface{}{
 			"folder": f.folderID,
@@ -859,7 +867,7 @@ func (f *sendReceiveFolder) deleteFileWithCurrent(file, cur protocol.FileInfo, h
 	// care not declare another err.
 	var err error
 
-	l.Debugln(f, "Deleting file", file.Name)
+	l.Debugln(f, "Deleting file or symlink", file.Name)
 
 	f.evLogger.Log(events.ItemStarted, map[string]string{
 		"folder": f.folderID,
@@ -869,8 +877,15 @@ func (f *sendReceiveFolder) deleteFileWithCurrent(file, cur protocol.FileInfo, h
 	})
 
 	defer func() {
+		kind := "file"
+		if file.IsSymlink() {
+			kind = "symlink"
+		}
 		if err != nil {
 			f.newPullError(file.Name, fmt.Errorf("delete file: %w", err))
+			slog.Info("Failed to delete "+kind, f.LogAttr(), file.LogAttr(), slogutil.Error(err))
+		} else {
+			slog.Info("Deleted "+kind, f.LogAttr(), file.LogAttr())
 		}
 		f.evLogger.Log(events.ItemFinished, map[string]interface{}{
 			"folder": f.folderID,
@@ -927,6 +942,8 @@ func (f *sendReceiveFolder) deleteFileWithCurrent(file, cur protocol.FileInfo, h
 		err = nil
 		dbUpdateChan <- dbUpdateJob{file, dbUpdateDeleteFile}
 	}
+
+	slog.Info("Deleted file", f.LogAttr(), file.LogAttr())
 }
 
 // renameFile attempts to rename an existing file to a destination
@@ -950,6 +967,11 @@ func (f *sendReceiveFolder) renameFile(cur, source, target protocol.FileInfo, db
 	})
 
 	defer func() {
+		if err != nil {
+			slog.Info("Failed to rename file", f.LogAttr(), target.LogAttr(), slog.String("from", source.Name), slogutil.Error(err))
+		} else {
+			slog.Info("Renamed file", f.LogAttr(), target.LogAttr(), slog.String("from", source.Name))
+		}
 		f.evLogger.Log(events.ItemFinished, map[string]interface{}{
 			"folder": f.folderID,
 			"item":   source.Name,
@@ -1237,13 +1259,20 @@ func (f *sendReceiveFolder) shortcutFile(file protocol.FileInfo, dbUpdateChan ch
 	})
 
 	var err error
-	defer f.evLogger.Log(events.ItemFinished, map[string]interface{}{
-		"folder": f.folderID,
-		"item":   file.Name,
-		"error":  events.Error(err),
-		"type":   "file",
-		"action": "metadata",
-	})
+	defer func() {
+		if err != nil {
+			slog.Info("Failed to update file metadata", f.LogAttr(), file.LogAttr(), slogutil.Error(err))
+		} else {
+			slog.Info("Updated file metadata", f.LogAttr(), file.LogAttr())
+		}
+		f.evLogger.Log(events.ItemFinished, map[string]interface{}{
+			"folder": f.folderID,
+			"item":   file.Name,
+			"error":  events.Error(err),
+			"type":   "file",
+			"action": "metadata",
+		})
+	}()
 
 	f.queue.Done(file.Name)
 
@@ -1395,7 +1424,7 @@ func (f *sendReceiveFolder) copyBlockFromFolder(folderID string, block protocol.
 			// We just ignore this and continue pulling instead (though
 			// there's a good chance that will fail too, if the DB is
 			// unhealthy).
-			l.Debugf("Failed to get information from DB about block %v in copier (folderID %v, file %v): %v", block.Hash, f.folderID, state.file.Name)
+			l.Debugf("Failed to get information from DB about block %v in copier (folderID %v, file %v): %v", block.Hash, f.folderID, state.file.Name, err)
 			return false
 		}
 
@@ -1480,7 +1509,7 @@ func (*sendReceiveFolder) verifyBuffer(buf []byte, block protocol.BlockInfo) err
 
 func (f *sendReceiveFolder) pullerRoutine(in <-chan pullBlockState, out chan<- *sharedPullerState) {
 	requestLimiter := semaphore.New(f.PullerMaxPendingKiB * 1024)
-	wg := sync.NewWaitGroup()
+	var wg sync.WaitGroup
 
 	for state := range in {
 		if state.failed() != nil {
@@ -1666,6 +1695,8 @@ func (f *sendReceiveFolder) finisherRoutine(in <-chan *sharedPullerState, dbUpda
 			if err != nil {
 				f.newPullError(state.file.Name, fmt.Errorf("finishing: %w", err))
 			} else {
+				slog.Info("Synced file", f.LogAttr(), state.file.LogAttr(), slog.Group("blocks", slog.Int("local", state.reused+state.copyTotal), slog.Int("download", state.pullTotal)))
+
 				minBlocksPerBlock := state.file.BlockSize() / protocol.MinBlockSize
 				blockStatsMut.Lock()
 				blockStats["total"] += (state.reused + state.copyTotal + state.pullTotal) * minBlocksPerBlock
@@ -1673,8 +1704,7 @@ func (f *sendReceiveFolder) finisherRoutine(in <-chan *sharedPullerState, dbUpda
 				blockStats["pulled"] += state.pullTotal * minBlocksPerBlock
 				// copyOriginShifted is counted towards copyOrigin due to progress bar reasons
 				// for reporting reasons we want to separate these.
-				blockStats["copyOrigin"] += (state.copyOrigin - state.copyOriginShifted) * minBlocksPerBlock
-				blockStats["copyOriginShifted"] += state.copyOriginShifted * minBlocksPerBlock
+				blockStats["copyOrigin"] += state.copyOrigin * minBlocksPerBlock
 				blockStats["copyElsewhere"] += (state.copyTotal - state.copyOrigin) * minBlocksPerBlock
 				blockStatsMut.Unlock()
 			}
@@ -1777,7 +1807,7 @@ loop:
 					// (resp. whatever caused the error) will cause this file to
 					// change. Log at info level to leave a trace if a user
 					// notices, but no need to warn
-					l.Infof("Error updating metadata for %v at database commit: %v", job.file.Name, err)
+					f.sl.Warn("Failed to update metadata at database commit", slogutil.FilePath(job.file.Name), slogutil.Error(err))
 				}
 			}
 			job.file.Sequence = 0
@@ -1831,7 +1861,7 @@ func (f *sendReceiveFolder) inConflict(current, replacement protocol.Vector) boo
 
 func (f *sendReceiveFolder) moveForConflict(name, lastModBy string, scanChan chan<- string) error {
 	if isConflict(name) {
-		l.Infoln("Conflict for", name, "which is already a conflict copy; not copying again.")
+		f.sl.Info("Conflict on existing conflict copy; not copying again", slogutil.FilePath(name))
 		if err := f.mtimefs.Remove(name); err != nil && !fs.IsNotExist(err) {
 			return fmt.Errorf("%s: %w", contextRemovingOldItem, err)
 		}

+ 4 - 4
lib/model/folder_sendrecv_test.go

@@ -17,6 +17,7 @@ import (
 	"runtime/pprof"
 	"strconv"
 	"strings"
+	"sync"
 	"testing"
 	"time"
 
@@ -28,7 +29,6 @@ import (
 	"github.com/syncthing/syncthing/lib/ignore"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/scanner"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 var blocks = []protocol.BlockInfo{
@@ -471,7 +471,7 @@ func TestDeregisterOnFailInPull(t *testing.T) {
 	dbUpdateChan := make(chan dbUpdateJob, 1)
 
 	copyChan, copyWg := startCopier(f, pullChan, finisherBufferChan)
-	pullWg := sync.NewWaitGroup()
+	var pullWg sync.WaitGroup
 	pullWg.Add(1)
 	go func() {
 		f.pullerRoutine(pullChan, finisherBufferChan)
@@ -1268,9 +1268,9 @@ func cleanupSharedPullerState(s *sharedPullerState) {
 	s.writer.mut.Unlock()
 }
 
-func startCopier(f *sendReceiveFolder, pullChan chan<- pullBlockState, finisherChan chan<- *sharedPullerState) (chan copyBlocksState, sync.WaitGroup) {
+func startCopier(f *sendReceiveFolder, pullChan chan<- pullBlockState, finisherChan chan<- *sharedPullerState) (chan copyBlocksState, *sync.WaitGroup) {
 	copyChan := make(chan copyBlocksState)
-	wg := sync.NewWaitGroup()
+	wg := new(sync.WaitGroup)
 	wg.Add(1)
 	go func() {
 		f.copierRoutine(copyChan, pullChan, finisherChan)

+ 2 - 2
lib/model/folder_sendrecv_windows.go

@@ -20,13 +20,13 @@ func (f *sendReceiveFolder) syncOwnership(file *protocol.FileInfo, path string)
 		return nil
 	}
 
-	l.Debugln("Owner name for %s is %s (group=%v)", path, file.Platform.Windows.OwnerName, file.Platform.Windows.OwnerIsGroup)
+	l.Debugf("Owner name for %s is %s (group=%v)", path, file.Platform.Windows.OwnerName, file.Platform.Windows.OwnerIsGroup)
 	usid, gsid, err := lookupUserAndGroup(file.Platform.Windows.OwnerName, file.Platform.Windows.OwnerIsGroup)
 	if err != nil {
 		return err
 	}
 
-	l.Debugln("Owner for %s resolved to uid=%q gid=%q", path, usid, gsid)
+	l.Debugf("Owner for %s resolved to uid=%q gid=%q", path, usid, gsid)
 	return f.mtimefs.Lchown(path, usid, gsid)
 }
 

+ 2 - 3
lib/model/folder_summary.go

@@ -14,6 +14,7 @@ import (
 	"errors"
 	"fmt"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/thejerf/suture/v4"
@@ -23,7 +24,6 @@ import (
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/protocol"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type FolderSummaryService interface {
@@ -49,14 +49,13 @@ type folderSummaryService struct {
 
 func NewFolderSummaryService(cfg config.Wrapper, m Model, id protocol.DeviceID, evLogger events.Logger) FolderSummaryService {
 	service := &folderSummaryService{
-		Supervisor: suture.New("folderSummaryService", svcutil.SpecWithDebugLogger(l)),
+		Supervisor: suture.New("folderSummaryService", svcutil.SpecWithDebugLogger()),
 		cfg:        cfg,
 		model:      m,
 		id:         id,
 		evLogger:   evLogger,
 		immediate:  make(chan string),
 		folders:    make(map[string]struct{}),
-		foldersMut: sync.NewMutex(),
 	}
 
 	service.Add(svcutil.AsService(service.listenForUpdates, fmt.Sprintf("%s/listenForUpdates", service)))

+ 1 - 1
lib/model/folder_test.go

@@ -171,7 +171,7 @@ func TestSetPlatformData(t *testing.T) {
 
 	// Minimum required to support setPlatformData
 	sr := &sendReceiveFolder{
-		folder: folder{
+		folder: &folder{
 			FolderConfiguration: config.FolderConfiguration{
 				SyncXattrs: true,
 			},

+ 3 - 8
lib/model/folderstate.go

@@ -7,10 +7,11 @@
 package model
 
 import (
+	"log/slog"
+	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/lib/events"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type folderState int
@@ -94,7 +95,6 @@ func newStateTracker(id string, evLogger events.Logger) stateTracker {
 	return stateTracker{
 		folderID: id,
 		evLogger: evLogger,
-		mut:      sync.NewMutex(),
 	}
 }
 
@@ -115,12 +115,6 @@ func (s *stateTracker) setState(newState folderState) {
 		metricFolderState.WithLabelValues(s.folderID).Set(float64(s.current))
 	}()
 
-	/* This should hold later...
-	if s.current != FolderIdle && (newState == FolderScanning || newState == FolderSyncing) {
-		panic("illegal state transition " + s.current.String() + " -> " + newState.String())
-	}
-	*/
-
 	eventData := map[string]interface{}{
 		"folder": s.folderID,
 		"to":     newState.String(),
@@ -135,6 +129,7 @@ func (s *stateTracker) setState(newState folderState) {
 	s.changed = time.Now().Truncate(time.Second)
 
 	s.evLogger.Log(events.StateChanged, eventData)
+	slog.Info("Folder changed state", "folder", s.folderID, "state", newState)
 }
 
 // getState returns the current state, the time when it last changed, and the

+ 6 - 5
lib/model/indexhandler.go

@@ -10,6 +10,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"log/slog"
 	"sync"
 	"time"
 
@@ -78,7 +79,7 @@ func newIndexHandler(conn protocol.Connection, downloads *deviceDownloadState, f
 			// the IndexID, or something else weird has
 			// happened. We send a full index to reset the
 			// situation.
-			l.Infof("Device %v folder %s is delta index compatible, but seems out of sync with reality", conn.DeviceID().Short(), folder.Description())
+			slog.Warn("Peer is delta index compatible, but seems out of sync with reality", conn.DeviceID().LogAttr(), folder.LogAttr())
 			startSequence = 0
 		} else {
 			l.Debugf("Device %v folder %s is delta index compatible (mlv=%d)", conn.DeviceID().Short(), folder.Description(), startInfo.local.MaxSequence)
@@ -93,7 +94,7 @@ func newIndexHandler(conn protocol.Connection, downloads *deviceDownloadState, f
 		// not the right one. Either they are confused or we
 		// must have reset our database since last talking to
 		// them. We'll start with a full index transfer.
-		l.Infof("Device %v folder %s has mismatching index ID for us (%v != %v)", conn.DeviceID().Short(), folder.Description(), startInfo.local.IndexID, myIndexID)
+		slog.Warn("Peer has mismatching index ID for us", conn.DeviceID().LogAttr(), folder.LogAttr(), slog.Group("indexid", slog.Any("ours", myIndexID), slog.Any("theirs", startInfo.local.IndexID)))
 		startSequence = 0
 	}
 
@@ -118,7 +119,7 @@ func newIndexHandler(conn protocol.Connection, downloads *deviceDownloadState, f
 		// will probably send us a full index. We drop any
 		// information we have and remember this new index ID
 		// instead.
-		l.Infof("Device %v folder %s has a new index ID (%v)", conn.DeviceID().Short(), folder.Description(), startInfo.remote.IndexID)
+		slog.Info("Peer has a new index ID", conn.DeviceID().LogAttr(), folder.LogAttr(), slog.Any("indexid", startInfo.remote.IndexID))
 		if err := sdb.DropAllFiles(folder.ID, conn.DeviceID()); err != nil {
 			return nil, err
 		}
@@ -361,7 +362,7 @@ func (s *indexHandler) receive(fs []protocol.FileInfo, update bool, op string, p
 	s.cond.L.Unlock()
 
 	if paused {
-		l.Infof("%v for paused folder %q", op, s.folder)
+		slog.Warn("Unexpected operation on paused folder", "op", op, "folder", s.folder)
 		return fmt.Errorf("%v: %w", s.folder, ErrFolderPaused)
 	}
 
@@ -662,7 +663,7 @@ func (r *indexHandlerRegistry) ReceiveIndex(folder string, fs []protocol.FileInf
 	defer r.mut.Unlock()
 	is, isOk := r.indexHandlers.Get(folder)
 	if !isOk {
-		l.Infof("%v for nonexistent or paused folder %q", op, folder)
+		slog.Warn("Unexpected operation on nonexistent or paused folder", "op", op, "folder", folder)
 		return fmt.Errorf("%s: %w", folder, ErrFolderMissing)
 	}
 	return is.receive(fs, update, op, prevSequence, lastSequence)

+ 76 - 79
lib/model/model.go

@@ -17,6 +17,7 @@ import (
 	"fmt"
 	"io"
 	"iter"
+	"log/slog"
 	"net"
 	"os"
 	"path/filepath"
@@ -24,7 +25,7 @@ import (
 	"runtime"
 	"slices"
 	"strings"
-	stdsync "sync"
+	"sync"
 	"sync/atomic"
 	"time"
 
@@ -32,6 +33,7 @@ import (
 
 	"github.com/syncthing/syncthing/internal/db"
 	"github.com/syncthing/syncthing/internal/itererr"
+	"github.com/syncthing/syncthing/internal/slogutil"
 	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections"
@@ -45,7 +47,6 @@ import (
 	"github.com/syncthing/syncthing/lib/semaphore"
 	"github.com/syncthing/syncthing/lib/stats"
 	"github.com/syncthing/syncthing/lib/svcutil"
-	"github.com/syncthing/syncthing/lib/sync"
 	"github.com/syncthing/syncthing/lib/ur/contract"
 	"github.com/syncthing/syncthing/lib/versioner"
 )
@@ -213,7 +214,7 @@ var (
 // where it sends index information to connected peers and responds to requests
 // for file data without altering the local folder in any way.
 func NewModel(cfg config.Wrapper, id protocol.DeviceID, sdb db.DB, protectedFiles []string, evLogger events.Logger, keyGen *protocol.KeyGenerator) Model {
-	spec := svcutil.SpecWithDebugLogger(l)
+	spec := svcutil.SpecWithDebugLogger()
 	m := &model{
 		Supervisor: suture.New("model", spec),
 
@@ -236,7 +237,6 @@ func NewModel(cfg config.Wrapper, id protocol.DeviceID, sdb db.DB, protectedFile
 		observed:             db.NewObservedDB(sdb),
 
 		// fields protected by mut
-		mut:                            sync.NewRWMutex(),
 		folderCfgs:                     make(map[string]config.FolderConfiguration),
 		deviceStatRefs:                 make(map[protocol.DeviceID]*stats.DeviceStatisticsReference),
 		folderIgnores:                  make(map[string]*ignore.Matcher),
@@ -288,7 +288,7 @@ func (m *model) serve(ctx context.Context) error {
 			l.Debugln(m, "fatal error, stopping", err)
 			return svcutil.AsFatalErr(err, svcutil.ExitError)
 		case <-m.promotionTimer.C:
-			l.Debugln("promotion timer fired")
+			slog.Debug("Promotion timer fired")
 			m.promoteConnections()
 		}
 	}
@@ -340,7 +340,7 @@ func (m *model) addAndStartFolderLocked(cfg config.FolderConfiguration, cacheIgn
 	ignores := ignore.New(cfg.Filesystem(), ignore.WithCache(cacheIgnoredFiles))
 	if cfg.Type != config.FolderTypeReceiveEncrypted {
 		if err := ignores.Load(".stignore"); err != nil && !fs.IsNotExist(err) {
-			l.Warnln("Loading ignores:", err)
+			slog.Error("Failed to load ignores", slogutil.Error(err))
 		}
 	}
 
@@ -354,7 +354,7 @@ func (m *model) addAndStartFolderLockedWithIgnores(cfg config.FolderConfiguratio
 
 	_, ok := m.folderRunners.Get(cfg.ID)
 	if ok {
-		l.Warnln("Cannot start already running folder", cfg.Description())
+		slog.Error("Cannot start already running folder", cfg.LogAttr())
 		panic("cannot start already running folder")
 	}
 
@@ -388,9 +388,9 @@ func (m *model) addAndStartFolderLockedWithIgnores(cfg config.FolderConfiguratio
 		// it'll show up as errored later.
 
 		if err := cfg.CreateRoot(); err != nil {
-			l.Warnln("Failed to create folder root directory:", err)
+			slog.Error("Failed to create folder root directory", cfg.LogAttr(), slogutil.Error(err))
 		} else if err = cfg.CreateMarker(); err != nil {
-			l.Warnln("Failed to create folder marker:", err)
+			slog.Error("Failed to create folder marker", cfg.LogAttr(), slogutil.Error(err))
 		}
 	}
 
@@ -398,7 +398,7 @@ func (m *model) addAndStartFolderLockedWithIgnores(cfg config.FolderConfiguratio
 		if encryptionToken, err := readEncryptionToken(cfg); err == nil {
 			m.folderEncryptionPasswordTokens[folder] = encryptionToken
 		} else if !fs.IsNotExist(err) {
-			l.Warnf("Failed to read encryption token: %v", err)
+			slog.Error("Failed to read encryption token", cfg.LogAttr(), slogutil.Error(err))
 		}
 	}
 
@@ -423,7 +423,7 @@ func (m *model) addAndStartFolderLockedWithIgnores(cfg config.FolderConfiguratio
 	p := folderFactory(m, ignores, cfg, ver, m.evLogger, m.folderIOLimiter)
 	m.folderRunners.Add(folder, p)
 
-	l.Infof("Ready to synchronize %s (%s)", cfg.Description(), cfg.Type)
+	slog.Info("Ready to synchronize", cfg.LogAttr())
 }
 
 func (m *model) warnAboutOverwritingProtectedFiles(cfg config.FolderConfiguration, ignores *ignore.Matcher) {
@@ -455,13 +455,13 @@ func (m *model) warnAboutOverwritingProtectedFiles(cfg config.FolderConfiguratio
 	}
 
 	if len(filesAtRisk) > 0 {
-		l.Warnln("Some protected files may be overwritten and cause issues. See https://docs.syncthing.net/users/config.html#syncing-configuration-files for more information. The at risk files are:", strings.Join(filesAtRisk, ", "))
+		slog.Warn("Some protected files may be overwritten and cause issues; see https://docs.syncthing.net/users/config.html#syncing-configuration-files for more information", slog.Any("filesAtRisk", filesAtRisk))
 	}
 }
 
 func (m *model) removeFolder(cfg config.FolderConfiguration) {
-	l.Infoln("Removing folder", cfg.Description())
-	defer l.Infoln("Removed folder", cfg.Description())
+	slog.Info("Removing folder", cfg.LogAttr())
+	defer slog.Info("Removed folder", cfg.LogAttr())
 
 	m.mut.RLock()
 	wait := m.folderRunners.StopAndWaitChan(cfg.ID, 0)
@@ -515,7 +515,7 @@ func (m *model) restartFolder(from, to config.FolderConfiguration, cacheIgnoredF
 		panic("bug: cannot restart empty folder ID")
 	}
 	if to.ID != from.ID {
-		l.Warnf("bug: folder restart cannot change ID %q -> %q", from.ID, to.ID)
+		slog.Error("Bug: folder restart cannot change ID", "from", from.ID, "to", to.ID)
 		panic("bug: folder restart cannot change ID")
 	}
 	folder := to.ID
@@ -549,16 +549,14 @@ func (m *model) restartFolder(from, to config.FolderConfiguration, cacheIgnoredF
 		return nil
 	})
 
-	var infoMsg string
 	switch {
 	case to.Paused:
-		infoMsg = "Paused"
+		slog.Info("Paused folder", to.LogAttr())
 	case from.Paused:
-		infoMsg = "Unpaused"
+		slog.Info("Unpaused folder", to.LogAttr())
 	default:
-		infoMsg = "Restarted"
+		slog.Info("Restarted folder", to.LogAttr())
 	}
-	l.Infof("%v folder %v (%v)", infoMsg, to.Description(), to.Type)
 
 	return nil
 }
@@ -1172,7 +1170,7 @@ func (m *model) handleIndex(conn protocol.Connection, folder string, fs []protoc
 	l.Debugf("%v (in): %s / %q: %d files", op, deviceID, folder, len(fs))
 
 	if cfg, ok := m.cfg.Folder(folder); !ok || !cfg.SharedWith(deviceID) {
-		l.Warnf("%v for unexpected folder ID %q sent from device %q; ensure that the folder exists and that this device is selected under \"Share With\" in the folder configuration.", op, folder, deviceID)
+		slog.Warn(`Operation for unexpected folder ID; ensure that the folder exists and that this device is selected under "Share With" in the folder configuration.`, slog.String("operation", op), cfg.LogAttr(), deviceID.LogAttr())
 		return fmt.Errorf("%s: %w", folder, ErrFolderMissing)
 	} else if cfg.Paused {
 		l.Debugf("%v for paused folder (ID %q) sent from device %q.", op, folder, deviceID)
@@ -1243,11 +1241,11 @@ func (m *model) ClusterConfig(conn protocol.Connection, cm *protocol.ClusterConf
 			}
 		}
 		if info.remote.ID == protocol.EmptyDeviceID {
-			l.Infof("Device %v sent cluster-config without the device info for the remote on folder %v", deviceID.Short(), folder.Description())
+			slog.Warn("Device sent cluster-config without the device info for the remote", folder.LogAttr(), deviceID.LogAttr())
 			return errMissingRemoteInClusterConfig
 		}
 		if info.local.ID == protocol.EmptyDeviceID {
-			l.Infof("Device %v sent cluster-config without the device info for us locally on folder %v", deviceID.Short(), folder.Description())
+			slog.Warn("Device sent cluster-config without the device info for us locally", folder.LogAttr(), deviceID.LogAttr())
 			return errMissingLocalInClusterConfig
 		}
 		ccDeviceInfos[folder.ID] = info
@@ -1255,7 +1253,7 @@ func (m *model) ClusterConfig(conn protocol.Connection, cm *protocol.ClusterConf
 
 	for _, info := range ccDeviceInfos {
 		if deviceCfg.Introducer && info.local.Introducer {
-			l.Warnf("Remote %v is an introducer to us, and we are to them - only one should be introducer to the other, see https://docs.syncthing.net/users/introducer.html", deviceCfg.Description())
+			slog.Error("Remote is an introducer to us, and we are to them - only one should be introducer to the other, see https://docs.syncthing.net/users/introducer.html", deviceCfg.DeviceID.LogAttr())
 		}
 		break
 	}
@@ -1359,7 +1357,7 @@ func (m *model) ensureIndexHandler(conn protocol.Connection) *indexHandlerRegist
 		// the other side has decided to start using a new primary
 		// connection but we haven't seen it close yet. Ideally it will
 		// close shortly by itself...
-		l.Infof("Abandoning old index handler for %s (%s) in favour of %s", deviceID.Short(), indexHandlerRegistry.conn.ConnectionID(), connID)
+		slog.Warn("Abandoning old index handler in favour of new connection", deviceID.LogAttr(), slog.String("old", indexHandlerRegistry.conn.ConnectionID()), slog.String("new", connID))
 		m.indexHandlers.RemoveAndWait(deviceID, 0)
 	}
 
@@ -1399,7 +1397,7 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi
 	deviceID := deviceCfg.DeviceID
 	expiredPending, err := m.observed.PendingFoldersForDevice(deviceID)
 	if err != nil {
-		l.Infof("Could not get pending folders for cleanup: %v", err)
+		slog.Warn("Failed to list pending folders for cleanup", slogutil.Error(err))
 	}
 	of := db.ObservedFolder{Time: time.Now().Truncate(time.Second)}
 	for _, folder := range folders {
@@ -1412,7 +1410,7 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi
 		if !ok {
 			indexHandlers.Remove(folder.ID)
 			if deviceCfg.IgnoredFolder(folder.ID) {
-				l.Infof("Ignoring folder %s from device %s since it is in the list of ignored folders", folder.Description(), deviceID)
+				slog.Info("Ignoring announced folder", folder.LogAttr(), deviceID.LogAttr())
 				continue
 			}
 			delete(expiredPending, folder.ID)
@@ -1420,7 +1418,7 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi
 			of.ReceiveEncrypted = len(ccDeviceInfos[folder.ID].local.EncryptionPasswordToken) > 0
 			of.RemoteEncrypted = len(ccDeviceInfos[folder.ID].remote.EncryptionPasswordToken) > 0
 			if err := m.observed.AddOrUpdatePendingFolder(folder.ID, of, deviceID); err != nil {
-				l.Warnf("Failed to persist pending folder entry to database: %v", err)
+				slog.Warn("Failed to persist pending folder entry to database", slogutil.Error(err))
 			}
 			if folder.IsRunning() {
 				indexHandlers.AddIndexInfo(folder.ID, ccDeviceInfos[folder.ID])
@@ -1438,7 +1436,7 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi
 				"folderLabel": folder.Label,
 				"device":      deviceID.String(),
 			})
-			l.Infof("Unexpected folder %s sent from device %q; ensure that the folder exists and that this device is selected under \"Share With\" in the folder configuration.", folder.Description(), deviceID)
+			slog.Warn(`Unexpected folder ID in ClusterConfig; ensure that the folder exists and that this device is selected under "Share With" in the folder configuration.`, folder.LogAttr(), deviceID.LogAttr())
 			continue
 		}
 
@@ -1463,16 +1461,16 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi
 			}
 			m.folderEncryptionFailures[folder.ID][deviceID] = err
 			m.mut.Unlock()
-			msg := fmt.Sprintf("Failure checking encryption consistency with device %v for folder %v: %v", deviceID, cfg.Description(), err)
+			const msg = "Failed to verify encryption consistency"
 			if sameError {
-				l.Debugln(msg)
+				slog.Debug(msg, cfg.LogAttr(), deviceID.LogAttr(), slogutil.Error(err))
 			} else {
 				var rerr *redactedError
 				if errors.As(err, &rerr) {
 					err = rerr.redacted
 				}
 				m.evLogger.Log(events.Failure, err.Error())
-				l.Warnln(msg)
+				slog.Error(msg, cfg.LogAttr(), deviceID.LogAttr(), slogutil.Error(err))
 			}
 			return tempIndexFolders, seenFolders, err
 		}
@@ -1507,8 +1505,8 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi
 	expiredPendingList := make([]map[string]string, 0, len(expiredPending))
 	for folder := range expiredPending {
 		if err = m.observed.RemovePendingFolderForDevice(folder, deviceID); err != nil {
-			msg := "Failed to remove pending folder-device entry"
-			l.Warnf("%v (%v, %v): %v", msg, folder, deviceID, err)
+			const msg = "Failed to remove pending folder-device entry"
+			slog.Warn(msg, slog.String("folder", folder), deviceID.LogAttr(), slogutil.Error(err))
 			m.evLogger.Log(events.Failure, msg)
 			continue
 		}
@@ -1689,13 +1687,13 @@ func (m *model) handleIntroductions(introducerCfg config.DeviceConfiguration, cm
 			}
 
 			if fcfg.Type != config.FolderTypeReceiveEncrypted && device.EncryptionPasswordToken != nil {
-				l.Infof("Cannot share folder %s with %v because the introducer %v encrypts data, which requires a password", folder.Description(), device.ID, introducerCfg.DeviceID)
+				slog.Warn("Cannot share folder in untrusted mode with introduced device because it requires a password", folder.LogAttr(), slog.Any("device", device.ID), slog.Any("introducer", introducerCfg.DeviceID))
 				continue
 			}
 
 			// We don't yet share this folder with this device. Add the device
 			// to sharing list of the folder.
-			l.Infof("Sharing folder %s with %v (vouched for by introducer %v)", folder.Description(), device.ID, introducerCfg.DeviceID)
+			slog.Info("Sharing folder vouched for by introducer", folder.LogAttr(), slog.Any("device", device.ID), slog.Any("introducer", introducerCfg.DeviceID))
 			fcfg.Devices = append(fcfg.Devices, config.FolderDeviceConfiguration{
 				DeviceID:     device.ID,
 				IntroducedBy: introducerCfg.DeviceID,
@@ -1732,7 +1730,7 @@ func (*model) handleDeintroductions(introducerCfg config.DeviceConfiguration, fo
 				// We could not find that folder shared on the
 				// introducer with the device that was introduced to us.
 				// We should follow and unshare as well.
-				l.Infof("Unsharing folder %s with %v as introducer %v no longer shares the folder with that device", folderCfg.Description(), folderCfg.Devices[k].DeviceID, folderCfg.Devices[k].IntroducedBy)
+				slog.Info("Unsharing folder as introducer no longer shares the folder with that device", folderCfg.LogAttr(), slog.Any("device", folderCfg.Devices[k].DeviceID), slog.Any("introducer", folderCfg.Devices[k].IntroducedBy))
 				folderCfg.Devices = append(folderCfg.Devices[:k], folderCfg.Devices[k+1:]...)
 				folders[folderID] = folderCfg
 				k--
@@ -1750,12 +1748,11 @@ func (*model) handleDeintroductions(introducerCfg config.DeviceConfiguration, fo
 				if _, ok := devicesNotIntroduced[deviceID]; !ok {
 					// The introducer no longer shares any folder with the
 					// device, remove the device.
-					l.Infof("Removing device %v as introducer %v no longer shares any folders with that device", deviceID, device.IntroducedBy)
+					slog.Info("Removing device as introducer no longer shares any folders with that device", "device", deviceID, "introducer", device.IntroducedBy)
 					changed = true
 					delete(devices, deviceID)
 					continue
 				}
-				l.Infof("Would have removed %v as %v no longer shares any folders, yet there are other folders that are shared with this device that haven't been introduced by this introducer.", deviceID, device.IntroducedBy)
 			}
 		}
 	}
@@ -1776,7 +1773,7 @@ func (m *model) handleAutoAccepts(deviceID protocol.DeviceID, folder protocol.Fo
 			pathAlternatives = append(pathAlternatives, alt)
 		}
 		if len(pathAlternatives) == 0 {
-			l.Infof("Failed to auto-accept folder %s from %s due to lack of path alternatives", folder.Description(), deviceID)
+			slog.Error("Failed to auto-accept folder due to lack of path alternatives", folder.LogAttr(), deviceID.LogAttr())
 			return config.FolderConfiguration{}, false
 		}
 		for _, path := range pathAlternatives {
@@ -1788,7 +1785,7 @@ func (m *model) handleAutoAccepts(deviceID protocol.DeviceID, folder protocol.Fo
 			// Attempt to create it to make sure it does, now.
 			fullPath := filepath.Join(defaultFolderCfg.Path, path)
 			if err := defaultPathFs.MkdirAll(path, 0o700); err != nil {
-				l.Warnf("Failed to create path for auto-accepted folder %s at path %s: %v", folder.Description(), fullPath, err)
+				slog.Error("Failed to create path for auto-accepted folder", folder.LogAttr(), slogutil.FilePath(fullPath), slogutil.Error(err))
 				continue
 			}
 
@@ -1812,14 +1809,14 @@ func (m *model) handleAutoAccepts(deviceID protocol.DeviceID, folder protocol.Fo
 			} else {
 				ignores := m.cfg.DefaultIgnores()
 				if err := m.setIgnores(fcfg, ignores.Lines); err != nil {
-					l.Warnf("Failed to apply default ignores to auto-accepted folder %s at path %s: %v", folder.Description(), fcfg.Path, err)
+					slog.Error("Failed to apply default ignores to auto-accepted folder", folder.LogAttr(), slogutil.FilePath(fullPath), slogutil.Error(err))
 				}
 			}
 
-			l.Infof("Auto-accepted %s folder %s at path %s", deviceID, folder.Description(), fcfg.Path)
+			slog.Info("Auto-accepted folder", fcfg.LogAttr(), slogutil.FilePath(fcfg.Path))
 			return fcfg, true
 		}
-		l.Infof("Failed to auto-accept folder %s from %s due to path conflict", folder.Description(), deviceID)
+		slog.Error("Failed to auto-accept folder due to path conflict", folder.LogAttr(), deviceID.LogAttr())
 		return config.FolderConfiguration{}, false
 	} else {
 		if slices.Contains(cfg.DeviceIDs(), deviceID) {
@@ -1828,19 +1825,19 @@ func (m *model) handleAutoAccepts(deviceID protocol.DeviceID, folder protocol.Fo
 		}
 		if cfg.Type == config.FolderTypeReceiveEncrypted {
 			if len(ccDeviceInfos.remote.EncryptionPasswordToken) == 0 && len(ccDeviceInfos.local.EncryptionPasswordToken) == 0 {
-				l.Infof("Failed to auto-accept device %s on existing folder %s as the remote wants to send us unencrypted data, but the folder type is receive-encrypted", folder.Description(), deviceID)
+				slog.Info("Failed to auto-accept device on existing folder as the remote wants to send us unencrypted data, but the folder type is receive-encrypted", folder.LogAttr(), deviceID.LogAttr())
 				return config.FolderConfiguration{}, false
 			}
 		} else {
 			if len(ccDeviceInfos.remote.EncryptionPasswordToken) > 0 || len(ccDeviceInfos.local.EncryptionPasswordToken) > 0 {
-				l.Infof("Failed to auto-accept device %s on existing folder %s as the remote wants to send us encrypted data, but the folder type is not receive-encrypted", folder.Description(), deviceID)
+				slog.Info("Failed to auto-accept device on existing folder as the remote wants to send us encrypted data, but the folder type is not receive-encrypted", folder.LogAttr(), deviceID.LogAttr())
 				return config.FolderConfiguration{}, false
 			}
 		}
 		cfg.Devices = append(cfg.Devices, config.FolderDeviceConfiguration{
 			DeviceID: deviceID,
 		})
-		l.Infof("Shared %s with %s due to auto-accept", folder.ID, deviceID)
+		slog.Info("Shared folder due to auto-accept", folder.LogAttr(), deviceID.LogAttr())
 		return cfg, true
 	}
 }
@@ -1853,7 +1850,7 @@ func (m *model) introduceDevice(device protocol.Device, introducerCfg config.Dev
 		}
 	}
 
-	l.Infof("Adding device %v to config (vouched for by introducer %v)", device.ID, introducerCfg.DeviceID)
+	slog.Info("Adding device to config (vouched for by introducer)", device.ID.LogAttr(), slog.Any("introducer", introducerCfg.DeviceID.Short()))
 	newDeviceCfg := m.cfg.DefaultDevice()
 	newDeviceCfg.DeviceID = device.ID
 	newDeviceCfg.Name = device.Name
@@ -1864,7 +1861,7 @@ func (m *model) introduceDevice(device protocol.Device, introducerCfg config.Dev
 
 	// The introducers' introducers are also our introducers.
 	if device.Introducer {
-		l.Infof("Device %v is now also an introducer", device.ID)
+		slog.Info("Device is now also an introducer", device.ID.LogAttr())
 		newDeviceCfg.Introducer = true
 		newDeviceCfg.SkipIntroductionRemovals = device.SkipIntroductionRemovals
 	}
@@ -1921,10 +1918,10 @@ func (m *model) Closed(conn protocol.Connection, err error) {
 	m.mut.RUnlock()
 
 	k := map[bool]string{false: "secondary", true: "primary"}[removedIsPrimary]
-	l.Infof("Lost %s connection to %s at %s: %v (%d remain)", k, deviceID.Short(), conn, err, len(remainingConns))
+	slog.Info("Lost device connection", slog.String("kind", k), deviceID.LogAttr(), slog.Any("connection", conn), slogutil.Error(err), slog.Int("remaining", len(remainingConns)))
 
 	if len(remainingConns) == 0 {
-		l.Infof("Connection to %s at %s closed: %v", deviceID.Short(), conn, err)
+		slog.Info("Connection closed", deviceID.LogAttr(), slog.Any("connection", conn), slogutil.Error(err))
 		m.evLogger.Log(events.DeviceDisconnected, map[string]string{
 			"id":    deviceID.String(),
 			"error": err.Error(),
@@ -1937,7 +1934,7 @@ func (m *model) Closed(conn protocol.Connection, err error) {
 type requestResponse struct {
 	data   []byte
 	closed chan struct{}
-	once   stdsync.Once
+	once   sync.Once
 }
 
 func newRequestResponse(size int) *requestResponse {
@@ -1983,7 +1980,7 @@ func (m *model) Request(conn protocol.Connection, req *protocol.Request) (out pr
 	}
 
 	if !folderCfg.SharedWith(deviceID) {
-		l.Warnf("Request from %s for file %s in unshared folder %q", deviceID.Short(), req.Name, req.Folder)
+		slog.Warn("Request for file in unshared folder", slog.String("folder", req.Folder), deviceID.LogAttr(), slogutil.FilePath(req.Name))
 		return nil, protocol.ErrGeneric
 	}
 	if folderCfg.Paused {
@@ -2248,7 +2245,7 @@ func (m *model) setIgnores(cfg config.FolderConfiguration, content []string) err
 	}
 
 	if err := ignore.WriteIgnores(cfg.Filesystem(), ".stignore", content); err != nil {
-		l.Warnln("Saving .stignore:", err)
+		slog.Error("Failed to save .stignore", slogutil.Error(err))
 		return err
 	}
 
@@ -2267,7 +2264,7 @@ func (m *model) setIgnores(cfg config.FolderConfiguration, content []string) err
 func (m *model) OnHello(remoteID protocol.DeviceID, addr net.Addr, hello protocol.Hello) error {
 	if _, ok := m.cfg.Device(remoteID); !ok {
 		if err := m.observed.AddOrUpdatePendingDevice(remoteID, hello.DeviceName, addr.String()); err != nil {
-			l.Warnf("Failed to persist pending device entry to database: %v", err)
+			slog.Warn("Failed to persist pending device entry to database", slogutil.Error(err))
 		}
 		m.evLogger.Log(events.PendingDevicesChanged, map[string][]interface{}{
 			"added": {map[string]string{
@@ -2294,7 +2291,7 @@ func (m *model) AddConnection(conn protocol.Connection, hello protocol.Hello) {
 	deviceID := conn.DeviceID()
 	deviceCfg, ok := m.cfg.Device(deviceID)
 	if !ok {
-		l.Infoln("Trying to add connection to unknown device")
+		slog.Info("Trying to add connection to unknown device")
 		return
 	}
 
@@ -2327,9 +2324,9 @@ func (m *model) AddConnection(conn protocol.Connection, hello protocol.Hello) {
 	m.evLogger.Log(events.DeviceConnected, event)
 
 	if len(m.deviceConnIDs[deviceID]) == 1 {
-		l.Infof(`Device %s client is "%s %s" named "%s" at %s`, deviceID.Short(), hello.ClientName, hello.ClientVersion, hello.DeviceName, conn)
+		slog.Info("New device connection", deviceID.LogAttr(), slogutil.Address(conn.RemoteAddr()), slog.Group("remote", slog.String("name", hello.DeviceName), slog.String("client", hello.ClientName), slog.String("version", hello.ClientVersion)))
 	} else {
-		l.Infof(`Additional connection (+%d) for device %s at %s`, len(m.deviceConnIDs[deviceID])-1, deviceID.Short(), conn)
+		slog.Info("Additional device connection", deviceID.LogAttr(), slogutil.Address(conn.RemoteAddr()), slog.Int("count", len(m.deviceConnIDs[deviceID])-1))
 	}
 
 	m.mut.Unlock()
@@ -2500,9 +2497,9 @@ func (m *model) ScanFolders() map[string]error {
 	m.mut.RUnlock()
 
 	errors := make(map[string]error, len(m.folderCfgs))
-	errorsMut := sync.NewMutex()
+	var errorsMut sync.Mutex
 
-	wg := sync.NewWaitGroup()
+	var wg sync.WaitGroup
 	wg.Add(len(folders))
 	for _, folder := range folders {
 		go func() {
@@ -2922,7 +2919,7 @@ func (m *model) ResetFolder(folder string) error {
 	if ok {
 		return errors.New("folder must be paused when resetting")
 	}
-	l.Infof("Cleaning metadata for reset folder %q", folder)
+	slog.Info("Cleaning metadata for reset folder", "folder", folder)
 	return m.sdb.DropFolder(folder)
 }
 
@@ -2969,9 +2966,9 @@ func (m *model) CommitConfiguration(from, to config.Configuration) bool {
 		if _, ok := fromFolders[folderID]; !ok {
 			// A folder was added.
 			if cfg.Paused {
-				l.Infoln("Paused folder", cfg.Description())
+				slog.Info("Paused folder", cfg.LogAttr())
 			} else {
-				l.Infoln("Adding folder", cfg.Description())
+				slog.Info("Adding folder", cfg.LogAttr())
 				if err := m.newFolder(cfg, to.Options.CacheIgnoredFiles); err != nil {
 					m.fatal(err)
 					return true
@@ -3049,7 +3046,7 @@ func (m *model) CommitConfiguration(from, to config.Configuration) bool {
 		}
 
 		if toCfg.Paused {
-			l.Infoln("Pausing", deviceID)
+			slog.Info("Pausing device", deviceID.LogAttr())
 			closeDevices = append(closeDevices, deviceID)
 			m.evLogger.Log(events.DevicePaused, map[string]string{"device": deviceID.String()})
 		} else {
@@ -3058,7 +3055,7 @@ func (m *model) CommitConfiguration(from, to config.Configuration) bool {
 				closeDevices = append(closeDevices, deviceID)
 			}
 
-			l.Infoln("Resuming", deviceID)
+			slog.Info("Resuming device", deviceID.LogAttr())
 			m.evLogger.Log(events.DeviceResumed, map[string]string{"device": deviceID.String()})
 		}
 
@@ -3132,8 +3129,8 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 	var removedPendingFolders []map[string]string
 	pendingFolders, err := m.observed.PendingFolders()
 	if err != nil {
-		msg := "Could not iterate through pending folder entries for cleanup"
-		l.Warnf("%v: %v", msg, err)
+		const msg = "Could not iterate through pending folder entries for cleanup"
+		slog.Warn(msg, slogutil.Error(err))
 		m.evLogger.Log(events.Failure, msg)
 		// Continue with pending devices below, loop is skipped.
 	}
@@ -3144,8 +3141,8 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 			// at all (but might become pending again).
 			l.Debugf("Discarding pending removed folder %v from all devices", folderID)
 			if err := m.observed.RemovePendingFolder(folderID); err != nil {
-				msg := "Failed to remove pending folder entry"
-				l.Warnf("%v (%v): %v", msg, folderID, err)
+				const msg = "Failed to remove pending folder entry"
+				slog.Warn(msg, slog.String("folder", folderID), slogutil.Error(err))
 				m.evLogger.Log(events.Failure, msg)
 			} else {
 				removedPendingFolders = append(removedPendingFolders, map[string]string{
@@ -3171,8 +3168,8 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 			continue
 		removeFolderForDevice:
 			if err := m.observed.RemovePendingFolderForDevice(folderID, deviceID); err != nil {
-				msg := "Failed to remove pending folder-device entry"
-				l.Warnf("%v (%v, %v): %v", msg, folderID, deviceID, err)
+				const msg = "Failed to remove pending folder-device entry"
+				slog.Warn(msg, slog.String("folder", folderID), deviceID.LogAttr(), slogutil.Error(err))
 				m.evLogger.Log(events.Failure, msg)
 				continue
 			}
@@ -3191,8 +3188,8 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 	var removedPendingDevices []map[string]string
 	pendingDevices, err := m.observed.PendingDevices()
 	if err != nil {
-		msg := "Could not iterate through pending device entries for cleanup"
-		l.Warnf("%v: %v", msg, err)
+		const msg = "Could not iterate through pending device entries for cleanup"
+		slog.Warn(msg, slogutil.Error(err))
 		m.evLogger.Log(events.Failure, msg)
 		return
 	}
@@ -3208,8 +3205,8 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 		continue
 	removeDevice:
 		if err := m.observed.RemovePendingDevice(deviceID); err != nil {
-			msg := "Failed to remove pending device entry"
-			l.Warnf("%v: %v", msg, err)
+			const msg = "Failed to remove pending device entry"
+			slog.Warn(msg, slogutil.Error(err))
 			m.evLogger.Log(events.Failure, msg)
 			continue
 		}
@@ -3379,12 +3376,12 @@ func (s folderDeviceSet) hasDevice(dev protocol.DeviceID) bool {
 
 // syncMutexMap is a type safe wrapper for a sync.Map that holds mutexes
 type syncMutexMap struct {
-	inner stdsync.Map
+	inner sync.Map
 }
 
-func (m *syncMutexMap) Get(key string) sync.Mutex {
-	v, _ := m.inner.LoadOrStore(key, sync.NewMutex())
-	return v.(sync.Mutex)
+func (m *syncMutexMap) Get(key string) *sync.Mutex {
+	v, _ := m.inner.LoadOrStore(key, new(sync.Mutex))
+	return v.(*sync.Mutex)
 }
 
 type deviceIDSet map[protocol.DeviceID]struct{}

+ 1 - 1
lib/model/model_test.go

@@ -3620,7 +3620,7 @@ func TestIssue6961(t *testing.T) {
 	if info, err := tfs.Lstat(name); err != nil {
 		t.Fatal(err)
 	} else {
-		l.Infoln("intest", info.Mode)
+		t.Log(info.Mode())
 	}
 	m.ScanFolders()
 

+ 6 - 6
lib/model/progressemitter.go

@@ -9,12 +9,13 @@ package model
 import (
 	"context"
 	"fmt"
+	"log/slog"
+	"sync"
 	"time"
 
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/protocol"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type ProgressEmitter struct {
@@ -53,7 +54,6 @@ func NewProgressEmitter(cfg config.Wrapper, evLogger events.Logger) *ProgressEmi
 		connections:        make(map[protocol.DeviceID]protocol.Connection),
 		foldersByConns:     make(map[protocol.DeviceID][]string),
 		evLogger:           evLogger,
-		mut:                sync.NewMutex(),
 	}
 
 	t.CommitConfiguration(config.Configuration{}, cfg.RawCopy())
@@ -72,7 +72,7 @@ func (t *ProgressEmitter) Serve(ctx context.Context) error {
 	for {
 		select {
 		case <-ctx.Done():
-			l.Debugln("progress emitter: stopping")
+			slog.Debug("Progress emitter: stopping")
 			return nil
 		case <-t.timer.C:
 			t.mut.Lock()
@@ -218,16 +218,16 @@ func (t *ProgressEmitter) CommitConfiguration(_, to config.Configuration) bool {
 	if newInterval > 0 {
 		if t.disabled {
 			t.disabled = false
-			l.Debugln("progress emitter: enabled")
+			slog.Debug("Progress emitter: enabled")
 		}
 		if t.interval != newInterval {
 			t.interval = newInterval
-			l.Debugln("progress emitter: updated interval", t.interval)
+			l.Debugln("Progress emitter: updated interval", t.interval)
 		}
 	} else if !t.disabled {
 		t.clearLocked()
 		t.disabled = true
-		l.Debugln("progress emitter: disabled")
+		slog.Debug("Progress emitter: disabled")
 	}
 	t.minBlocks = to.Options.TempIndexMinBlocks
 	if t.interval < time.Second {

+ 0 - 10
lib/model/progressemitter_test.go

@@ -18,7 +18,6 @@ import (
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/protocol"
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 var timeout = 100 * time.Millisecond
@@ -79,7 +78,6 @@ func TestProgressEmitter(t *testing.T) {
 
 	s := sharedPullerState{
 		updated: time.Now(),
-		mut:     sync.NewRWMutex(),
 	}
 	p.Register(&s)
 
@@ -222,7 +220,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Version: v1,
 			Blocks:  blocks,
 		},
-		mut:              sync.NewRWMutex(),
 		availableUpdated: time.Now(),
 	}
 	p.registry["folder"]["1"] = state1
@@ -305,7 +302,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Version: v1,
 			Blocks:  blocks,
 		},
-		mut:              sync.NewRWMutex(),
 		available:        []int{1, 2, 3},
 		availableUpdated: time.Now(),
 	}
@@ -316,7 +312,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Version: v1,
 			Blocks:  blocks,
 		},
-		mut:              sync.NewRWMutex(),
 		available:        []int{1, 2, 3},
 		availableUpdated: time.Now(),
 	}
@@ -327,7 +322,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Version: v1,
 			Blocks:  blocks,
 		},
-		mut:              sync.NewRWMutex(),
 		available:        []int{1, 2, 3},
 		availableUpdated: time.Now(),
 	}
@@ -375,7 +369,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Type:    protocol.FileInfoTypeDirectory,
 			Blocks:  blocks,
 		},
-		mut:              sync.NewRWMutex(),
 		available:        []int{1, 2, 3},
 		availableUpdated: time.Now(),
 	}
@@ -387,7 +380,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Version: v1,
 			Type:    protocol.FileInfoTypeSymlink,
 		},
-		mut:              sync.NewRWMutex(),
 		available:        []int{1, 2, 3},
 		availableUpdated: time.Now(),
 	}
@@ -399,7 +391,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Version: v1,
 			Blocks:  blocks,
 		},
-		mut:              sync.NewRWMutex(),
 		available:        []int{1, 2, 3},
 		availableUpdated: time.Now(),
 	}
@@ -411,7 +402,6 @@ func TestSendDownloadProgressMessages(t *testing.T) {
 			Version: v1,
 			Blocks:  blocks[:3],
 		},
-		mut:              sync.NewRWMutex(),
 		available:        []int{1, 2, 3},
 		availableUpdated: time.Now(),
 	}

+ 2 - 5
lib/model/queue.go

@@ -7,9 +7,8 @@
 package model
 
 import (
+	"sync"
 	"time"
-
-	"github.com/syncthing/syncthing/lib/sync"
 )
 
 type jobQueue struct {
@@ -25,9 +24,7 @@ type jobQueueEntry struct {
 }
 
 func newJobQueue() *jobQueue {
-	return &jobQueue{
-		mut: sync.NewMutex(),
-	}
+	return &jobQueue{}
 }
 
 func (q *jobQueue) Push(file string, size int64, modified time.Time) {

+ 1 - 1
lib/model/requests_test.go

@@ -1043,7 +1043,7 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
 		if !f.Version.Equal(protocol.Vector{}) && f.Deleted {
 			t.Error("Received deleted index entry with non-empty version")
 		}
-		l.Infoln(f)
+		t.Log(f)
 		close(done)
 		return nil
 	})

+ 1 - 1
lib/model/service_map.go

@@ -37,7 +37,7 @@ func newServiceMap[K comparable, S suture.Service](eventLogger events.Logger) *s
 		tokens:      make(map[K]suture.ServiceToken),
 		eventLogger: eventLogger,
 	}
-	m.supervisor = suture.New(m.String(), svcutil.SpecWithDebugLogger(l))
+	m.supervisor = suture.New(m.String(), svcutil.SpecWithDebugLogger())
 	return m
 }
 

Некоторые файлы не были показаны из-за большого количества измененных файлов