Analytics helper for NetworkStorage. Sends player analytics events and session signals to the Network Storage service, builds payloads, auth headers and logs results. It normalizes event types, respects runtime config flags and suppresses disallowed custom events.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
namespace Sandbox;
public static partial class NetworkStorage
{
public static Task TrackAnalyticsEvent( string eventType, object payload = null )
=> SendAnalyticsEvent( eventType, payload, "custom", null, null );
public static Task TrackAnalyticsWarning( string code, string message = null, object context = null )
=> AnalyticsCaptureWarnings
? SendAnalyticsEvent( code, context, "warning", message, null )
: Task.CompletedTask;
public static Task TrackAnalyticsError( Exception exception, string code = null, object context = null )
=> TrackAnalyticsError( code ?? exception?.GetType().Name ?? "error", exception?.Message, exception?.StackTrace, context );
public static Task TrackAnalyticsError( string code, string message = null, string stack = null, object context = null )
=> AnalyticsCaptureErrors
? SendAnalyticsEvent( code, context, "error", message, AnalyticsRetainErrorStacks ? stack : null )
: Task.CompletedTask;
public static Task TrackSessionStart( object context = null )
=> AnalyticsCaptureSessions ? SendSessionSignal( "join", 0, context, null, null ) : Task.CompletedTask;
public static Task TrackSessionHeartbeat( double sessionSeconds = 0, object context = null, object fps = null )
=> AnalyticsCaptureSessions ? SendSessionSignal( "heartbeat", sessionSeconds, context, fps, null ) : Task.CompletedTask;
public static Task TrackSessionEnd( double durationSeconds = 0, object context = null )
=> AnalyticsCaptureSessions ? SendSessionSignal( "leave", durationSeconds, context, null, null ) : Task.CompletedTask;
internal static Task TrackManagedSessionSignal( string eventType, string sessionId, double sessionSeconds, object context = null, object fps = null )
=> AnalyticsCaptureSessions ? SendSessionSignal( eventType, sessionSeconds, context, fps, sessionId ) : Task.CompletedTask;
internal static Task TrackManagedDiagnosticEvent( string eventType, object payload = null, string label = null )
=> EnablePlayerAnalytics ? SendAnalyticsEvent( eventType, payload, "info", label, null ) : Task.CompletedTask;
private static async Task SendSessionSignal( string eventType, double sessionSeconds, object context, object fps, string sessionId )
{
try
{
EnsureConfigured();
await EnsureRuntimeSecurityConfigAsync( "analytics-session" );
if ( !EnablePlayerAnalytics || !AnalyticsCaptureSessions ) return;
var body = new Dictionary<string, object>
{
["steamId"] = Game.SteamId.ToString(),
["sessionId"] = sessionId,
["sessionSeconds"] = Math.Max( 0, sessionSeconds ),
["event"] = eventType,
["playerName"] = ResolvePlayerName(),
["source"] = "network-storage-library",
["libraryVersion"] = NetworkStorage.PackageVersion,
["packageIdent"] = NetworkStoragePackageInfo.PackageIdent,
["context"] = context,
["fps"] = fps
};
var headers = await BuildAuthHeaders();
var url = BuildUrl( $"/storage/{Uri.EscapeDataString( ProjectId )}/stats/heartbeat" );
var content = Http.CreateJsonContent( body );
await Http.RequestStringAsync( url, "POST", content, headers );
if ( NetworkStorageLogConfig.LogRequests )
{
var logLine = $"Reported session:{eventType} session={sessionId ?? "manual"} seconds={sessionSeconds:0}s";
NetLog.Info( "analytics", logLine );
Log.Info( $"[NetworkStorage] Analytics {logLine}" );
}
}
catch ( Exception ex )
{
if ( NetworkStorageLogConfig.LogErrors )
NetLog.Error( "analytics", $"Session analytics failed: {ex.Message}" );
}
}
private static async Task SendAnalyticsEvent( string eventType, object payload, string severity, string message, string stack )
{
try
{
EnsureConfigured();
await EnsureRuntimeSecurityConfigAsync( "analytics" );
if ( !EnablePlayerAnalytics ) return;
var normalized = NormalizeAnalyticsEventType( eventType );
if ( string.IsNullOrWhiteSpace( normalized ) ) return;
if ( severity == "custom" && !IsAnalyticsEventAllowed( normalized ) )
{
if ( NetworkStorageLogConfig.LogRequests )
NetLog.Info( "analytics", $"Suppressed disallowed event {normalized}" );
return;
}
var steamId = Game.SteamId.ToString();
var reportType = severity == "session" ? $"session.{normalized}" : normalized;
var body = new Dictionary<string, object>
{
["steamId"] = steamId,
["type"] = reportType,
["severity"] = severity == "custom" ? null : severity,
["label"] = severity == "info" && !string.IsNullOrWhiteSpace( message ) ? message : normalized,
["message"] = message,
["stack"] = stack,
["context"] = payload,
["playerName"] = ResolvePlayerName(),
["sessionId"] = NetworkStorageAnalyticsRuntime.CurrentSessionId,
["source"] = "network-storage-library",
["libraryVersion"] = NetworkStorage.PackageVersion,
["packageIdent"] = NetworkStoragePackageInfo.PackageIdent
};
var headers = await BuildAuthHeaders();
var url = BuildUrl( $"/storage/{Uri.EscapeDataString( ProjectId )}/analytics/events" );
var content = Http.CreateJsonContent( body );
await Http.RequestStringAsync( url, "POST", content, headers );
if ( NetworkStorageLogConfig.LogRequests )
NetLog.Info( "analytics", $"Reported {severity}:{normalized}" );
}
catch ( Exception ex )
{
if ( NetworkStorageLogConfig.LogErrors )
NetLog.Error( "analytics", $"Analytics report failed: {ex.Message}" );
}
}
/// <summary>
/// Resolves the local player's display name for analytics payloads.
/// Prefers <see cref="Connection.Local.DisplayName"/> (active networking
/// session); falls back to the Steam persona name (solo/menu play where
/// Connection.Local is null). Returns null when neither is available.
/// </summary>
private static string ResolvePlayerName()
{
try
{
var displayName = Connection.Local?.DisplayName;
if ( !string.IsNullOrWhiteSpace( displayName ) ) return displayName;
}
catch { /* Connection.Local can throw before networking is initialized */ }
try
{
var persona = Sandbox.Utility.Steam.PersonaName;
if ( !string.IsNullOrWhiteSpace( persona ) ) return persona;
}
catch { /* Steam persona not available (e.g. dedicated server) */ }
return null;
}
}