Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Unity Server and Client sample that utilize the GameServer SDK.

More information [here](UnityMirror/README.md).

## UnityServerTelemetry

Unity sample scripts that collect game server performance metrics (simulation rate, memory, CPU, player counts) and send them to the PlayFab Telemetry API using a telemetry key. Can be dropped into any Unity MPS project.

More information [here](UnityServerTelemetry/README.md).

## UnrealThirdPersonMP

Unreal Server and Client sample that utilize the GameServer SDK which is integrated through an Unreal plugin.
Expand Down
153 changes: 153 additions & 0 deletions UnityServerTelemetry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# 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 | Not render FPS — server simulation loop rate |
Comment thread
dgkanatsios marked this conversation as resolved.
Outdated
| 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 | GC alloc/frame (bytes) | `ProfilerRecorder` | Per-frame allocation pressure |
| 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();

// Read title ID from GSDK config if needed
var config = PlayFabMultiplayerAgentAPI.GetConfigSettings();
if (config.ContainsKey("titleId"))
{
telemetryManager.titleId = config["titleId"];
}

// Telemetry starts automatically in ServerTelemetryManager.Start()
}

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
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 |

## 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.
- This is sample code — for production use, consider adding retry logic with exponential backoff, bounded queues, and event deduplication.
136 changes: 136 additions & 0 deletions UnityServerTelemetry/Scripts/PlayFabTelemetrySender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System;
using System.Collections;
using System.Collections.Generic;
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;

public PlayFabTelemetrySender(string titleId, string telemetryKey, string serverId)
{
_titleId = titleId;
_telemetryKey = telemetryKey;
_serverId = serverId;
_url = $"https://{titleId}.playfabapi.com/Event/WriteTelemetryEvents";
Comment thread
dgkanatsios marked this conversation as resolved.
Outdated
}

/// <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)
{
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);

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)");
}
}
Comment thread
dgkanatsios marked this conversation as resolved.
}
}

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") ? 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"));
break;
case double d:
sb.Append(d.ToString("G"));
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;
}
Comment thread
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");
}
}
Loading