feat(ui): GUI debug logging follows daemon log level + debug bundle

When the daemon is set to debug/trace, the GUI now automatically writes a
rotated gui-client.log in the user's config dir and the daemon's debug bundle
collects it. The UI learns the level both at startup (daemon already in debug)
and live, by piggybacking the existing SubscribeEvents stream: the daemon
publishes a marked log-level-changed SystemEvent (and a per-subscription
snapshot), which DaemonFeed routes to guilog.DebugLog instead of an OS toast.
The UI registers its log path via a new RegisterUILog RPC so the root daemon,
which can't resolve the user's config dir, knows where to find the file.

Manual --log-file (any value) disables the daemon-driven file logging.

Fix: client/ui SetLogLevel looked up proto.LogLevel_value with the lowercase
logrus name, which never matched the uppercase enum keys and silently fell back
to INFO — so trace/debug requests from the bundle flow had no effect.
This commit is contained in:
Zoltán Papp
2026-06-11 18:33:21 +02:00
parent 96f0c7a165
commit ff6aef5e2a
17 changed files with 971 additions and 419 deletions

View File

@@ -229,9 +229,16 @@ scutil_dns.txt (macOS only):
const (
clientLogFile = "client.log"
uiLogFile = "gui-client.log"
errorLogFile = "netbird.err"
stdoutLogFile = "netbird.out"
// Rotated-log glob prefixes (base log name without extension) passed to
// addRotatedLogFiles. The daemon's own log and the GUI log live in the same
// dir, so the prefixes must be disjoint to keep their rotated siblings apart.
clientLogPrefix = "client"
uiLogPrefix = "gui-client"
darwinErrorLogPath = "/var/log/netbird.out.log"
darwinStdoutLogPath = "/var/log/netbird.err.log"
)
@@ -249,6 +256,7 @@ type BundleGenerator struct {
statusRecorder *peer.Status
syncResponse *mgmProto.SyncResponse
logPath string
uiLogPath string
tempDir string
cpuProfile []byte
capturePath string
@@ -275,6 +283,7 @@ type GeneratorDependencies struct {
StatusRecorder *peer.Status
SyncResponse *mgmProto.SyncResponse
LogPath string
UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
CPUProfile []byte
CapturePath string
@@ -298,6 +307,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
statusRecorder: deps.StatusRecorder,
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
uiLogPath: deps.UILogPath,
tempDir: deps.TempDir,
cpuProfile: deps.CPUProfile,
capturePath: deps.CapturePath,
@@ -408,6 +418,10 @@ func (g *BundleGenerator) createArchive() error {
log.Errorf("failed to add logs to debug bundle: %v", err)
}
if err := g.addUILog(); err != nil {
log.Errorf("failed to add UI log to debug bundle: %v", err)
}
if err := g.addUpdateLogs(); err != nil {
log.Errorf("failed to add updater logs: %v", err)
}
@@ -972,7 +986,7 @@ func (g *BundleGenerator) addLogfile() error {
return fmt.Errorf("add client log file to zip: %w", err)
}
g.addRotatedLogFiles(logDir)
g.addRotatedLogFiles(logDir, clientLogPrefix)
stdErrLogPath := filepath.Join(logDir, errorLogFile)
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
@@ -992,6 +1006,25 @@ func (g *BundleGenerator) addLogfile() error {
return nil
}
// addUILog adds the desktop UI's gui-client.log (and its rotated siblings) to
// the bundle. The path is reported by the UI via RegisterUILog; empty when no
// UI registered one (e.g. headless / server). Missing file is non-fatal — the
// UI only writes it while the daemon is in debug, so it's often absent.
func (g *BundleGenerator) addUILog() error {
if g.uiLogPath == "" {
log.Debugf("no UI log path registered, skipping in debug bundle")
return nil
}
if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil {
return fmt.Errorf("add UI log file to zip: %w", err)
}
g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix)
return nil
}
// addSingleLogfile adds a single log file to the archive
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
logFile, err := os.Open(logPath)
@@ -1064,14 +1097,16 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
return nil
}
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount
func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
// prefix is the base log name without extension (e.g. "client", "gui-client");
// the glob matches both files rotated by us and by logrotate on linux.
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
if g.logFileCount == 0 {
return
}
// This regex will match both logs rotated by us and logrotate on linux
pattern := filepath.Join(logDir, "client*.log.*")
// This pattern matches both logs rotated by us and logrotate on linux
pattern := filepath.Join(logDir, prefix+"*.log.*")
files, err := filepath.Glob(pattern)
if err != nil {
log.Warnf("failed to glob rotated logs: %v", err)

View File

@@ -40,6 +40,25 @@ func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
}
// TestAddRotatedLogFiles_GUIPrefix asserts the prefix parameter scopes the glob
// to the GUI log: gui-client.log.* rotated siblings are picked up and the
// daemon's own client.log.* are not (and vice versa, covered above). This is
// the load-bearing check for the gui-client.log bundle collection — the old
// "client*.log.*" glob would have missed gui-client rotations.
func TestAddRotatedLogFiles_GUIPrefix(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "gui-client.log.1"), "gui rotated\n")
writeGzFile(t, filepath.Join(dir, "gui-client.log.2.gz"), "gui rotated gz\n")
writeFile(t, filepath.Join(dir, "client.log.1"), "daemon rotated\n")
names := runAddRotatedLogFilesPrefix(t, dir, "gui-client", 10)
require.Contains(t, names, "gui-client.log.1", "gui-client rotated file should be in bundle")
require.Contains(t, names, "gui-client.log.2.gz", "gui-client gz rotated file should be in bundle")
require.NotContains(t, names, "client.log.1", "daemon rotated file must not match the gui-client prefix")
}
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
// logFileCount rotated files are bundled, ordered by mtime.
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
@@ -67,6 +86,10 @@ func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
// zip writer and returns the set of entry names that ended up in the archive.
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
return runAddRotatedLogFilesPrefix(t, dir, "client", logFileCount)
}
func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount uint32) map[string]struct{} {
t.Helper()
var buf bytes.Buffer
@@ -74,7 +97,7 @@ func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[st
archive: zip.NewWriter(&buf),
logFileCount: logFileCount,
}
g.addRotatedLogFiles(dir)
g.addRotatedLogFiles(dir, prefix)
require.NoError(t, g.archive.Close())
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,11 @@ service DaemonService {
rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {}
// RegisterUILog records the desktop UI's absolute log path so the daemon's
// debug bundle can collect it (the daemon runs as root and can't resolve the
// user's config dir).
rpc RegisterUILog(RegisterUILogRequest) returns (RegisterUILogResponse) {}
rpc SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse) {}
rpc SetConfig(SetConfigRequest) returns (SetConfigResponse) {}
@@ -547,6 +552,13 @@ message SetLogLevelRequest {
message SetLogLevelResponse {
}
message RegisterUILogRequest {
string path = 1;
}
message RegisterUILogResponse {
}
// State represents a daemon state entry
message State {
string name = 1;

View File

@@ -68,6 +68,10 @@ type DaemonServiceClient interface {
StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error)
SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error)
GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error)
// RegisterUILog records the desktop UI's absolute log path so the daemon's
// debug bundle can collect it (the daemon runs as root and can't resolve the
// user's config dir).
RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error)
SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error)
SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error)
AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error)
@@ -404,6 +408,15 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques
return out, nil
}
func (c *daemonServiceClient) RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) {
out := new(RegisterUILogResponse)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/RegisterUILog", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) {
out := new(SwitchProfileResponse)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/SwitchProfile", in, out, opts...)
@@ -652,6 +665,10 @@ type DaemonServiceServer interface {
StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error)
SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error
GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error)
// RegisterUILog records the desktop UI's absolute log path so the daemon's
// debug bundle can collect it (the daemon runs as root and can't resolve the
// user's config dir).
RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error)
SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error)
SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error)
AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error)
@@ -772,6 +789,9 @@ func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, Daemo
func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetEvents not implemented")
}
func (UnimplementedDaemonServiceServer) RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RegisterUILog not implemented")
}
func (UnimplementedDaemonServiceServer) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SwitchProfile not implemented")
}
@@ -1283,6 +1303,24 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _DaemonService_RegisterUILog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterUILogRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).RegisterUILog(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/daemon.DaemonService/RegisterUILog",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).RegisterUILog(ctx, req.(*RegisterUILogRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SwitchProfileRequest)
if err := dec(in); err != nil {
@@ -1719,6 +1757,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetEvents",
Handler: _DaemonService_GetEvents_Handler,
},
{
MethodName: "RegisterUILog",
Handler: _DaemonService_RegisterUILog_Handler,
},
{
MethodName: "SwitchProfile",
Handler: _DaemonService_SwitchProfile_Handler,

30
client/proto/metadata.go Normal file
View File

@@ -0,0 +1,30 @@
package proto
// SystemEvent metadata markers. The daemon stamps these on internal control
// events it publishes over SubscribeEvents (profile-list refresh, log-level
// change); the desktop UI recognises them and acts on them instead of
// surfacing them as user-facing notifications.
//
// These live in the proto package — the shared contract both the daemon
// (client/server) and the UI (client/ui/services) already import — so producer
// and consumer reference the same constant rather than duplicating literals.
// This file is hand-written and not touched by protoc.
const (
// MetadataKindKey is the SystemEvent.metadata key carrying the event-kind
// marker (one of the MetadataKind* values below).
MetadataKindKey = "kind"
// MetadataKindProfileListChanged marks a CLI-driven profile add/remove that
// should nudge the UI's profile views to refresh.
MetadataKindProfileListChanged = "profile-list-changed"
// MetadataKindLogLevelChanged marks a daemon log-level change (or the
// per-subscription snapshot) that drives the GUI's file logging on/off.
MetadataKindLogLevelChanged = "log-level-changed"
// MetadataProfileKey carries the profile name for
// MetadataKindProfileListChanged.
MetadataProfileKey = "profile"
// MetadataLevelKey carries the lowercase logrus level name for
// MetadataKindLogLevelChanged.
MetadataLevelKey = "level"
)

View File

@@ -67,6 +67,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
StatusRecorder: s.statusRecorder,
SyncResponse: syncResponse,
LogPath: s.logFile,
UILogPath: s.uiLogPath,
CPUProfile: cpuProfileData,
CapturePath: capturePath,
RefreshStatus: refreshStatus,
@@ -127,9 +128,26 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
log.Infof("Log level set to %s", level.String())
// Signal the desktop UI so it can attach/detach its gui-client.log. Rides
// the SubscribeEvents stream as a marked event (see publishLogLevelChanged).
s.publishLogLevelChanged(level.String())
return &proto.SetLogLevelResponse{}, nil
}
// RegisterUILog records the desktop UI's absolute log path so DebugBundle can
// collect the GUI log. The daemon runs as root and can't resolve the user's
// config dir, so the UI reports it. Last-writer-wins (one UI per socket).
func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.uiLogPath = req.GetPath()
log.Infof("registered UI log path: %s", s.uiLogPath)
return &proto.RegisterUILogResponse{}, nil
}
// SetSyncResponsePersistence sets the sync response persistence for the server.
func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) {
s.mutex.Lock()

View File

@@ -1,7 +1,9 @@
package server
import (
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/proto"
)
@@ -16,6 +18,15 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
log.Debug("client subscribed to events")
s.startUpdateManagerForGUI()
// Replay the current log level to this subscriber so a freshly-connected UI
// learns it even when the daemon was already started with --log-level debug
// (the change-driven publishLogLevelChanged only fires on SetLogLevel). Sent
// directly on this stream rather than via PublishEvent so it reaches only
// the new subscriber, not every connected client.
if err := s.sendCurrentLogLevel(stream); err != nil {
return err
}
for {
select {
case event := <-subscription.Events():
@@ -28,3 +39,24 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
}
}
}
// sendCurrentLogLevel sends a marked log-level-changed SystemEvent carrying the
// daemon's current level directly to one subscriber. Mirrors the shape
// publishLogLevelChanged emits so the UI's dispatchSystemEvent handles both the
// same way.
func (s *Server) sendCurrentLogLevel(stream proto.DaemonService_SubscribeEventsServer) error {
level := log.GetLevel().String()
event := &proto.SystemEvent{
Id: uuid.New().String(),
Severity: proto.SystemEvent_INFO,
Category: proto.SystemEvent_SYSTEM,
Message: "Log level changed",
Metadata: map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
Timestamp: timestamppb.Now(),
}
if err := stream.Send(event); err != nil {
log.Warnf("error sending initial log level event: %v", err)
return err
}
return nil
}

