Logrocket fixes

This commit is contained in:
Savya Bikram Shah
2026-06-01 15:47:14 +05:45
parent f9826325c6
commit 28810235a5
124 changed files with 1178949 additions and 405 deletions

View File

@@ -2,6 +2,15 @@ using UnityEngine;
namespace Darkmatter.Services.LogRocket.Configuration
{
/// <summary>
/// Configuration for the LogRocket service.
///
/// Session replay is recorded natively and automatically by the LogRocket mobile SDK
/// from init - there is no per-frame upload from Unity. Use
/// <see cref="Core.Contracts.Services.LogRocket.ILogRocketService"/>'s
/// StartSessionReplay / StopSessionReplay for manual session control. NOTE: native
/// capture may not see Unity's Metal/GL surface - verify replay content on a device.
/// </summary>
[CreateAssetMenu(menuName = "Darkmatter/Services/LogRocket Config", fileName = "LogRocketConfig")]
public sealed class LogRocketConfig : ScriptableObject
{
@@ -16,24 +25,8 @@ namespace Darkmatter.Services.LogRocket.Configuration
[Tooltip("Minimum Unity log type forwarded.")]
public LogType MinimumLogType = LogType.Warning;
[Header("Session Replay")]
[Tooltip("Periodically capture a screenshot and upload it as a replay frame.")]
public bool EnableSessionReplay = true;
[Tooltip("Seconds between captured frames. Lower interval = larger payload.")]
[Range(0.25f, 10f)] public float CaptureIntervalSeconds = 2f;
[Tooltip("Max width in pixels for captured frames. Frames downscale to fit.")]
[Range(128, 2048)] public int MaxFrameWidth = 720;
[Tooltip("JPEG quality (1-100) used when encoding replay frames.")]
[Range(10, 100)] public int JpegQuality = 60;
[Header("Behaviour")]
[Tooltip("Disable LogRocket entirely in the editor.")]
public bool DisableInEditor = true;
[Tooltip("Auto-start session on service init.")]
public bool AutoStart = true;
}
}

View File

@@ -3,24 +3,28 @@
External Dependency Manager (EDM4U) dependency spec for the LogRocket SDK.
EDM injects these into the generated iOS Podfile / Android Gradle on build.
Verify the versions against the current LogRocket release before shipping:
iOS pod: https://cocoapods.org/pods/LogRocket (ObjC class: LROSDK)
Android Maven: confirm group/artifact + the bridge's reflected class name
in LogRocketUnityBridge.java (com.logrocket.core.LogRocket is
an UNVERIFIED guess).
Android: VERIFIED against the actual com.logrocket:logrocket:3.1.0 AAR bytecode
(javap). The com.logrocket.core.LogRocket class exists only in the 3.x line - the
2.x line ships com.logrocket.core.SDK instead - so the range MUST stay >= 3.0,
or the direct-call bridge will not compile/link. Latest at time of writing: 3.1.0.
iOS: NOT a pod. The LogRocket.xcframework 3.1.0 (a dynamic Swift framework) is
vendored directly at Assets/Plugins/iOS/LogRocket.xcframework. The CocoaPods
integration linked it but did not embed it into the .app, causing a launch-time
dyld failure (@rpath/LogRocket.framework/LogRocket not loaded). Vendoring routes
it through Unity's plugin pipeline, which auto-embeds + signs dynamic frameworks.
The .m bridge (verified against this exact 3.1.0 ObjC header) is unchanged.
NOTE: the Android bridge (Plugins/Android/LogRocketUnityBridge.java) calls the
SDK directly, so the androidPackage below is REQUIRED - removing it will break
the Android compile.
-->
<dependencies>
<iosPods>
<iosPod name="LogRocket" version="~> 1.47" addToAllTargets="false">
<sources>
<source>https://github.com/CocoaPods/Specs</source>
</sources>
</iosPod>
</iosPods>
<androidPackages>
<!-- TODO: confirm the real Maven coordinate. Placeholder commented out so it
does not break Android resolution until verified.
<androidPackage spec="com.logrocket:logrocket:+" />
-->
<androidPackage spec="com.logrocket:logrocket:[3.0,4.0)">
<repositories>
<repository>https://storage.googleapis.com/logrocket-maven/</repository>
</repositories>
</androidPackage>
</androidPackages>
</dependencies>

View File

@@ -11,7 +11,6 @@ namespace Darkmatter.Services.LogRocket.Systems
void Track(string eventName, IReadOnlyDictionary<string, object> properties);
void Log(string severity, string message);
void LogException(string message, string stackTrace);
void CaptureFrame(byte[] jpegBytes, int width, int height);
void StartReplay();
void StopReplay();
}

View File

