Analytics/NetworkStorageAnalyticsRuntime.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Sandbox.Diagnostics;
namespace Sandbox;
/// <summary>
/// Lightweight managed analytics runtime. Created automatically after NetworkStorage.Configure().
/// Sends join/leave and a 30 second heartbeat with privacy-safe s&box/runtime telemetry.
/// </summary>
internal sealed class NetworkStorageAnalyticsRuntime : Component
{
private const float HeartbeatInterval = 30f;
private const float VoiceActivityThreshold = 0.02f;
private static NetworkStorageAnalyticsRuntime _instance;
private static bool _ensureQueued;
private static readonly object EndpointLock = new();
private static int _endpointCallCount;
private static int _endpointFailureCount;
private static string _lastEndpointSlug;
private static string _lastEndpointErrorCode;
private static string _lastEndpointErrorMessage;
private static double _lastEndpointLatencyMs;
private string _sessionId;
private double _sessionStartedAt;
private double _nextHeartbeatAt;
private double _fpsSum;
private int _fpsSamples;
private float _fpsPeak;
private float _fpsMin = float.MaxValue;
private bool _leaveSent;
private double _voiceSpeakingSeconds;
private int _voiceListeningCount;
private float _voicePeakAmplitude;
internal static void EnsureCreated( string reason = "unspecified" )
{
if ( _instance is not null ) return;
if ( Game.ActiveScene is null )
{
QueueEnsureRetry( reason );
return;
}
foreach ( var existing in Game.ActiveScene.GetAllComponents<NetworkStorageAnalyticsRuntime>() )
{
_instance = existing;
if ( NetworkStorageLogConfig.LogConfig )
Log.Info( $"[NetworkStorage] Analytics runtime found in active scene (reason={reason})." );
return;
}
var go = new GameObject( true, "Network Storage Analytics" );
_instance = go.Components.Create<NetworkStorageAnalyticsRuntime>();
if ( NetworkStorageLogConfig.LogConfig )
Log.Info( $"[NetworkStorage] Analytics runtime created (reason={reason}); join + 30s heartbeat reporting enabled." );
}
internal static void RecordEndpointDiagnostic( string slug, bool ok, double elapsedMs = 0, string code = null, string message = null )
{
if ( string.IsNullOrWhiteSpace( slug ) ) return;
if ( string.Equals( slug, "analytics", StringComparison.OrdinalIgnoreCase ) ) return;
lock ( EndpointLock )
{
_endpointCallCount++;
_lastEndpointSlug = slug;
_lastEndpointLatencyMs = Math.Max( 0, elapsedMs );
if ( !ok )
{
_endpointFailureCount++;
_lastEndpointErrorCode = string.IsNullOrWhiteSpace( code ) ? "UNKNOWN" : code;
_lastEndpointErrorMessage = Redact( message ?? "" );
}
}
if ( ok || !NetworkStorage.EnablePlayerAnalytics || !NetworkStorage.AnalyticsCaptureEndpointDiagnostics ) return;
_ = NetworkStorage.TrackManagedDiagnosticEvent( "endpoint.client_error", new
{
managedTelemetry = "endpoint-diagnostics",
endpointSlug = slug,
code = string.IsNullOrWhiteSpace( code ) ? "UNKNOWN" : code,
message = Redact( message ?? "" ),
elapsedMs = Math.Max( 0, elapsedMs ),
clientType = NetworkStorage.GetClientType(),
isHost = NetworkStorage.IsHost,
proxyEnabled = NetworkStorage.ProxyEnabled,
revisionId = NetworkStoragePackageInfo.RuntimeRevisionId,
packageIdent = NetworkStoragePackageInfo.PackageIdent,
libraryVersion = NetworkStorage.PackageVersion
}, "Endpoint client error" );
}
private static void QueueEnsureRetry( string reason )
{
if ( _ensureQueued ) return;
_ensureQueued = true;
_ = EnsureCreatedWhenSceneReady();
if ( NetworkStorageLogConfig.LogConfig )
Log.Info( $"[NetworkStorage] Analytics runtime waiting for active scene (reason={reason})..." );
}
private static async System.Threading.Tasks.Task EnsureCreatedWhenSceneReady()
{
for ( var attempt = 1; attempt <= 30; attempt++ )
{
await System.Threading.Tasks.Task.Delay( 500 );
if ( _instance is not null ) return;
if ( Game.ActiveScene is null ) continue;
_ensureQueued = false;
EnsureCreated( "scene-ready-retry" );
return;
}
_ensureQueued = false;
if ( NetworkStorageLogConfig.LogErrors )
Log.Warning( "[NetworkStorage] Analytics runtime could not start because no active scene became available." );
}
protected override void OnEnabled()
{
_instance = this;
_sessionId = Guid.NewGuid().ToString( "N" );
_sessionStartedAt = RealTime.Now;
_nextHeartbeatAt = RealTime.Now + 2f;
ResetFpsWindow();
if ( NetworkStorageLogConfig.LogRequests )
Log.Info( $"[NetworkStorage] Analytics session join queued session={_sessionId}" );
_ = NetworkStorage.TrackManagedSessionSignal( "join", _sessionId, 0, BuildContext( "join" ) );
}
protected override void OnUpdate()
{
SampleFps();
SampleVoice();
if ( RealTime.Now < _nextHeartbeatAt ) return;
_nextHeartbeatAt = RealTime.Now + HeartbeatInterval;
var fps = NetworkStorage.AnalyticsManagedPerformance ? BuildFpsPayload( true ) : null;
if ( !NetworkStorage.AnalyticsManagedPerformance ) ResetFpsWindow();
if ( NetworkStorageLogConfig.LogRequests )
Log.Info( $"[NetworkStorage] Analytics heartbeat queued session={_sessionId} seconds={SessionSeconds:0}s" );
_ = NetworkStorage.TrackManagedSessionSignal( "heartbeat", _sessionId, SessionSeconds, BuildContext( "heartbeat" ), fps );
}
protected override void OnDestroy()
{
SendLeaveOnce();
if ( _instance == this ) _instance = null;
}
private double SessionSeconds => Math.Max( 0, RealTime.Now - _sessionStartedAt );
private object BuildContext( string eventName )
{
var context = new Dictionary<string, object>
{
["schemaVersion"] = 1,
["sessionId"] = _sessionId,
["eventName"] = eventName
};
if ( NetworkStorage.AnalyticsManagedRuntime ) context["runtime"] = BuildRuntimeSnapshot();
if ( NetworkStorage.AnalyticsManagedRevision ) context["revision"] = BuildRevisionSnapshot();
if ( NetworkStorage.AnalyticsManagedPerformance ) context["performance"] = BuildPerformanceSnapshot();
if ( NetworkStorage.AnalyticsManagedNetwork ) context["network"] = BuildNetworkSnapshot();
if ( NetworkStorage.AnalyticsManagedVoice ) context["voice"] = BuildVoiceSnapshot( reset: eventName == "heartbeat" );
if ( NetworkStorage.AnalyticsManagedInputFocus ) context["inputFocus"] = BuildInputFocusSnapshot();
if ( NetworkStorage.AnalyticsManagedScene ) context["scene"] = BuildSceneSnapshot();
if ( NetworkStorage.AnalyticsManagedSystemInfo ) context["system"] = BuildSystemSnapshot();
if ( NetworkStorage.AnalyticsCaptureEndpointDiagnostics ) context["endpointHealth"] = TakeEndpointHealthSnapshot();
return context;
}
private object BuildRuntimeSnapshot() => new
{
clientType = NetworkStorage.GetClientType(),
isHost = NetworkStorage.IsHost,
isNetworkActive = Networking.IsActive,
isNetworkHost = Networking.IsHost,
isNetworkClient = Networking.IsClient,
isProxyEnabled = NetworkStorage.ProxyEnabled,
authSessionsEnabled = NetworkStorage.EnableAuthSessions,
encryptedRequestsEnabled = NetworkStorage.EnableEncryptedRequests,
securityConfigVersion = NetworkStorage.RuntimeSecurityConfigVersion,
packageIdent = NetworkStoragePackageInfo.PackageIdent,
packageTitle = NetworkStoragePackageInfo.PackageTitle,
libraryVersion = NetworkStorage.PackageVersion,
apiVersion = NetworkStorage.ApiVersion
};
private object BuildRevisionSnapshot() => new
{
currentRevisionId = NetworkStoragePackageInfo.CurrentRevisionId,
runtimeRevisionId = NetworkStoragePackageInfo.RuntimeRevisionId,
latestRevisionId = NetworkStoragePackageInfo.ServerCurrentRevision ?? NetworkStoragePackageInfo.LatestRevisionId,
isOutdated = NetworkStoragePackageInfo.IsOutdatedRevision,
graceRemainingMinutes = NetworkStoragePackageInfo.GraceRemainingMinutes,
graceExpired = NetworkStoragePackageInfo.GraceExpired,
action = NetworkStoragePackageInfo.RevisionAction,
message = Redact( NetworkStoragePackageInfo.RevisionMessage ?? "" ),
publishStatus = NetworkStoragePackageInfo.PublishStatus,
packageIdent = NetworkStoragePackageInfo.PackageIdent,
clientType = NetworkStoragePackageInfo.RuntimeClientType,
isPublishedGameBundle = NetworkStoragePackageInfo.IsPublishedGameBundle
};
private object BuildPerformanceSnapshot()
{
try
{
var last = PerformanceStats.LastSecond;
var frame = FrameStats.Current;
return new
{
frameTimeMs = PerformanceStats.FrameTime * 1000.0,
gpuFrameTimeMs = PerformanceStats.GpuFrametime,
lastSecondFrameAvgMs = last.FrameAvg,
lastSecondFrameMinMs = last.FrameMin,
lastSecondFrameMaxMs = last.FrameMax,
bytesAllocated = PerformanceStats.BytesAllocated,
memoryMb = Math.Round( PerformanceStats.ApproximateProcessMemoryUsage / 1024.0 / 1024.0, 1 ),
gc0 = PerformanceStats.Gen0Collections,
gc1 = PerformanceStats.Gen1Collections,
gc2 = PerformanceStats.Gen2Collections,
gcPauseTicks = PerformanceStats.GcPause,
exceptions = PerformanceStats.Exceptions,
drawCalls = frame.DrawCalls,
trianglesRendered = frame.TrianglesRendered,
objectsRendered = frame.ObjectsRendered,
sceneViewsRendered = frame.SceneViewsRendered
};
}
catch
{
return new
{
frameTimeMs = Time.Delta > 0 ? Time.Delta * 1000.0 : 0
};
}
}
private object BuildNetworkSnapshot()
{
var local = Connection.Local;
var stats = local?.Stats;
return new
{
isActive = Networking.IsActive,
isHost = Networking.IsHost,
isClient = Networking.IsClient,
isConnecting = Networking.IsConnecting,
connectionCount = Connection.All?.Count ?? 0,
hostSteamId = Connection.Host?.SteamId.ToString() ?? "",
roster = Connection.All?.Take( 64 ).Select( c => new { steamId = c.SteamId.ToString(), isHost = c.IsHost, isActive = c.IsActive, isConnecting = c.IsConnecting } ).ToArray(),
localSteamId = Game.SteamId.ToString(),
localDisplayName = Redact( Connection.Local?.DisplayName ?? "" ),
pingMs = local?.Ping ?? 0,
latencyMs = (local?.Latency ?? 0) * 1000f,
connectionQuality = stats?.ConnectionQuality ?? 0,
inBytesPerSecond = stats?.InBytesPerSecond ?? 0,
outBytesPerSecond = stats?.OutBytesPerSecond ?? 0,
sendRateBytesPerSecond = stats?.SendRateBytesPerSecond ?? 0,
hostPingMs = Connection.Host?.Ping ?? 0,
serverName = Redact( Networking.ServerName ?? "" ),
mapName = Redact( Networking.MapName ?? "" )
};
}
private object BuildVoiceSnapshot( bool reset )
{
var payload = new
{
speakingSeconds = Math.Round( _voiceSpeakingSeconds, 2 ),
listeningCount = _voiceListeningCount,
peakAmplitude = Math.Round( _voicePeakAmplitude, 3 )
};
if ( reset )
{
_voiceSpeakingSeconds = 0;
_voiceListeningCount = 0;
_voicePeakAmplitude = 0;
}
return payload;
}
private object BuildInputFocusSnapshot() => new
{
isPaused = Game.IsPaused,
isPlaying = Game.IsPlaying,
isMainMenuVisible = Game.IsMainMenuVisible,
isRecordingVideo = Game.IsRecordingVideo
};
private object BuildSceneSnapshot()
{
var scene = Game.ActiveScene;
if ( scene is null ) return new { name = "", isLoading = false, componentCount = 0 };
var componentCount = 0;
try { componentCount = scene.GetAllComponents<Component>().Take( 10000 ).Count(); } catch { }
return new
{
name = scene.Name ?? "",
isEditor = scene.IsEditor,
isLoading = scene.IsLoading,
timeScale = scene.TimeScale,
componentCount
};
}
private object BuildSystemSnapshot() => new
{
clientType = NetworkStorage.GetClientType(),
platform = Game.IsRunningOnHandheld ? "handheld" : "unknown",
osVersion = "",
isEditor = Game.IsEditor,
isDedicated = NetworkStorage.GetClientType() == "dedicated",
isRunningInVr = Game.IsRunningInVR,
isRunningOnHandheld = Game.IsRunningOnHandheld,
memoryMb = Math.Round( PerformanceStats.ApproximateProcessMemoryUsage / 1024.0 / 1024.0, 1 )
};
private static object TakeEndpointHealthSnapshot()
{
lock ( EndpointLock )
{
var payload = new
{
callCount = _endpointCallCount,
failureCount = _endpointFailureCount,
lastEndpointSlug = _lastEndpointSlug,
lastErrorSlug = _lastEndpointSlug,
lastErrorCode = _lastEndpointErrorCode,
lastErrorMessage = _lastEndpointErrorMessage,
lastLatencyMs = Math.Round( _lastEndpointLatencyMs, 1 )
};
_endpointCallCount = 0;
_endpointFailureCount = 0;
_lastEndpointLatencyMs = 0;
return payload;
}
}
private void SampleFps()
{
if ( Time.Delta <= 0 ) return;
var fps = 1f / Time.Delta;
if ( float.IsNaN( fps ) || float.IsInfinity( fps ) || fps <= 0 ) return;
fps = Math.Clamp( fps, 1f, 1000f );
_fpsSum += fps;
_fpsSamples++;
_fpsPeak = Math.Max( _fpsPeak, fps );
_fpsMin = Math.Min( _fpsMin, fps );
}
private void SampleVoice()
{
if ( !NetworkStorage.AnalyticsManagedVoice || Game.ActiveScene is null ) return;
try
{
var listening = 0;
foreach ( var voice in Game.ActiveScene.GetAllComponents<Voice>() )
{
if ( voice is null || !voice.Enabled ) continue;
if ( voice.IsListening ) listening++;
var amplitude = Math.Max( 0, voice.Amplitude );
if ( voice.IsRecording || amplitude > VoiceActivityThreshold )
{
_voiceSpeakingSeconds += Math.Max( 0, Time.Delta );
_voicePeakAmplitude = Math.Max( _voicePeakAmplitude, amplitude );
}
}
_voiceListeningCount = Math.Max( _voiceListeningCount, listening );
}
catch
{
// Voice telemetry is best-effort and must never affect gameplay.
}
}
private object BuildFpsPayload( bool reset )
{
var samples = Math.Max( 0, _fpsSamples );
var average = samples > 0 ? _fpsSum / samples : 0;
var peak = samples > 0 ? _fpsPeak : 0;
var min = samples > 0 && _fpsMin < float.MaxValue ? _fpsMin : 0;
var payload = new
{
average,
peak,
max = peak,
min,
samples,
windowSeconds = HeartbeatInterval
};
if ( reset ) ResetFpsWindow();
return payload;
}
private void ResetFpsWindow()
{
_fpsSum = 0;
_fpsSamples = 0;
_fpsPeak = 0;
_fpsMin = float.MaxValue;
}
private void SendLeaveOnce()
{
if ( _leaveSent ) return;
_leaveSent = true;
if ( NetworkStorageLogConfig.LogRequests )
Log.Info( $"[NetworkStorage] Analytics session leave queued session={_sessionId} seconds={SessionSeconds:0}s" );
_ = NetworkStorage.TrackManagedSessionSignal( "leave", _sessionId, SessionSeconds, BuildContext( "leave" ) );
}
private static string Redact( string value )
{
if ( string.IsNullOrEmpty( value ) ) return "";
var text = value.Length > 500 ? value[..500] : value;
text = Regex.Replace( text, "sbox_(sk|ns)_[A-Za-z0-9_-]+", "[redacted-key]" );
text = Regex.Replace( text, "(?i)(token|secret|password|authorization|api[-_]?key)=\\S+", "$1=[redacted]" );
return text;
}
}