View File

@@ -67,6 +67,12 @@ type Server struct {
logFile string
// uiLogPath is the desktop UI's absolute log path, reported via
// RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle
// can collect the GUI log even though the daemon runs as root and can't
// resolve the user's config dir. Last-writer-wins (one UI per socket).
uiLogPath string
oauthAuthFlow oauthAuthFlow
// extendAuthSessionFlow holds the pending PKCE flow created by
// RequestExtendAuthSession until WaitExtendAuthSession resolves it.
@@ -1916,8 +1922,8 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent
// recognises the metadata "kind" marker and translates it into its internal
// profile-changed signal that both the tray menu and the React profile views
// already subscribe to (see client/ui/services/daemon_feed.go,
// MetadataKindProfileListChanged). userMessage is intentionally empty so this
// already subscribe to (see proto.MetadataKindProfileListChanged, recognised in
// client/ui/services/daemon_feed.go). userMessage is intentionally empty so this
// stays a silent refresh signal rather than a user-facing notification.
func (s *Server) publishProfileListChanged(profileName string) {
s.statusRecorder.PublishEvent(
@@ -1925,7 +1931,26 @@ func (s *Server) publishProfileListChanged(profileName string) {
proto.SystemEvent_SYSTEM,
"Profile list changed",
"",
map[string]string{"kind": "profile-list-changed", "profile": profileName},
map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName},
)
}
// publishLogLevelChanged signals the desktop UI that the daemon log level
// changed, so it can attach/detach its rotated gui-client.log. Like
// publishProfileListChanged, this rides the SubscribeEvents stream as a marked
// INFO/SYSTEM event (kind "log-level-changed", level the lowercase logrus
// name); the UI's dispatchSystemEvent recognises the marker and routes it to
// the logging toggle instead of an OS toast (userMessage is empty so it stays
// a silent control signal). The "level" value matches log.Level.String()
// (e.g. "debug", "info") so the UI can parse it directly. See
// proto.MetadataKindLogLevelChanged, recognised in client/ui/services/daemon_feed.go.
func (s *Server) publishLogLevelChanged(level string) {
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"Log level changed",
"",
map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
)
}

