diff --git a/Appegy.UniLogger.Lab/Assets/Scripts/ULoggerInitializer.cs b/Appegy.UniLogger.Lab/Assets/Scripts/ULoggerInitializer.cs index 4814b83..c8cc335 100644 --- a/Appegy.UniLogger.Lab/Assets/Scripts/ULoggerInitializer.cs +++ b/Appegy.UniLogger.Lab/Assets/Scripts/ULoggerInitializer.cs @@ -8,7 +8,6 @@ public static class ULoggerInitializer [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void AutoConfigureLogger() { - // Disable stacktrace for logs and warnings in build if (!Application.isEditor) { Application.SetStackTraceLogType(LogType.Log, StackTraceLogType.None); @@ -17,43 +16,24 @@ private static void AutoConfigureLogger() Application.SetStackTraceLogType(LogType.Assert, StackTraceLogType.ScriptOnly); } - // Initialize ULogger and the unity console target - InitializeUnityTarget(); - - // Mirror logs into a rolling file - InitializeFileTarget(); - } - - private static void InitializeUnityTarget() - { - // Customize formatter for logs unity target - var formatter = Application.isEditor - ? new Formatter(FormatOptions.RichText | FormatOptions.Tags) - : new Formatter(FormatOptions.Tags | FormatOptions.LogType); - - // Prepare filterer for unity target (by default all logs are allowed) - var filterer = new Filterer(true); - - // Now you can disable logs in filterer by log's level or tag - // For example - // Disable all Trace logs: - // filterer.Disable(LogLevel.Trace); - // Disable all logs with tag Unsorted - // filterer.Mute("Unsorted"); - - // When formatter and filterer are ready - initialize logger and add the unity console target - ULogger.Initialize(); - ULogger.AddTarget(new UnityTarget(formatter, filterer)); + ULogger.Configure(c => c + .WriteTo.Unity(t => t + .Format(Application.isEditor + ? FormatOptions.RichText | FormatOptions.Tags + : FormatOptions.Tags | FormatOptions.LogType) + .Filter(Filterer.AllowAllTags())) + .WriteTo.File(t => t + .Path(LogFilePath()) + .Format(FormatOptions.Time | FormatOptions.Tags | FormatOptions.LogType) + .RollEvery(1024 * 1024) + .Keep(5))); } - private static void InitializeFileTarget() + private static string LogFilePath() { - // In the editor keep logs next to the Assets folder; in a build use the persistent data path - var path = Application.isEditor + return Application.isEditor ? Path.Combine(Application.dataPath, "..", "Logs", "game.log") : Path.Combine(Application.persistentDataPath, "Logs", "game.log"); - var formatter = new Formatter(FormatOptions.Time | FormatOptions.Tags | FormatOptions.LogType); - ULogger.AddTarget(new FileTarget(path, fileSizeLimitBytes: 1024 * 1024, retainedFileCountLimit: 5, formatter: formatter)); } } } diff --git a/src/Runtime/LoggerConfiguration.cs b/src/Runtime/LoggerConfiguration.cs new file mode 100644 index 0000000..b56e18f --- /dev/null +++ b/src/Runtime/LoggerConfiguration.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace Appegy.UniLogger +{ + public sealed class LoggerConfiguration + { + private readonly List _targets = new(); + + public LoggerConfiguration() + { + WriteTo = new LoggerSinkConfiguration(this); + } + + public LoggerSinkConfiguration WriteTo { get; } + + internal IReadOnlyList BuildTargets() => _targets; + + internal LoggerConfiguration Add(Target target) + { + _targets.Add(target); + return this; + } + } + + public sealed class LoggerSinkConfiguration + { + private readonly LoggerConfiguration _configuration; + + internal LoggerSinkConfiguration(LoggerConfiguration configuration) + { + _configuration = configuration; + } + + public LoggerConfiguration Unity([CanBeNull] Action configure = null) + { + var builder = new UnityTargetBuilder(); + configure?.Invoke(builder); + return _configuration.Add(builder.Build()); + } + + public LoggerConfiguration File([NotNull] Action configure) + { + if (configure == null) throw new ArgumentNullException(nameof(configure)); + var builder = new FileTargetBuilder(); + configure.Invoke(builder); + return _configuration.Add(builder.Build()); + } + + public LoggerConfiguration Memory([NotNull] Action configure) + { + if (configure == null) throw new ArgumentNullException(nameof(configure)); + var builder = new MemoryTargetBuilder(); + configure.Invoke(builder); + return _configuration.Add(builder.Build()); + } + + public LoggerConfiguration Target([NotNull] Target target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + return _configuration.Add(target); + } + } +} diff --git a/src/Runtime/LoggerConfiguration.cs.meta b/src/Runtime/LoggerConfiguration.cs.meta new file mode 100644 index 0000000..6cc74af --- /dev/null +++ b/src/Runtime/LoggerConfiguration.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e8c5ccb25eb69394e91c97feb90d822e \ No newline at end of file diff --git a/src/Runtime/Targets/File/FileTargetBuilder.cs b/src/Runtime/Targets/File/FileTargetBuilder.cs new file mode 100644 index 0000000..aae818f --- /dev/null +++ b/src/Runtime/Targets/File/FileTargetBuilder.cs @@ -0,0 +1,62 @@ +using System; +using JetBrains.Annotations; + +namespace Appegy.UniLogger +{ + public sealed class FileTargetBuilder + { + private string _path; + private FormatOptions _format = FormatOptions.None; + private Filterer _filterer; + private long _rollEveryBytes; + private int _retainedFileCount; + private bool _autoFlush; + + public FileTargetBuilder Path([NotNull] string path) + { + _path = path; + return this; + } + + public FileTargetBuilder Format(FormatOptions options) + { + _format = options; + return this; + } + + public FileTargetBuilder Filter([CanBeNull] Filterer filterer) + { + _filterer = filterer; + return this; + } + + public FileTargetBuilder RollEvery(long bytes) + { + _rollEveryBytes = bytes; + return this; + } + + public FileTargetBuilder Keep(int count) + { + _retainedFileCount = count; + return this; + } + + public FileTargetBuilder AutoFlush(bool enabled = true) + { + _autoFlush = enabled; + return this; + } + + internal FileTarget Build() + { + if (string.IsNullOrEmpty(_path)) + { + throw new InvalidOperationException("File target requires a path. Use .Path(...)."); + } + var formatter = new Formatter(_format); + var filterer = _filterer ?? Filterer.AllowAllTags(); + return new FileTarget(_path, _rollEveryBytes, _retainedFileCount, _autoFlush, formatter, filterer); + } + } +} diff --git a/src/Runtime/Targets/File/FileTargetBuilder.cs.meta b/src/Runtime/Targets/File/FileTargetBuilder.cs.meta new file mode 100644 index 0000000..0a6e619 --- /dev/null +++ b/src/Runtime/Targets/File/FileTargetBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c78c94ba3b8fda929abbbf805ee13fca \ No newline at end of file diff --git a/src/Runtime/Targets/Filterer.cs b/src/Runtime/Targets/Filterer.cs index 91f6303..0168c2c 100644 --- a/src/Runtime/Targets/Filterer.cs +++ b/src/Runtime/Targets/Filterer.cs @@ -31,6 +31,10 @@ public Filterer(bool allTagsEnabledByDefault) _snapshot = new Snapshot(logTypeEnabled, new HashSet()); } + public static Filterer AllowAllTags() => new(true); + + public static Filterer BlockAllTags() => new(false); + /// /// Returns true when and are not filtered and allowed to show /// diff --git a/src/Runtime/Targets/Memory/MemoryTargetBuilder.cs b/src/Runtime/Targets/Memory/MemoryTargetBuilder.cs new file mode 100644 index 0000000..1a95a40 --- /dev/null +++ b/src/Runtime/Targets/Memory/MemoryTargetBuilder.cs @@ -0,0 +1,41 @@ +using System; +using JetBrains.Annotations; + +namespace Appegy.UniLogger +{ + public sealed class MemoryTargetBuilder + { + private int _capacity; + private FormatOptions _format = FormatOptions.None; + private Filterer _filterer; + + public MemoryTargetBuilder Capacity(int capacity) + { + _capacity = capacity; + return this; + } + + public MemoryTargetBuilder Format(FormatOptions options) + { + _format = options; + return this; + } + + public MemoryTargetBuilder Filter([CanBeNull] Filterer filterer) + { + _filterer = filterer; + return this; + } + + internal InMemoryTarget Build() + { + if (_capacity <= 0) + { + throw new InvalidOperationException("Memory target requires a positive capacity. Use .Capacity(...)."); + } + var formatter = new Formatter(_format); + var filterer = _filterer ?? Filterer.AllowAllTags(); + return new InMemoryTarget(_capacity, formatter, filterer); + } + } +} diff --git a/src/Runtime/Targets/Memory/MemoryTargetBuilder.cs.meta b/src/Runtime/Targets/Memory/MemoryTargetBuilder.cs.meta new file mode 100644 index 0000000..8e71d30 --- /dev/null +++ b/src/Runtime/Targets/Memory/MemoryTargetBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f292c194fd8223f03aaf7cad823d7fee \ No newline at end of file diff --git a/src/Runtime/Targets/Unity/UnityTargetBuilder.cs b/src/Runtime/Targets/Unity/UnityTargetBuilder.cs new file mode 100644 index 0000000..53b1645 --- /dev/null +++ b/src/Runtime/Targets/Unity/UnityTargetBuilder.cs @@ -0,0 +1,29 @@ +using JetBrains.Annotations; + +namespace Appegy.UniLogger +{ + public sealed class UnityTargetBuilder + { + private FormatOptions _format = FormatOptions.None; + private Filterer _filterer; + + public UnityTargetBuilder Format(FormatOptions options) + { + _format = options; + return this; + } + + public UnityTargetBuilder Filter([CanBeNull] Filterer filterer) + { + _filterer = filterer; + return this; + } + + internal UnityTarget Build() + { + var formatter = new Formatter(_format); + var filterer = _filterer ?? Filterer.AllowAllTags(); + return new UnityTarget(formatter, filterer); + } + } +} diff --git a/src/Runtime/Targets/Unity/UnityTargetBuilder.cs.meta b/src/Runtime/Targets/Unity/UnityTargetBuilder.cs.meta new file mode 100644 index 0000000..1eab8c5 --- /dev/null +++ b/src/Runtime/Targets/Unity/UnityTargetBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88aca06464660e8db9d871d3207792d6 \ No newline at end of file diff --git a/src/Runtime/Types/LoggerConfig.cs b/src/Runtime/Types/LoggerConfig.cs deleted file mode 100644 index 630df69..0000000 --- a/src/Runtime/Types/LoggerConfig.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using UnityEngine; - -namespace Appegy.UniLogger -{ - public class LoggerConfig - { - private const string KeyPrefix = nameof(ULogger) + ".Tags."; - private readonly ConcurrentDictionary<(string, LogLevel), bool> _loggingLevelCache = new(); - - internal List Targets { get; } = new(); - - public Formatter UnityFormatter { get; internal set; } - - public LoggerConfig AddTarget(Target target) - { - Targets.Add(target); - return this; - } - - public bool IsTagEnabled(TLoggerType tag, LogLevel type) - where TLoggerType : struct, Enum - { - return IsTagEnabled(tag.GetTag(), type); - } - - public bool IsTagEnabled(Type tag, LogLevel type) - { - return IsTagEnabled(tag.GetTag(), type); - } - - public bool IsTagEnabled(string tag, LogLevel type) - { - var key = (tag, type); - if (!_loggingLevelCache.TryGetValue(key, out var supported)) - { - supported = ReadFromPrefs(tag, type); - _loggingLevelCache[key] = supported; - } - return supported; - } - - public void SetTagEnabled(TLoggerType tag, LogLevel type, bool enabled) - where TLoggerType : struct, Enum - { - SetTagEnabled(tag.GetTag(), type, enabled); - } - - public void SetTagEnabled(Type tag, LogLevel type, bool enabled) - { - SetTagEnabled(tag.GetTag(), type, enabled); - } - - public void SetTagEnabled(string tag, LogLevel type, bool enabled) - { - var key = (tag, level: type); - if (_loggingLevelCache.TryGetValue(key, out var current) && current == enabled) - { - return; - } - _loggingLevelCache[key] = enabled; - WriteToPrefs(tag, type, enabled); - } - - private static bool ReadFromPrefs(string tag, LogLevel type) - { - return PlayerPrefs.GetInt(KeyPrefix + tag + "." + type, 1) == 1; - } - - private static void WriteToPrefs(string tag, LogLevel type, bool enabled) - { - PlayerPrefs.SetInt(KeyPrefix + tag + "." + type, enabled ? 1 : 0); - } - } -} diff --git a/src/Runtime/Types/LoggerConfig.cs.meta b/src/Runtime/Types/LoggerConfig.cs.meta deleted file mode 100644 index 4bb038a..0000000 --- a/src/Runtime/Types/LoggerConfig.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: b6358e5f2c224d140b8f06f05f803284 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/src/Runtime/ULogger.Lifecycle.cs b/src/Runtime/ULogger.Lifecycle.cs index ed7e29c..f4b5439 100644 --- a/src/Runtime/ULogger.Lifecycle.cs +++ b/src/Runtime/ULogger.Lifecycle.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using UnityEngine; @@ -8,17 +9,24 @@ public partial class ULogger { public static void Initialize() { - ThreadDispatcher.EnsureMainThread(nameof(Initialize)); + Configure(_ => { }); + } + + public static void Configure(Action configure) + { + if (configure == null) throw new ArgumentNullException(nameof(configure)); + ThreadDispatcher.EnsureMainThread(nameof(Configure)); + + var data = BuildData(configure); + if (Data != null) { Terminate(); } - Data = new ULoggerData(); - Data.Dispatcher = new LogDispatcher(Data); - OriginalHandler = Debug.unityLogger.logHandler; ManagedStackTraceConverter.SetProjectRoot(Application.dataPath); + Data = data; Debug.unityLogger.logHandler = new UnityLogger(); Application.logMessageReceivedThreaded += OnNativeLogReceived; @@ -26,6 +34,41 @@ public static void Initialize() Application.quitting += Terminate; } + private static ULoggerData BuildData(Action configure) + { + var configuration = new LoggerConfiguration(); + var data = new ULoggerData(); + try + { + configure(configuration); + foreach (var target in configuration.BuildTargets()) + { + if (!data.AddTarget(target)) + { + throw new InvalidOperationException($"A target of type '{target.GetType().Name}' is already registered."); + } + } + } + catch + { + DisposeTargets(configuration.BuildTargets()); + throw; + } + data.Dispatcher = new LogDispatcher(data); + return data; + } + + private static void DisposeTargets(IReadOnlyList targets) + { + foreach (var target in targets) + { + if (target is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + public static void Terminate() { ThreadDispatcher.EnsureMainThread(nameof(Terminate)); @@ -39,13 +82,7 @@ public static void Terminate() Application.quitting -= Terminate; Data.Dispatcher.Dispose(); - foreach (var target in Data.Targets) - { - if (target is IDisposable disposable) - { - disposable.Dispose(); - } - } + DisposeTargets(Data.Targets); Data = null; } diff --git a/src/Tests/FiltererFactoryTests.cs b/src/Tests/FiltererFactoryTests.cs new file mode 100644 index 0000000..9bb5ef5 --- /dev/null +++ b/src/Tests/FiltererFactoryTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace Appegy.UniLogger +{ + public class FiltererFactoryTests + { + [Test] + public void WhenAllowAllTags_ThanTagIsAllowedByDefault() + { + var filterer = Filterer.AllowAllTags(); + + filterer.IsAllowed(LogLevel.Trace, "Tag").Should().Be(true); + } + + [Test] + public void WhenBlockAllTags_ThanTagIsNotAllowedByDefault() + { + var filterer = Filterer.BlockAllTags(); + + filterer.IsAllowed(LogLevel.Trace, "Tag").Should().Be(false); + } + + [Test] + public void WhenAllowAllTags_AndTagMuted_ThanTagIsNotAllowed() + { + var filterer = Filterer.AllowAllTags().Mute("Tag"); + + filterer.IsAllowed(LogLevel.Trace, "Tag").Should().Be(false); + } + + [Test] + public void WhenAllowAllTags_AndLevelDisabled_ThanLevelIsNotAllowed() + { + var filterer = Filterer.AllowAllTags().Disable(LogLevel.Trace); + + filterer.IsAllowed(LogLevel.Trace, "Tag").Should().Be(false); + filterer.IsAllowed(LogLevel.Log, "Tag").Should().Be(true); + } + + [Test] + public void WhenBlockAllTags_AndTagAllowed_ThanTagIsAllowed() + { + var filterer = Filterer.BlockAllTags().Allow("Tag"); + + filterer.IsAllowed(LogLevel.Trace, "Tag").Should().Be(true); + } + } +} diff --git a/src/Tests/FiltererFactoryTests.cs.meta b/src/Tests/FiltererFactoryTests.cs.meta new file mode 100644 index 0000000..319f1f0 --- /dev/null +++ b/src/Tests/FiltererFactoryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f1387b638e5bf9c3dbf0e57b3d65b0d1 \ No newline at end of file diff --git a/src/Tests/LoggerConfigurationTests.cs b/src/Tests/LoggerConfigurationTests.cs new file mode 100644 index 0000000..911de5f --- /dev/null +++ b/src/Tests/LoggerConfigurationTests.cs @@ -0,0 +1,137 @@ +using System; +using FluentAssertions; +using NUnit.Framework; + +namespace Appegy.UniLogger +{ + public class LoggerConfigurationTests + { + private sealed class SyncRecordingTarget : Target + { + public string LastMessage; + + public override bool RunSynchronously => true; + + protected internal override void Log(in LogEntry entry, string stackTrace) + { + LastMessage = entry.String; + } + + protected internal override void LogException(Exception exception, in LogEntry entry) + { + } + } + + [Test] + public void WhenConfigured_ThanTargetsAreBuiltInOrderWithCorrectTypes() + { + var configuration = new LoggerConfiguration(); + configuration + .WriteTo.Unity() + .WriteTo.File(t => t.Path("Temp/logger-config-test.log")) + .WriteTo.Memory(t => t.Capacity(1024)); + + var targets = configuration.BuildTargets(); + + targets.Should().HaveCount(3); + targets[0].Should().BeOfType(); + targets[1].Should().BeOfType(); + targets[2].Should().BeOfType(); + + (targets[1] as IDisposable)?.Dispose(); + } + + [Test] + public void WhenTargetAddedViaEscapeHatch_ThanItIsBuilt() + { + var custom = new SyncRecordingTarget(); + var configuration = new LoggerConfiguration(); + configuration.WriteTo.Target(custom); + + configuration.BuildTargets().Should().ContainSingle().Which.Should().BeSameAs(custom); + } + + [Test] + public void WhenFileTargetHasNoPath_ThanBuildThrows() + { + var configuration = new LoggerConfiguration(); + + Action build = () => configuration.WriteTo.File(t => t.Format(FormatOptions.Tags)); + + build.Should().Throw(); + } + + [Test] + public void WhenMemoryTargetHasNoCapacity_ThanBuildThrows() + { + var configuration = new LoggerConfiguration(); + + Action build = () => configuration.WriteTo.Memory(t => t.Format(FormatOptions.Tags)); + + build.Should().Throw(); + } + + [Test] + public void WhenConfigured_ThanTargetsAreRegisteredAndReceiveLogs() + { + var target = new SyncRecordingTarget(); + try + { + ULogger.Configure(c => c.WriteTo.Target(target)); + + ULogger.GetTarget().Should().BeSameAs(target); + + ULogger.GetLogger("Cfg").Log("hello"); + + target.LastMessage.Should().Be("hello"); + } + finally + { + ULogger.Terminate(); + } + } + + [Test] + public void WhenTwoTargetsOfSameType_ThanConfigureThrows() + { + Action configure = () => ULogger.Configure(c => c + .WriteTo.Target(new SyncRecordingTarget()) + .WriteTo.Target(new SyncRecordingTarget())); + + try + { + configure.Should().Throw(); + } + finally + { + if (ULogger.GetTarget() != null) + { + ULogger.Terminate(); + } + } + } + + [Test] + public void WhenReconfigureThrows_ThanExistingLoggerIsKept() + { + var working = new SyncRecordingTarget(); + try + { + ULogger.Configure(c => c.WriteTo.Target(working)); + + Action reconfigure = () => ULogger.Configure(c => c + .WriteTo.Memory(t => t.Capacity(0))); + + reconfigure.Should().Throw(); + + ULogger.GetTarget().Should().BeSameAs(working); + ULogger.GetLogger("Cfg").Log("still-here"); + working.LastMessage.Should().Be("still-here"); + } + finally + { + ULogger.Terminate(); + } + } + } +} diff --git a/src/Tests/LoggerConfigurationTests.cs.meta b/src/Tests/LoggerConfigurationTests.cs.meta new file mode 100644 index 0000000..adb7b38 --- /dev/null +++ b/src/Tests/LoggerConfigurationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6118e279ee65fd65faa99b1150be3465 \ No newline at end of file diff --git a/tasks/fluent-logger-configuration.md b/tasks/fluent-logger-configuration.md new file mode 100644 index 0000000..6296e98 --- /dev/null +++ b/tasks/fluent-logger-configuration.md @@ -0,0 +1,118 @@ +# Таска: Fluent `LoggerConfiguration` для инициализации ULogger + +Ветка: `ivan/logger-init-task` (от `origin/main`). Type префикс коммитов — `feat`/`refactor` по конвенции репо. + +## Контекст (зачем) + +Сейчас инициализация — последовательность статических вызовов: + +```csharp +ULogger.Initialize(); +ULogger.AddTarget(new UnityTarget(formatter, filterer)); +ULogger.AddTarget(new FileTarget(path, formatter: fmt)); +``` + +Боли (подтверждены чтением кода): + +1. **Окно потери логов.** `Initialize()` (`src/Runtime/ULogger.Lifecycle.cs:22-25`) сразу подменяет `Debug.unityLogger.logHandler` и вешает `Application.logMessageReceivedThreaded`, но `ULoggerData.Targets` ещё пуст. Лог между `Initialize()` и первым `AddTarget` видит `Data != null` → обходит пустые sync/async-таргеты → **молча теряется** (фоллбэк на `Debug.unityLogger` только при `Data == null`). +2. **`new Filterer(true)` — opaque** (что значит `true`?). +3. **Два способа сказать одно:** `SetAllowed(LogLevel.Trace, false)` ≡ `Disable(LogLevel.Trace)`. +4. **Boilerplate 3-в-1** на каждый таргет. +5. **Нет атомарной реконфигурации** — только `AddTarget` и `Terminate()→Initialize()`. + +Цель: Serilog-style fluent `LoggerConfiguration`, который собирает конфиг целиком и применяет **атомарно** (таргеты в `ULoggerData` до активации хуков). Модель `Filterer` (bool[4] уровней + HashSet тегов + immutable snapshot) и контракт таргетов НЕ меняются — меняется только эргономика конструирования. + +## Целевой публичный API + +```csharp +ULogger.Configure(c => c + .WriteTo.Unity(t => t + .Format(FormatOptions.RichText | FormatOptions.Tags) + .Filter(Filterer.AllowAllTags().Disable(LogLevel.Trace).Mute("Input"))) + .WriteTo.File(t => t + .Path(path) + .Format(FormatOptions.Time | FormatOptions.Tags | FormatOptions.LogType) + .RollEvery(1024 * 1024) + .Keep(5)) + .WriteTo.Memory(t => t.Capacity(64 * 1024)) + .WriteTo.Target(new MyCustomTarget())); // escape-hatch для готовых/кастомных таргетов +``` + +Публичный init-API после рефактора: +- `ULogger.Configure(Action configure)` — основной, атомарный (main-thread only). +- `ULogger.Initialize()` — back-compat алиас = `Configure(_ => {})` (init без таргетов, дальше runtime `AddTarget`). Поведение идентично текущему. +- `ULogger.AddTarget(T)` — без изменений (runtime-добавление таргетов). +- `ULogger.Terminate()` — без изменений. + +Builder-дизайн: `.Format(FormatOptions)` + `.Filter(Filterer)` + специфичные таргету ручки. Опущено `.Filter(...)` → `Filterer.AllowAllTags()` (parity с дефолтом `Target` = `new Filterer(true)`). Опущено `.Format(...)` → `FormatOptions.None`. Фильтр передаётся **экземпляром** через фабрику-цепочку на самом `Filterer` (единый source of truth, без лямбд-замыканий). + +## Дизайн атомарного `Configure` (ключевой момент корректности) + +Порядок операций внутри `Configure` (доказано безопасным через чтение `LogDispatcher.cs:14-23,46-86` и `ULoggerData.cs:17-31`): + +1. `ThreadDispatcher.EnsureMainThread(nameof(Configure))`. +2. `var configuration = new LoggerConfiguration(); configure(configuration);` +3. `if (Data != null) Terminate();` +4. `var data = new ULoggerData();` +5. `foreach (var t in configuration.BuildTargets()) if (!data.AddTarget(t)) throw new InvalidOperationException($"Duplicate target type '{t.GetType().Name}'.");` — `ULoggerData.AddTarget` возвращает `false` на дубль типа (parity с текущим `ULogger.AddTarget`, который кидает). +6. `data.Dispatcher = new LogDispatcher(data);` — writer-тред стартует сразу, но читает `volatile`-массивы таргетов лениво; таргеты уже на месте → безопасно. +7. `OriginalHandler = Debug.unityLogger.logHandler;` +8. `ManagedStackTraceConverter.SetProjectRoot(Application.dataPath);` +9. `Data = data;` — **статический `Data` становится виден хукам только теперь, уже с таргетами.** +10. `Debug.unityLogger.logHandler = new UnityLogger();` +11. `Application.logMessageReceivedThreaded += OnNativeLogReceived;` +12. `TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;` +13. `Application.quitting += Terminate;` + +Почему можно так переупорядочить: `OnNativeLogReceived` (`ULogger.cs:47`) читает статический `Data`, но вызывается только после шага 11. `LogDispatcher` хранит ссылку на `ULoggerData` (не на статический `Data`) и читает живые массивы — наполнять таргеты до/после его ctor одинаково безопасно через `volatile` swap в `ULoggerData.AddTarget`. + +## Файлы + +### Создать +- `src/Runtime/LoggerConfiguration.cs` — `sealed class LoggerConfiguration` (держит `List`, свойство `WriteTo`, internal `IReadOnlyList BuildTargets()`, internal `Add(Target)`) + `sealed class LoggerSinkConfiguration` (методы `Unity/File/Memory/Target`, каждый возвращает `LoggerConfiguration` для чейнинга). `WriteTo` — get-only свойство, инициализируется в ctor (`new LoggerSinkConfiguration(this)`), одна аллокация на конфиг. +- `src/Runtime/Targets/Unity/UnityTargetBuilder.cs` — `Format(FormatOptions)`, `Filter(Filterer)`, internal `Build() → new UnityTarget(new Formatter(_format), _filterer ?? Filterer.AllowAllTags())`. +- `src/Runtime/Targets/File/FileTargetBuilder.cs` — `Path(string)`, `Format`, `Filter`, `RollEvery(long)`, `Keep(int)`, `AutoFlush(bool = true)`, `Build() → new FileTarget(_path, _rollEveryBytes, _retainedFileCount, _autoFlush, formatter, filterer)`. Маппинг ручек → параметры ctor (`FileTarget.cs:27-34`): `RollEvery`→`fileSizeLimitBytes`, `Keep`→`retainedFileCountLimit`, `AutoFlush`→`autoFlush`. В `Build()` кинуть `InvalidOperationException`, если `_path` пустой. +- `src/Runtime/Targets/Memory/MemoryTargetBuilder.cs` — `Capacity(int)`, `Format`, `Filter`, `Build() → new InMemoryTarget(_capacity, formatter, filterer)`. Кинуть `InvalidOperationException`, если `_capacity <= 0`. + +### Изменить +- `src/Runtime/ULogger.Lifecycle.cs` — добавить `public static void Configure(Action)` с порядком выше; упростить `Initialize()` до `Configure(_ => {});` (поведение идентично: хуки с нулём таргетов, дальше `AddTarget`). `Terminate()` не трогать. +- `src/Runtime/Targets/Filterer.cs` — добавить статические фабрики `public static Filterer AllowAllTags() => new(true);` и `public static Filterer BlockAllTags() => new(false);`. **Ctor `Filterer(bool)`, `SetAllowed`, `Enable/Disable/Allow/Mute` оставить публичными** (контракт `FiltererTests` + back-compat; zero-warnings — НЕ вешать `[Obsolete]`). + +### Удалить +- `src/Runtime/Types/LoggerConfig.cs` — `public class LoggerConfig` (PlayerPrefs-тоглы тегов): **0 ссылок** по всему репо, `UnityFormatter`/`AddTarget` никем не зовутся, к `Filterer` не подключён. Мёртвый код — удалить. + +### Мигрировать +- `Appegy.UniLogger.Lab/Assets/Scripts/ULoggerInitializer.cs` (`:45-56`) — заменить `Initialize()` + 2× `AddTarget(new ...)` на один `ULogger.Configure(c => c.WriteTo.Unity(...).WriteTo.File(...))`. Блок `Application.SetStackTraceLogType` (нативный стек Unity, отдельная концепция от `Target.SetStackTraceEnabled`) оставить как есть. + +## Тесты (`src/Tests/`) + +Существующие тесты init-API не трогают (юзают `ULoggerData` напрямую) → остаются зелёными. Добавить: +- `LoggerConfigurationTests.cs`: + - materialization: `Configure` с Unity+File+Memory → `GetTargets()` содержит 3 таргета в порядке регистрации, корректных типов. + - delivery: после `Configure` лог доходит до sync-таргета (переиспользовать pattern `RecordingTarget` из `ExceptionDeliveryTests.cs:9`). + - atomicity/native capture: после `Configure` нативный `Debug.Log` ловится таргетом с `CapturesNativeLogs=true` (pattern из `NativeLogRoutingTests.cs`). + - duplicate-type: два `.WriteTo.Unity(...)` → `InvalidOperationException`. + - escape-hatch: `.WriteTo.Target(new CustomTarget())` регистрирует кастомный таргет. +- `FiltererFactoryTests.cs`: `Filterer.AllowAllTags()` ≡ `new Filterer(true)`, `Filterer.BlockAllTags()` ≡ `new Filterer(false)` (по `IsAllowed`); цепочки `AllowAllTags().Disable(level).Mute(tag)` и `BlockAllTags().Allow(tag)`. + +## Доки (под-таска — делает @ivan в рамках этой задачи) + +Обновить под новый API: +- `README.md` — секции **Quick start**, **Logs filtering** (`new Filterer(true/false)` → фабрики), **Logs formatting** (примеры через `.Format(...)` в builder'е), **Logs targets** (регистрация через `WriteTo`). +- `docs/logger-lifecycle.md`, `docs/formatting-and-filtering.md`, `docs/targets.md` — заменить placeholder'ы контентом под новый API. + +Целевые примеры API — в разделе «Целевой публичный API» выше и в дизайне builder'ов. + +## Out of scope / решения + +- НЕ вешать `[Obsolete]` на ctor `Filterer(bool)` (zero-warnings + back-compat). Фабрики = рекомендуемый путь в доке. +- НЕ вводить flat-chain `.RichText()/.Tags()` на builder'ах — `.Format(FormatOptions.X|Y)` лаконичнее. +- `Target.SetStackTraceEnabled` (0 вызовов) не трогать; в builder его не выносить. +- `ULOGGER_DISABLE_ALL_LOGS` на init не спец-обрабатывать — parity с текущим поведением (`Lifecycle.cs` не имеет `#if`, init компилируется и работает; только emission-методы стрипаются). Проверить, что новый `Configure` тоже не ломается под этим define. + +## Verify + +1. **Unity MCP** (после КАЖДОГО C#-изменения): recompile → дождаться `isCompiling == false` → `read_console` (errors+warnings). Zero-warnings policy — фиксить всё. На API-изменениях проверять весь проект (Lab/`Assembly-Csharp`), не только пакет. +2. `run_tests` (EditMode) через MCP: новые + существующие (`FiltererTests`, `ULoggerDataTests`, `NativeLogRoutingTests`, `ExceptionDeliveryTests`, `FileTargetTests`, `InMemoryTargetTests`) — все зелёные. +3. Play-mode дым в Lab: войти в Play, убедиться что логи `GameManager`/`Debug.Log` идут в Unity-консоль с форматированием и в rolling-файл (`Appegy.UniLogger.Lab/Logs/game.log`), без потери и без дублей. +4. `dotnet build`/Rider для nullable-предупреждений (консоль Unity часть пропускает — см. CLAUDE.md).