From 978c6011445b656546d07cc9f4c0f4c56dc2fca7 Mon Sep 17 00:00:00 2001 From: Ali Farhadi Date: Wed, 22 Jul 2026 13:03:26 +0330 Subject: [PATCH 1/4] feat: Unity SDK Network Logging - Implemented network logging feature that emits bf_network logs for the dashboard Network view - Added BugfenderHttpMessageHandler for HttpClient and BugfenderUnityWebRequest helpers for UnityWebRequest - Added URL allow/deny filters, body capture options, obfuscation handlers, and per-minute rate limiting - Injected X-Bugfender-Session-ID and X-Bugfender-Request-ID correlation headers on instrumented requests - Forwarded network logging config APIs to the native Android and iOS SDKs - Bumped package version to 3.1.0 and documented usage in README, CHANGELOG, and TESTING --- CHANGELOG.md | 41 +- README.md | 40 ++ Runtime/Bugfender.cs | 249 ++++++++++++- Runtime/BugfenderNativeIos.cs | 18 + Runtime/NetworkLogging.meta | 8 + .../BugfenderHttpMessageHandler.cs | 201 ++++++++++ .../BugfenderHttpMessageHandler.cs.meta | 11 + .../BugfenderUnityWebRequest.cs | 231 ++++++++++++ .../BugfenderUnityWebRequest.cs.meta | 11 + .../NetworkLogging/NetworkLoggingManager.cs | 350 ++++++++++++++++++ .../NetworkLoggingManager.cs.meta | 11 + .../NetworkLogging/NetworkLoggingPayload.cs | 306 +++++++++++++++ .../NetworkLoggingPayload.cs.meta | 11 + .../NetworkLogging/NetworkLoggingRateLimit.cs | 39 ++ .../NetworkLoggingRateLimit.cs.meta | 11 + Runtime/NetworkLogging/NetworkLoggingTypes.cs | 76 ++++ .../NetworkLoggingTypes.cs.meta | 11 + Runtime/NetworkLogging/NetworkLoggingUtils.cs | 345 +++++++++++++++++ .../NetworkLoggingUtils.cs.meta | 11 + Runtime/Plugins/iOS/BugfenderBridge.mm | 59 +++ TESTING.md | 12 + package.json | 2 +- 22 files changed, 2051 insertions(+), 3 deletions(-) create mode 100644 Runtime/NetworkLogging.meta create mode 100644 Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs create mode 100644 Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs.meta create mode 100644 Runtime/NetworkLogging/BugfenderUnityWebRequest.cs create mode 100644 Runtime/NetworkLogging/BugfenderUnityWebRequest.cs.meta create mode 100644 Runtime/NetworkLogging/NetworkLoggingManager.cs create mode 100644 Runtime/NetworkLogging/NetworkLoggingManager.cs.meta create mode 100644 Runtime/NetworkLogging/NetworkLoggingPayload.cs create mode 100644 Runtime/NetworkLogging/NetworkLoggingPayload.cs.meta create mode 100644 Runtime/NetworkLogging/NetworkLoggingRateLimit.cs create mode 100644 Runtime/NetworkLogging/NetworkLoggingRateLimit.cs.meta create mode 100644 Runtime/NetworkLogging/NetworkLoggingTypes.cs create mode 100644 Runtime/NetworkLogging/NetworkLoggingTypes.cs.meta create mode 100644 Runtime/NetworkLogging/NetworkLoggingUtils.cs create mode 100644 Runtime/NetworkLogging/NetworkLoggingUtils.cs.meta diff --git a/CHANGELOG.md b/CHANGELOG.md index f60d7e8..264628f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +## [3.1.0] + +### Added + +- Network logging (opt-in): capture HTTP requests as `bf_network` logs for the Bugfender dashboard Network view. + - `Bugfender.SetNetworkLoggingEnabled` + - `Bugfender.SetNetworkLoggingCaptureBodies` + - `Bugfender.SetNetworkLoggingCaptureErrorResponseBodies` + - `Bugfender.SetNetworkLoggingRequestObfuscationHandler` / `SetNetworkLoggingResponseObfuscationHandler` + - `Bugfender.SetNetworkLoggingURLFilter` + - `Bugfender.SetNetworkLoggingMaxRequestsPerMinute` + - `Bugfender.LogNetwork` for custom HTTP stacks + - `BugfenderHttpMessageHandler` for `HttpClient` + - `BugfenderUnityWebRequest` helpers for `UnityWebRequest` +- Correlation headers `X-Bugfender-Session-ID` and `X-Bugfender-Request-ID` are injected on instrumented requests when network logging is enabled. +- Config APIs are also forwarded to the native Android / iOS SDKs (OkHttp / URLSession traffic). + +### Changed + +- SDK reports build version `30100` to the Bugfender backend. + +### Compatibility + +- **Unity:** 2022.3 or later (including Unity 6). +- **iOS:** Xcode 15+; requires a Bugfender iOS SDK that exposes network logging APIs (2.2+ / 3.x). +- **Android:** Requires Bugfender Android SDK 3.6+ (already pulled by this package). + +### Documentation + +- [Bugfender for Unity](https://docs.bugfender.com/docs/platforms/hybrid-platforms/bugfender-for-unity) +- [GitHub Releases](https://github.com/bugfender/BugfenderSDK-Unity/releases) + +### Installation + +See [README.md](README.md). Quick UPM (git URL): `https://github.com/bugfender/BugfenderSDK-Unity.git` — optional pin: `#v3.1.0`. + ## [3.0.1] ### Added @@ -35,5 +73,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). See [README.md](README.md). Quick UPM (git URL): `https://github.com/bugfender/BugfenderSDK-Unity.git` — optional pin: `#v3.0.1`. -[Unreleased]: https://github.com/bugfender/BugfenderSDK-Unity/compare/v3.0.1...HEAD +[Unreleased]: https://github.com/bugfender/BugfenderSDK-Unity/compare/v3.1.0...HEAD +[3.1.0]: https://github.com/bugfender/BugfenderSDK-Unity/releases/tag/v3.1.0 [3.0.1]: https://github.com/bugfender/BugfenderSDK-Unity/releases/tag/v3.0.1 diff --git a/README.md b/README.md index 47d2528..ff6c116 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,46 @@ You want the Bugfender SDK to initialize early to capture as many logs and error You can change the priority in the **Project Settings** > **Script Execution Order**. +### Network logging + +Network logging is **opt-in** and disabled by default. When enabled, HTTP requests can appear in the Bugfender dashboard as `bf_network` logs. + +```csharp +Bugfender.SetNetworkLoggingEnabled(true); +// Optional: +Bugfender.SetNetworkLoggingCaptureBodies(false); +Bugfender.SetNetworkLoggingCaptureErrorResponseBodies(true); +Bugfender.SetNetworkLoggingURLFilter( + allowlist: new[] { "https://api.example.com/*" }, + denylist: new[] { "*/secrets/*" }); +Bugfender.SetNetworkLoggingMaxRequestsPerMinute(60); +``` + +Unity does not have a single global HTTP stack. Instrument traffic with one of: + +**`HttpClient`** — wrap with `BugfenderHttpMessageHandler`: + +```csharp +var client = new HttpClient(new BugfenderHttpMessageHandler()); +var response = await client.GetAsync("https://api.example.com/users"); +``` + +**`UnityWebRequest`** — use the helpers: + +```csharp +var request = UnityWebRequest.Get("https://api.example.com/users"); +yield return BugfenderUnityWebRequest.Send(request); +``` + +Or manually: `BugfenderUnityWebRequest.Prepare(request)` before send, then `Complete(...)` after. + +**Other HTTP libraries** — build a `NetworkLogEntry` and call `Bugfender.LogNetwork(entry)`. + +When network logging is enabled, instrumented requests also receive correlation headers: +`X-Bugfender-Session-ID` and `X-Bugfender-Request-ID`. + +On Android/iOS the same config APIs are forwarded to the native SDKs (OkHttp / URLSession). Typical Unity game traffic still needs the C# helpers above. + ### Adjust the native Bugfender SDK versions This package imports the native Bugfender SDKs for iOS and Android using Swift Package Manager and Gradle. diff --git a/Runtime/Bugfender.cs b/Runtime/Bugfender.cs index 3f7d6a4..564d4a8 100755 --- a/Runtime/Bugfender.cs +++ b/Runtime/Bugfender.cs @@ -1,9 +1,11 @@ +using System; +using System.Collections.Generic; using UnityEngine; using UnityEngine.Diagnostics; public class Bugfender : MonoBehaviour { private const string SDK_TYPE = "unity"; - private const int SDK_TYPE_VERSION = 30001; + private const int SDK_TYPE_VERSION = 30100; public string APP_KEY; public bool ENABLE_UI_EVENT_LOGGING = false; @@ -101,6 +103,7 @@ void Start() BugfenderNativeIos.EnableNSLogLogging(); } #endif + ConfigureNetworkLoggingRuntime(); /* Some examples on how to use Bugfender: * Bugfender.Log("BF Initialized"); * Bugfender.SetDeviceString("key","value"); @@ -111,6 +114,55 @@ void Start() */ } + private void ConfigureNetworkLoggingRuntime() + { + NetworkLoggingManager.Configure(TryGetSessionIdentifier, string.IsNullOrWhiteSpace(API_URL) ? null : API_URL); + } + + private static string TryGetSessionIdentifier() + { +#if UNITY_IOS && !UNITY_EDITOR + try + { + var native = BugfenderNativeIos.GetSessionIdentifier(); + if (!string.IsNullOrEmpty(native)) + { + return native; + } + } + catch + { + // fall through to URL parsing + } +#endif + var sessionUrl = SessionIdentifierUrl(); + return ExtractIdentifierFromDashboardUrl(sessionUrl, "session"); + } + + private static string ExtractIdentifierFromDashboardUrl(string url, string type) + { + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(type)) + { + return null; + } + + var marker = "/" + type + "/"; + var index = url.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + return null; + } + + var id = url.Substring(index + marker.Length); + var query = id.IndexOfAny(new[] { '?', '#' }); + if (query >= 0) + { + id = id.Substring(0, query); + } + + return id.Trim('/'); + } + public static void SetDeviceString(string key, string value) { #if UNITY_ANDROID && !UNITY_EDITOR @@ -301,4 +353,199 @@ public static void SetSDKType(string sdkType, int version) #endif } + /// + /// Enable or disable network request/response capture. Defaults to false. + /// Captured entries are sent as logs tagged bf_network. + /// + /// + /// Unity has no global HTTP interceptor. Use for + /// HttpClient, for UnityWebRequest, + /// or for other HTTP stacks. On Android/iOS this also forwards + /// the setting to the native SDK (OkHttp / URLSession traffic). + /// + public static void SetNetworkLoggingEnabled(bool enabled) + { + NetworkLoggingManager.SetEnabled(enabled); +#if UNITY_ANDROID && !UNITY_EDITOR + if (bugfender != null) { + try { bugfender.CallStatic("setNetworkLoggingEnabled", enabled); } catch (AndroidJavaException) { } + } +#elif UNITY_IOS && !UNITY_EDITOR + BugfenderNativeIos.SetNetworkLoggingEnabled(enabled); +#else + Debug.Log("[BF] Set network logging enabled: " + enabled); +#endif + } + + /// + /// Capture request and response bodies (full mode). Defaults to false. + /// + public static void SetNetworkLoggingCaptureBodies(bool capture) + { + NetworkLoggingManager.SetCaptureBodies(capture); +#if UNITY_ANDROID && !UNITY_EDITOR + if (bugfender != null) { + try { bugfender.CallStatic("setNetworkLoggingCaptureBodies", capture); } catch (AndroidJavaException) { } + } +#elif UNITY_IOS && !UNITY_EDITOR + BugfenderNativeIos.SetNetworkLoggingCaptureBodies(capture); +#else + Debug.Log("[BF] Set network logging capture bodies: " + capture); +#endif + } + + /// + /// Capture response bodies only for HTTP status codes >= 400 when full body capture is disabled. + /// Defaults to false. + /// + public static void SetNetworkLoggingCaptureErrorResponseBodies(bool capture) + { + NetworkLoggingManager.SetCaptureErrorResponseBodies(capture); +#if UNITY_ANDROID && !UNITY_EDITOR + if (bugfender != null) { + try { bugfender.CallStatic("setNetworkLoggingCaptureErrorResponseBodies", capture); } catch (AndroidJavaException) { } + } +#elif UNITY_IOS && !UNITY_EDITOR + BugfenderNativeIos.SetNetworkLoggingCaptureErrorResponseBodies(capture); +#else + Debug.Log("[BF] Set network logging capture error response bodies: " + capture); +#endif + } + + /// + /// Optional request obfuscation handler applied before a network log is sent. + /// + public static void SetNetworkLoggingRequestObfuscationHandler(NetworkLoggingRequestObfuscationHandler handler) + { + NetworkLoggingManager.SetRequestObfuscationHandler(handler); + } + + /// + /// Optional response obfuscation handler applied before a network log is sent. + /// + public static void SetNetworkLoggingResponseObfuscationHandler(NetworkLoggingResponseObfuscationHandler handler) + { + NetworkLoggingManager.SetResponseObfuscationHandler(handler); + } + + /// + /// Filter which URLs are captured. Patterns support plain substrings and wildcards + /// (for example https://*.example.com/*). Pass null for either list to leave that filter unset. + /// + public static void SetNetworkLoggingURLFilter(IList allowlist, IList denylist) + { + NetworkLoggingManager.SetURLFilter(allowlist, denylist); +#if UNITY_ANDROID && !UNITY_EDITOR + if (bugfender != null) { + try + { + using (var javaAllow = ToJavaStringList(allowlist)) + using (var javaDeny = ToJavaStringList(denylist)) + { + bugfender.CallStatic("setNetworkLoggingURLFilter", javaAllow, javaDeny); + } + } + catch (AndroidJavaException) { } + } +#elif UNITY_IOS && !UNITY_EDITOR + BugfenderNativeIos.SetNetworkLoggingURLFilter( + JoinPatterns(allowlist), + JoinPatterns(denylist)); +#else + Debug.Log("[BF] Set network logging URL filter"); +#endif + } + + /// + /// Limit how many network logs are captured per calendar minute. Pass null to disable the limit. + /// + public static void SetNetworkLoggingMaxRequestsPerMinute(int? count) + { + NetworkLoggingManager.SetMaxRequestsPerMinute(count); +#if UNITY_ANDROID && !UNITY_EDITOR + if (bugfender != null) { + try + { + if (count.HasValue) + { + using (var boxed = new AndroidJavaObject("java.lang.Integer", count.Value)) + { + bugfender.CallStatic("setNetworkLoggingMaxRequestsPerMinute", boxed); + } + } + else + { + bugfender.CallStatic("setNetworkLoggingMaxRequestsPerMinute", null as AndroidJavaObject); + } + } + catch (AndroidJavaException) { } + } +#elif UNITY_IOS && !UNITY_EDITOR + BugfenderNativeIos.SetNetworkLoggingMaxRequestsPerMinute(count.HasValue ? count.Value : -1); +#else + Debug.Log("[BF] Set network logging max requests per minute: " + count); +#endif + } + + /// + /// Manually log a network request. Use this for HTTP stacks that are not + /// HttpClient / UnityWebRequest (for example BestHTTP). + /// Always emits when called; does not apply URL filters or rate limits. + /// + public static void LogNetwork(NetworkLogEntry entry) + { + if (entry == null) + { + return; + } + + if (string.IsNullOrEmpty(entry.RequestId)) + { + entry.RequestId = NetworkLoggingManager.CreateRequestId(); + } + + if (entry.RequestHeaders == null) + { + entry.RequestHeaders = new Dictionary(); + } + + if (entry.ResponseHeaders == null) + { + entry.ResponseHeaders = new Dictionary(); + } + + NetworkLoggingManager.Emit(entry); + } + +#if UNITY_ANDROID && !UNITY_EDITOR + private static AndroidJavaObject ToJavaStringList(IList items) + { + if (items == null) + { + return null; + } + + var list = new AndroidJavaObject("java.util.ArrayList"); + foreach (var item in items) + { + if (item != null) + { + list.Call("add", item); + } + } + + return list; + } +#endif + + private static string JoinPatterns(IList patterns) + { + if (patterns == null || patterns.Count == 0) + { + return null; + } + + return string.Join("\n", patterns); + } + } diff --git a/Runtime/BugfenderNativeIos.cs b/Runtime/BugfenderNativeIos.cs index 8686504..3608ee1 100644 --- a/Runtime/BugfenderNativeIos.cs +++ b/Runtime/BugfenderNativeIos.cs @@ -53,5 +53,23 @@ internal static class BugfenderNativeIos [DllImport("__Internal", EntryPoint = "BugfenderForceSendOnce")] internal static extern void ForceSendOnce(); + + [DllImport("__Internal", EntryPoint = "BugfenderGetSessionIdentifier")] + internal static extern string GetSessionIdentifier(); + + [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingEnabled")] + internal static extern void SetNetworkLoggingEnabled(bool enabled); + + [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingCaptureBodies")] + internal static extern void SetNetworkLoggingCaptureBodies(bool capture); + + [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingCaptureErrorResponseBodies")] + internal static extern void SetNetworkLoggingCaptureErrorResponseBodies(bool capture); + + [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingURLFilter")] + internal static extern void SetNetworkLoggingURLFilter(string allowlistJoined, string denylistJoined); + + [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingMaxRequestsPerMinute")] + internal static extern void SetNetworkLoggingMaxRequestsPerMinute(int countOrNegative); } #endif diff --git a/Runtime/NetworkLogging.meta b/Runtime/NetworkLogging.meta new file mode 100644 index 0000000..609c930 --- /dev/null +++ b/Runtime/NetworkLogging.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 226eae60fa5547978787377d143b2e9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs b/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs new file mode 100644 index 0000000..a569286 --- /dev/null +++ b/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +/// +/// that injects Bugfender correlation headers and captures +/// network logs for traffic when network logging is enabled. +/// +public sealed class BugfenderHttpMessageHandler : DelegatingHandler +{ + public BugfenderHttpMessageHandler() + : this(new HttpClientHandler()) + { + } + + public BugfenderHttpMessageHandler(HttpMessageHandler innerHandler) + : base(innerHandler ?? new HttpClientHandler()) + { + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + var url = request.RequestUri != null ? request.RequestUri.ToString() : string.Empty; + var method = request.Method != null ? request.Method.Method : "GET"; + var startTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var stopwatch = Stopwatch.StartNew(); + + string requestId = null; + var shouldInject = NetworkLoggingManager.ShouldInjectHeaders(); + if (shouldInject) + { + requestId = NetworkLoggingManager.CreateRequestId(); + var sessionId = NetworkLoggingManager.GetSessionId(); + if (!string.IsNullOrEmpty(sessionId)) + { + request.Headers.Remove(NetworkLoggingManager.SessionHeaderName); + request.Headers.TryAddWithoutValidation(NetworkLoggingManager.SessionHeaderName, sessionId); + } + + request.Headers.Remove(NetworkLoggingManager.RequestHeaderName); + request.Headers.TryAddWithoutValidation(NetworkLoggingManager.RequestHeaderName, requestId); + } + + var shouldCapture = NetworkLoggingManager.ShouldCapture(url); + Dictionary requestHeaders = null; + string requestBody = null; + int? requestSize = null; + var includeRequestBody = false; + + if (shouldCapture) + { + requestHeaders = ExtractRequestHeaders(request); + requestSize = NetworkLoggingUtils.GetContentLength(requestHeaders); + includeRequestBody = NetworkLoggingManager.ShouldCaptureRequestBody( + NetworkLoggingUtils.GetHeader(requestHeaders, "content-type")); + + if (includeRequestBody && request.Content != null) + { + try + { + await request.Content.LoadIntoBufferAsync().ConfigureAwait(false); + requestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!requestSize.HasValue && requestBody != null) + { + requestSize = NetworkLoggingUtils.ByteLength(requestBody); + } + } + catch + { + requestBody = null; + } + } + } + + try + { + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + if (shouldCapture) + { + var responseHeaders = ExtractResponseHeaders(response); + var statusCode = (int)response.StatusCode; + var includeResponseBody = NetworkLoggingManager.ShouldCaptureResponseBody( + NetworkLoggingUtils.GetHeader(responseHeaders, "content-type"), + statusCode); + string responseBody = null; + var responseSize = NetworkLoggingUtils.GetContentLength(responseHeaders); + + if (includeResponseBody && response.Content != null) + { + try + { + await response.Content.LoadIntoBufferAsync().ConfigureAwait(false); + responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!responseSize.HasValue && responseBody != null) + { + responseSize = NetworkLoggingUtils.ByteLength(responseBody); + } + } + catch + { + responseBody = null; + } + } + + NetworkLoggingManager.Emit(new NetworkLogEntry + { + Url = url, + Method = method, + RequestId = requestId ?? NetworkLoggingManager.CreateRequestId(), + StartTimeMs = startTimeMs, + DurationMs = stopwatch.ElapsedMilliseconds, + StatusCode = statusCode, + RequestSize = requestSize, + ResponseSize = responseSize, + RequestHeaders = requestHeaders, + ResponseHeaders = responseHeaders, + RequestBody = requestBody, + ResponseBody = responseBody, + IncludeRequestBody = includeRequestBody, + IncludeResponseBody = includeResponseBody, + }); + } + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + if (shouldCapture) + { + NetworkLoggingManager.Emit(new NetworkLogEntry + { + Url = url, + Method = method, + RequestId = requestId ?? NetworkLoggingManager.CreateRequestId(), + StartTimeMs = startTimeMs, + DurationMs = stopwatch.ElapsedMilliseconds, + RequestSize = requestSize, + RequestHeaders = requestHeaders ?? new Dictionary(), + ResponseHeaders = new Dictionary(), + RequestBody = requestBody, + IncludeRequestBody = includeRequestBody, + IncludeResponseBody = false, + Error = ex.Message, + }); + } + + throw; + } + } + + private static Dictionary ExtractRequestHeaders(HttpRequestMessage request) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in request.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + + if (request.Content != null) + { + foreach (var header in request.Content.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + } + + return headers; + } + + private static Dictionary ExtractResponseHeaders(HttpResponseMessage response) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in response.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + + if (response.Content != null) + { + foreach (var header in response.Content.Headers) + { + headers[header.Key] = string.Join(", ", header.Value); + } + } + + return headers; + } +} diff --git a/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs.meta b/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs.meta new file mode 100644 index 0000000..1bce52b --- /dev/null +++ b/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7114f3267c7e40e2a2f07ba4f6c374a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/BugfenderUnityWebRequest.cs b/Runtime/NetworkLogging/BugfenderUnityWebRequest.cs new file mode 100644 index 0000000..87274be --- /dev/null +++ b/Runtime/NetworkLogging/BugfenderUnityWebRequest.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using UnityEngine.Networking; + +/// +/// Helpers to instrument for Bugfender network logging. +/// Unity has no global HTTP interceptor; call these helpers (or ) +/// around your requests. +/// +public static class BugfenderUnityWebRequest +{ + /// + /// Injects correlation headers when network logging is enabled. Call before + /// . + /// + public static string Prepare(UnityWebRequest request) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (!NetworkLoggingManager.ShouldInjectHeaders()) + { + return null; + } + + var requestId = NetworkLoggingManager.CreateRequestId(); + var sessionId = NetworkLoggingManager.GetSessionId(); + if (!string.IsNullOrEmpty(sessionId)) + { + request.SetRequestHeader(NetworkLoggingManager.SessionHeaderName, sessionId); + } + + request.SetRequestHeader(NetworkLoggingManager.RequestHeaderName, requestId); + return requestId; + } + + /// + /// Captures a completed (or failed) as a network log when enabled. + /// Pass from a prior check so rate + /// limits are applied at request start (same as ). + /// + public static void Complete( + UnityWebRequest request, + string requestId, + long startTimeMs, + long durationMs, + string requestBody = null, + IDictionary requestHeaders = null, + bool? shouldCapture = null) + { + if (request == null) + { + return; + } + + var url = request.url ?? string.Empty; + var capture = shouldCapture ?? NetworkLoggingManager.ShouldCapture(url); + if (!capture) + { + return; + } + + var headers = requestHeaders != null + ? new Dictionary(requestHeaders) + : new Dictionary(); + + if (!string.IsNullOrEmpty(requestId) + && !headers.ContainsKey(NetworkLoggingManager.RequestHeaderName)) + { + headers[NetworkLoggingManager.RequestHeaderName] = requestId; + } + + var sessionId = NetworkLoggingManager.GetSessionId(); + if (!string.IsNullOrEmpty(sessionId) + && !headers.ContainsKey(NetworkLoggingManager.SessionHeaderName)) + { + headers[NetworkLoggingManager.SessionHeaderName] = sessionId; + } + + var responseHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + var all = request.GetResponseHeaders(); + if (all != null) + { + foreach (var pair in all) + { + responseHeaders[pair.Key] = pair.Value; + } + } + } + catch + { + // ignored + } + + int? statusCode = null; + if (request.responseCode > 0) + { + statusCode = (int)request.responseCode; + } + + var contentType = NetworkLoggingUtils.GetHeader(responseHeaders, "content-type"); + var includeRequestBody = NetworkLoggingManager.ShouldCaptureRequestBody( + NetworkLoggingUtils.GetHeader(headers, "content-type")); + var includeResponseBody = NetworkLoggingManager.ShouldCaptureResponseBody(contentType, statusCode); + + string responseBody = null; + if (includeResponseBody) + { + try + { + responseBody = request.downloadHandler != null ? request.downloadHandler.text : null; + } + catch + { + responseBody = null; + } + } + + string error = null; + if (request.result == UnityWebRequest.Result.ConnectionError + || request.result == UnityWebRequest.Result.DataProcessingError) + { + error = request.error; + } + else if (!statusCode.HasValue && !string.IsNullOrEmpty(request.error)) + { + error = request.error; + } + + var requestSize = NetworkLoggingUtils.GetContentLength(headers); + if (!requestSize.HasValue && includeRequestBody && requestBody != null) + { + requestSize = NetworkLoggingUtils.ByteLength(requestBody); + } + + var responseSize = NetworkLoggingUtils.GetContentLength(responseHeaders); + if (!responseSize.HasValue && includeResponseBody && responseBody != null) + { + responseSize = NetworkLoggingUtils.ByteLength(responseBody); + } + + NetworkLoggingManager.Emit(new NetworkLogEntry + { + Url = url, + Method = string.IsNullOrEmpty(request.method) ? "GET" : request.method.ToUpperInvariant(), + RequestId = string.IsNullOrEmpty(requestId) ? NetworkLoggingManager.CreateRequestId() : requestId, + StartTimeMs = startTimeMs, + DurationMs = durationMs < 0 ? 0 : durationMs, + StatusCode = statusCode, + RequestSize = requestSize, + ResponseSize = responseSize, + RequestHeaders = headers, + ResponseHeaders = responseHeaders, + RequestBody = includeRequestBody ? requestBody : null, + ResponseBody = includeResponseBody ? responseBody : null, + IncludeRequestBody = includeRequestBody, + IncludeResponseBody = includeResponseBody, + Error = error, + }); + } + + /// + /// Returns whether the URL should be captured under current filters and rate limits. + /// + public static bool ShouldCapture(string url) + { + return NetworkLoggingManager.ShouldCapture(url ?? string.Empty); + } + + /// + /// Sends a while capturing a network log when enabled. + /// Usage: yield return BugfenderUnityWebRequest.Send(request); + /// + public static IEnumerator Send(UnityWebRequest request, string requestBody = null, IDictionary requestHeaders = null) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + var startTimeMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var stopwatch = Stopwatch.StartNew(); + var requestId = Prepare(request); + var shouldCapture = ShouldCapture(request.url); + + var capturedHeaders = requestHeaders != null + ? new Dictionary(requestHeaders) + : new Dictionary(); + + if (!string.IsNullOrEmpty(requestId)) + { + capturedHeaders[NetworkLoggingManager.RequestHeaderName] = requestId; + var sessionId = NetworkLoggingManager.GetSessionId(); + if (!string.IsNullOrEmpty(sessionId)) + { + capturedHeaders[NetworkLoggingManager.SessionHeaderName] = sessionId; + } + } + + var operation = request.SendWebRequest(); + while (!operation.isDone) + { + yield return null; + } + + stopwatch.Stop(); + Complete(request, requestId, startTimeMs, stopwatch.ElapsedMilliseconds, requestBody, capturedHeaders, shouldCapture); + } + + /// + /// Convenience helper for JSON POST requests with network logging. + /// + public static UnityWebRequest PostJson(string url, string jsonBody) + { + var bodyRaw = Encoding.UTF8.GetBytes(jsonBody ?? string.Empty); + var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST) + { + uploadHandler = new UploadHandlerRaw(bodyRaw), + downloadHandler = new DownloadHandlerBuffer(), + }; + request.SetRequestHeader("Content-Type", "application/json"); + return request; + } +} diff --git a/Runtime/NetworkLogging/BugfenderUnityWebRequest.cs.meta b/Runtime/NetworkLogging/BugfenderUnityWebRequest.cs.meta new file mode 100644 index 0000000..4353776 --- /dev/null +++ b/Runtime/NetworkLogging/BugfenderUnityWebRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a0e35151191044b3b82fb2c20c1f9b8f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/NetworkLoggingManager.cs b/Runtime/NetworkLogging/NetworkLoggingManager.cs new file mode 100644 index 0000000..93bbf3b --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingManager.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; + +/// +/// Shared network logging config, filtering, rate limiting and emission for Unity HTTP traffic. +/// +internal static class NetworkLoggingManager +{ + public const string SessionHeaderName = "X-Bugfender-Session-ID"; + public const string RequestHeaderName = "X-Bugfender-Request-ID"; + + private static readonly object Sync = new object(); + private static readonly NetworkLoggingRateLimit RateLimit = new NetworkLoggingRateLimit(); + + private static bool _enabled; + private static bool _captureBodies; + private static bool _captureErrorResponseBodies; + private static NetworkLoggingRequestObfuscationHandler _requestObfuscationHandler; + private static NetworkLoggingResponseObfuscationHandler _responseObfuscationHandler; + private static List _allowlist; + private static List _denylist; + private static int? _maxRequestsPerMinute; + private static string _apiUrl = "https://api.bugfender.com"; + private static Func _sessionIdProvider = () => null; + + public static bool Enabled + { + get { lock (Sync) { return _enabled; } } + } + + public static bool CaptureBodies + { + get { lock (Sync) { return _captureBodies; } } + } + + public static bool CaptureErrorResponseBodies + { + get { lock (Sync) { return _captureErrorResponseBodies; } } + } + + public static void Configure( + Func sessionIdProvider = null, + string apiUrl = null) + { + lock (Sync) + { + if (sessionIdProvider != null) + { + _sessionIdProvider = sessionIdProvider; + } + + if (!string.IsNullOrWhiteSpace(apiUrl)) + { + _apiUrl = apiUrl.Trim().TrimEnd('/'); + } + } + } + + public static void SetEnabled(bool enabled) + { + lock (Sync) + { + _enabled = enabled; + } + } + + public static void SetCaptureBodies(bool capture) + { + lock (Sync) + { + _captureBodies = capture; + } + } + + public static void SetCaptureErrorResponseBodies(bool capture) + { + lock (Sync) + { + _captureErrorResponseBodies = capture; + } + } + + public static void SetRequestObfuscationHandler(NetworkLoggingRequestObfuscationHandler handler) + { + lock (Sync) + { + _requestObfuscationHandler = handler; + } + } + + public static void SetResponseObfuscationHandler(NetworkLoggingResponseObfuscationHandler handler) + { + lock (Sync) + { + _responseObfuscationHandler = handler; + } + } + + public static void SetURLFilter(IEnumerable allowlist, IEnumerable denylist) + { + lock (Sync) + { + _allowlist = NetworkLoggingUtils.SanitizeUrlPatterns(allowlist); + _denylist = NetworkLoggingUtils.SanitizeUrlPatterns(denylist); + } + } + + public static void SetMaxRequestsPerMinute(int? count) + { + lock (Sync) + { + if (count.HasValue && count.Value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count), "Max requests per minute must be null or a positive integer."); + } + + _maxRequestsPerMinute = count; + } + } + + public static string CreateRequestId() + { + return Guid.NewGuid().ToString(); + } + + public static string GetSessionId() + { + try + { + return _sessionIdProvider != null ? _sessionIdProvider() : null; + } + catch + { + return null; + } + } + + public static bool ShouldInjectHeaders() + { + return Enabled; + } + + public static bool ShouldCapture(string url) + { + lock (Sync) + { + if (!_enabled) + { + return false; + } + + if (IsBugfenderApi(url)) + { + return false; + } + + if (!NetworkLoggingUtils.MatchesURLFilters(url, _allowlist, _denylist)) + { + return false; + } + + return RateLimit.ShouldCapture(_maxRequestsPerMinute); + } + } + + public static bool ShouldCaptureResponseBody(string contentType, int? statusCode) + { + lock (Sync) + { + if (!NetworkLoggingUtils.IsTextLikeContentType(contentType)) + { + return false; + } + + if (_captureBodies) + { + return true; + } + + return _captureErrorResponseBodies && statusCode.HasValue && statusCode.Value >= 400; + } + } + + public static bool ShouldCaptureRequestBody(string contentType) + { + lock (Sync) + { + return _captureBodies && NetworkLoggingUtils.IsTextLikeContentType(contentType); + } + } + + public static void Emit(NetworkLogEntry entry) + { + if (entry == null) + { + return; + } + + NetworkLoggingRequestObfuscationHandler requestHandler; + NetworkLoggingResponseObfuscationHandler responseHandler; + + lock (Sync) + { + requestHandler = _requestObfuscationHandler; + responseHandler = _responseObfuscationHandler; + } + + var url = entry.Url ?? string.Empty; + var requestHeaders = NetworkLoggingUtils.LowercaseHeaders(entry.RequestHeaders); + var responseHeaders = NetworkLoggingUtils.LowercaseHeaders(entry.ResponseHeaders); + var requestBody = entry.RequestBody; + var responseBody = entry.ResponseBody; + var includeRequestBody = entry.IncludeRequestBody; + var includeResponseBody = entry.IncludeResponseBody; + + if (requestHandler != null) + { + var obfuscated = ApplyRequestObfuscation(requestHandler, url, requestHeaders, requestBody); + url = obfuscated.Url; + requestHeaders = NetworkLoggingUtils.LowercaseHeaders(obfuscated.Headers); + requestBody = obfuscated.Body; + } + + if (responseHandler != null) + { + var obfuscated = ApplyResponseObfuscation(responseHandler, responseHeaders, responseBody); + responseHeaders = NetworkLoggingUtils.LowercaseHeaders(obfuscated.Headers); + responseBody = obfuscated.Body; + } + + var text = NetworkLoggingPayload.BuildLogText(new NetworkLogEntry + { + Url = url, + Method = entry.Method, + RequestId = entry.RequestId, + StartTimeMs = entry.StartTimeMs, + DurationMs = entry.DurationMs, + StatusCode = entry.StatusCode, + RequestSize = entry.RequestSize, + ResponseSize = entry.ResponseSize, + RequestHeaders = requestHeaders, + ResponseHeaders = responseHeaders, + RequestBody = requestBody, + ResponseBody = responseBody, + IncludeRequestBody = includeRequestBody, + IncludeResponseBody = includeResponseBody, + Error = entry.Error, + Timing = entry.Timing, + }); + + Bugfender.Log(Bugfender.LogLevel.Info, NetworkLoggingPayload.NetworkLogTag, text); + } + + private static NetworkRequestData ApplyRequestObfuscation( + NetworkLoggingRequestObfuscationHandler handler, + string url, + IDictionary headers, + string body) + { + if (handler == null) + { + return new NetworkRequestData(url, headers, body); + } + + try + { + var copy = new Dictionary(headers); + var result = handler(url, copy, body); + return new NetworkRequestData( + result.Url ?? url, + result.Headers ?? new Dictionary(), + result.Body); + } + catch + { + return new NetworkRequestData(url, new Dictionary(), null); + } + } + + private static NetworkResponseData ApplyResponseObfuscation( + NetworkLoggingResponseObfuscationHandler handler, + IDictionary headers, + string body) + { + if (handler == null) + { + return new NetworkResponseData(headers, body); + } + + try + { + var copy = new Dictionary(headers); + var result = handler(copy, body); + return new NetworkResponseData( + result.Headers ?? new Dictionary(), + result.Body); + } + catch + { + return new NetworkResponseData(new Dictionary(), null); + } + } + + private static bool IsBugfenderApi(string url) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + string apiUrl; + lock (Sync) + { + apiUrl = _apiUrl; + } + + try + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed)) + { + return false; + } + + if (!Uri.TryCreate(apiUrl, UriKind.Absolute, out var api)) + { + return url.IndexOf("bugfender.com", StringComparison.OrdinalIgnoreCase) >= 0; + } + + if (!string.Equals(parsed.Scheme, api.Scheme, StringComparison.OrdinalIgnoreCase) + || !string.Equals(parsed.Authority, api.Authority, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var apiPath = api.AbsolutePath.TrimEnd('/'); + if (string.IsNullOrEmpty(apiPath) || apiPath == "/") + { + return true; + } + + var path = parsed.AbsolutePath; + return path == apiPath + || path.StartsWith(apiPath + "/", StringComparison.Ordinal); + } + catch + { + return false; + } + } +} diff --git a/Runtime/NetworkLogging/NetworkLoggingManager.cs.meta b/Runtime/NetworkLogging/NetworkLoggingManager.cs.meta new file mode 100644 index 0000000..6e4e937 --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 59533fb7927b4f34932d87e313d396e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/NetworkLoggingPayload.cs b/Runtime/NetworkLogging/NetworkLoggingPayload.cs new file mode 100644 index 0000000..1505cd2 --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingPayload.cs @@ -0,0 +1,306 @@ +using System.Collections.Generic; +using System.Text; + +internal static class NetworkLoggingPayload +{ + internal const string NetworkLogTag = "bf_network"; + private const int MaxLogTextSizeBytes = 32_000; + private const int MaxBodySizeBytes = 4_096; + private const int MaxHeaderValueSize = 256; + private const int MaxHeaders = 40; + private const string PayloadExceededErrorJson = + "{\"error\":\"Network log omitted because payload exceeded max size\"}"; + + public static string BuildLogText(NetworkLogEntry input) + { + var payload = FitPayload(ToPayload(input)); + var text = Serialize(payload); + return NetworkLoggingUtils.ByteLength(text) <= MaxLogTextSizeBytes + ? text + : PayloadExceededErrorJson; + } + + private sealed class Payload + { + public string Url; + public string Method; + public string RequestId; + public long StartTime; + public long DurationMs; + public int? StatusCode; + public int? RequestSize; + public int? ResponseSize; + public Dictionary RequestHeaders = new Dictionary(); + public Dictionary ResponseHeaders = new Dictionary(); + public string RequestBody; + public bool HasRequestBody; + public string ResponseBody; + public bool HasResponseBody; + public string Error; + public NetworkTimingBreakdown Timing; + } + + private static Payload ToPayload(NetworkLogEntry input) + { + var payload = new Payload + { + Url = input.Url ?? string.Empty, + Method = string.IsNullOrEmpty(input.Method) ? "GET" : input.Method.ToUpperInvariant(), + RequestId = input.RequestId ?? string.Empty, + StartTime = input.StartTimeMs, + DurationMs = input.DurationMs < 0 ? 0 : input.DurationMs, + StatusCode = input.StatusCode, + RequestSize = input.RequestSize, + ResponseSize = input.ResponseSize, + RequestHeaders = NetworkLoggingUtils.NormalizeHeaders(input.RequestHeaders), + ResponseHeaders = NetworkLoggingUtils.NormalizeHeaders(input.ResponseHeaders), + Error = string.IsNullOrEmpty(input.Error) ? null : input.Error, + Timing = input.Timing ?? default, + }; + + if (input.IncludeRequestBody) + { + payload.HasRequestBody = true; + payload.RequestBody = input.RequestBody == null + ? null + : NetworkLoggingUtils.TruncateUtf8(input.RequestBody, MaxBodySizeBytes); + } + + if (input.IncludeResponseBody) + { + payload.HasResponseBody = true; + payload.ResponseBody = input.ResponseBody == null + ? null + : NetworkLoggingUtils.TruncateUtf8(input.ResponseBody, MaxBodySizeBytes); + } + + return payload; + } + + private static bool SizeFits(Payload payload) + { + return NetworkLoggingUtils.ByteLength(Serialize(payload)) <= MaxLogTextSizeBytes; + } + + private static Payload FitPayload(Payload payload) + { + if (SizeFits(payload)) + { + return payload; + } + + payload.HasResponseBody = false; + payload.ResponseBody = null; + if (SizeFits(payload)) + { + return payload; + } + + payload.HasRequestBody = false; + payload.RequestBody = null; + if (SizeFits(payload)) + { + return payload; + } + + payload.ResponseHeaders = NetworkLoggingUtils.LimitHeaders(payload.ResponseHeaders, MaxHeaders, MaxHeaderValueSize); + payload.RequestHeaders = NetworkLoggingUtils.LimitHeaders(payload.RequestHeaders, MaxHeaders, MaxHeaderValueSize); + if (SizeFits(payload)) + { + return payload; + } + + payload.ResponseHeaders = new Dictionary(); + if (SizeFits(payload)) + { + return payload; + } + + payload.RequestHeaders = new Dictionary(); + if (SizeFits(payload)) + { + return payload; + } + + payload.Url = NetworkLoggingUtils.TruncateUtf8(payload.Url, 2_048); + if (payload.Error != null) + { + payload.Error = NetworkLoggingUtils.TruncateUtf8(payload.Error, 1_024); + } + + if (SizeFits(payload)) + { + return payload; + } + + var minimal = new Payload + { + Url = NetworkLoggingUtils.TruncateUtf8(payload.Url, 512), + Method = NetworkLoggingUtils.TruncateUtf8(payload.Method, 32), + RequestId = payload.RequestId, + StartTime = payload.StartTime, + DurationMs = payload.DurationMs, + StatusCode = payload.StatusCode, + }; + + if (SizeFits(minimal)) + { + return minimal; + } + + return null; + } + + private static string Serialize(Payload payload) + { + if (payload == null) + { + return PayloadExceededErrorJson; + } + + var sb = new StringBuilder(512); + sb.Append('{'); + AppendString(sb, "url", payload.Url, false); + AppendString(sb, "method", payload.Method, true); + AppendString(sb, "request_id", payload.RequestId, true); + AppendNumber(sb, "start_time", payload.StartTime, true); + AppendNumber(sb, "duration_ms", payload.DurationMs, true); + + if (payload.StatusCode.HasValue) + { + AppendNumber(sb, "status_code", payload.StatusCode.Value, true); + } + + if (payload.RequestSize.HasValue) + { + AppendNumber(sb, "request_size", payload.RequestSize.Value, true); + } + + if (payload.ResponseSize.HasValue) + { + AppendNumber(sb, "response_size", payload.ResponseSize.Value, true); + } + + AppendObject(sb, "request_headers", payload.RequestHeaders, true); + AppendObject(sb, "response_headers", payload.ResponseHeaders, true); + + if (payload.HasRequestBody) + { + AppendNullableString(sb, "request_body", payload.RequestBody, true); + } + + if (payload.HasResponseBody) + { + AppendNullableString(sb, "response_body", payload.ResponseBody, true); + } + + if (!string.IsNullOrEmpty(payload.Error)) + { + AppendString(sb, "error", payload.Error, true); + } + + AppendTiming(sb, payload.Timing, true); + sb.Append('}'); + return sb.ToString(); + } + + private static void AppendString(StringBuilder sb, string key, string value, bool leadingComma) + { + if (leadingComma) + { + sb.Append(','); + } + + sb.Append(NetworkLoggingUtils.EscapeJson(key)); + sb.Append(':'); + sb.Append(NetworkLoggingUtils.EscapeJson(value ?? string.Empty)); + } + + private static void AppendNullableString(StringBuilder sb, string key, string value, bool leadingComma) + { + if (leadingComma) + { + sb.Append(','); + } + + sb.Append(NetworkLoggingUtils.EscapeJson(key)); + sb.Append(':'); + sb.Append(value == null ? "null" : NetworkLoggingUtils.EscapeJson(value)); + } + + private static void AppendNumber(StringBuilder sb, string key, long value, bool leadingComma) + { + if (leadingComma) + { + sb.Append(','); + } + + sb.Append(NetworkLoggingUtils.EscapeJson(key)); + sb.Append(':'); + sb.Append(value); + } + + private static void AppendObject(StringBuilder sb, string key, IDictionary headers, bool leadingComma) + { + if (leadingComma) + { + sb.Append(','); + } + + sb.Append(NetworkLoggingUtils.EscapeJson(key)); + sb.Append(":{"); + if (headers != null) + { + var first = true; + foreach (var pair in headers) + { + if (!first) + { + sb.Append(','); + } + + first = false; + sb.Append(NetworkLoggingUtils.EscapeJson(pair.Key)); + sb.Append(':'); + sb.Append(NetworkLoggingUtils.EscapeJson(pair.Value ?? string.Empty)); + } + } + + sb.Append('}'); + } + + private static void AppendTiming(StringBuilder sb, NetworkTimingBreakdown timing, bool leadingComma) + { + if (leadingComma) + { + sb.Append(','); + } + + sb.Append("\"timing\":{"); + var first = true; + AppendTimingField(sb, "dns_ms", timing.DnsMs, ref first); + AppendTimingField(sb, "connect_ms", timing.ConnectMs, ref first); + AppendTimingField(sb, "tls_ms", timing.TlsMs, ref first); + AppendTimingField(sb, "ttfb_ms", timing.TtfbMs, ref first); + AppendTimingField(sb, "download_ms", timing.DownloadMs, ref first); + sb.Append('}'); + } + + private static void AppendTimingField(StringBuilder sb, string key, long? value, ref bool first) + { + if (!value.HasValue || value.Value < 0) + { + return; + } + + if (!first) + { + sb.Append(','); + } + + first = false; + sb.Append(NetworkLoggingUtils.EscapeJson(key)); + sb.Append(':'); + sb.Append(value.Value); + } +} diff --git a/Runtime/NetworkLogging/NetworkLoggingPayload.cs.meta b/Runtime/NetworkLogging/NetworkLoggingPayload.cs.meta new file mode 100644 index 0000000..f00e61e --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingPayload.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2ee93f770db746d39134bf290fcadf48 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/NetworkLoggingRateLimit.cs b/Runtime/NetworkLogging/NetworkLoggingRateLimit.cs new file mode 100644 index 0000000..6eefcd0 --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingRateLimit.cs @@ -0,0 +1,39 @@ +using System; + +/// +/// Rate limits network log capture per calendar minute (aligned with Android / JS SDKs). +/// +internal sealed class NetworkLoggingRateLimit +{ + private readonly Func _getNowMs; + private int _count; + private long? _lastMinute; + + public NetworkLoggingRateLimit(Func getNowMs = null) + { + _getNowMs = getNowMs ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + public bool ShouldCapture(int? maxRequestsPerMinute) + { + if (!maxRequestsPerMinute.HasValue) + { + return true; + } + + var nowMinute = _getNowMs() / 60_000L; + if (!_lastMinute.HasValue || _lastMinute.Value != nowMinute) + { + _lastMinute = nowMinute; + _count = 0; + } + + if (_count >= maxRequestsPerMinute.Value) + { + return false; + } + + _count++; + return true; + } +} diff --git a/Runtime/NetworkLogging/NetworkLoggingRateLimit.cs.meta b/Runtime/NetworkLogging/NetworkLoggingRateLimit.cs.meta new file mode 100644 index 0000000..9bf94f9 --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingRateLimit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 08296d2c723b43298ee9edaef6117b17 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/NetworkLoggingTypes.cs b/Runtime/NetworkLogging/NetworkLoggingTypes.cs new file mode 100644 index 0000000..7754b25 --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingTypes.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; + +/// +/// Request obfuscation: receive URL, headers and body; return possibly redacted values. +/// +public delegate NetworkRequestData NetworkLoggingRequestObfuscationHandler( + string url, + IDictionary headers, + string body); + +/// +/// Response obfuscation: receive headers and body; return possibly redacted values. +/// +public delegate NetworkResponseData NetworkLoggingResponseObfuscationHandler( + IDictionary headers, + string body); + +/// Obfuscated request fields returned by a request obfuscation handler. +public struct NetworkRequestData +{ + public string Url; + public IDictionary Headers; + public string Body; + + public NetworkRequestData(string url, IDictionary headers, string body) + { + Url = url; + Headers = headers; + Body = body; + } +} + +/// Obfuscated response fields returned by a response obfuscation handler. +public struct NetworkResponseData +{ + public IDictionary Headers; + public string Body; + + public NetworkResponseData(IDictionary headers, string body) + { + Headers = headers; + Body = body; + } +} + +/// Optional timing breakdown for a captured network request. Omit unavailable phases. +public struct NetworkTimingBreakdown +{ + public long? DnsMs; + public long? ConnectMs; + public long? TlsMs; + public long? TtfbMs; + public long? DownloadMs; +} + +/// Input for building a bf_network log entry. +public sealed class NetworkLogEntry +{ + public string Url; + public string Method; + public string RequestId; + public long StartTimeMs; + public long DurationMs; + public int? StatusCode; + public int? RequestSize; + public int? ResponseSize; + public IDictionary RequestHeaders; + public IDictionary ResponseHeaders; + public string RequestBody; + public string ResponseBody; + public bool IncludeRequestBody; + public bool IncludeResponseBody; + public string Error; + public NetworkTimingBreakdown? Timing; +} diff --git a/Runtime/NetworkLogging/NetworkLoggingTypes.cs.meta b/Runtime/NetworkLogging/NetworkLoggingTypes.cs.meta new file mode 100644 index 0000000..594dcee --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c303df065fff4cc397fe774b85b03443 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/NetworkLoggingUtils.cs b/Runtime/NetworkLogging/NetworkLoggingUtils.cs new file mode 100644 index 0000000..773f43f --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingUtils.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Text; + +internal static class NetworkLoggingUtils +{ + private static readonly string[] TextLikeContentTypes = + { + "text/", + "application/json", + "application/xml", + "application/x-www-form-urlencoded", + }; + + public static int ByteLength(string input) + { + if (string.IsNullOrEmpty(input)) + { + return 0; + } + + return Encoding.UTF8.GetByteCount(input); + } + + public static string TruncateUtf8(string input, int maxBytes) + { + if (string.IsNullOrEmpty(input) || maxBytes <= 0) + { + return string.IsNullOrEmpty(input) ? input : string.Empty; + } + + var bytes = Encoding.UTF8.GetBytes(input); + if (bytes.Length <= maxBytes) + { + return input; + } + + var end = maxBytes; + while (end > 0 && (bytes[end] & 0xC0) == 0x80) + { + end--; + } + + return Encoding.UTF8.GetString(bytes, 0, end); + } + + public static Dictionary NormalizeHeaders(IDictionary headers) + { + var result = new Dictionary(StringComparer.Ordinal); + if (headers == null) + { + return result; + } + + foreach (var pair in headers) + { + if (pair.Key == null) + { + continue; + } + + result[pair.Key] = pair.Value ?? string.Empty; + } + + return result; + } + + public static Dictionary LowercaseHeaders(IDictionary headers) + { + var result = new Dictionary(StringComparer.Ordinal); + if (headers == null) + { + return result; + } + + foreach (var pair in headers) + { + if (pair.Key == null) + { + continue; + } + + result[pair.Key.ToLowerInvariant()] = pair.Value ?? string.Empty; + } + + return result; + } + + public static Dictionary LimitHeaders(IDictionary headers, int maxCount, int maxValueLength) + { + var limited = new Dictionary(StringComparer.Ordinal); + if (headers == null) + { + return limited; + } + + var count = 0; + foreach (var pair in headers) + { + if (count >= maxCount) + { + break; + } + + var value = pair.Value ?? string.Empty; + if (value.Length > maxValueLength) + { + value = value.Substring(0, maxValueLength); + } + + limited[pair.Key] = value; + count++; + } + + return limited; + } + + public static int? GetContentLength(IDictionary headers) + { + if (headers == null) + { + return null; + } + + string raw = null; + foreach (var pair in headers) + { + if (string.Equals(pair.Key, "content-length", StringComparison.OrdinalIgnoreCase)) + { + raw = pair.Value; + break; + } + } + + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + if (!int.TryParse(raw, out var parsed)) + { + return null; + } + + return parsed; + } + + public static bool IsTextLikeContentType(string contentType) + { + if (string.IsNullOrEmpty(contentType)) + { + return false; + } + + var lower = contentType.ToLowerInvariant(); + foreach (var type in TextLikeContentTypes) + { + if (lower.IndexOf(type, StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + public static string GetHeader(IDictionary headers, string name) + { + if (headers == null || string.IsNullOrEmpty(name)) + { + return null; + } + + foreach (var pair in headers) + { + if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return pair.Value; + } + } + + return null; + } + + public static List SanitizeUrlPatterns(IEnumerable patterns) + { + if (patterns == null) + { + return null; + } + + var sanitized = new List(); + foreach (var pattern in patterns) + { + if (pattern == null) + { + continue; + } + + var trimmed = pattern.Trim(); + if (trimmed.Length > 0) + { + sanitized.Add(trimmed); + } + } + + return sanitized.Count > 0 ? sanitized : null; + } + + public static bool MatchesUrlPattern(string url, string pattern) + { + if (pattern.IndexOf('*') < 0) + { + return url.IndexOf(pattern, StringComparison.Ordinal) >= 0; + } + + var pieces = pattern.Split('*'); + var position = 0; + + if (!pattern.StartsWith("*", StringComparison.Ordinal) && pieces[0].Length > 0) + { + if (!url.StartsWith(pieces[0], StringComparison.Ordinal)) + { + return false; + } + + position = pieces[0].Length; + } + + for (var i = 1; i < pieces.Length; i++) + { + var piece = pieces[i]; + if (piece.Length == 0) + { + continue; + } + + var foundAt = url.IndexOf(piece, position, StringComparison.Ordinal); + if (foundAt < 0) + { + return false; + } + + position = foundAt + piece.Length; + } + + if (!pattern.EndsWith("*", StringComparison.Ordinal) && pieces.Length > 0) + { + var tail = pieces[pieces.Length - 1]; + if (tail.Length > 0 && !url.EndsWith(tail, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + public static bool MatchesURLFilters(string url, IList allowlist, IList denylist) + { + if (allowlist != null && allowlist.Count > 0) + { + var isAllowed = false; + foreach (var pattern in allowlist) + { + if (MatchesUrlPattern(url, pattern)) + { + isAllowed = true; + break; + } + } + + if (!isAllowed) + { + return false; + } + } + + if (denylist != null && denylist.Count > 0) + { + foreach (var pattern in denylist) + { + if (MatchesUrlPattern(url, pattern)) + { + return false; + } + } + } + + return true; + } + + public static string EscapeJson(string value) + { + if (value == null) + { + return "null"; + } + + var sb = new StringBuilder(value.Length + 16); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\b': + sb.Append("\\b"); + break; + case '\f': + sb.Append("\\f"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + if (c < 0x20) + { + sb.Append("\\u"); + sb.Append(((int)c).ToString("x4")); + } + else + { + sb.Append(c); + } + + break; + } + } + + sb.Append('"'); + return sb.ToString(); + } +} diff --git a/Runtime/NetworkLogging/NetworkLoggingUtils.cs.meta b/Runtime/NetworkLogging/NetworkLoggingUtils.cs.meta new file mode 100644 index 0000000..9910da9 --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingUtils.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3eee37fa1a284d36a6bd5249c65af131 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Plugins/iOS/BugfenderBridge.mm b/Runtime/Plugins/iOS/BugfenderBridge.mm index 0b59eec..04e7a79 100644 --- a/Runtime/Plugins/iOS/BugfenderBridge.mm +++ b/Runtime/Plugins/iOS/BugfenderBridge.mm @@ -110,4 +110,63 @@ void BugfenderForceSendOnce() { [Bugfender forceSendOnce]; } +char* BugfenderGetSessionIdentifier() { + // sessionIdentifier is deprecated but still the reliable UUID source for correlation headers. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSString* sessionId = [Bugfender sessionIdentifier]; +#pragma clang diagnostic pop + return convertNSStringToCString(sessionId); +} + +void BugfenderSetNetworkLoggingEnabled(bool enabled) { + if ([Bugfender respondsToSelector:@selector(setNetworkLoggingEnabled:)]) { + [Bugfender setNetworkLoggingEnabled:enabled]; + } +} + +void BugfenderSetNetworkLoggingCaptureBodies(bool capture) { + if ([Bugfender respondsToSelector:@selector(setNetworkLoggingCaptureBodies:)]) { + [Bugfender setNetworkLoggingCaptureBodies:capture]; + } +} + +void BugfenderSetNetworkLoggingCaptureErrorResponseBodies(bool capture) { + if ([Bugfender respondsToSelector:@selector(setNetworkLoggingCaptureErrorResponseBodies:)]) { + [Bugfender setNetworkLoggingCaptureErrorResponseBodies:capture]; + } +} + +NSArray* patternsFromJoinedString(const char* joined) { + NSString* text = convertCStringToNSString(joined); + if (text.length == 0) { + return nil; + } + NSArray* parts = [text componentsSeparatedByString:@"\n"]; + NSMutableArray* patterns = [NSMutableArray array]; + for (NSString* part in parts) { + NSString* trimmed = [part stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (trimmed.length > 0) { + [patterns addObject:trimmed]; + } + } + return patterns.count > 0 ? patterns : nil; +} + +void BugfenderSetNetworkLoggingURLFilter(const char* allowlistJoined, const char* denylistJoined) { + if (![Bugfender respondsToSelector:@selector(setNetworkLoggingURLFilterWithAllowlist:denylist:)]) { + return; + } + [Bugfender setNetworkLoggingURLFilterWithAllowlist:patternsFromJoinedString(allowlistJoined) + denylist:patternsFromJoinedString(denylistJoined)]; +} + +void BugfenderSetNetworkLoggingMaxRequestsPerMinute(int countOrNegative) { + if (![Bugfender respondsToSelector:@selector(setNetworkLoggingMaxRequestsPerMinute:)]) { + return; + } + NSNumber* value = countOrNegative < 0 ? nil : @(countOrNegative); + [Bugfender setNetworkLoggingMaxRequestsPerMinute:value]; +} + } diff --git a/TESTING.md b/TESTING.md index 2aff016..1ab7e88 100644 --- a/TESTING.md +++ b/TESTING.md @@ -57,6 +57,18 @@ Expected result: 4. Confirm Bugfender initializes successfully. 5. Send a test log and verify it appears in Bugfender. +## Network logging checks + +1. Call `Bugfender.SetNetworkLoggingEnabled(true)` after init. +2. Send an HTTP request with either: + - `new HttpClient(new BugfenderHttpMessageHandler())`, or + - `yield return BugfenderUnityWebRequest.Send(request)` +3. Confirm a log with tag `bf_network` appears in the device session. +4. Confirm the JSON payload includes `url`, `method`, `request_id`, `start_time`, `duration_ms`, `request_headers`, `response_headers`, and `timing`. +5. Confirm instrumented requests include `X-Bugfender-Request-ID` (and `X-Bugfender-Session-ID` when a session id is available). +6. Optional: enable body capture / error-body capture and verify bodies appear only as configured. +7. Optional: set an allowlist/denylist and rate limit and verify filtering. + ## What to capture on failure When reporting a failure, include: diff --git a/package.json b/package.json index 4fc0de4..f812c35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.bugfender.unity", - "version": "3.0.1", + "version": "3.1.0", "displayName": "Bugfender", "description": "Unity bindings for the native Bugfender iOS and Android SDKs", "unity": "2022.3", From ca742dd185b55cd2f314573fac59f6f12cb13dc7 Mon Sep 17 00:00:00 2001 From: Ali Farhadi Date: Tue, 28 Jul 2026 14:52:11 +0330 Subject: [PATCH 2/4] feat: Network Logging - Bumped Unity Bugfender package to 4.0.0 - Added network logging APIs with URL filters, rate limits, body capture, HttpClient and UnityWebRequest helpers, and correlation headers - Connected request/response obfuscation handlers via native Android OkHttp and iOS URLSession bridges - Bumped native Android SDK to 4.+ with android-okhttp and iOS BugfenderSDK to 3.0.1+ - Added Package Manager Network Logging sample and updated README, CHANGELOG, and TESTING --- CHANGELOG.md | 20 +- Editor/IOSProjectBuildCustomizer.cs | 2 +- README.md | 22 +- Runtime/Bugfender.cs | 2 +- Runtime/BugfenderNativeIos.cs | 16 + .../NetworkLogging/NetworkLoggingManager.cs | 27 ++ .../NetworkLoggingNativeBridge.cs | 307 ++++++++++++++++++ .../NetworkLoggingNativeBridge.cs.meta | 11 + Runtime/NetworkLogging/NetworkLoggingUtils.cs | 194 +++++++++++ .../Android/Bugfender.androidlib/build.gradle | 15 +- .../src/main/AndroidManifest.xml | 3 + .../androidlib/NetworkObfuscationResult.java | 34 ++ .../UnityNetworkObfuscationBridge.java | 206 ++++++++++++ .../UnityNetworkRequestObfuscator.java | 10 + .../UnityNetworkResponseObfuscator.java | 10 + Runtime/Plugins/iOS/BugfenderBridge.mm | 132 ++++++++ TESTING.md | 24 +- package.json | 11 +- 18 files changed, 1022 insertions(+), 24 deletions(-) create mode 100644 Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs create mode 100644 Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs.meta create mode 100644 Runtime/Plugins/Android/Bugfender.androidlib/src/main/AndroidManifest.xml create mode 100644 Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java create mode 100644 Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java create mode 100644 Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkRequestObfuscator.java create mode 100644 Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkResponseObfuscator.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 264628f..86180c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,7 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] - -## [3.1.0] +## [4.0.0] ### Added @@ -21,17 +19,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `BugfenderHttpMessageHandler` for `HttpClient` - `BugfenderUnityWebRequest` helpers for `UnityWebRequest` - Correlation headers `X-Bugfender-Session-ID` and `X-Bugfender-Request-ID` are injected on instrumented requests when network logging is enabled. -- Config APIs are also forwarded to the native Android / iOS SDKs (OkHttp / URLSession traffic). +- Package Manager sample **Network Logging** (`Samples~/NetworkLogging`) demonstrating capture options, filters, rate limits, obfuscation, `HttpClient`, and `UnityWebRequest`. ### Changed -- SDK reports build version `30100` to the Bugfender backend. +- Bump native Android SDK to `4.+` and add `android-okhttp:4.+`. +- Bump native iOS SDK SPM pin to `3.0.1`. +- Config APIs are forwarded to the native Android / iOS SDKs (OkHttp / URLSession traffic). +- SDK reports build version `40000` to the Bugfender backend. ### Compatibility - **Unity:** 2022.3 or later (including Unity 6). -- **iOS:** Xcode 15+; requires a Bugfender iOS SDK that exposes network logging APIs (2.2+ / 3.x). -- **Android:** Requires Bugfender Android SDK 3.6+ (already pulled by this package). +- **iOS:** Xcode 15+; Bugfender iOS SDK 3.0.1+. +- **Android:** Bugfender Android SDK 4.x + `android-okhttp` (pulled by this package). ### Documentation @@ -40,7 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Installation -See [README.md](README.md). Quick UPM (git URL): `https://github.com/bugfender/BugfenderSDK-Unity.git` — optional pin: `#v3.1.0`. +See [README.md](README.md). Quick UPM (git URL): `https://github.com/bugfender/BugfenderSDK-Unity.git` — optional pin: `#v4.0.0`. ## [3.0.1] @@ -73,6 +74,5 @@ See [README.md](README.md). Quick UPM (git URL): `https://github.com/bugfender/B See [README.md](README.md). Quick UPM (git URL): `https://github.com/bugfender/BugfenderSDK-Unity.git` — optional pin: `#v3.0.1`. -[Unreleased]: https://github.com/bugfender/BugfenderSDK-Unity/compare/v3.1.0...HEAD -[3.1.0]: https://github.com/bugfender/BugfenderSDK-Unity/releases/tag/v3.1.0 +[4.0.0]: https://github.com/bugfender/BugfenderSDK-Unity/releases/tag/v4.0.0 [3.0.1]: https://github.com/bugfender/BugfenderSDK-Unity/releases/tag/v3.0.1 diff --git a/Editor/IOSProjectBuildCustomizer.cs b/Editor/IOSProjectBuildCustomizer.cs index 44a60e3..615c6be 100644 --- a/Editor/IOSProjectBuildCustomizer.cs +++ b/Editor/IOSProjectBuildCustomizer.cs @@ -22,7 +22,7 @@ public static void OnPostprocessBuild(BuildTarget buildTarget, string pathToBuil string mainTargetGuid = pbxProject.GetUnityMainTargetGuid(); // Get the remote package GUID - string packageGuid = pbxProject.AddRemotePackageReferenceAtVersionUpToNextMajor("https://github.com/bugfender/BugfenderSDK-iOS", "2.2.0"); + string packageGuid = pbxProject.AddRemotePackageReferenceAtVersionUpToNextMajor("https://github.com/bugfender/BugfenderSDK-iOS", "3.0.1"); // Add the Remote Package to the Xcode project (both Unity framework and main target) pbxProject.AddRemotePackageFrameworkToProject(pbxProject.GetUnityFrameworkTargetGuid(), "BugfenderLibrary", packageGuid, false /* required dependency */); diff --git a/README.md b/README.md index ff6c116..74fde84 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,20 @@ Bugfender.SetNetworkLoggingURLFilter( allowlist: new[] { "https://api.example.com/*" }, denylist: new[] { "*/secrets/*" }); Bugfender.SetNetworkLoggingMaxRequestsPerMinute(60); + +// Redact secrets before logs are sent (also applied to native OkHttp / URLSession capture): +Bugfender.SetNetworkLoggingRequestObfuscationHandler((url, headers, body) => +{ + if (headers.ContainsKey("Authorization")) + { + headers["Authorization"] = "***"; + } + return new NetworkRequestData(url, headers, body); +}); +Bugfender.SetNetworkLoggingResponseObfuscationHandler((headers, body) => +{ + return new NetworkResponseData(headers, body); +}); ``` Unity does not have a single global HTTP stack. Instrument traffic with one of: @@ -61,15 +75,15 @@ Or manually: `BugfenderUnityWebRequest.Prepare(request)` before send, then `Comp When network logging is enabled, instrumented requests also receive correlation headers: `X-Bugfender-Session-ID` and `X-Bugfender-Request-ID`. -On Android/iOS the same config APIs are forwarded to the native SDKs (OkHttp / URLSession). Typical Unity game traffic still needs the C# helpers above. +On Android/iOS the same config APIs (including obfuscation handlers) are forwarded to the native SDKs (OkHttp / URLSession). Typical Unity game traffic still needs the C# helpers above. Android ships `android-okhttp`; native OkHttp clients must add `BugfenderOkHttpInterceptor` (and optionally `BugfenderOkHttpEventListenerFactory`) themselves. ### Adjust the native Bugfender SDK versions This package imports the native Bugfender SDKs for iOS and Android using Swift Package Manager and Gradle. By default, the latest compatible versions are used. If you would like to tweak that, you can fork this project and edit these files: -* For iOS: `Editor/IOSProjectBuildCustomizer.cs` -* For Android: `Runtime/Plugins/Android/Bugfender.androidlib/build.gradle` +* For iOS: `Editor/IOSProjectBuildCustomizer.cs` (SPM `3.0.1`+) +* For Android: `Runtime/Plugins/Android/Bugfender.androidlib/build.gradle` (`com.bugfender.sdk:android:4.+` and `android-okhttp:4.+`) ## Changelog @@ -78,5 +92,7 @@ See [CHANGELOG.md](CHANGELOG.md) for version history. ## Example project Check out this project to see Bugfender in action: https://github.com/bugfender/unity-demo +This package also includes a **Network Logging** sample (Package Manager → Bugfender → Samples) that configures capture, filters, rate limits, and obfuscation handlers for `HttpClient` and `UnityWebRequest`. + ## Testing See `TESTING.md` for a manual validation checklist for Unity import, Android builds, iOS builds, and runtime verification. diff --git a/Runtime/Bugfender.cs b/Runtime/Bugfender.cs index 564d4a8..5522269 100755 --- a/Runtime/Bugfender.cs +++ b/Runtime/Bugfender.cs @@ -5,7 +5,7 @@ public class Bugfender : MonoBehaviour { private const string SDK_TYPE = "unity"; - private const int SDK_TYPE_VERSION = 30100; + private const int SDK_TYPE_VERSION = 40000; public string APP_KEY; public bool ENABLE_UI_EVENT_LOGGING = false; diff --git a/Runtime/BugfenderNativeIos.cs b/Runtime/BugfenderNativeIos.cs index 3608ee1..a36b149 100644 --- a/Runtime/BugfenderNativeIos.cs +++ b/Runtime/BugfenderNativeIos.cs @@ -1,4 +1,5 @@ #if UNITY_IOS && !UNITY_EDITOR +using System; using System.Runtime.InteropServices; /// @@ -71,5 +72,20 @@ internal static class BugfenderNativeIos [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingMaxRequestsPerMinute")] internal static extern void SetNetworkLoggingMaxRequestsPerMinute(int countOrNegative); + + [DllImport("__Internal", EntryPoint = "BugfenderRegisterNetworkRequestObfuscationCallback")] + internal static extern void RegisterNetworkRequestObfuscationCallback(RequestObfuscationNativeDelegate callback); + + [DllImport("__Internal", EntryPoint = "BugfenderRegisterNetworkResponseObfuscationCallback")] + internal static extern void RegisterNetworkResponseObfuscationCallback(ResponseObfuscationNativeDelegate callback); + + [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingRequestObfuscationHandlerEnabled")] + internal static extern void SetNetworkLoggingRequestObfuscationHandlerEnabled(bool enabled); + + [DllImport("__Internal", EntryPoint = "BugfenderSetNetworkLoggingResponseObfuscationHandlerEnabled")] + internal static extern void SetNetworkLoggingResponseObfuscationHandlerEnabled(bool enabled); + + internal delegate IntPtr RequestObfuscationNativeDelegate(IntPtr url, IntPtr headersJson, IntPtr body); + internal delegate IntPtr ResponseObfuscationNativeDelegate(IntPtr headersJson, IntPtr body); } #endif diff --git a/Runtime/NetworkLogging/NetworkLoggingManager.cs b/Runtime/NetworkLogging/NetworkLoggingManager.cs index 93bbf3b..a0545bc 100644 --- a/Runtime/NetworkLogging/NetworkLoggingManager.cs +++ b/Runtime/NetworkLogging/NetworkLoggingManager.cs @@ -86,6 +86,8 @@ public static void SetRequestObfuscationHandler(NetworkLoggingRequestObfuscation { _requestObfuscationHandler = handler; } + + SyncNativeObfuscationHandlers(); } public static void SetResponseObfuscationHandler(NetworkLoggingResponseObfuscationHandler handler) @@ -94,6 +96,31 @@ public static void SetResponseObfuscationHandler(NetworkLoggingResponseObfuscati { _responseObfuscationHandler = handler; } + + SyncNativeObfuscationHandlers(); + } + + public static NetworkLoggingRequestObfuscationHandler RequestObfuscationHandler + { + get { lock (Sync) { return _requestObfuscationHandler; } } + } + + public static NetworkLoggingResponseObfuscationHandler ResponseObfuscationHandler + { + get { lock (Sync) { return _responseObfuscationHandler; } } + } + + private static void SyncNativeObfuscationHandlers() + { + NetworkLoggingRequestObfuscationHandler requestHandler; + NetworkLoggingResponseObfuscationHandler responseHandler; + lock (Sync) + { + requestHandler = _requestObfuscationHandler; + responseHandler = _responseObfuscationHandler; + } + + NetworkLoggingNativeBridge.SyncObfuscationHandlers(requestHandler, responseHandler); } public static void SetURLFilter(IEnumerable allowlist, IEnumerable denylist) diff --git a/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs b/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs new file mode 100644 index 0000000..8519f3f --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using AOT; +using UnityEngine; + +/// +/// Forwards Unity obfuscation handlers to the native Android / iOS Bugfender SDKs +/// so OkHttp / URLSession capture runs the same C# redaction callbacks. +/// +internal static class NetworkLoggingNativeBridge +{ + private static bool _iosCallbacksRegistered; + +#if UNITY_ANDROID && !UNITY_EDITOR + private static AndroidJavaClass _bridgeClass; + private static RequestObfuscatorProxy _requestProxy; + private static ResponseObfuscatorProxy _responseProxy; +#endif + + public static void SyncObfuscationHandlers( + NetworkLoggingRequestObfuscationHandler requestHandler, + NetworkLoggingResponseObfuscationHandler responseHandler) + { +#if UNITY_ANDROID && !UNITY_EDITOR + SyncAndroid(requestHandler, responseHandler); +#elif UNITY_IOS && !UNITY_EDITOR + SyncIos(requestHandler, responseHandler); +#else + // Editor / unsupported platforms: C# capture path only. +#endif + } + +#if UNITY_ANDROID && !UNITY_EDITOR + private static void SyncAndroid( + NetworkLoggingRequestObfuscationHandler requestHandler, + NetworkLoggingResponseObfuscationHandler responseHandler) + { + try + { + if (_bridgeClass == null) + { + _bridgeClass = new AndroidJavaClass("com.bugfender.unity.androidlib.UnityNetworkObfuscationBridge"); + } + + if (requestHandler != null) + { + _requestProxy = new RequestObfuscatorProxy(requestHandler); + _bridgeClass.CallStatic("setRequestObfuscationHandler", _requestProxy); + } + else + { + _requestProxy = null; + _bridgeClass.CallStatic("setRequestObfuscationHandler", null as AndroidJavaObject); + } + + if (responseHandler != null) + { + _responseProxy = new ResponseObfuscatorProxy(responseHandler); + _bridgeClass.CallStatic("setResponseObfuscationHandler", _responseProxy); + } + else + { + _responseProxy = null; + _bridgeClass.CallStatic("setResponseObfuscationHandler", null as AndroidJavaObject); + } + } + catch (Exception ex) + { + Debug.LogWarning("[BF] Failed to sync Android network obfuscation handlers: " + ex.Message); + } + } + + private sealed class RequestObfuscatorProxy : AndroidJavaProxy + { + private readonly NetworkLoggingRequestObfuscationHandler _handler; + + public RequestObfuscatorProxy(NetworkLoggingRequestObfuscationHandler handler) + : base("com.bugfender.unity.androidlib.UnityNetworkRequestObfuscator") + { + _handler = handler; + } + + public AndroidJavaObject obfuscate(string url, AndroidJavaObject headers, string body) + { + var managedHeaders = JavaMapToDictionary(headers); + NetworkRequestData result; + try + { + result = _handler(url ?? string.Empty, managedHeaders, body); + } + catch (Exception) + { + result = new NetworkRequestData(url ?? string.Empty, new Dictionary(), null); + } + + return ToJavaResult(result.Url, result.Headers, result.Body); + } + } + + private sealed class ResponseObfuscatorProxy : AndroidJavaProxy + { + private readonly NetworkLoggingResponseObfuscationHandler _handler; + + public ResponseObfuscatorProxy(NetworkLoggingResponseObfuscationHandler handler) + : base("com.bugfender.unity.androidlib.UnityNetworkResponseObfuscator") + { + _handler = handler; + } + + public AndroidJavaObject obfuscate(AndroidJavaObject headers, string body) + { + var managedHeaders = JavaMapToDictionary(headers); + NetworkResponseData result; + try + { + result = _handler(managedHeaders, body); + } + catch (Exception) + { + result = new NetworkResponseData(new Dictionary(), null); + } + + return ToJavaResult(null, result.Headers, result.Body); + } + } + + private static Dictionary JavaMapToDictionary(AndroidJavaObject map) + { + var result = new Dictionary(StringComparer.Ordinal); + if (map == null) + { + return result; + } + + try + { + var entrySet = map.Call("entrySet"); + var iterator = entrySet.Call("iterator"); + while (iterator.Call("hasNext")) + { + var entry = iterator.Call("next"); + var keyObj = entry.Call("getKey"); + var valueObj = entry.Call("getValue"); + var key = keyObj != null ? keyObj.Call("toString") : null; + if (string.IsNullOrEmpty(key)) + { + continue; + } + + result[key] = valueObj != null ? valueObj.Call("toString") : string.Empty; + } + } + catch (Exception) + { + // Fall back to empty headers. + } + + return result; + } + + private static AndroidJavaObject ToJavaResult(string url, IDictionary headers, string body) + { + using (var javaHeaders = new AndroidJavaObject("java.util.HashMap")) + { + if (headers != null) + { + foreach (var pair in headers) + { + if (pair.Key == null) + { + continue; + } + + javaHeaders.Call("put", pair.Key, pair.Value ?? string.Empty); + } + } + + return new AndroidJavaObject( + "com.bugfender.unity.androidlib.NetworkObfuscationResult", + url, + javaHeaders, + body); + } + } +#endif + +#if UNITY_IOS && !UNITY_EDITOR + private static void SyncIos( + NetworkLoggingRequestObfuscationHandler requestHandler, + NetworkLoggingResponseObfuscationHandler responseHandler) + { + try + { + if (!_iosCallbacksRegistered) + { + BugfenderNativeIos.RegisterNetworkRequestObfuscationCallback(OnNativeRequestObfuscation); + BugfenderNativeIos.RegisterNetworkResponseObfuscationCallback(OnNativeResponseObfuscation); + _iosCallbacksRegistered = true; + } + + BugfenderNativeIos.SetNetworkLoggingRequestObfuscationHandlerEnabled(requestHandler != null); + BugfenderNativeIos.SetNetworkLoggingResponseObfuscationHandlerEnabled(responseHandler != null); + } + catch (Exception ex) + { + Debug.LogWarning("[BF] Failed to sync iOS network obfuscation handlers: " + ex.Message); + } + } + + [MonoPInvokeCallback(typeof(BugfenderNativeIos.RequestObfuscationNativeDelegate))] + private static IntPtr OnNativeRequestObfuscation(IntPtr urlPtr, IntPtr headersJsonPtr, IntPtr bodyPtr) + { + var url = PtrToUtf8(urlPtr) ?? string.Empty; + var headersJson = PtrToUtf8(headersJsonPtr) ?? "{}"; + var body = bodyPtr != IntPtr.Zero ? PtrToUtf8(bodyPtr) : null; + var headers = NetworkLoggingUtils.ParseStringMapJson(headersJson); + + var handler = NetworkLoggingManager.RequestObfuscationHandler; + NetworkRequestData result; + if (handler != null) + { + try + { + result = handler(url, headers, body); + } + catch + { + result = new NetworkRequestData(url, new Dictionary(), null); + } + } + else + { + result = new NetworkRequestData(url, headers, body); + } + + var payload = NetworkLoggingUtils.BuildRequestObfuscationPayload(result.Url, result.Headers, result.Body); + return AllocUtf8(payload); + } + + [MonoPInvokeCallback(typeof(BugfenderNativeIos.ResponseObfuscationNativeDelegate))] + private static IntPtr OnNativeResponseObfuscation(IntPtr headersJsonPtr, IntPtr bodyPtr) + { + var headersJson = PtrToUtf8(headersJsonPtr) ?? "{}"; + var body = bodyPtr != IntPtr.Zero ? PtrToUtf8(bodyPtr) : null; + var headers = NetworkLoggingUtils.ParseStringMapJson(headersJson); + + var handler = NetworkLoggingManager.ResponseObfuscationHandler; + NetworkResponseData result; + if (handler != null) + { + try + { + result = handler(headers, body); + } + catch + { + result = new NetworkResponseData(new Dictionary(), null); + } + } + else + { + result = new NetworkResponseData(headers, body); + } + + var payload = NetworkLoggingUtils.BuildResponseObfuscationPayload(result.Headers, result.Body); + return AllocUtf8(payload); + } + + private static string PtrToUtf8(IntPtr ptr) + { + if (ptr == IntPtr.Zero) + { + return null; + } + + var length = 0; + while (Marshal.ReadByte(ptr, length) != 0) + { + length++; + } + + if (length == 0) + { + return string.Empty; + } + + var bytes = new byte[length]; + Marshal.Copy(ptr, bytes, 0, length); + return System.Text.Encoding.UTF8.GetString(bytes); + } + + private static IntPtr AllocUtf8(string value) + { + if (value == null) + { + value = string.Empty; + } + + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + var ptr = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, ptr, bytes.Length); + Marshal.WriteByte(ptr, bytes.Length, 0); + return ptr; + } +#endif +} \ No newline at end of file diff --git a/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs.meta b/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs.meta new file mode 100644 index 0000000..59728d9 --- /dev/null +++ b/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8c4e2a91f6b74d0e9a1b5c7d3e8f0a12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/NetworkLogging/NetworkLoggingUtils.cs b/Runtime/NetworkLogging/NetworkLoggingUtils.cs index 773f43f..abf6b62 100644 --- a/Runtime/NetworkLogging/NetworkLoggingUtils.cs +++ b/Runtime/NetworkLogging/NetworkLoggingUtils.cs @@ -342,4 +342,198 @@ public static string EscapeJson(string value) sb.Append('"'); return sb.ToString(); } + + public static string SerializeStringMap(IDictionary headers) + { + var sb = new StringBuilder(); + sb.Append('{'); + if (headers != null) + { + var first = true; + foreach (var pair in headers) + { + if (pair.Key == null) + { + continue; + } + + if (!first) + { + sb.Append(','); + } + + first = false; + sb.Append(EscapeJson(pair.Key)); + sb.Append(':'); + sb.Append(EscapeJson(pair.Value ?? string.Empty)); + } + } + + sb.Append('}'); + return sb.ToString(); + } + + public static Dictionary ParseStringMapJson(string json) + { + var result = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(json)) + { + return result; + } + + var i = 0; + SkipWs(json, ref i); + if (i >= json.Length || json[i] != '{') + { + return result; + } + + i++; + while (i < json.Length) + { + SkipWs(json, ref i); + if (i < json.Length && json[i] == '}') + { + break; + } + + if (!TryParseJsonString(json, ref i, out var key)) + { + break; + } + + SkipWs(json, ref i); + if (i >= json.Length || json[i] != ':') + { + break; + } + + i++; + SkipWs(json, ref i); + if (!TryParseJsonStringOrNull(json, ref i, out var value)) + { + break; + } + + result[key] = value ?? string.Empty; + SkipWs(json, ref i); + if (i < json.Length && json[i] == ',') + { + i++; + } + } + + return result; + } + + public static string BuildRequestObfuscationPayload(string url, IDictionary headers, string body) + { + var sb = new StringBuilder(); + sb.Append("{\"url\":"); + sb.Append(EscapeJson(url ?? string.Empty)); + sb.Append(",\"headers\":"); + sb.Append(SerializeStringMap(headers)); + sb.Append(",\"body\":"); + sb.Append(body == null ? "null" : EscapeJson(body)); + sb.Append('}'); + return sb.ToString(); + } + + public static string BuildResponseObfuscationPayload(IDictionary headers, string body) + { + var sb = new StringBuilder(); + sb.Append("{\"headers\":"); + sb.Append(SerializeStringMap(headers)); + sb.Append(",\"body\":"); + sb.Append(body == null ? "null" : EscapeJson(body)); + sb.Append('}'); + return sb.ToString(); + } + + private static void SkipWs(string json, ref int i) + { + while (i < json.Length && char.IsWhiteSpace(json[i])) + { + i++; + } + } + + private static bool TryParseJsonString(string json, ref int i, out string value) + { + value = null; + SkipWs(json, ref i); + if (i >= json.Length || json[i] != '"') + { + return false; + } + + i++; + var sb = new StringBuilder(); + while (i < json.Length) + { + var c = json[i++]; + if (c == '"') + { + value = sb.ToString(); + return true; + } + + if (c != '\\' || i >= json.Length) + { + sb.Append(c); + continue; + } + + var escaped = json[i++]; + switch (escaped) + { + case '"': + case '\\': + case '/': + sb.Append(escaped); + break; + case 'b': + sb.Append('\b'); + break; + case 'f': + sb.Append('\f'); + break; + case 'n': + sb.Append('\n'); + break; + case 'r': + sb.Append('\r'); + break; + case 't': + sb.Append('\t'); + break; + case 'u': + if (i + 4 <= json.Length + && int.TryParse(json.Substring(i, 4), System.Globalization.NumberStyles.HexNumber, null, out var code)) + { + sb.Append((char)code); + i += 4; + } + break; + default: + sb.Append(escaped); + break; + } + } + + return false; + } + + private static bool TryParseJsonStringOrNull(string json, ref int i, out string value) + { + value = null; + SkipWs(json, ref i); + if (i + 4 <= json.Length && string.Compare(json, i, "null", 0, 4, StringComparison.Ordinal) == 0) + { + i += 4; + return true; + } + + return TryParseJsonString(json, ref i, out value); + } } diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle b/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle index c90bea2..f5a0f9e 100644 --- a/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle +++ b/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle @@ -7,8 +7,21 @@ repositories { android { namespace "com.bugfender.unity.androidlib" compileSdk 34 + + defaultConfig { + minSdk 21 + } + + sourceSets { + main { + java.srcDirs = ['src/main/java'] + manifest.srcFile 'src/main/AndroidManifest.xml' + } + } } dependencies { - implementation 'com.bugfender.sdk:android:3.6.2' + implementation 'com.bugfender.sdk:android:4.+' + implementation 'com.bugfender.sdk:android-okhttp:4.+' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' } diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/AndroidManifest.xml b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d0f0324 --- /dev/null +++ b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java new file mode 100644 index 0000000..70391b7 --- /dev/null +++ b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java @@ -0,0 +1,34 @@ +package com.bugfender.unity.androidlib; + +import java.util.HashMap; +import java.util.Map; + +/** + * Result of a Unity-side network obfuscation callback. + */ +public final class NetworkObfuscationResult { + private final String url; + private final HashMap headers; + private final String body; + + public NetworkObfuscationResult(String url, Map headers, String body) { + this.url = url; + this.headers = new HashMap(); + if (headers != null) { + this.headers.putAll(headers); + } + this.body = body; + } + + public String getUrl() { + return url; + } + + public HashMap getHeaders() { + return headers; + } + + public String getBody() { + return body; + } +} diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java new file mode 100644 index 0000000..58e7838 --- /dev/null +++ b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java @@ -0,0 +1,206 @@ +package com.bugfender.unity.androidlib; + +import com.bugfender.sdk.Bugfender; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Map; + +/** + * Wires Unity obfuscation callbacks into the Android Bugfender SDK. + *