View File

@@ -7,7 +7,7 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th
## Layout
### Go (top-level package `main`)
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch``app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch``app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; default is **empty** so "no flag" is distinguishable from explicit `--log-file console` — when empty `parseFlagsAndInitLog` falls back to `console` for `InitLog` and returns `userSetLogFile=false`), `--log-level` (`trace|debug|info|warn|error`, default `info`). See "GUI debug logging" below for what `userSetLogFile` gates.
- `tray.go``Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
- **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes``fillExitNodeSubmenu`), daemon-version row (`tray_status.go`), and the About → Update row (`tray_update.go applyState``onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers.
- `tray_linux.go``init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when unprivileged userns are blocked.
@@ -35,7 +35,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
| `Peers` | `peers.go` | Daemon status snapshot + two long-running streams (`SubscribeStatus``EventStatus`, `SubscribeEvents``EventSystem`). Emits synthetic `StatusDaemonUnavailable` when the socket is unreachable. Owns the profile-switch suppression filter (`BeginProfileSwitch` / `CancelProfileSwitch` / `shouldSuppress`). Fan-outs update metadata into dedicated `EventUpdateAvailable` / `EventUpdateProgress` events. |
| `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. |
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RegisterUILog` (report the GUI log path to the daemon for bundle collection) / `RevealFile` (cross-platform "show in file manager"). |
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpiration(seconds)` / `CloseSessionExpiration` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
@@ -50,6 +50,17 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
- Pinned versions (see `daemon.pb.go` header): `protoc v7.34.1`, `protoc-gen-go v1.36.6`. CI's `proto-version-check` workflow fails on mismatch.
- After proto regen, also regen Wails bindings so the TS layer picks up new fields.
## GUI debug logging
When the daemon is put into **debug**/**trace** level, the GUI automatically writes a rotated `gui-client.log` in `os.UserConfigDir()/netbird/` and the daemon's **debug bundle** collects it. Pieces:
- **`uilogpath.go`** (package `main`) — `uiLogPath()` resolves the path; `newDebugLog(userSetLogFile)` builds the `guilog.DebugLog` (disabled when the user passed `--log-file`, or when the config dir can't be resolved).
- **`guilog/debuglog.go``guilog.DebugLog`** (own package, **not** a Wails service — no React binding) — owns the file-logging side effects. `Apply(level)`: on debug/trace attaches `gui-client.log` alongside the console (via `util.SetLogOutputs`, MultiWriter, rotated by timberjack like the daemon log) and raises the logrus level; on a higher level detaches the file and restores `info`. Idempotent (`fileOn` guard) so the startup replay + a racing change-event are harmless. The `gui-client.log` is left on disk on quit (rotated by timberjack) for the debug bundle — there's no shutdown cleanup. `Path()` returns "" when disabled so the daemon won't try to collect a file the GUI never writes.
- **Activation rule:** any `--log-file` (even `console`) is a manual override → the controller is disabled and never touches logging. Only the *absence* of `--log-file` enables the daemon-driven `gui-client.log`. This is why `parseFlagsAndInitLog` seeds the flag default empty (Layout note above).
- **Level signalling — no new stream.** The daemon publishes a **marked `SystemEvent`** (`metadata[proto.MetadataKindKey]==proto.MetadataKindLogLevelChanged`, `metadata[proto.MetadataLevelKey]==<logrus name>`) on the existing `SubscribeEvents` stream. `DaemonFeed.dispatchSystemEvent` recognises the marker and routes it to the controller's `Apply` instead of an OS toast (same pattern as `proto.MetadataKindProfileListChanged`). The controller is injected into `NewDaemonFeed` (a `services.LogController` interface — no exported setter, so the Wails-bound `DaemonFeed` gains no binding), and `DaemonFeed` **re-registers the UI log path** (`RegisterUILog` RPC) on every event-stream (re)connect, so a daemon restart re-learns it. Startup case (daemon already in debug) is covered daemon-side: `Server.SubscribeEvents` (`client/server/event.go`) sends the current level once to each new subscriber; `SetLogLevel` publishes on change (`publishLogLevelChanged` in `client/server/server.go`). The metadata key/value markers live in `client/proto/metadata.go` so producer (daemon) and consumer (UI) share one definition.
- **Daemon side of the bundle:** `Server.RegisterUILog` stores the path in `Server.uiLogPath`; `DebugBundle` passes it to `debug.GeneratorDependencies.UILogPath`; `BundleGenerator.addUILog` adds `gui-client.log` + rotated siblings (`addRotatedLogFiles(dir, "gui-client")` — the glob is prefix-parametrised so it doesn't collide with the daemon's own `client*.log.*`). Missing file is non-fatal (the GUI only writes it while in debug).
- **JS-side logs/errors** already reach the same logrus pipeline via `frontend/src/lib/logs.ts``services.UILog.Log`, so once the file is attached, frontend console/`unhandledrejection`/`error` output is captured too. Go-side uncaught-goroutine panics go to stderr only (out of scope).
## Events bus
`main.go` registers five typed events for the frontend: `netbird:status` (`Status`), `netbird:event` (`SystemEvent`), `netbird:profile:changed` (`ProfileRef`), `netbird:update:available` (`UpdateAvailable`), `netbird:update:progress` (`UpdateProgress`). `netbird:profile:changed` fires from `ProfileSwitcher.SwitchActive` after a successful daemon-side switch — both the React `ProfileContext` and the tray subscribe so a flip driven from one surface paints in the others (the daemon itself does not emit a profile event). Plus three plain-string events:

View File

@@ -8,6 +8,11 @@ import { useProfile } from "@/contexts/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
const TRACE_LOG_FILE_COUNT = 5;
const PLAIN_LOG_FILE_COUNT = 1;
// Lowercase logrus level name sent to Debug.SetLogLevel (the Go binding
// upper-cases before the proto enum lookup). Raising to trace is what drives
// the daemon's verbose logging and the GUI's gui-client.log during a bundle.
const TRACE_LOG_LEVEL = "trace";
const DEFAULT_LOG_LEVEL = "info";
export type DebugStage =
| { kind: "idle" }
@@ -68,7 +73,7 @@ const runTracePhase = async (
// empty
}
throwIfAborted(signal);
await DebugSvc.SetLogLevel({ level: "trace" });
await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
level.raised = true;
throwIfAborted(signal);
@@ -123,7 +128,7 @@ const useDebugBundle = () => {
const signal = ctrl.signal;
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
const level: LevelState = { original: "info", raised: false };
const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
try {
if (trace) {

View File

@@ -0,0 +1,92 @@
//go:build !android && !ios && !freebsd && !js
// Package guilog manages the desktop UI's own file log (gui-client.log), which
// follows the daemon's log level: when the daemon is in debug/trace the GUI
// attaches a rotated file alongside the console so its (and the React frontend's
// forwarded) output is captured for the debug bundle. It is intentionally not a
// Wails service — it has no frontend-facing methods and generates no TS
// bindings — so it lives outside client/ui/services.
package guilog
import (
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/util"
)
// DebugLog is the daemon-debug-driven GUI file log. The daemon publishes a
// marked "log-level-changed" SystemEvent over SubscribeEvents (both on change
// and once per new subscription, so a daemon already in debug is picked up at
// startup); services.DaemonFeed routes it here via Apply.
//
// When the daemon is in debug/trace and the GUI owns its log (no manual
// --log-file), it attaches a rotated gui-client.log alongside the console and
// raises the logrus level; back to a higher level it detaches the file and
// restores info. The file is left on disk (rotated by timberjack) for the debug
// bundle to collect. When the user set --log-file explicitly, it is disabled and
// never touches logging.
type DebugLog struct {
uiPath string
enabled bool
mu sync.Mutex
fileOn bool
}
// NewDebugLog builds the GUI debug log. uiPath is the absolute gui-client.log
// path; enabled is false when the user passed --log-file (manual override), in
// which case it leaves logging untouched.
func NewDebugLog(uiPath string, enabled bool) *DebugLog {
return &DebugLog{uiPath: uiPath, enabled: enabled}
}
// Path returns the GUI log path to register with the daemon, or "" when the GUI
// doesn't own its log (manual --log-file) — in that case the daemon shouldn't
// try to collect a gui-client.log the GUI never writes.
func (d *DebugLog) Path() string {
if !d.enabled {
return ""
}
return d.uiPath
}
// Apply reacts to a daemon log level (the lowercase logrus name, e.g. "debug").
// Idempotent: repeated identical levels are no-ops, so the startup replay plus a
// racing change-event do no harm.
func (d *DebugLog) Apply(level string) {
if !d.enabled {
return
}
// "debug or more verbose" (debug/trace) turns the file log on; anything less
// verbose turns it off. Compare numerically against logrus' own levels so
// there are no hard-coded level-name literals.
lvl, err := log.ParseLevel(level)
if err != nil {
lvl = log.InfoLevel
}
debug := lvl >= log.DebugLevel
d.mu.Lock()
defer d.mu.Unlock()
switch {
case debug && !d.fileOn:
if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole, d.uiPath); err != nil {
log.Errorf("attach GUI file log %s: %v", d.uiPath, err)
return
}
log.SetLevel(lvl)
d.fileOn = true
log.Infof("GUI file logging enabled (daemon level %s), writing to %s", level, d.uiPath)
case !debug && d.fileOn:
if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole); err != nil {
log.Errorf("detach GUI file log: %v", err)
}
log.SetLevel(log.InfoLevel)
d.fileOn = false
log.Infof("GUI file logging disabled (daemon level: %s)", level)
}
}

View File

@@ -84,9 +84,16 @@ func init() {
}
func main() {
daemonAddr := parseFlagsAndInitLog()
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
conn := NewConn(daemonAddr)
// GUI file logging: when the user didn't pass --log-file, the GUI manages a
// gui-client.log that follows the daemon's debug level (attached when the
// daemon is in debug/trace, detached otherwise, rotated by timberjack) and is
// included in the debug bundle. It rides DaemonFeed's SubscribeEvents stream
// (passed into NewDaemonFeed below; see guilog.DebugLog).
debugLog := newDebugLog(userSetLogFile)
// tray is captured in the SingleInstance callback below; the var is
// declared before app.New so the closure has a stable reference.
var tray *Tray
@@ -103,7 +110,7 @@ func main() {
// Wails-bound facade over the holder plus the install RPCs.
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
notifier := notifications.New()
// macOS won't surface any toast until the app has requested permission;
// the request runs after ApplicationStarted so the notifier's Startup has
@@ -230,18 +237,30 @@ func requestNotificationAuthorization(notifier *notifications.NotificationServic
}
// parseFlagsAndInitLog parses the CLI flags, initialises the logger, and
// returns the resolved daemon gRPC address.
func parseFlagsAndInitLog() string {
// returns the resolved daemon gRPC address plus userSetLogFile — true when the
// user passed --log-file explicitly. userSetLogFile is the manual-override
// signal: when true the GUI leaves logging alone (the daemon's debug level
// won't attach gui-client.log); when false the GUI manages a per-session
// gui-client.log driven by the daemon level. The default seed is empty (not
// "console") so "no flag" and an explicit "--log-file console" are
// distinguishable; an empty result falls back to console for InitLog.
func parseFlagsAndInitLog() (string, bool) {
daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port")
logFiles := &stringList{values: []string{"console"}}
flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console.")
logFiles := &stringList{}
flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console. Passing any value disables the daemon-debug-driven gui-client.log.")
logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.")
flag.Parse()
if err := util.InitLog(*logLevel, logFiles.values...); err != nil {
userSetLogFile := len(logFiles.values) > 0
targets := logFiles.values
if !userSetLogFile {
targets = []string{"console"}
}
if err := util.InitLog(*logLevel, targets...); err != nil {
log.Fatalf("init log: %v", err)
}
return *daemonAddr
return *daemonAddr, userSetLogFile
}
// newApplication constructs the Wails application. onSecondInstance is invoked

View File

@@ -46,20 +46,11 @@ const (
// tray (Go side) so the frontend stays passive on this flow.
EventSessionWarning = "netbird:session:warning"
// MetadataKindProfileListChanged is the SystemEvent.metadata["kind"]
// marker the daemon stamps on the INFO/SYSTEM event it publishes after a
// CLI-driven AddProfile / RemoveProfile (the daemon emits no dedicated
// profile RPC event). dispatchSystemEvent recognises it and re-emits the
// existing EventProfileChanged so the tray and React profile views refresh
// — closing the gap the SubscribeStatus path can't, since a profile
// add/remove doesn't change the daemon's status string (the tray's
// iconChanged guard would swallow it). The daemon side hard-codes the same
// string literal in client/server/server.go (client/server cannot import
// this UI package).
MetadataKindProfileListChanged = "profile-list-changed"
// metadataKindKey is the SystemEvent.metadata key the "kind" marker lives
// under. Kept in sync with the daemon-side literal in client/server.
metadataKindKey = "kind"
// The SystemEvent.metadata markers the daemon stamps on its internal
// control events live in the shared proto package
// (proto.MetadataKind*/MetadataKindKey/MetadataLevelKey) so producer
// (client/server) and consumer (here) reference the same constants. See
// dispatchSystemEvent for how they're recognised.
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
// daemon's gRPC socket is unreachable (daemon not running, socket
@@ -198,6 +189,13 @@ type DaemonFeed struct {
conn DaemonConn
emitter Emitter
updater *updater.Holder
// logCtl reacts to the daemon's log level (delivered as a marked
// SystemEvent over the same SubscribeEvents stream) by attaching/detaching
// the GUI file log. nil when the GUI doesn't manage its log (server build /
// not wired), in which case the marker is ignored. Held as a narrow
// interface so this package doesn't depend on client/ui/guilog (the concrete
// type lives there; main passes it into NewDaemonFeed).
logCtl LogController
mu sync.Mutex
cancel context.CancelFunc
@@ -217,8 +215,24 @@ type DaemonFeed struct {
switchLoginWatchUntil time.Time
}
func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder) *DaemonFeed {
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder}
// LogController is the subset of client/ui/guilog.DebugLog that DaemonFeed
// drives: Apply turns the GUI file log on/off for a daemon level, Path is the
// gui-client.log path to register with the daemon (empty when the GUI doesn't
// own its log). Kept as an interface so services doesn't import guilog. The
// daemon delivers log-level changes as marked SystemEvents on the same
// SubscribeEvents stream this feed consumes, so it rides along here rather than
// opening a second daemon subscription.
type LogController interface {
Apply(level string)
Path() string
}
// NewDaemonFeed builds the feed. logCtl may be nil (server build / GUI log not
// managed), in which case log-level markers on the event stream are ignored.
// Injected at construction rather than via a setter so DaemonFeed (a Wails
// service) exposes no extra method to the binding generator.
func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder, logCtl LogController) *DaemonFeed {
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl}
}
// BeginProfileSwitch is called by ProfileSwitcher at the start of a switch
@@ -525,6 +539,17 @@ func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error {
if err != nil {
return fmt.Errorf("subscribe: %w", err)
}
// Re-register the GUI log path on every (re)connect so a daemon restart
// re-learns it and a later debug bundle still finds the file. Best-effort —
// a failure here must not abort the event stream. Done even when file
// logging is off (enabled but not in debug), so the path is known ahead of
// any debug toggle.
if s.logCtl != nil && s.logCtl.Path() != "" {
if _, err := cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: s.logCtl.Path()}); err != nil {
log.Warnf("register UI log path: %v", err)
}
}
for {
ev, err := stream.Recv()
if err != nil {
@@ -549,10 +574,19 @@ func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
// ProfileContext.refresh already subscribe to) and stop — it's an internal
// refresh signal, not a user-facing notification, so it must not reach the
// Recent Events list or fire an OS toast.
if se.Metadata[metadataKindKey] == MetadataKindProfileListChanged {
if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindProfileListChanged {
s.emitter.Emit(EventProfileChanged, ProfileRef{})
return
}
// A marked log-level-changed event drives the GUI file log on/off. It's an
// internal control signal, not a user-facing notification — handle and stop
// so it never reaches the Recent Events list or fires an OS toast.
if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindLogLevelChanged {
if s.logCtl != nil {
s.logCtl.Apply(se.Metadata[proto.MetadataLevelKey])
}
return
}
s.emitter.Emit(EventDaemonNotification, se)
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
s.emitter.Emit(EventSessionWarning, warn)

View File

@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/version"
@@ -99,12 +100,29 @@ func (s *Debug) RevealFile(_ context.Context, path string) error {
return cmd.Start()
}
// RegisterUILog tells the daemon the absolute path of the GUI's log file so
// the daemon's debug bundle can collect it (the daemon runs as root and can't
// resolve the user's config dir). Called by LogLevelWatcher on each daemon
// (re)connect.
func (s *Debug) RegisterUILog(ctx context.Context, path string) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
_, err = cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: path})
return err
}
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
level, ok := proto.LogLevel_value[lvl.Level]
// proto.LogLevel_value keys are the enum names (TRACE/DEBUG/INFO/...), but
// callers (the React side, GetLogLevel) use the lowercase logrus names
// ("trace"/"debug"/...). Upper-case before the lookup so a lowercase level
// doesn't silently fall back to INFO.
level, ok := proto.LogLevel_value[strings.ToUpper(lvl.Level)]
if !ok {
level = int32(proto.LogLevel_INFO)
}

41
client/ui/uilogpath.go Normal file
View File

@@ -0,0 +1,41 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/ui/guilog"
)
// uiLogFileName is the base name of the GUI's log. Rotated siblings
// (gui-client.log.*, *.gz) share the prefix; the daemon's debug bundle globs
// "gui-client*.log.*" to collect them (see addUILog in client/internal/debug).
const uiLogFileName = "gui-client.log"
// uiLogPath resolves os.UserConfigDir()/netbird/gui-client.log — the per-OS-user
// path the GUI writes its log to while the daemon is in debug, and the path it
// registers with the daemon for debug-bundle collection. Native separators are
// preserved (the daemon os.Open()s this path).
func uiLogPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "netbird", uiLogFileName), nil
}
// newDebugLog builds the GUI debug log. userSetLogFile disables it (manual
// --log-file override). If the config dir can't be resolved it's created
// disabled, so the GUI keeps working without file logging.
func newDebugLog(userSetLogFile bool) *guilog.DebugLog {
path, err := uiLogPath()
if err != nil {
log.Warnf("resolve GUI log path: %v; GUI file logging disabled", err)
return guilog.NewDebugLog("", false)
}
return guilog.NewDebugLog(path, !userSetLogFile)
}

View File

@@ -40,6 +40,45 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
if err != nil {
return fmt.Errorf("failed parsing log-level %s: %w", logLevel, err)
}
logFmt, err := buildWriters(logger, logs...)
if err != nil {
return err
}
switch logFmt {
case "json":
formatter.SetJSONFormatter(logger)
case "syslog":
formatter.SetSyslogFormatter(logger)
default:
formatter.SetTextFormatter(logger)
}
logger.SetLevel(level)
setGRPCLibLogger(logger)
return nil
}
// SetLogOutputs re-points an already-initialized logger to the given targets
// (console/syslog/file), with the same target semantics as InitLogger, but
// without re-parsing the level or resetting the formatter. The desktop GUI uses
// it to attach the rotated gui-client.log alongside the console when the daemon
// enters debug, and drop back to console-only when it leaves.
func SetLogOutputs(logger *log.Logger, logs ...string) error {
if _, err := buildWriters(logger, logs...); err != nil {
return err
}
setGRPCLibLogger(logger)
return nil
}
// buildWriters resolves the given log targets to writers and points the logger
// at them (single writer or MultiWriter). It returns the log format implied by
// the targets (syslog forces "syslog"; otherwise the NB_LOG_FORMAT env value).
// Shared by InitLogger and SetLogOutputs.
func buildWriters(logger *log.Logger, logs ...string) (string, error) {
var writers []io.Writer
logFmt := os.Getenv("NB_LOG_FORMAT")
@@ -61,7 +100,7 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
default:
writer, err := setupLogFile(logPath, isRotationDisabled(logger))
if err != nil {
return fmt.Errorf("failed setting up log file: %s, %w", logPath, err)
return "", fmt.Errorf("failed setting up log file: %s, %w", logPath, err)
}
writers = append(writers, writer)
}
@@ -73,19 +112,7 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
logger.SetOutput(writers[0])
}
switch logFmt {
case "json":
formatter.SetJSONFormatter(logger)
case "syslog":
formatter.SetSyslogFormatter(logger)
default:
formatter.SetTextFormatter(logger)
}
logger.SetLevel(level)
setGRPCLibLogger(logger)
return nil
return logFmt, nil
}
// FindFirstLogPath returns the first logs entry that could be a log path, that is neither empty, nor a special value