From c6e78d77b240d9e16421a13a64b3cdf7ba336307 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:33:39 +0900 Subject: [PATCH] Healthchecks --- CHANGELOG.md | 1 + .../TestingRelated/ModuleHealthCheckTests.cs | 354 ++++++++++++++++++ countlyCommon/TestingRelated/TestHelper.cs | 10 +- countlyCommon/countlyCommon/CountlyBase.cs | 20 + .../Entities/CountlyConfigBase.cs | 17 + .../countlyCommon/Helpers/UtilityHelper.cs | 7 + .../Modules/ModuleHealthCheck.cs | 191 ++++++++++ countlyCommon/countlyCommon/Server/ApiBase.cs | 7 + netstd/CountlyTest_461/CountlyTest_461.csproj | 3 + 9 files changed, 606 insertions(+), 4 deletions(-) create mode 100644 countlyCommon/TestingRelated/ModuleHealthCheckTests.cs create mode 100644 countlyCommon/countlyCommon/Modules/ModuleHealthCheck.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e31037f2..70768e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## XX.XX.XX +* 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). * The server can also gate individual features: sessions ("st"), views ("vt"), custom events ("cet"), crash reporting ("crt"), location ("lt"), and content ("ecz" to enter the content zone automatically, "rcz" to allow refreshing it, "czi" to override the content zone poll interval). diff --git a/countlyCommon/TestingRelated/ModuleHealthCheckTests.cs b/countlyCommon/TestingRelated/ModuleHealthCheckTests.cs new file mode 100644 index 00000000..d4d614d9 --- /dev/null +++ b/countlyCommon/TestingRelated/ModuleHealthCheckTests.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CountlySDK; +using CountlySDK.CountlyCommon; +using CountlySDK.CountlyCommon.Entities; +using CountlySDK.CountlyCommon.Server; +using CountlySDK.Entities; +using CountlySDK.Helpers; +using Newtonsoft.Json.Linq; +using Xunit; +using static CountlySDK.CountlyCommon.CountlyBase; + +namespace TestProject_common +{ + public class ModuleHealthCheckTests : IDisposable + { + public ModuleHealthCheckTests() + { + CountlyImpl.SetPCLStorageIfNeeded(); + Countly.Halt(); + TestHelper.CleanDataFiles(); + } + + public void Dispose() { } + + [Fact] + /// DisableHealthCheck chains fluently and sets the flag. + public void ConfigApi_DisableHealthCheck_ChainsAndStores() + { + CountlyConfig cc = TestHelper.GetConfig(); + CountlyConfig returned = (CountlyConfig)cc.DisableHealthCheck(); + + Assert.Same(cc, returned); + Assert.True(cc.healthCheckDisabled); + } + + [Fact] + /// Warning/error counters accumulate. + public void Counters_Accumulate() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.LogWarning(); + m.LogWarning(); + m.LogError(); + + Assert.Equal(2, m.WarningCount); + Assert.Equal(1, m.ErrorCount); + } + + [Fact] + /// Status code is guarded to (0,1000); the error message always overwrites. + public void FailedRequest_RecordsAndGuardsStatus() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.LogFailedNetworkRequest(503, "A"); + Assert.Equal(503, m.LastStatusCode); + Assert.Equal("A", m.LastErrorMessage); + + m.LogFailedNetworkRequest(0, "B"); // 0 rejected for sc, em overwrites + m.LogFailedNetworkRequest(1000, "C"); // 1000 rejected for sc, em overwrites + Assert.Equal(503, m.LastStatusCode); + Assert.Equal("C", m.LastErrorMessage); + } + + [Fact] + /// Error message is truncated to 1000 chars. + public void FailedRequest_TruncatesMessage() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.LogFailedNetworkRequest(500, new string('x', 1500)); + Assert.Equal(1000, m.LastErrorMessage.Length); + } + + [Fact] + /// SaveState then reload restores all counters. + public void SaveAndLoad_RoundTrip() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.LogError(); + m.LogWarning(); + m.LogFailedNetworkRequest(500, "err"); + m.SaveState(); + + ModuleHealthCheck m2 = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + Assert.Equal(1, m2.ErrorCount); + Assert.Equal(1, m2.WarningCount); + Assert.Equal(500, m2.LastStatusCode); + Assert.Equal("err", m2.LastErrorMessage); + } + + [Fact] + /// No stored file → zero defaults (status -1, message empty). + public void Load_Missing_ZeroDefaults() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + Assert.Equal(0, m.ErrorCount); + Assert.Equal(0, m.WarningCount); + Assert.Equal(-1, m.LastStatusCode); + Assert.Equal("", m.LastErrorMessage); + } + + [Fact] + /// Malformed stored JSON → reset to zero defaults and storage cleared. + public void Load_Malformed_ResetsAndClears() + { + Storage.Instance.SaveToFile( + ModuleHealthCheck.healthCheckFilename, new HealthCheckEntity { Json = "not-json" }).Wait(); + + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + Assert.Equal(0, m.ErrorCount); + + // storage was wiped, so a second load also yields zero defaults + ModuleHealthCheck m2 = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + Assert.Equal(0, m2.ErrorCount); + Assert.Equal(-1, m2.LastStatusCode); + } + + [Fact] + /// ClearAndSave zeroes counters and wipes storage. + public void ClearAndSave_Resets() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.LogError(); + m.ClearAndSave(); + Assert.Equal(0, m.ErrorCount); + + ModuleHealthCheck m2 = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + Assert.Equal(0, m2.ErrorCount); + } + + [Fact] + /// Registered hook counts WARNING/ERROR logs but not DEBUG. + public void LogHook_CountsWarningsAndErrors() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.RegisterHooks(); + try { + UtilityHelper.CountlyLogging("w", LogLevel.WARNING); + UtilityHelper.CountlyLogging("e", LogLevel.ERROR); + UtilityHelper.CountlyLogging("d", LogLevel.DEBUG); + + Assert.Equal(1, m.WarningCount); + Assert.Equal(1, m.ErrorCount); + } finally { + m.UnregisterHooks(); + } + } + + [Fact] + /// Counting is independent of the console-logging flag. + public void LogHook_CountsWhenLoggingDisabled() + { + Countly.IsLoggingEnabled = false; + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.RegisterHooks(); + try { + UtilityHelper.CountlyLogging("w", LogLevel.WARNING); + Assert.Equal(1, m.WarningCount); + } finally { + m.UnregisterHooks(); + } + } + + [Fact] + /// ApiBase failed-request hook records status/body; UnregisterHooks clears it. + public void FailedRequestHook_RecordsViaApiBase() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.RegisterHooks(); + try { + ApiBase.FailedRequestHook(504, "Gateway"); + Assert.Equal(504, m.LastStatusCode); + Assert.Equal("Gateway", m.LastErrorMessage); + } finally { + m.UnregisterHooks(); + } + Assert.Null(ApiBase.FailedRequestHook); + } + + [Fact] + /// After UnregisterHooks, further logs are not counted. + public void UnregisterHooks_StopsCounting() + { + ModuleHealthCheck m = new ModuleHealthCheck(null, TestHelper.SERVER_URL, false, TestHelper.APP_VERSION); + m.RegisterHooks(); + m.UnregisterHooks(); + UtilityHelper.CountlyLogging("e", LogLevel.ERROR); + Assert.Equal(0, m.ErrorCount); + } + + private static List Reqs(MockHttpServer s) + { + return s.Requests.ToList(); + } + + private static bool IsHc(MockHttpServer.RequestInfo r) + { + return r.Body != null && r.Body.Contains("hc="); + } + + [Fact] + /// Health check is sent to /i, after the SBS (method=sc) request, off-queue (no rr). + public void HealthCheck_SentToI_AfterSbs_OffQueue() + { + MockHttpServer server = new MockHttpServer((body) => "{\"result\":\"Success\"}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + Countly.Instance.Init(cc).Wait(); + + List reqs = Reqs(server); + int scIdx = reqs.FindIndex(r => r.Body != null && r.Body.Contains("method=sc")); + int hcIdx = reqs.FindIndex(IsHc); + + Assert.True(scIdx >= 0, "SBS request not sent"); + Assert.True(hcIdx >= 0, "health check request not sent"); + Assert.True(hcIdx > scIdx, "health check must be after SBS"); + Assert.Contains("/i", reqs[hcIdx].Path); + Assert.DoesNotContain("rr=", reqs[hcIdx].Body); // direct request has no remaining-request-count + server.Dispose(); + } + + [Fact] + /// Payload carries metrics (only _app_version) and an hc object with all six keys. + public void HealthCheck_Payload_HasMetricsAndAllHcKeys() + { + MockHttpServer server = new MockHttpServer((body) => "{\"result\":\"Success\"}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + Countly.Instance.Init(cc).Wait(); + + MockHttpServer.RequestInfo hc = Reqs(server).First(IsHc); + Dictionary p = TestHelper.GetParams(hc.Body); + + Assert.True(p.ContainsKey("metrics")); + JObject metrics = JObject.Parse(p["metrics"]); + Assert.Equal(TestHelper.APP_VERSION, metrics.Value("_app_version")); + Assert.Equal(1, metrics.Count); + + JObject hcObj = JObject.Parse(p["hc"]); + foreach (string k in new[] { "el", "wl", "sc", "em", "bom", "cbom" }) { + Assert.True(hcObj.ContainsKey(k), "missing hc key: " + k); + } + Assert.Equal(0, hcObj.Value("bom")); + Assert.Equal(0, hcObj.Value("cbom")); + server.Dispose(); + } + + [Fact] + /// DisableHealthCheck suppresses the request. + public void HealthCheck_Disabled_NoRequest() + { + MockHttpServer server = new MockHttpServer((body) => "{\"result\":\"Success\"}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + cc.DisableHealthCheck(); + Countly.Instance.Init(cc).Wait(); + + Assert.DoesNotContain(Reqs(server), IsHc); + server.Dispose(); + } + + [Fact] + /// Backend mode suppresses the request. + public void HealthCheck_BackendMode_NoRequest() + { + MockHttpServer server = new MockHttpServer((body) => "{\"result\":\"Success\"}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + cc.EnableBackendMode(); + Countly.Instance.Init(cc).Wait(); + + Assert.DoesNotContain(Reqs(server), IsHc); + server.Dispose(); + } + + [Fact] + /// Server networking=false suppresses the request. + public void HealthCheck_NetworkingDisabled_NoRequest() + { + MockHttpServer server = new MockHttpServer((body) => + body.Contains("method=sc") ? "{\"v\":1,\"t\":1,\"c\":{\"networking\":false}}" : "{\"result\":\"Success\"}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + Countly.Instance.Init(cc).Wait(); + + Assert.DoesNotContain(Reqs(server), IsHc); + server.Dispose(); + } + + [Fact] + /// On success the accumulated counters are sent, then cleared and storage wiped. + public void HealthCheck_Success_SendsThenClears() + { + Storage.Instance.SaveToFile(ModuleHealthCheck.healthCheckFilename, + new HealthCheckEntity { Json = "{\"LErr\":5,\"LWar\":3,\"RStatC\":503,\"REMsg\":\"x\",\"BReq\":0,\"CBReq\":0}" }).Wait(); + + // Valid SBS envelope so init emits no internal WARNING (which would bump wl). + MockHttpServer server = new MockHttpServer((body) => + body.Contains("method=sc") ? "{\"v\":1,\"t\":1,\"c\":{\"tracking\":true}}" : "{\"result\":\"Success\"}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + Countly.Instance.Init(cc).Wait(); + + JObject hcObj = JObject.Parse(TestHelper.GetParams(Reqs(server).First(IsHc).Body)["hc"]); + Assert.Equal(5, hcObj.Value("el")); + Assert.Equal(3, hcObj.Value("wl")); + Assert.Equal(503, hcObj.Value("sc")); + + Assert.Equal(0, Countly.Instance.moduleHealthCheck.ErrorCount); + Assert.Equal(0, Countly.Instance.moduleHealthCheck.WarningCount); + HealthCheckEntity e = Storage.Instance.LoadFromFile(ModuleHealthCheck.healthCheckFilename).Result; + Assert.True(e == null || string.IsNullOrEmpty(e.Json)); + server.Dispose(); + } + + [Fact] + /// On failure (no result key) counters and storage are retained. + public void HealthCheck_Failure_RetainsCounters() + { + Storage.Instance.SaveToFile(ModuleHealthCheck.healthCheckFilename, + new HealthCheckEntity { Json = "{\"LErr\":5,\"LWar\":0,\"RStatC\":-1,\"REMsg\":\"\",\"BReq\":0,\"CBReq\":0}" }).Wait(); + + MockHttpServer server = new MockHttpServer((body) => + body.Contains("method=sc") ? "{\"garbage\":1}" : "{\"noresult\":true}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + Countly.Instance.Init(cc).Wait(); + + Assert.Equal(5, Countly.Instance.moduleHealthCheck.ErrorCount); + HealthCheckEntity e = Storage.Instance.LoadFromFile(ModuleHealthCheck.healthCheckFilename).Result; + Assert.False(e == null || string.IsNullOrEmpty(e.Json)); + server.Dispose(); + } + + [Fact] + /// Only one health check is sent per lifecycle (one-shot). + public void HealthCheck_OneShot_NoSecondSend() + { + MockHttpServer server = new MockHttpServer((body) => "{\"result\":\"Success\"}"); + CountlyConfig cc = TestHelper.GetConfig(); + cc.serverUrl = server.Url; + Countly.Instance.Init(cc).Wait(); + + int firstCount = Reqs(server).Count(IsHc); + Countly.Instance.moduleHealthCheck.SendHealthCheck().Wait(); + int secondCount = Reqs(server).Count(IsHc); + + Assert.Equal(1, firstCount); + Assert.Equal(firstCount, secondCount); + server.Dispose(); + } + } +} diff --git a/countlyCommon/TestingRelated/TestHelper.cs b/countlyCommon/TestingRelated/TestHelper.cs index 0dcac81f..101c4365 100644 --- a/countlyCommon/TestingRelated/TestHelper.cs +++ b/countlyCommon/TestingRelated/TestHelper.cs @@ -329,16 +329,18 @@ public static async void CleanDataFiles() Storage.Instance.DeleteFile(Countly.storedRequestsFilename).Wait(); Storage.Instance.DeleteFile(Device.deviceFilename).Wait(); Storage.Instance.DeleteFile(ModuleServerConfig.serverConfigFilename).Wait(); + Storage.Instance.DeleteFile(ModuleHealthCheck.healthCheckFilename).Wait(); } /// - /// Returns the captured requests excluding the SDK Behavior Settings (Server Config) - /// fetch (method=sc). That request is on by default and sent off-queue at init, so tests - /// asserting on the normal request flow filter it out. + /// Returns the captured requests excluding the init-time off-queue infrastructure + /// requests: the SDK Behavior Settings (Server Config) fetch (method=sc) and the SDK + /// Health Check (hc=). Both are on by default and sent off-queue at init, so tests + /// asserting on the normal request flow filter them out. /// public static List NonServerConfigRequests(MockHttpServer server) { - return server.Requests.Where(r => r.Body == null || !r.Body.Contains("method=sc")).ToList(); + return server.Requests.Where(r => r.Body == null || (!r.Body.Contains("method=sc") && !r.Body.Contains("hc="))).ToList(); } public static string DCSSerialize(object obj) diff --git a/countlyCommon/countlyCommon/CountlyBase.cs b/countlyCommon/countlyCommon/CountlyBase.cs index e08a8a90..acc310fb 100644 --- a/countlyCommon/countlyCommon/CountlyBase.cs +++ b/countlyCommon/countlyCommon/CountlyBase.cs @@ -63,6 +63,7 @@ public enum LogLevel { VERBOSE, DEBUG, INFO, WARNING, ERROR }; internal ModuleFeedback moduleFeedback; internal ModuleContent moduleContent; internal ModuleServerConfig moduleServerConfig; + internal ModuleHealthCheck moduleHealthCheck; public abstract string sdkName(); @@ -236,6 +237,8 @@ internal async Task UpdateSessionInternal(int? elapsedTime = null) return; } + moduleHealthCheck?.SaveState(); + if (moduleServerConfig != null && !moduleServerConfig.GetSessionTrackingEnabled()) { UtilityHelper.CountlyLogging("[CountlyBase] UpdateSessionInternal, session tracking disabled by server config, ignoring"); return; @@ -270,6 +273,8 @@ protected async Task EndSessionInternal() UtilityHelper.CountlyLogging("[CountlyBase] SessionEnd, Backend Mode enabled, returning"); return; } + + moduleHealthCheck?.SaveState(); UtilityHelper.CountlyLogging("[CountlyBase] EndSessionInternal'"); if (moduleServerConfig != null && !moduleServerConfig.GetSessionTrackingEnabled()) { @@ -1293,6 +1298,8 @@ internal async Task HaltInternal(bool clearStorage = true) moduleContent = null; if (moduleServerConfig != null) { moduleServerConfig.StopTimer(); } moduleServerConfig = null; + if (moduleHealthCheck != null) { moduleHealthCheck.UnregisterHooks(); } + moduleHealthCheck = null; } if (clearStorage) { await ClearStorage(); @@ -1308,6 +1315,7 @@ protected async Task ClearStorage() await Storage.Instance.DeleteFile(storedRequestsFilename); await Storage.Instance.DeleteFile(Device.deviceFilename); await Storage.Instance.DeleteFile(ModuleServerConfig.serverConfigFilename); + await Storage.Instance.DeleteFile(ModuleHealthCheck.healthCheckFilename); } /// @@ -1665,6 +1673,12 @@ protected async Task InitBase(CountlyConfig config) moduleRemoteConfig = new ModuleRemoteConfig(requestHelper, ServerUrl); moduleFeedback = new ModuleFeedback(requestHelper, ServerUrl); moduleContent = new ModuleContent(requestHelper, ServerUrl); + + string hcAppVersion = (config.MetricOverride != null && config.MetricOverride.ContainsKey("_app_version")) + ? config.MetricOverride["_app_version"] : config.appVersion; + moduleHealthCheck = new ModuleHealthCheck(requestHelper, ServerUrl, config.healthCheckDisabled, hcAppVersion); + moduleHealthCheck.RegisterHooks(); + UtilityHelper.CountlyLogging("[CountlyBase] Finished 'InitBase'"); await OnInitComplete(); @@ -1703,6 +1717,12 @@ and no session consent is given. Send empty location as separate request.*/ consentRequired = Configuration.consentRequired; TryAutoEnterContentZone(); } + + // Health check: non-queued direct request to /i, sent after the SBS fetch. + bool networkingOk = moduleServerConfig == null || moduleServerConfig.GetNetworkingEnabled(); + if (moduleHealthCheck != null && !Configuration.backendMode && networkingOk) { + await moduleHealthCheck.SendHealthCheck(); + } } public enum DeviceIdType { DeveloperProvided = 0, SDKGenerated = 1 }; diff --git a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs index 25fc6e15..1312f185 100644 --- a/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs +++ b/countlyCommon/countlyCommon/Entities/CountlyConfigBase.cs @@ -140,6 +140,12 @@ public int ContentZoneTimerInterval { /// internal bool sdkBehaviorSettingsUpdatesDisabled = false; + // + /// When true, the SDK still accumulates health counters but never sends the + /// health check request. + /// + internal bool healthCheckDisabled = false; + /// /// Disabled the location tracking on the Countly server /// @@ -325,5 +331,16 @@ public CountlyConfigBase DisableSDKBehaviorSettingsUpdates() sdkBehaviorSettingsUpdatesDisabled = true; return this; } + + /// + /// Disables the SDK health check feature entirely. Counters still accumulate in + /// memory, but no health check request is sent during initialization. + /// + /// Config for call chaining + public CountlyConfigBase DisableHealthCheck() + { + healthCheckDisabled = true; + return this; + } } } diff --git a/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs b/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs index cfea252c..4c836ecb 100644 --- a/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs +++ b/countlyCommon/countlyCommon/Helpers/UtilityHelper.cs @@ -67,8 +67,15 @@ public static String DecodeDataForURL(String data) return unescapedString; } + // Optional hook invoked for WARNING/ERROR logs (health check counters). Independent of IsLoggingEnabled. + internal static System.Action InternalLogHook = null; + public static void CountlyLogging(String msg, LogLevel level = LogLevel.DEBUG) { + if (level == LogLevel.WARNING || level == LogLevel.ERROR) { + InternalLogHook?.Invoke(level); + } + if (Countly.IsLoggingEnabled) { StringBuilder fullMessage = new StringBuilder(msg.Length + 10); diff --git a/countlyCommon/countlyCommon/Modules/ModuleHealthCheck.cs b/countlyCommon/countlyCommon/Modules/ModuleHealthCheck.cs new file mode 100644 index 00000000..93203277 --- /dev/null +++ b/countlyCommon/countlyCommon/Modules/ModuleHealthCheck.cs @@ -0,0 +1,191 @@ +using System; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using CountlySDK.CountlyCommon.Helpers; +using CountlySDK.CountlyCommon.Server.Responses; +using CountlySDK.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using static CountlySDK.CountlyCommon.CountlyBase; + +namespace CountlySDK.CountlyCommon +{ + internal class ModuleHealthCheck + { + internal const string healthCheckFilename = "health_check.xml"; + + private readonly RequestHelper requestHelper; + private readonly string ServerUrl; + private readonly string appVersion; + private readonly bool disabled; + private readonly object hcLock = new object(); + + // Counters since the last successful health check submission. + private int countWarn = 0; + private int countError = 0; + private int statusCode = -1; + private string errorMessage = ""; + // Backoff counters: no backoff mechanism exists yet; serialized as 0 for wire compatibility. + private int backoff = 0; + private int consecutiveBackoff = 0; + + private bool healthCheckSent = false; + + internal ModuleHealthCheck(RequestHelper requestHelper, string serverUrl, bool disabled, string appVersion) + { + this.requestHelper = requestHelper; + ServerUrl = serverUrl; + this.disabled = disabled; + this.appVersion = appVersion; + LoadState(); + } + + // ---- static hook wiring ---- + internal void RegisterHooks() + { + UtilityHelper.InternalLogHook = OnInternalLog; + CountlySDK.CountlyCommon.Server.ApiBase.FailedRequestHook = LogFailedNetworkRequest; + } + + internal void UnregisterHooks() + { + UtilityHelper.InternalLogHook = null; + CountlySDK.CountlyCommon.Server.ApiBase.FailedRequestHook = null; + } + + private void OnInternalLog(LogLevel level) + { + if (level == LogLevel.WARNING) { LogWarning(); } else if (level == LogLevel.ERROR) { LogError(); } + } + + // ---- test/read accessors ---- + internal int WarningCount { get { lock (hcLock) { return countWarn; } } } + internal int ErrorCount { get { lock (hcLock) { return countError; } } } + internal int LastStatusCode { get { lock (hcLock) { return statusCode; } } } + internal string LastErrorMessage { get { lock (hcLock) { return errorMessage; } } } + + // ---- counter accumulation ---- + internal void LogWarning() { lock (hcLock) { countWarn++; } } + + internal void LogError() { lock (hcLock) { countError++; } } + + internal void LogFailedNetworkRequest(int statusCodeIn, string errorResponse) + { + lock (hcLock) { + if (statusCodeIn > 0 && statusCodeIn < 1000) { statusCode = statusCodeIn; } + if (errorResponse == null) { errorResponse = ""; } + if (errorResponse.Length > 1000) { errorResponse = errorResponse.Substring(0, 1000); } + errorMessage = errorResponse; + } + } + + // ---- persistence ---- + private void LoadState() + { + HealthCheckEntity e = Storage.Instance.LoadFromFile(healthCheckFilename).Result; + if (e == null || string.IsNullOrEmpty(e.Json)) { return; } + + try { + JObject o = JObject.Parse(e.Json); + lock (hcLock) { + countError = o.Value("LErr") ?? 0; + countWarn = o.Value("LWar") ?? 0; + statusCode = o.Value("RStatC") ?? -1; + errorMessage = o.Value("REMsg") ?? ""; + backoff = o.Value("BReq") ?? 0; + consecutiveBackoff = o.Value("CBReq") ?? 0; + } + } catch (Exception ex) { + UtilityHelper.CountlyLogging("[ModuleHealthCheck] malformed stored state, resetting: " + ex.Message, LogLevel.WARNING); + ResetCounters(); + ClearAndSave(); + } + } + + internal void SaveState() + { + JObject o = new JObject(); + lock (hcLock) { + o["LErr"] = countError; + o["LWar"] = countWarn; + o["RStatC"] = statusCode; + o["REMsg"] = errorMessage; + o["BReq"] = backoff; + o["CBReq"] = consecutiveBackoff; + } + Storage.Instance.SaveToFile(healthCheckFilename, + new HealthCheckEntity { Json = o.ToString(Formatting.None) }).Wait(); + } + + internal void ClearAndSave() + { + ResetCounters(); + Storage.Instance.SaveToFile(healthCheckFilename, + new HealthCheckEntity { Json = "" }).Wait(); + } + + private void ResetCounters() + { + lock (hcLock) { + countWarn = 0; + countError = 0; + statusCode = -1; + errorMessage = ""; + backoff = 0; + consecutiveBackoff = 0; + } + } + + // ---- send ---- + internal async Task SendHealthCheck() + { + if (disabled) { + UtilityHelper.CountlyLogging("[ModuleHealthCheck] SendHealthCheck, disabled, skipping"); + return; + } + + lock (hcLock) { + if (healthCheckSent) { + UtilityHelper.CountlyLogging("[ModuleHealthCheck] SendHealthCheck, already sent this lifecycle, skipping"); + return; + } + healthCheckSent = true; + } + + string metricsJson = new JObject { { "_app_version", appVersion } }.ToString(Formatting.None); + string hcJson = BuildHealthCheckJson(); + + string basePayload = await requestHelper.BuildRequest(); + string payload = string.Format("{0}&metrics={1}&hc={2}", basePayload, + UtilityHelper.EncodeDataForURL(metricsJson), UtilityHelper.EncodeDataForURL(hcJson)); + + RequestResult result = await Api.Instance.SendDirectRequest(ServerUrl, payload, "/i"); + if (result != null && result.IsSuccess()) { + ClearAndSave(); + } else { + UtilityHelper.CountlyLogging("[ModuleHealthCheck] SendHealthCheck, send failed, retaining counters", LogLevel.WARNING); + } + } + + private string BuildHealthCheckJson() + { + JObject o = new JObject(); + lock (hcLock) { + o["el"] = countError; + o["wl"] = countWarn; + o["sc"] = statusCode; + o["em"] = errorMessage; + o["bom"] = backoff; + o["cbom"] = consecutiveBackoff; + } + return o.ToString(Formatting.None); + } + } + + [DataContract] + internal class HealthCheckEntity + { + [DataMember] + public string Json; + } +} diff --git a/countlyCommon/countlyCommon/Server/ApiBase.cs b/countlyCommon/countlyCommon/Server/ApiBase.cs index 999fdcea..27997496 100644 --- a/countlyCommon/countlyCommon/Server/ApiBase.cs +++ b/countlyCommon/countlyCommon/Server/ApiBase.cs @@ -19,6 +19,8 @@ abstract class ApiBase internal const string sdkEndpoint = "/i"; internal string tamperingProtectionSalt = null; internal IDictionary customNetworkRequestHeaders = null; + // Optional hook invoked when a request comes back as an HTTP-level failure (health check sc/em). + internal static Action FailedRequestHook = null; public async Task SendSession(string serverUrl, int rr, SessionEvent sessionEvent, CountlyUserDetails userDetails = null) { @@ -120,6 +122,10 @@ protected async Task CallJob(string address, string requestData, RequestResult requestResult = await RequestAsync(address + endpoint, requestData, imageData, customNetworkRequestHeaders); tcs.SetResult(requestResult); + if (requestResult.responseCode < 200 || requestResult.responseCode >= 300) { + FailedRequestHook?.Invoke(requestResult.responseCode, requestResult.responseText); + } + if (requestResult.responseText != null) { UtilityHelper.CountlyLogging(requestResult.responseText); } else { @@ -129,6 +135,7 @@ protected async Task CallJob(string address, string requestData, RequestResult requestResult = new RequestResult(); requestResult.responseText = "Encountered an exception while making a request, " + ex; UtilityHelper.CountlyLogging(requestResult.responseText); + FailedRequestHook?.Invoke(requestResult.responseCode, requestResult.responseText); } return await tcs.Task; diff --git a/netstd/CountlyTest_461/CountlyTest_461.csproj b/netstd/CountlyTest_461/CountlyTest_461.csproj index d9566355..cbf51469 100644 --- a/netstd/CountlyTest_461/CountlyTest_461.csproj +++ b/netstd/CountlyTest_461/CountlyTest_461.csproj @@ -100,6 +100,9 @@ ModuleServerConfigTests.cs + + ModuleHealthCheckTests.cs + RequestTestCases.cs