Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
155 changes: 155 additions & 0 deletions UnityServerTelemetry/README.md
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.
142 changes: 142 additions & 0 deletions UnityServerTelemetry/Scripts/PlayFabTelemetrySender.cs
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)");
}
}
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") ? 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;
}
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