diff --git a/CHANGELOG.md b/CHANGELOG.md index 70768e1c..16306af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## XX.XX.XX +* Added support for a Log Listener: a callback set at initialization via the new configuration option "SetLogListener" that receives every SDK log message and its level. It fires independently of the console logging flag, so SDK logs can be captured in release builds without printing to the console. * Added support for SDK Health Checks: once per initialization, right after the SDK Behavior Settings fetch, the SDK sends a non-queued direct request to "/i" reporting internal warning/error log counts and the last failed request's status/body. This can be turned off with the new configuration option "DisableHealthCheck()". * Added support for SDK Behavior Settings (Server Config), enabled by default: * The server can gate "tracking" and "networking", enforce consent ("cr", enable-only), and override request/event queue sizes, session update interval, logging, and the SDK limits (key/value/segmentation/breadcrumb/stack-trace). diff --git a/countlyCommon/TestingRelated/ModuleLogListenerTests.cs b/countlyCommon/TestingRelated/ModuleLogListenerTests.cs new file mode 100644 index 00000000..14ac998d --- /dev/null +++ b/countlyCommon/TestingRelated/ModuleLogListenerTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using CountlySDK; +using CountlySDK.CountlyCommon; +using CountlySDK.Entities; +using CountlySDK.Helpers; +using Xunit; +using static CountlySDK.CountlyCommon.CountlyBase; + +namespace TestProject_common +{ + public class ModuleLogListenerTests : IDisposable + { + public ModuleLogListenerTests() + { + CountlyImpl.SetPCLStorageIfNeeded(); + Countly.Halt(); + TestHelper.CleanDataFiles(); + UtilityHelper.LogListenerHook = null; + Countly.IsLoggingEnabled = false; + } + + public void Dispose() + { + UtilityHelper.LogListenerHook = null; + Countly.IsLoggingEnabled = false; + } + + // ---- Task 1: core hook behavior ---- + + [Fact] + /// Listener set, console flag OFF: receives the raw message + level (no [LEVEL] prefix). + public void Listener_FlagOff_ReceivesRawMessageAndLevel() + { + Countly.IsLoggingEnabled = false; + string gotMsg = null; + LogLevel gotLevel = LogLevel.DEBUG; + UtilityHelper.LogListenerHook = (m, l) => { gotMsg = m; gotLevel = l; }; + + UtilityHelper.CountlyLogging("hello world", LogLevel.INFO); + + Assert.Equal("hello world", gotMsg); // raw, unprefixed + Assert.Equal(LogLevel.INFO, gotLevel); + Assert.DoesNotContain("[INFO]", gotMsg); + } + + [Fact] + /// Every level is delivered with its exact enum value. + public void Listener_ReceivesEveryLevel() + { + var seen = new List(); + UtilityHelper.LogListenerHook = (m, l) => seen.Add(l); + + foreach (LogLevel lvl in new[] { LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARNING, LogLevel.ERROR }) { + UtilityHelper.CountlyLogging("m", lvl); + } + + Assert.Equal(new[] { LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARNING, LogLevel.ERROR }, seen); + } + + [Fact] + /// Listener fires even when the console flag is ON (console path is additive). + public void Listener_FiresWhenFlagOn() + { + Countly.IsLoggingEnabled = true; + int count = 0; + UtilityHelper.LogListenerHook = (m, l) => count++; + + UtilityHelper.CountlyLogging("x", LogLevel.DEBUG); + + Assert.Equal(1, count); + } + + [Fact] + /// A throwing listener is swallowed and does not break subsequent logging. + public void Listener_Throwing_DoesNotBreakLogging() + { + UtilityHelper.LogListenerHook = (m, l) => throw new InvalidOperationException("boom"); + // must not throw: + UtilityHelper.CountlyLogging("first", LogLevel.ERROR); + + string got = null; + UtilityHelper.LogListenerHook = (m, l) => got = m; + UtilityHelper.CountlyLogging("second", LogLevel.DEBUG); + + Assert.Equal("second", got); // SDK logging still works after a bad listener + } + + // ---- Task 2: config surface ---- + + [Fact] + /// SetLogListener chains fluently and stores the delegate. + public void ConfigApi_SetLogListener_ChainsAndStores() + { + CountlyConfig cc = TestHelper.GetConfig(); + Action cb = (m, l) => { }; + + CountlyConfig returned = (CountlyConfig)cc.SetLogListener(cb); + + Assert.Same(cc, returned); + Assert.Same(cb, cc.LogListener); + } + + // ---- Task 3: init/halt lifecycle ---- + + [Fact] + /// Init wires the config listener; it receives SDK logs emitted during init. + public void Init_WiresListenerFromConfig() + { + MockHttpServer server = new MockHttpServer((body) => "{\"result\":\"Success\"}"); + var messages = new List(); + + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + cc.SetLogListener((m, l) => messages.Add(m)); + + Countly.Instance.Init(cc).Wait(); + + Assert.NotEmpty(messages); + Assert.Contains(messages, m => m.Contains("InitBase")); + server.Dispose(); + } + + [Fact] + /// Halt clears the listener hook; later logs no longer reach the callback. + public void Halt_ClearsListener() + { + int count = 0; + UtilityHelper.LogListenerHook = (m, l) => count++; + + Countly.Instance.HaltInternal().Wait(); + + Assert.Null(UtilityHelper.LogListenerHook); + + // Halt itself logs a few messages before it clears the hook, so reset the baseline + // here: the point is that a log emitted AFTER the hook is cleared reaches nobody. + count = 0; + UtilityHelper.CountlyLogging("after halt", LogLevel.DEBUG); + Assert.Equal(0, count); + } + } +} diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs index acc310fb..77ce6e72 100644 --- a/countlyCommon/countlyCommon/CountlyBase.cs +++ b/countlyCommon/countlyCommon/CountlyBase.cs @@ -1300,6 +1300,7 @@ internal async Task HaltInternal(bool clearStorage = true) moduleServerConfig = null; if (moduleHealthCheck != null) { moduleHealthCheck.UnregisterHooks(); } moduleHealthCheck = null; + UtilityHelper.LogListenerHook = null; } if (clearStorage) { await ClearStorage(); @@ -1596,6 +1597,7 @@ internal async Task AddRequest(string networkRequest, bool isIdMerge = false) protected async Task InitBase(CountlyConfig config) { + UtilityHelper.LogListenerHook = config.LogListener; UtilityHelper.CountlyLogging("[CountlyBase] Calling 'InitBase' on SDK flavor: " + sdkName()); if (!IsServerURLCorrect(config.serverUrl)) { UtilityHelper.CountlyLogging("[CountlyBase] InitBase: Invalid server url!"); diff --git a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs index 1312f185..ca9f097d 100644 --- a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs +++ b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs @@ -64,6 +64,12 @@ public int ContentZoneTimerInterval { /// Optional callback invoked when a shown content item is closed. (Experimental.) public System.Action GlobalContentCallback { get; set; } + /// + /// Optional listener invoked for every SDK log message, independent of the console + /// logging flag (Countly.IsLoggingEnabled). Receives the log message and its level. + /// + public System.Action LogListener { get; set; } + // /// Maximum size of all string keys /// @@ -342,5 +348,17 @@ public CountlyConfigBase DisableHealthCheck() healthCheckDisabled = true; return this; } + + /// + /// Sets a listener that receives every SDK log message and its level. Fires regardless + /// of whether console logging is enabled. A throwing listener cannot break the SDK. + /// + /// Callback receiving (message, level). + /// Config for call chaining + public CountlyConfigBase SetLogListener(System.Action logListener) + { + LogListener = logListener; + return this; + } } } diff --git a/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs b/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs index 4c836ecb..a5dc2f49 100644 --- a/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs +++ b/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs @@ -70,12 +70,33 @@ public static String DecodeDataForURL(String data) // Optional hook invoked for WARNING/ERROR logs (health check counters). Independent of IsLoggingEnabled. internal static System.Action InternalLogHook = null; + // Optional listener invoked for EVERY log (all levels), independent of IsLoggingEnabled. + // Receives (raw message, level). Set from config at init; cleared on Halt. + internal static System.Action LogListenerHook = null; + public static void CountlyLogging(String msg, LogLevel level = LogLevel.DEBUG) { if (level == LogLevel.WARNING || level == LogLevel.ERROR) { InternalLogHook?.Invoke(level); } + // Log listener: fires for every log, independent of the console flag. + // Snapshot to a local so a concurrent Halt (which nulls the hook) can't cause a NRE + // between the null-check and the invoke. + System.Action listener = LogListenerHook; + if (listener != null) { + try { + listener.Invoke(msg, level); + } catch (Exception ex) { + // A faulty listener must never break the SDK. Only surface the failure when + // console logging is on, so a broken listener stays silent in the release + // scenario this feature exists for (flag off => no console output). + if (Countly.IsLoggingEnabled) { + System.Diagnostics.Debug.WriteLine("[UtilityHelper] CountlyLogging: log listener threw: " + ex); + } + } + } + if (Countly.IsLoggingEnabled) { StringBuilder fullMessage = new StringBuilder(msg.Length + 10); diff --git a/netstd/CountlyTest_461/CountlyTest_461.csproj b/netstd/CountlyTest_461/CountlyTest_461.csproj index cbf51469..772be4a6 100644 --- a/netstd/CountlyTest_461/CountlyTest_461.csproj +++ b/netstd/CountlyTest_461/CountlyTest_461.csproj @@ -103,6 +103,9 @@ ModuleHealthCheckTests.cs + + ModuleLogListenerTests.cs + RequestTestCases.cs