Logrocket fixes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1aae6350f04254f3dabfb7a552a50cb4
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user