+ * Android SDK 4.0.1 ships R8-obfuscated handler types ({@code s1}/{@code t1}). + * Resolve them via reflection so this bridge stays compatible with the published Maven artifact. + */ +public final class UnityNetworkObfuscationBridge { + private UnityNetworkObfuscationBridge() { + } + + public static void setRequestObfuscationHandler(UnityNetworkRequestObfuscator handler) { + setObfuscationHandler( + "setNetworkLoggingRequestObfuscationHandler", + handler == null ? null : createRequestHandler(handler) + ); + } + + public static void setResponseObfuscationHandler(UnityNetworkResponseObfuscator handler) { + setObfuscationHandler( + "setNetworkLoggingResponseObfuscationHandler", + handler == null ? null : createResponseHandler(handler) + ); + } + + private static void setObfuscationHandler(String methodName, Object handler) { + try { + Method setter = findBugfenderMethod(methodName, 1); + if (setter == null) { + return; + } + setter.invoke(null, handler); + } catch (Exception ignored) { + // Optional API; ignore if unavailable. + } + } + + private static Method findBugfenderMethod(String name, int paramCount) { + for (Method method : Bugfender.class.getMethods()) { + if (name.equals(method.getName()) && method.getParameterTypes().length == paramCount) { + return method; + } + } + return null; + } + + private static Object createRequestHandler(final UnityNetworkRequestObfuscator unityHandler) { + Method setter = findBugfenderMethod("setNetworkLoggingRequestObfuscationHandler", 1); + if (setter == null) { + return null; + } + Class handlerType = setter.getParameterTypes()[0]; + return Proxy.newProxyInstance( + handlerType.getClassLoader(), + new Class[]{handlerType}, + new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (method.getDeclaringClass() == Object.class) { + return invokeObjectMethod(proxy, method, args); + } + if (args == null || args.length < 3) { + return null; + } + String url = args[0] instanceof String ? (String) args[0] : ""; + @SuppressWarnings("unchecked") + Map headers = args[1] instanceof Map + ? (Map) args[1] + : new HashMap(); + String body = args[2] instanceof String ? (String) args[2] : null; + + HashMap headersCopy = new HashMap(); + if (headers != null) { + headersCopy.putAll(headers); + } + + NetworkObfuscationResult result = unityHandler.obfuscate( + url != null ? url : "", + headersCopy, + body + ); + + String obfuscatedUrl = url; + Map obfuscatedHeaders = headersCopy; + String obfuscatedBody = body; + if (result != null) { + if (result.getUrl() != null) { + obfuscatedUrl = result.getUrl(); + } + if (result.getHeaders() != null) { + obfuscatedHeaders = result.getHeaders(); + } + obfuscatedBody = result.getBody(); + } + return newNetworkData( + method.getReturnType(), + obfuscatedUrl, + obfuscatedHeaders, + obfuscatedBody + ); + } + } + ); + } + + private static Object createResponseHandler(final UnityNetworkResponseObfuscator unityHandler) { + Method setter = findBugfenderMethod("setNetworkLoggingResponseObfuscationHandler", 1); + if (setter == null) { + return null; + } + Class handlerType = setter.getParameterTypes()[0]; + return Proxy.newProxyInstance( + handlerType.getClassLoader(), + new Class[]{handlerType}, + new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (method.getDeclaringClass() == Object.class) { + return invokeObjectMethod(proxy, method, args); + } + if (args == null || args.length < 2) { + return null; + } + @SuppressWarnings("unchecked") + Map headers = args[0] instanceof Map + ? (Map) args[0] + : new HashMap(); + String body = args[1] instanceof String ? (String) args[1] : null; + + HashMap headersCopy = new HashMap(); + if (headers != null) { + headersCopy.putAll(headers); + } + + NetworkObfuscationResult result = unityHandler.obfuscate(headersCopy, body); + + Map obfuscatedHeaders = headersCopy; + String obfuscatedBody = body; + if (result != null) { + if (result.getHeaders() != null) { + obfuscatedHeaders = result.getHeaders(); + } + obfuscatedBody = result.getBody(); + } + return newNetworkData( + method.getReturnType(), + null, + obfuscatedHeaders, + obfuscatedBody + ); + } + } + ); + } + + private static Object newNetworkData( + Class type, + String url, + Map headers, + String body + ) throws Exception { + for (Constructor constructor : type.getConstructors()) { + Class[] params = constructor.getParameterTypes(); + if ( + params.length == 3 && + params[0] == String.class && + Map.class.isAssignableFrom(params[1]) && + params[2] == String.class + ) { + return constructor.newInstance(url, headers, body); + } + if ( + params.length == 2 && + Map.class.isAssignableFrom(params[0]) && + params[1] == String.class + ) { + return constructor.newInstance(headers, body); + } + } + return null; + } + + private static Object invokeObjectMethod(Object proxy, Method method, Object[] args) { + String name = method.getName(); + if ("toString".equals(name)) { + return "UnityNetworkObfuscationHandlerProxy"; + } + if ("hashCode".equals(name)) { + return System.identityHashCode(proxy); + } + if ("equals".equals(name)) { + return proxy == (args != null && args.length > 0 ? args[0] : null); + } + return null; + } +} diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkRequestObfuscator.java b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkRequestObfuscator.java new file mode 100644 index 0000000..d126d5e --- /dev/null +++ b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkRequestObfuscator.java @@ -0,0 +1,10 @@ +package com.bugfender.unity.androidlib; + +import java.util.HashMap; + +/** + * Implemented from C# via {@code AndroidJavaProxy} so native OkHttp capture can invoke Unity obfuscation handlers. + */ +public interface UnityNetworkRequestObfuscator { + NetworkObfuscationResult obfuscate(String url, HashMap headers, String body); +} diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkResponseObfuscator.java b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkResponseObfuscator.java new file mode 100644 index 0000000..3950323 --- /dev/null +++ b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkResponseObfuscator.java @@ -0,0 +1,10 @@ +package com.bugfender.unity.androidlib; + +import java.util.HashMap; + +/** + * Implemented from C# via {@code AndroidJavaProxy} so native OkHttp capture can invoke Unity obfuscation handlers. + */ +public interface UnityNetworkResponseObfuscator { + NetworkObfuscationResult obfuscate(HashMap headers, String body); +} diff --git a/Runtime/Plugins/iOS/BugfenderBridge.mm b/Runtime/Plugins/iOS/BugfenderBridge.mm index 04e7a79..da6d4fe 100644 --- a/Runtime/Plugins/iOS/BugfenderBridge.mm +++ b/Runtime/Plugins/iOS/BugfenderBridge.mm @@ -1,5 +1,7 @@ #import #import +#include +#include NSString* convertCStringToNSString(const char* s) { @@ -169,4 +171,134 @@ void BugfenderSetNetworkLoggingMaxRequestsPerMinute(int countOrNegative) { [Bugfender setNetworkLoggingMaxRequestsPerMinute:value]; } +typedef char* (*BugfenderUnityRequestObfuscationCallback)(const char* url, const char* headersJson, const char* body); +typedef char* (*BugfenderUnityResponseObfuscationCallback)(const char* headersJson, const char* body); + +static BugfenderUnityRequestObfuscationCallback s_requestObfuscationCallback = NULL; +static BugfenderUnityResponseObfuscationCallback s_responseObfuscationCallback = NULL; + +static NSString* jsonFromDictionary(NSDictionary* headers) { + NSDictionary* source = headers ?: @{}; + NSData* data = [NSJSONSerialization dataWithJSONObject:source options:0 error:nil]; + if (data == nil) { + return @"{}"; + } + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}"; +} + +static NSDictionary* parseObfuscationPayload(const char* jsonCString) { + if (jsonCString == NULL) { + return nil; + } + NSString* json = [NSString stringWithUTF8String:jsonCString]; + free((void*)jsonCString); + if (json.length == 0) { + return nil; + } + NSData* data = [json dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) { + return nil; + } + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary*)object : nil; +} + +void BugfenderRegisterNetworkRequestObfuscationCallback(BugfenderUnityRequestObfuscationCallback callback) { + s_requestObfuscationCallback = callback; +} + +void BugfenderRegisterNetworkResponseObfuscationCallback(BugfenderUnityResponseObfuscationCallback callback) { + s_responseObfuscationCallback = callback; +} + +void BugfenderSetNetworkLoggingRequestObfuscationHandlerEnabled(bool enabled) { + if (![Bugfender respondsToSelector:@selector(setNetworkLoggingRequestObfuscationHandler:)]) { + return; + } + if (!enabled) { + [Bugfender setNetworkLoggingRequestObfuscationHandler:nil]; + return; + } + [Bugfender setNetworkLoggingRequestObfuscationHandler:^BFNetworkRequestData * _Nonnull(NSString * _Nonnull url, NSDictionary * _Nonnull headers, NSString * _Nullable body) { + if (s_requestObfuscationCallback == NULL) { + return [[BFNetworkRequestData alloc] initWithURL:url headers:headers body:body]; + } + NSString* headersJson = jsonFromDictionary(headers); + char* resultC = s_requestObfuscationCallback( + [url UTF8String], + [headersJson UTF8String], + body != nil ? [body UTF8String] : NULL); + NSDictionary* payload = parseObfuscationPayload(resultC); + if (payload == nil) { + return [[BFNetworkRequestData alloc] initWithURL:url headers:headers body:body]; + } + NSString* obfuscatedUrl = [payload[@"url"] isKindOfClass:[NSString class]] ? payload[@"url"] : url; + NSDictionary* obfuscatedHeaders = headers; + id headersValue = payload[@"headers"]; + if ([headersValue isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary* mapped = [NSMutableDictionary dictionary]; + for (id key in (NSDictionary*)headersValue) { + if (![key isKindOfClass:[NSString class]]) { + continue; + } + id value = ((NSDictionary*)headersValue)[key]; + mapped[(NSString*)key] = [value isKindOfClass:[NSString class]] ? (NSString*)value : (value ? [value description] : @""); + } + obfuscatedHeaders = mapped; + } + NSString* obfuscatedBody = body; + id bodyValue = payload[@"body"]; + if (bodyValue == nil || bodyValue == [NSNull null]) { + obfuscatedBody = nil; + } else if ([bodyValue isKindOfClass:[NSString class]]) { + obfuscatedBody = (NSString*)bodyValue; + } + return [[BFNetworkRequestData alloc] initWithURL:obfuscatedUrl headers:obfuscatedHeaders body:obfuscatedBody]; + }]; +} + +void BugfenderSetNetworkLoggingResponseObfuscationHandlerEnabled(bool enabled) { + if (![Bugfender respondsToSelector:@selector(setNetworkLoggingResponseObfuscationHandler:)]) { + return; + } + if (!enabled) { + [Bugfender setNetworkLoggingResponseObfuscationHandler:nil]; + return; + } + [Bugfender setNetworkLoggingResponseObfuscationHandler:^BFNetworkResponseData * _Nonnull(NSDictionary * _Nonnull headers, NSString * _Nullable body) { + if (s_responseObfuscationCallback == NULL) { + return [[BFNetworkResponseData alloc] initWithHeaders:headers body:body]; + } + NSString* headersJson = jsonFromDictionary(headers); + char* resultC = s_responseObfuscationCallback( + [headersJson UTF8String], + body != nil ? [body UTF8String] : NULL); + NSDictionary* payload = parseObfuscationPayload(resultC); + if (payload == nil) { + return [[BFNetworkResponseData alloc] initWithHeaders:headers body:body]; + } + NSDictionary* obfuscatedHeaders = headers; + id headersValue = payload[@"headers"]; + if ([headersValue isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary* mapped = [NSMutableDictionary dictionary]; + for (id key in (NSDictionary*)headersValue) { + if (![key isKindOfClass:[NSString class]]) { + continue; + } + id value = ((NSDictionary*)headersValue)[key]; + mapped[(NSString*)key] = [value isKindOfClass:[NSString class]] ? (NSString*)value : (value ? [value description] : @""); + } + obfuscatedHeaders = mapped; + } + NSString* obfuscatedBody = body; + id bodyValue = payload[@"body"]; + if (bodyValue == nil || bodyValue == [NSNull null]) { + obfuscatedBody = nil; + } else if ([bodyValue isKindOfClass:[NSString class]]) { + obfuscatedBody = (NSString*)bodyValue; + } + return [[BFNetworkResponseData alloc] initWithHeaders:obfuscatedHeaders body:obfuscatedBody]; + }]; +} + } diff --git a/TESTING.md b/TESTING.md index 1ab7e88..85514bc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -60,14 +60,26 @@ Expected result: ## Network logging checks 1. Call `Bugfender.SetNetworkLoggingEnabled(true)` after init. -2. Send an HTTP request with either: +2. Register obfuscation handlers and confirm sensitive headers/bodies are redacted: + ```csharp + Bugfender.SetNetworkLoggingRequestObfuscationHandler((url, headers, body) => + { + if (headers.ContainsKey("Authorization")) + headers["Authorization"] = "***"; + return new NetworkRequestData(url, headers, body); + }); + Bugfender.SetNetworkLoggingResponseObfuscationHandler((headers, body) => + new NetworkResponseData(headers, body)); + ``` +3. Send an HTTP request with either: - `new HttpClient(new BugfenderHttpMessageHandler())`, or - `yield return BugfenderUnityWebRequest.Send(request)` -3. Confirm a log with tag `bf_network` appears in the device session. -4. Confirm the JSON payload includes `url`, `method`, `request_id`, `start_time`, `duration_ms`, `request_headers`, `response_headers`, and `timing`. -5. Confirm instrumented requests include `X-Bugfender-Request-ID` (and `X-Bugfender-Session-ID` when a session id is available). -6. Optional: enable body capture / error-body capture and verify bodies appear only as configured. -7. Optional: set an allowlist/denylist and rate limit and verify filtering. +4. Confirm a log with tag `bf_network` appears in the device session. +5. Confirm the JSON payload includes `url`, `method`, `request_id`, `start_time`, `duration_ms`, `request_headers`, `response_headers`, and `timing`. +6. Confirm instrumented requests include `X-Bugfender-Request-ID` (and `X-Bugfender-Session-ID` when a session id is available). +7. Optional: enable body capture / error-body capture and verify bodies appear only as configured. +8. Optional: set an allowlist/denylist and rate limit and verify filtering. +9. On device builds, confirm Gradle resolves `com.bugfender.sdk:android:4.+` and `android-okhttp:4.+`, and iOS SPM resolves BugfenderSDK-iOS `3.0.1`+. ## What to capture on failure diff --git a/package.json b/package.json index f812c35..55c2792 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.bugfender.unity", - "version": "3.1.0", + "version": "3.0.1", "displayName": "Bugfender", "description": "Unity bindings for the native Bugfender iOS and Android SDKs", "unity": "2022.3", @@ -11,5 +11,12 @@ "name": "Bugfender (Beenario GmbH)", "email": "support@bugfender.com", "url": "https://bugfender.com" - } + }, + "samples": [ + { + "displayName": "Network Logging", + "description": "Enable network capture, URL filters, rate limits, and request/response obfuscation handlers. Exercises HttpClient and UnityWebRequest helpers.", + "path": "Samples~/NetworkLogging" + } + ] } From a4e65661bc0b464a9bbcb567af3892f8128e5da9 Mon Sep 17 00:00:00 2001 From: Ali Farhadi Date: Tue, 28 Jul 2026 18:02:44 +0330 Subject: [PATCH 3/4] feat: Network Logging - Fixed Android network logs failing when HttpClient ran off the main thread - Fixed the obfuscation bridge so HashMap updates stop crashing in JNI --- Runtime/Bugfender.cs | 133 ++++++++++++++++-- .../BugfenderHttpMessageHandler.cs | 74 ++++++---- .../NetworkLogging/NetworkLoggingManager.cs | 9 +- .../NetworkLoggingNativeBridge.cs | 11 +- .../androidlib/NetworkObfuscationResult.java | 19 ++- .../UnityNetworkObfuscationBridge.java | 6 + 6 files changed, 201 insertions(+), 51 deletions(-) diff --git a/Runtime/Bugfender.cs b/Runtime/Bugfender.cs index 5522269..5ee6131 100755 --- a/Runtime/Bugfender.cs +++ b/Runtime/Bugfender.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; using UnityEngine; using UnityEngine.Diagnostics; @@ -18,6 +20,11 @@ public class Bugfender : MonoBehaviour { public enum LogLevel { Debug, Warning, Error, Trace, Info, Fatal }; + private static int _mainThreadId; + private static SynchronizationContext _unitySynchronizationContext; + private static readonly ConcurrentQueue _mainThreadActions = new ConcurrentQueue(); + private static Bugfender _instance; + private static bool BugfenderResourceFlagTrue(string resourceName) { var asset = Resources.Load(resourceName); @@ -29,6 +36,85 @@ private static bool ResourcesWantNativeLogCapture() return BugfenderResourceFlagTrue("bugfender_native_log_capture"); } + ///

+ /// AndroidJava / JNI must run on the Unity main thread. + /// HttpClient continuations (and OkHttp callbacks) often run elsewhere. + private static void RunOnMainThread(Action action) + { + if (action == null) + { + return; + } + + if (_mainThreadId == 0 || Thread.CurrentThread.ManagedThreadId == _mainThreadId) + { + action(); + return; + } + + // Prefer UnitySynchronizationContext — survives scene unloads (unlike Update on a scene object). + var context = _unitySynchronizationContext; + if (context != null) + { + context.Post(_ => + { + try + { + action(); + } + catch (Exception ex) + { + Debug.LogWarning("[BF] Main-thread action failed: " + ex.Message); + } + }, null); + return; + } + + _mainThreadActions.Enqueue(action); + } + + void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + + _instance = this; + _mainThreadId = Thread.CurrentThread.ManagedThreadId; + if (SynchronizationContext.Current != null) + { + _unitySynchronizationContext = SynchronizationContext.Current; + } + + // Keep the dispatcher (and native AndroidJavaClass holder) across scene loads. + DontDestroyOnLoad(gameObject); + } + + void OnDestroy() + { + if (_instance == this) + { + _instance = null; + } + } + + void Update() + { + while (_mainThreadActions.TryDequeue(out var action)) + { + try + { + action(); + } + catch (Exception ex) + { + Debug.LogWarning("[BF] Main-thread action failed: " + ex.Message); + } + } + } + #if UNITY_ANDROID && !UNITY_EDITOR private static AndroidJavaClass bugfender; #endif @@ -214,11 +300,30 @@ public static void Log(string message) public static void Log(LogLevel logLevel, string tag, string message) { #if UNITY_ANDROID && !UNITY_EDITOR - if (bugfender != null) { - AndroidJavaClass levelClass = new AndroidJavaClass ("com.bugfender.sdk.LogLevel"); - AndroidJavaObject level = levelClass.GetStatic(logLevel.ToString()); - bugfender.CallStatic ("log", 0, "", "", level, tag, message); - } + // Capture args; network Emit often calls this from a thread-pool continuation. + var levelName = logLevel.ToString(); + var logTag = tag ?? string.Empty; + var logMessage = message ?? string.Empty; + RunOnMainThread(() => + { + if (bugfender == null) + { + return; + } + + try + { + using (var levelClass = new AndroidJavaClass("com.bugfender.sdk.LogLevel")) + { + var level = levelClass.GetStatic(levelName); + bugfender.CallStatic("log", 0, "", "", level, logTag, logMessage); + } + } + catch (Exception ex) + { + Debug.LogWarning("[BF] Log failed: " + ex.Message); + } + }); #elif UNITY_IOS && !UNITY_EDITOR int intLevel = (int)logLevel; BugfenderNativeIos.Log(intLevel, tag, message); @@ -330,9 +435,12 @@ public static void SetForceEnabled(bool enabled) public static void ForceSendOnce() { #if UNITY_ANDROID && !UNITY_EDITOR - if (bugfender != null) { - bugfender.CallStatic ("forceSendOnce"); - } + RunOnMainThread(() => + { + if (bugfender != null) { + try { bugfender.CallStatic ("forceSendOnce"); } catch (Exception) { } + } + }); #elif UNITY_IOS && !UNITY_EDITOR BugfenderNativeIos.ForceSendOnce(); #else @@ -526,11 +634,14 @@ private static AndroidJavaObject ToJavaStringList(IList items) } var list = new AndroidJavaObject("java.util.ArrayList"); - foreach (var item in items) + using (var bridge = new AndroidJavaClass("com.bugfender.unity.androidlib.UnityNetworkObfuscationBridge")) { - if (item != null) + foreach (var item in items) { - list.Call("add", item); + if (item != null) + { + bridge.CallStatic("listAddString", list, item); + } } } diff --git a/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs b/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs index a569286..189f12d 100644 --- a/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs +++ b/Runtime/NetworkLogging/BugfenderHttpMessageHandler.cs @@ -114,23 +114,30 @@ protected override async Task SendAsync( } } - NetworkLoggingManager.Emit(new NetworkLogEntry + try + { + NetworkLoggingManager.Emit(new NetworkLogEntry + { + Url = url, + Method = method, + RequestId = requestId ?? NetworkLoggingManager.CreateRequestId(), + StartTimeMs = startTimeMs, + DurationMs = stopwatch.ElapsedMilliseconds, + StatusCode = statusCode, + RequestSize = requestSize, + ResponseSize = responseSize, + RequestHeaders = requestHeaders, + ResponseHeaders = responseHeaders, + RequestBody = requestBody, + ResponseBody = responseBody, + IncludeRequestBody = includeRequestBody, + IncludeResponseBody = includeResponseBody, + }); + } + catch { - Url = url, - Method = method, - RequestId = requestId ?? NetworkLoggingManager.CreateRequestId(), - StartTimeMs = startTimeMs, - DurationMs = stopwatch.ElapsedMilliseconds, - StatusCode = statusCode, - RequestSize = requestSize, - ResponseSize = responseSize, - RequestHeaders = requestHeaders, - ResponseHeaders = responseHeaders, - RequestBody = requestBody, - ResponseBody = responseBody, - IncludeRequestBody = includeRequestBody, - IncludeResponseBody = includeResponseBody, - }); + // Never let capture/logging break the HTTP call. + } } return response; @@ -140,21 +147,28 @@ protected override async Task SendAsync( stopwatch.Stop(); if (shouldCapture) { - NetworkLoggingManager.Emit(new NetworkLogEntry + try + { + NetworkLoggingManager.Emit(new NetworkLogEntry + { + Url = url, + Method = method, + RequestId = requestId ?? NetworkLoggingManager.CreateRequestId(), + StartTimeMs = startTimeMs, + DurationMs = stopwatch.ElapsedMilliseconds, + RequestSize = requestSize, + RequestHeaders = requestHeaders ?? new Dictionary(), + ResponseHeaders = new Dictionary(), + RequestBody = requestBody, + IncludeRequestBody = includeRequestBody, + IncludeResponseBody = false, + Error = ex.Message, + }); + } + catch { - Url = url, - Method = method, - RequestId = requestId ?? NetworkLoggingManager.CreateRequestId(), - StartTimeMs = startTimeMs, - DurationMs = stopwatch.ElapsedMilliseconds, - RequestSize = requestSize, - RequestHeaders = requestHeaders ?? new Dictionary(), - ResponseHeaders = new Dictionary(), - RequestBody = requestBody, - IncludeRequestBody = includeRequestBody, - IncludeResponseBody = false, - Error = ex.Message, - }); + // Never let capture/logging break the HTTP call. + } } throw; diff --git a/Runtime/NetworkLogging/NetworkLoggingManager.cs b/Runtime/NetworkLogging/NetworkLoggingManager.cs index a0545bc..8252361 100644 --- a/Runtime/NetworkLogging/NetworkLoggingManager.cs +++ b/Runtime/NetworkLogging/NetworkLoggingManager.cs @@ -275,7 +275,14 @@ public static void Emit(NetworkLogEntry entry) Timing = entry.Timing, }); - Bugfender.Log(Bugfender.LogLevel.Info, NetworkLoggingPayload.NetworkLogTag, text); + try + { + Bugfender.Log(Bugfender.LogLevel.Info, NetworkLoggingPayload.NetworkLogTag, text); + } + catch + { + // Logging must never throw into HTTP pipelines. + } } private static NetworkRequestData ApplyRequestObfuscation( diff --git a/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs b/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs index 8519f3f..f64462c 100644 --- a/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs +++ b/Runtime/NetworkLogging/NetworkLoggingNativeBridge.cs @@ -161,8 +161,9 @@ private static Dictionary JavaMapToDictionary(AndroidJavaObject private static AndroidJavaObject ToJavaResult(string url, IDictionary headers, string body) { - using (var javaHeaders = new AndroidJavaObject("java.util.HashMap")) + using (var resultClass = new AndroidJavaClass("com.bugfender.unity.androidlib.NetworkObfuscationResult")) { + var javaHeaders = resultClass.CallStatic("newHeaderMap"); if (headers != null) { foreach (var pair in headers) @@ -172,15 +173,11 @@ private static AndroidJavaObject ToJavaResult(string url, IDictionary("put", pair.Key, pair.Value ?? string.Empty); + resultClass.CallStatic("putHeader", javaHeaders, pair.Key, pair.Value ?? string.Empty); } } - return new AndroidJavaObject( - "com.bugfender.unity.androidlib.NetworkObfuscationResult", - url, - javaHeaders, - body); + return resultClass.CallStatic("create", url, javaHeaders, body); } } #endif diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java index 70391b7..840c845 100644 --- a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java +++ b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/NetworkObfuscationResult.java @@ -1,7 +1,6 @@ package com.bugfender.unity.androidlib; import java.util.HashMap; -import java.util.Map; /** * Result of a Unity-side network obfuscation callback. @@ -11,7 +10,7 @@ public final class NetworkObfuscationResult { private final HashMap headers; private final String body; - public NetworkObfuscationResult(String url, Map headers, String body) { + public NetworkObfuscationResult(String url, HashMap headers, String body) { this.url = url; this.headers = new HashMap(); if (headers != null) { @@ -20,6 +19,22 @@ public NetworkObfuscationResult(String url, Map headers, String this.body = body; } + /** Preferred from C#: avoids HashMap.put(Object,Object) signature mismatches. */ + public static NetworkObfuscationResult create(String url, HashMap headers, String body) { + return new NetworkObfuscationResult(url, headers, body); + } + + public static HashMap newHeaderMap() { + return new HashMap(); + } + + public static void putHeader(HashMap map, String key, String value) { + if (map == null || key == null) { + return; + } + map.put(key, value != null ? value : ""); + } + public String getUrl() { return url; } diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java index 58e7838..a7d2569 100644 --- a/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java +++ b/Runtime/Plugins/Android/Bugfender.androidlib/src/main/java/com/bugfender/unity/androidlib/UnityNetworkObfuscationBridge.java @@ -19,6 +19,12 @@ public final class UnityNetworkObfuscationBridge { private UnityNetworkObfuscationBridge() { } + public static void listAddString(java.util.ArrayList list, String item) { + if (list != null && item != null) { + list.add(item); + } + } + public static void setRequestObfuscationHandler(UnityNetworkRequestObfuscator handler) { setObfuscationHandler( "setNetworkLoggingRequestObfuscationHandler", From 0f063719d2fdd6f83f4b3243eb2f604df86dd874 Mon Sep 17 00:00:00 2001 From: Aleix Ventayol Date: Wed, 29 Jul 2026 13:41:20 +0200 Subject: [PATCH 4/4] Preserve Android network bridge in minified builds --- Runtime/Plugins/Android/Bugfender.androidlib/build.gradle | 1 + Runtime/Plugins/Android/Bugfender.androidlib/consumer-rules.pro | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 Runtime/Plugins/Android/Bugfender.androidlib/consumer-rules.pro diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle b/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle index f5a0f9e..106d0be 100644 --- a/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle +++ b/Runtime/Plugins/Android/Bugfender.androidlib/build.gradle @@ -10,6 +10,7 @@ android { defaultConfig { minSdk 21 + consumerProguardFiles 'consumer-rules.pro' } sourceSets { diff --git a/Runtime/Plugins/Android/Bugfender.androidlib/consumer-rules.pro b/Runtime/Plugins/Android/Bugfender.androidlib/consumer-rules.pro new file mode 100644 index 0000000..95408fe --- /dev/null +++ b/Runtime/Plugins/Android/Bugfender.androidlib/consumer-rules.pro @@ -0,0 +1,2 @@ +# These classes and members are resolved by name from Unity's managed runtime. +-keep class com.bugfender.unity.androidlib.** { *; }