@@ -9,7 +9,8 @@ namespace Darkmatter.Services.LogRocket.Systems
{
private const string BridgeClass = "com.darkmatter.logrocket.LogRocketUnityBridge";
private AndroidJavaObject _bridge;
// The Java bridge exposes static methods (Plugins/Android/LogRocketUnityBridge.java).
private AndroidJavaClass _bridge;
public bool IsAvailable => _bridge != null;
public string SessionUrl => SafeCall<string>("getSessionUrl");
@@ -17,11 +18,10 @@ namespace Darkmatter.Services.LogRocket.Systems
{
try
{
using var bridgeClass = new AndroidJavaClass(BridgeClass);
using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
using var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
_bridge = bridgeClass.CallStatic<AndroidJavaObject>("getInstance");
_bridge.Call("init", activity, appId ?? string.Empty);
_bridge = new AndroidJavaClass(BridgeClass);
_bridge.CallStatic("init", activity, appId ?? string.Empty);
}
catch (Exception e)
{
@@ -33,56 +33,49 @@ namespace Darkmatter.Services.LogRocket.Systems
public void Identify(string userId, IReadOnlyDictionary<string, object> traits)
{
if (_bridge == null) return;
try { _bridge.Call("identify", userId ?? string.Empty, MapToJson(traits)); }
try { _bridge.CallStatic("identify", userId ?? string.Empty, MapToJson(traits)); }
catch (Exception e) { Debug.LogError($"[LogRocket] identify failed: {e}"); }
}
public void Track(string eventName, IReadOnlyDictionary<string, object> properties)
{
if (_bridge == null) return;
try { _bridge.Call("track", eventName ?? string.Empty, MapToJson(properties)); }
try { _bridge.CallStatic("track", eventName ?? string.Empty, MapToJson(properties)); }
catch (Exception e) { Debug.LogError($"[LogRocket] track failed: {e}"); }
}
public void Log(string severity, string message)
{
if (_bridge == null) return;
try { _bridge.Call("log", severity ?? "info", message ?? string.Empty); }
try { _bridge.CallStatic("log", severity ?? "info", message ?? string.Empty); }
catch (Exception e) { Debug.LogError($"[LogRocket] log failed: {e}"); }
}
public void LogException(string message, string stackTrace)
{
if (_bridge == null) return;
try { _bridge.Call("logException", message ?? string.Empty, stackTrace ?? string.Empty); }
try { _bridge.CallStatic("logException", message ?? string.Empty, stackTrace ?? string.Empty); }
catch (Exception e) { Debug.LogError($"[LogRocket] logException failed: {e}"); }
}
public void CaptureFrame(byte[] jpeg, int width, int height)
{
if (_bridge == null || jpeg == null || jpeg.Length == 0) return;
try { _bridge.Call("captureFrame", jpeg, width, height); }
catch (Exception e) { Debug.LogError($"[LogRocket] captureFrame failed: {e}"); }
}
public void StartReplay()
{
if (_bridge == null) return;
try { _bridge.Call("startReplay"); }
try { _bridge.CallStatic("startReplay"); }
catch (Exception e) { Debug.LogError($"[LogRocket] startReplay failed: {e}"); }
}
public void StopReplay()
{
if (_bridge == null) return;
try { _bridge.Call("stopReplay"); }
try { _bridge.CallStatic("stopReplay"); }
catch (Exception e) { Debug.LogError($"[LogRocket] stopReplay failed: {e}"); }
}
private T SafeCall<T>(string method)
{
if (_bridge == null) return default;
try { return _bridge.Call<T>(method); }
try { return _bridge.CallStatic<T>(method); }
catch { return default; }
}

View File

@@ -13,7 +13,6 @@ namespace Darkmatter.Services.LogRocket.Systems
[DllImport("__Internal")] private static extern void _lr_track(string eventName, string propsJson);
[DllImport("__Internal")] private static extern void _lr_log(string severity, string message);
[DllImport("__Internal")] private static extern void _lr_logException(string message, string stack);
[DllImport("__Internal")] private static extern void _lr_captureFrame(IntPtr bytes, int length, int width, int height);
[DllImport("__Internal")] private static extern void _lr_startReplay();
[DllImport("__Internal")] private static extern void _lr_stopReplay();
[DllImport("__Internal")] private static extern IntPtr _lr_sessionUrl();
@@ -68,15 +67,6 @@ namespace Darkmatter.Services.LogRocket.Systems
catch (Exception e) { Debug.LogError($"[LogRocket] logException failed: {e}"); }
}
public void CaptureFrame(byte[] jpeg, int width, int height)
{
if (!_initialised || jpeg == null || jpeg.Length == 0) return;
var handle = GCHandle.Alloc(jpeg, GCHandleType.Pinned);
try { _lr_captureFrame(handle.AddrOfPinnedObject(), jpeg.Length, width, height); }
catch (Exception e) { Debug.LogError($"[LogRocket] captureFrame failed: {e}"); }
finally { handle.Free(); }
}
public void StartReplay()
{
if (!_initialised) return;

View File

@@ -24,7 +24,6 @@ namespace Darkmatter.Services.LogRocket.Systems
public void LogException(string message, string stackTrace) =>
Debug.Log($"[LogRocket:exception] {message}\n{stackTrace}");
public void CaptureFrame(byte[] jpeg, int w, int h) { }
public void StartReplay() => Debug.Log("[LogRocket] StartReplay (noop)");
public void StopReplay() => Debug.Log("[LogRocket] StopReplay (noop)");

View File

@@ -16,7 +16,6 @@ namespace Darkmatter.Services.LogRocket.Systems
private readonly Queue<Action> _pending = new();
private UnityLogForwarder _logForwarder;
private SessionReplayCapture _replay;
private bool _ready;
private bool _disabled;
@@ -57,8 +56,7 @@ namespace Darkmatter.Services.LogRocket.Systems
_logForwarder.Enable();
}
if (_config.EnableSessionReplay && _config.AutoStart)
StartSessionReplay();
// Session replay records natively from init - no Unity-side start needed.
_ready = true;
FlushPending();
@@ -97,32 +95,17 @@ namespace Darkmatter.Services.LogRocket.Systems
Run(() => _native.LogException(message, stackTrace));
}
public void StartSessionReplay()
{
Run(() =>
{
_native.StartReplay();
if (_replay == null)
_replay = new SessionReplayCapture(_native, _config);
_replay.Start();
});
}
// Manual session control. Recording is automatic from init; these map to the
// native SDK's session controls (e.g. iOS startNewSession / shutdown). Useful
// to rotate or halt a recording, e.g. around a sensitive screen.
public void StartSessionReplay() => Run(() => _native.StartReplay());
public void StopSessionReplay()
{
Run(() =>
{
_replay?.Stop();
_native.StopReplay();
});
}
public void StopSessionReplay() => Run(() => _native.StopReplay());
public void Dispose()
{
_logForwarder?.Disable();
_logForwarder = null;
_replay?.Dispose();
_replay = null;
_pending.Clear();
}

View File

@@ -1,110 +0,0 @@
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Darkmatter.Services.LogRocket.Configuration;
using UnityEngine;
namespace Darkmatter.Services.LogRocket.Systems
{
internal sealed class SessionReplayCapture : IDisposable
{
private readonly ILogRocketNative _native;
private readonly LogRocketConfig _config;
private CancellationTokenSource _cts;
private bool _running;
public SessionReplayCapture(ILogRocketNative native, LogRocketConfig config)
{
_native = native;
_config = config;
}
public void Start()
{
if (_running) return;
_running = true;
_cts = new CancellationTokenSource();
Loop(_cts.Token).Forget();
}
public void Stop()
{
if (!_running) return;
_running = false;
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
public void Dispose() => Stop();
private async UniTaskVoid Loop(CancellationToken ct)
{
float interval = Mathf.Max(0.25f, _config.CaptureIntervalSeconds);
while (!ct.IsCancellationRequested && _running)
{
try
{
// Must be true end-of-frame: ScreenCapture reads the backbuffer after
// rendering completes. LastPostLateUpdate fires before render finish and
// makes CaptureScreenshotAsTexture return an invalid texture.
await UniTask.WaitForEndOfFrame(ct);
if (ct.IsCancellationRequested) break;
CaptureOnce();
}
catch (OperationCanceledException) { break; }
catch (Exception e)
{
Debug.LogError($"[LogRocket] replay capture failed: {e}");
}
try { await UniTask.Delay(TimeSpan.FromSeconds(interval), cancellationToken: ct); }
catch (OperationCanceledException) { break; }
}
}
private void CaptureOnce()
{
var src = ScreenCapture.CaptureScreenshotAsTexture();
if (src == null) return;
Texture2D scaled = null;
try
{
int targetW = Mathf.Min(_config.MaxFrameWidth, src.width);
int targetH = Mathf.RoundToInt(src.height * (targetW / (float)src.width));
scaled = targetW == src.width && targetH == src.height ? src : Resize(src, targetW, targetH);
var jpeg = scaled.EncodeToJPG(Mathf.Clamp(_config.JpegQuality, 10, 100));
_native.CaptureFrame(jpeg, scaled.width, scaled.height);
}
finally
{
if (scaled != null && scaled != src) UnityEngine.Object.Destroy(scaled);
UnityEngine.Object.Destroy(src);
}
}
private static Texture2D Resize(Texture2D src, int w, int h)
{
var rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32);
var prev = RenderTexture.active;
try
{
RenderTexture.active = rt;
GL.Clear(false, true, Color.clear);
Graphics.Blit(src, rt);
var dst = new Texture2D(w, h, TextureFormat.RGBA32, false);
dst.ReadPixels(new Rect(0, 0, w, h), 0, 0);
dst.Apply();
return dst;
}
finally
{
RenderTexture.active = prev;
RenderTexture.ReleaseTemporary(rt);
}
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1aae6350f04254f3dabfb7a552a50cb4

View File

@@ -15,9 +15,4 @@ MonoBehaviour:
AppId: kzvc7x/colorbook
ForwardUnityLogs: 1
MinimumLogType: 2
EnableSessionReplay: 1
CaptureIntervalSeconds: 2
MaxFrameWidth: 720
JpegQuality: 60
DisableInEditor: 0
AutoStart: 1
DisableInEditor: 1