Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
142 changes: 142 additions & 0 deletions countlyCommon/TestingRelated/ModuleLogListenerTests.cs
Original file line number Diff line number Diff line change
@@ -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]
/// <summary>Listener set, console flag OFF: receives the raw message + level (no [LEVEL] prefix).</summary>
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]
/// <summary>Every level is delivered with its exact enum value.</summary>
public void Listener_ReceivesEveryLevel()
{
var seen = new List<LogLevel>();
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]
/// <summary>Listener fires even when the console flag is ON (console path is additive).</summary>
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]
/// <summary>A throwing listener is swallowed and does not break subsequent logging.</summary>
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]
/// <summary>SetLogListener chains fluently and stores the delegate.</summary>
public void ConfigApi_SetLogListener_ChainsAndStores()
{
CountlyConfig cc = TestHelper.GetConfig();
Action<string, LogLevel> cb = (m, l) => { };

CountlyConfig returned = (CountlyConfig)cc.SetLogListener(cb);

Assert.Same(cc, returned);
Assert.Same(cb, cc.LogListener);
}

// ---- Task 3: init/halt lifecycle ----

[Fact]
/// <summary>Init wires the config listener; it receives SDK logs emitted during init.</summary>
public void Init_WiresListenerFromConfig()
{
MockHttpServer server = new MockHttpServer((body) => "{\"result\":\"Success\"}");
var messages = new List<string>();

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]
/// <summary>Halt clears the listener hook; later logs no longer reach the callback.</summary>
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);
}
}
}
2 changes: 2 additions & 0 deletions countlyCommon/countlyCommon/CountlyBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@
ModuleBackendMode backendMode = moduleBackendMode;
if (backendMode != null) {
backendMode.OnTimer();
Upload();

Check warning on line 235 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
}
return;
}
Expand Down Expand Up @@ -1300,6 +1300,7 @@
moduleServerConfig = null;
if (moduleHealthCheck != null) { moduleHealthCheck.UnregisterHooks(); }
moduleHealthCheck = null;
UtilityHelper.LogListenerHook = null;
}
if (clearStorage) {
await ClearStorage();
Expand Down Expand Up @@ -1542,7 +1543,7 @@
}
if (keptRequests.Count != originalCount) {
StoredRequests = keptRequests;
SaveStoredRequests();

Check warning on line 1546 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
}
}
}
Expand Down Expand Up @@ -1585,7 +1586,7 @@

StoredRequests.Enqueue(sr);
if (!Configuration.backendMode) {
SaveStoredRequests();

Check warning on line 1589 in countlyCommon/countlyCommon/CountlyBase.cs

View workflow job for this annotation

GitHub Actions / Feedback + UI unit tests (net8.0-windows)

Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
} else {
UtilityHelper.CountlyLogging("[CountlyBase] AddRequest, Backend mode enabled, request storage disabled");
}
Expand All @@ -1596,6 +1597,7 @@

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!");
Expand Down
18 changes: 18 additions & 0 deletions countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ public int ContentZoneTimerInterval {
/// <summary>Optional callback invoked when a shown content item is closed. (Experimental.)</summary>
public System.Action GlobalContentCallback { get; set; }

/// <summary>
/// Optional listener invoked for every SDK log message, independent of the console
/// logging flag (Countly.IsLoggingEnabled). Receives the log message and its level.
/// </summary>
public System.Action<string, LogLevel> LogListener { get; set; }

// <summary>
/// Maximum size of all string keys
/// </summary>
Expand Down Expand Up @@ -342,5 +348,17 @@ public CountlyConfigBase DisableHealthCheck()
healthCheckDisabled = true;
return this;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="logListener">Callback receiving (message, level).</param>
/// <returns>Config for call chaining</returns>
public CountlyConfigBase SetLogListener(System.Action<string, LogLevel> logListener)
{
LogListener = logListener;
return this;
}
}
}
21 changes: 21 additions & 0 deletions countlyCommon/countlyCommon/Helpers/UtilityHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogLevel> 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<string, LogLevel> 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<string, LogLevel> 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);

Expand Down
3 changes: 3 additions & 0 deletions netstd/CountlyTest_461/CountlyTest_461.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@
<Compile Include="..\..\countlyCommon\TestingRelated\ModuleHealthCheckTests.cs">
<Link>ModuleHealthCheckTests.cs</Link>
</Compile>
<Compile Include="..\..\countlyCommon\TestingRelated\ModuleLogListenerTests.cs">
<Link>ModuleLogListenerTests.cs</Link>
</Compile>
<Compile Include="..\..\countlyCommon\TestingRelated\RequestTestCases.cs">
<Link>RequestTestCases.cs</Link>
</Compile>
Expand Down
Loading