diff --git a/src/ClaudeDo.App/App.axaml.cs b/src/ClaudeDo.App/App.axaml.cs index 5d7c6733..0d9667aa 100644 --- a/src/ClaudeDo.App/App.axaml.cs +++ b/src/ClaudeDo.App/App.axaml.cs @@ -52,6 +52,7 @@ public partial class App : Application Dispatcher.UIThread.UnhandledException += (_, e) => { e.Handled = true; + Serilog.Log.Error(e.Exception, "Unhandled exception on the UI dispatcher"); shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message)); }; diff --git a/src/ClaudeDo.App/ClaudeDo.App.csproj b/src/ClaudeDo.App/ClaudeDo.App.csproj index 1257d87c..e0b0481c 100644 --- a/src/ClaudeDo.App/ClaudeDo.App.csproj +++ b/src/ClaudeDo.App/ClaudeDo.App.csproj @@ -26,6 +26,8 @@ + + diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 381207fc..88221107 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -13,6 +13,7 @@ using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals.Settings; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Serilog; using System; using System.Globalization; using System.IO; @@ -20,6 +21,7 @@ using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; +using System.Threading.Tasks; namespace ClaudeDo.App; @@ -34,6 +36,35 @@ sealed class Program if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App"); + var logRoot = Path.Combine(Paths.AppDataRoot(), "logs"); + Directory.CreateDirectory(logRoot); + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.File( + Path.Combine(logRoot, "ui-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7, + shared: true) + .CreateLogger(); + + var version = Assembly.GetEntryAssembly()? + .GetCustomAttribute()?.InformationalVersion + ?? "0.0.0"; + Log.Information("ClaudeDo.App starting (version: {Version})", version); + + // Last-resort net: any exception that misses every other handler still gets one + // Fatal line in the log before the process dies. + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (IsTerminating: {IsTerminating})", e.IsTerminating); + Log.CloseAndFlush(); + }; + TaskScheduler.UnobservedTaskException += (_, e) => + { + Log.Error(e.Exception, "Unobserved task exception"); + e.SetObserved(); + }; + var services = BuildServices(); using (var scope = services.CreateScope()) @@ -62,6 +93,8 @@ sealed class Program { /* best effort on shutdown */ } + + Log.CloseAndFlush(); } } diff --git a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj index 4b4dc211..9de87d15 100644 --- a/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj +++ b/src/ClaudeDo.Ui/ClaudeDo.Ui.csproj @@ -17,6 +17,7 @@ + diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 7943e390..51e36120 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -304,7 +304,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC ok = true; return result; } - catch { return default; } + catch (Exception ex) + { + Serilog.Log.Warning(ex, "Hub call {Method} failed", method); + return default; + } finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } } @@ -318,6 +322,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await invoke(); ok = true; } + catch (Exception ex) + { + Serilog.Log.Warning(ex, "Hub call {Method} failed", method); + throw; + } finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } } @@ -332,6 +341,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC ok = true; return result; } + catch (Exception ex) + { + Serilog.Log.Warning(ex, "Hub call {Method} failed", method); + throw; + } finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } } diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 8ee08b02..6a4c9ac4 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -208,6 +208,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable private void FlashFooter(string message, WorkerLogLevel level) { + if (level == WorkerLogLevel.Error) + Serilog.Log.Warning("Footer error: {Message}", message); + WorkerLogText = $"{DateTime.Now:HH:mm} ยท {message}"; WorkerLogLevel = level; IsWorkerLogVisible = true; diff --git a/src/ClaudeDo.Worker/Hub/HubExceptionLoggingFilter.cs b/src/ClaudeDo.Worker/Hub/HubExceptionLoggingFilter.cs new file mode 100644 index 00000000..0c70ddc8 --- /dev/null +++ b/src/ClaudeDo.Worker/Hub/HubExceptionLoggingFilter.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace ClaudeDo.Worker.Hub; + +/// +/// Logs a Warning for any exception thrown out of a hub method body, so a failed UI action +/// leaves a trace in worker-.log instead of only a generic error at the caller. +/// Does NOT see an argument-binding failure (e.g. a stray CancellationToken parameter) โ€” SignalR +/// rejects those while parsing the invocation message, before a hub method is ever dispatched. +/// +public sealed class HubExceptionLoggingFilter(ILogger logger) : IHubFilter +{ + public async ValueTask InvokeMethodAsync( + HubInvocationContext invocationContext, Func> next) + { + try + { + return await next(invocationContext); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Hub method {Method} failed", invocationContext.HubMethodName); + throw; + } + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 638dda93..cef3743f 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -28,6 +28,7 @@ using ClaudeDo.Worker.Usage.Interfaces; using ClaudeDo.Worker.Usage.TokenTracker; using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; using ClaudeDo.Worker.Worktrees; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Serilog; @@ -58,6 +59,11 @@ builder.Host.UseSerilog((ctx, lc) => lc .MinimumLevel.Information() .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning) + // SignalR logs an argument-binding failure (e.g. a stray CancellationToken parameter, see + // fix 352aaa52) at Debug on this specific source context โ€” HubExceptionLoggingFilter can't + // see it either, since SignalR rejects the invocation while parsing the message, before any + // hub method (and its filter pipeline) is dispatched. + .MinimumLevel.Override("Microsoft.AspNetCore.SignalR.Internal.DefaultHubDispatcher", Serilog.Events.LogEventLevel.Debug) .WriteTo.File( System.IO.Path.Combine(logRoot, "worker-.log"), rollingInterval: RollingInterval.Day, @@ -90,7 +96,11 @@ builder.Services.AddHostedService(); // Default MaximumParallelInvocationsPerClient=1 serializes every hub call on one connection โ€” // a slow one (e.g. GetTicketProjects hitting an unreachable ticket system) blocks unrelated // calls like UpdateList/UpdateListConfig behind it for the same 10s. -builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClient = 10) +builder.Services.AddSignalR(options => + { + options.MaximumParallelInvocationsPerClient = 10; + options.AddFilter(); + }) .AddJsonProtocol(options => { options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());