-
Notifications
You must be signed in to change notification settings - Fork 81
Add Unity server telemetry sample for PlayFab MPS #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
dgkanatsios
wants to merge
3
commits into
main
Choose a base branch
from
unity-server-telemetry-sample
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| # PlayFab MPS — Unity Server Telemetry Sample | ||
|
|
||
| ## Overview | ||
|
|
||
| This sample shows how to collect game server performance metrics from a Unity dedicated server and send them to the [PlayFab Telemetry API](https://learn.microsoft.com/en-us/rest/api/playfab/events/play-stream-events/write-telemetry-events) using a [telemetry key](https://learn.microsoft.com/en-us/gaming/playfab/data-analytics/ingest-data/telemetry-keys-overview). | ||
|
|
||
| It consists of three scripts that you can drop into any Unity game server project that uses PlayFab Multiplayer Servers (MPS). | ||
|
|
||
| ## Metrics Collected | ||
|
|
||
| | Category | Metric | Source | Notes | | ||
| |----------|--------|--------|-------| | ||
| | Simulation | Update loop rate (fps) | Frame counting | Server simulation loop rate (not rendering FPS) | | ||
| | Simulation | Avg frame time (ms) | `Time.unscaledDeltaTime` | Wall-clock frame duration, averaged over window | | ||
| | Simulation | Max frame time (ms) | `Time.unscaledDeltaTime` | Spike detection | | ||
| | Simulation | Fixed tick rate | FixedUpdate counting | Physics simulation rate | | ||
| | Memory | Total used (MB) | `ProfilerRecorder` | All Unity memory | | ||
| | Memory | GC used (MB) | `ProfilerRecorder` | Managed heap in use | | ||
| | Memory | GC reserved (MB) | `ProfilerRecorder` | Managed heap reserved | | ||
| | Memory | Last frame GC alloc (bytes) | `ProfilerRecorder` | GC allocation in the most recent frame | | ||
| | Memory | Mono heap (MB) | `Profiler` API | Mono backend | | ||
| | Memory | Mono used (MB) | `Profiler` API | Mono backend | | ||
| | CPU | CPU usage % | `System.Diagnostics.Process` | Best-effort; -1 if unavailable | | ||
| | CPU | GC gen 0/1/2 counts | `GC.CollectionCount()` | Cumulative since process start | | ||
| | CPU | Thread count | `System.Diagnostics.Process` | Best-effort; -1 if unavailable | | ||
| | Game | Connected players | Set externally | From your networking layer | | ||
| | Game | Max players | Set externally | Server capacity | | ||
| | Game | Server uptime (s) | `Time.realtimeSinceStartup` | Since process start | | ||
| | Game | Network objects | Set externally | Active networked entities | | ||
|
|
||
| ## Setup | ||
|
|
||
| ### 1. Add the scripts to your project | ||
|
|
||
| Copy the `Scripts/` folder into your Unity server project's `Assets/` directory: | ||
| - `ServerMetricsCollector.cs` | ||
| - `PlayFabTelemetrySender.cs` | ||
| - `ServerTelemetryManager.cs` | ||
|
|
||
| ### 2. Add to your scene | ||
|
|
||
| Create an empty GameObject in your server scene and attach the `ServerTelemetryManager` component. | ||
|
|
||
| ### 3. Configure the telemetry key | ||
|
|
||
| You need a PlayFab telemetry key. Create one in **PlayFab Game Manager → Data → Telemetry Keys**. | ||
|
|
||
| There are two ways to provide the key to your game server: | ||
|
|
||
| #### Option A: MPS Managed Secrets (recommended for production) | ||
|
|
||
| Use the [MPS secret management feature](https://learn.microsoft.com/en-us/gaming/playfab/multiplayer/servers/manage-secrets): | ||
|
|
||
| 1. Upload the telemetry key as a secret named `TelemetryKey` using the `UploadSecret` API | ||
| 2. Reference it in your build via `GameSecretReferences` | ||
| 3. The game server reads it automatically from the `PF_MPS_SECRET_TelemetryKey` environment variable | ||
|
|
||
| #### Option B: Hardcode in Inspector (for local testing) | ||
|
|
||
| Set the `telemetryKey` field directly on the `ServerTelemetryManager` component in the Inspector. | ||
|
|
||
| ### 4. Configure the Title ID | ||
|
|
||
| The `titleId` field can be: | ||
| - Set in the Inspector | ||
| - Or read from the `PF_TITLE_ID` environment variable (e.g. from GSDK config) | ||
|
|
||
| ## Integration with GSDK | ||
|
|
||
| If your server already uses the PlayFab GSDK (like the [UnityMirror sample](../UnityMirror/)), you can wire the telemetry manager into your GSDK lifecycle: | ||
|
|
||
| ```csharp | ||
| public class AgentListener : MonoBehaviour | ||
| { | ||
| public ServerTelemetryManager telemetryManager; | ||
|
|
||
| private List<ConnectedPlayer> _connectedPlayers; | ||
|
|
||
| void Start() | ||
| { | ||
| _connectedPlayers = new List<ConnectedPlayer>(); | ||
| PlayFabMultiplayerAgentAPI.Start(); | ||
|
|
||
| PlayFabMultiplayerAgentAPI.OnServerActiveCallback += OnServerActive; | ||
| PlayFabMultiplayerAgentAPI.OnShutDownCallback += OnShutdown; | ||
|
|
||
| // ... other GSDK setup ... | ||
|
|
||
| StartCoroutine(ReadyForPlayers()); | ||
| } | ||
|
|
||
| private void OnServerActive() | ||
| { | ||
| // Start your networking server... | ||
| UNetServer.StartListen(); | ||
|
|
||
| // Initialize telemetry with title ID from GSDK config | ||
| var config = PlayFabMultiplayerAgentAPI.GetConfigSettings(); | ||
| if (config.ContainsKey("titleId")) | ||
| { | ||
| // Initialize() can be called after Start() — it supports deferred init | ||
| // The telemetry key should already be set via MPS secret (env var) or Inspector | ||
| telemetryManager.Initialize(config["titleId"], telemetryManager.telemetryKey); | ||
| } | ||
| } | ||
|
|
||
| private void OnPlayerAdded(string playfabId) | ||
| { | ||
| _connectedPlayers.Add(new ConnectedPlayer(playfabId)); | ||
| PlayFabMultiplayerAgentAPI.UpdateConnectedPlayers(_connectedPlayers); | ||
|
|
||
| // Update telemetry with current player count | ||
| telemetryManager.SetGameMetrics(_connectedPlayers.Count, 32); | ||
| } | ||
|
|
||
| private void OnPlayerRemoved(string playfabId) | ||
| { | ||
| // ... remove player ... | ||
| telemetryManager.SetGameMetrics(_connectedPlayers.Count, 32); | ||
| } | ||
|
|
||
| private void OnShutdown() | ||
| { | ||
| telemetryManager.StopTelemetry(); | ||
| // ... shutdown logic ... | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## How It Works | ||
|
|
||
| 1. **ServerTelemetryManager** starts on `Start()` and resolves configuration from MPS secrets / environment variables / Inspector fields. If config is not yet available (e.g. waiting for GSDK), call `Initialize(titleId, telemetryKey)` later. | ||
| 2. Every `collectionIntervalSeconds` (default: 30s), it calls `ServerMetricsCollector.CollectMetrics()` to take a snapshot | ||
| 3. Snapshots are buffered in memory | ||
| 4. Every `sendIntervalSeconds` (default: 60s), buffered snapshots are sent to `POST https://{titleId}.playfabapi.com/Event/WriteTelemetryEvents` with the `X-TelemetryKey` header | ||
| 5. Each snapshot becomes one telemetry event with namespace `custom.server_telemetry` and name `server_metrics` | ||
|
|
||
| ## Configuration | ||
|
|
||
| | Field | Default | Description | | ||
| |-------|---------|-------------| | ||
| | `titleId` | (empty) | PlayFab Title ID | | ||
| | `telemetryKey` | (empty) | PlayFab Telemetry Key | | ||
| | `serverId` | Machine name | Identifier for this server instance | | ||
| | `collectionIntervalSeconds` | 30 | How often to collect metrics | | ||
| | `sendIntervalSeconds` | 60 | How often to flush buffered metrics to PlayFab | | ||
| | `maxBufferSize` | 500 | Maximum buffered snapshots; oldest dropped when full | | ||
|
|
||
| ## Limitations | ||
|
|
||
| - **CPU metrics** (`cpuUsagePercent`, `threadCount`) use `System.Diagnostics.Process` which may not be available on all platforms (especially IL2CPP). These fields report `-1` when unavailable. | ||
| - **ProfilerRecorder** counters may not be available in all build configurations. Unavailable counters report `-1`. | ||
| - **Mono heap metrics** are most useful with the Mono scripting backend; values may be limited on IL2CPP. | ||
| - **Buffered metrics are lost on crash** — `StopTelemetry()` stops collection but does not perform a synchronous flush. Call it from your GSDK shutdown callback for a clean stop. | ||
| - This is sample code — for production use, consider adding retry logic with exponential backoff and event deduplication. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| using System; | ||
| using System.Collections; | ||
| using System.Collections.Generic; | ||
| using System.Globalization; | ||
| using System.Text; | ||
| using UnityEngine; | ||
| using UnityEngine.Networking; | ||
|
|
||
| /// <summary> | ||
| /// Sends telemetry events to the PlayFab WriteTelemetryEvents API | ||
| /// using a telemetry key for authentication. | ||
| /// </summary> | ||
| public class PlayFabTelemetrySender | ||
| { | ||
| readonly string _titleId; | ||
| readonly string _telemetryKey; | ||
| readonly string _serverId; | ||
| readonly string _url; | ||
|
|
||
| const int MaxEventsPerBatch = 200; | ||
| const int RequestTimeoutSeconds = 30; | ||
| const int MaxEntityIdLength = 64; | ||
|
|
||
| public PlayFabTelemetrySender(string titleId, string telemetryKey, string serverId) | ||
| { | ||
| _titleId = titleId; | ||
| _telemetryKey = telemetryKey; | ||
| _serverId = serverId.Length > MaxEntityIdLength ? serverId.Substring(0, MaxEntityIdLength) : serverId; | ||
| _url = $"https://{_titleId}.playfabapi.com/Event/WriteTelemetryEvents"; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Sends a list of metric snapshots as telemetry events. | ||
| /// Each snapshot becomes one event. Batches into groups of 200 (API limit). | ||
| /// </summary> | ||
| public IEnumerator SendMetrics(List<Dictionary<string, object>> metricsList) | ||
| { | ||
| if (metricsList.Count == 0) yield break; | ||
|
|
||
| for (int i = 0; i < metricsList.Count; i += MaxEventsPerBatch) | ||
| { | ||
| int count = Mathf.Min(MaxEventsPerBatch, metricsList.Count - i); | ||
| string json = BuildRequestJson(metricsList, i, count); | ||
|
|
||
| using (var request = new UnityWebRequest(_url, UnityWebRequest.kHttpVerbPOST)) | ||
| { | ||
| byte[] bodyBytes = Encoding.UTF8.GetBytes(json); | ||
| request.uploadHandler = new UploadHandlerRaw(bodyBytes) { contentType = "application/json" }; | ||
| request.downloadHandler = new DownloadHandlerBuffer(); | ||
| request.SetRequestHeader("X-TelemetryKey", _telemetryKey); | ||
| request.timeout = RequestTimeoutSeconds; | ||
|
|
||
| yield return request.SendWebRequest(); | ||
|
|
||
| if (request.result != UnityWebRequest.Result.Success) | ||
| { | ||
| Debug.LogWarning($"[PlayFabTelemetrySender] Failed to send telemetry: {request.error} (HTTP {request.responseCode})"); | ||
| } | ||
| else | ||
| { | ||
| Debug.Log($"[PlayFabTelemetrySender] Sent {count} telemetry event(s)"); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| string BuildRequestJson(List<Dictionary<string, object>> metricsList, int startIndex, int count) | ||
| { | ||
| var sb = new StringBuilder(); | ||
| sb.Append("{\"Events\":["); | ||
|
|
||
| for (int i = startIndex; i < startIndex + count; i++) | ||
| { | ||
| if (i > startIndex) sb.Append(","); | ||
|
|
||
| var metrics = metricsList[i]; | ||
| sb.Append("{"); | ||
| sb.Append("\"EventNamespace\":\"custom.server_telemetry\","); | ||
| sb.Append("\"Name\":\"server_metrics\","); | ||
| string timestamp = metrics.ContainsKey("_timestamp") ? EscapeJson(metrics["_timestamp"].ToString()) : DateTime.UtcNow.ToString("O"); | ||
| sb.Append($"\"OriginalTimestamp\":\"{timestamp}\","); | ||
| sb.Append($"\"Entity\":{{\"type\":\"external\",\"id\":\"{EscapeJson(_serverId)}\"}},"); | ||
| sb.Append("\"Payload\":{"); | ||
|
|
||
| bool first = true; | ||
| foreach (var kvp in metrics) | ||
| { | ||
| // Skip internal fields | ||
| if (kvp.Key.StartsWith("_")) continue; | ||
| if (!first) sb.Append(","); | ||
| first = false; | ||
|
|
||
| sb.Append($"\"{kvp.Key}\":"); | ||
| AppendJsonValue(sb, kvp.Value); | ||
| } | ||
|
|
||
| sb.Append("}}"); | ||
| } | ||
|
|
||
| sb.Append("]}"); | ||
| return sb.ToString(); | ||
| } | ||
|
|
||
| static void AppendJsonValue(StringBuilder sb, object value) | ||
| { | ||
| switch (value) | ||
| { | ||
| case int i: | ||
| sb.Append(i); | ||
| break; | ||
| case long l: | ||
| sb.Append(l); | ||
| break; | ||
| case float f: | ||
| sb.Append(f.ToString("G", CultureInfo.InvariantCulture)); | ||
| break; | ||
| case double d: | ||
| sb.Append(d.ToString("G", CultureInfo.InvariantCulture)); | ||
| break; | ||
| case string s: | ||
| sb.Append($"\"{EscapeJson(s)}\""); | ||
| break; | ||
| case bool b: | ||
| sb.Append(b ? "true" : "false"); | ||
| break; | ||
| default: | ||
| sb.Append($"\"{EscapeJson(value?.ToString() ?? "null")}\""); | ||
| break; | ||
| } | ||
|
dgkanatsios marked this conversation as resolved.
|
||
| } | ||
|
|
||
| static string EscapeJson(string s) | ||
| { | ||
| return s.Replace("\\", "\\\\") | ||
| .Replace("\"", "\\\"") | ||
| .Replace("\n", "\\n") | ||
| .Replace("\r", "\\r") | ||
| .Replace("\t", "\\t") | ||
| .Replace("\b", "\\b") | ||
| .Replace("\f", "\\f"); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.