diff --git a/README.md b/README.md index 724ca52..0df31ae 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Unreal Server and Client sample that utilize the GameServer SDK which is integra More information [here](UnrealThirdPersonMP/README.md). +## UnrealMpsTelemetry + +Unreal dedicated server telemetry plugin sample intended to be wired into an existing GSDK-enabled server. + +More information [here](UnrealMpsTelemetry/README.md). + ## OpenArena This sample wraps the open source [OpenArena](https://openarena.fandom.com/wiki/Main_Page) game using a .NET Core app and Linux containers. diff --git a/UnrealMpsTelemetry/.gitignore b/UnrealMpsTelemetry/.gitignore new file mode 100644 index 0000000..cfbcb6f --- /dev/null +++ b/UnrealMpsTelemetry/.gitignore @@ -0,0 +1,2 @@ +Binaries/ +Intermediate/ diff --git a/UnrealMpsTelemetry/README.md b/UnrealMpsTelemetry/README.md new file mode 100644 index 0000000..56ef220 --- /dev/null +++ b/UnrealMpsTelemetry/README.md @@ -0,0 +1,250 @@ +# PlayFab MPS Unreal telemetry sample + +## Overview + +This sample shows how an Unreal dedicated server running on PlayFab Multiplayer Servers (MPS) can collect aggregated runtime and process metrics and send them to PlayFab as telemetry events. + +The sample is intentionally focused on metrics and telemetry. It does not include, reference, or wrap the Game Server SDK (GSDK). Game developers should import the official Unreal GSDK plugin into their own server project and wire this telemetry plugin into their existing MPS lifecycle code. + +If you already have an Unreal server project, use this as a plugin-style drop-in sample: copy `UnrealMpsTelemetry` into your project's `Plugins` folder, enable the plugin for your server target, and call `UMpsTelemetrySubsystem::InitializeForMps` from your GSDK startup flow after reading the title ID from GSDK config. + +## Code layout + +| Path | Purpose | +| --- | --- | +| `UnrealMpsTelemetry.uplugin` | Runtime plugin descriptor for dropping the sample into an Unreal project. | +| `Source/UnrealMpsTelemetry/Public/MpsTelemetrySubsystem.h` | Game instance subsystem that owns metrics collection and telemetry flushing. | +| `Source/UnrealMpsTelemetry/Public/MpsTelemetrySampleConfig.h` | Event names, cadence, queue limits, and telemetry key lookup. | +| `Source/UnrealMpsTelemetry/Private/UnrealServerMetricsCollector.*` | Collects Unreal tick and process memory metrics and supports custom metrics providers. | +| `Source/UnrealMpsTelemetry/Private/PlayFabTelemetryClient.*` | Queues, batches, serializes, and sends events to PlayFab telemetry. | + +The checked-in files are a reusable plugin sample, not a complete Unreal game project. Copy them into your own GSDK-enabled Unreal dedicated server and build with your normal Unreal toolchain. + +## What the sample collects + +The sample sends aggregated telemetry rather than raw per-tick or per-packet data. By default, it queues one compact summary event every 60 seconds and one final metrics event when shutdown is requested through the subsystem. + +Collected metrics include: + +| Area | Metrics | +| --- | --- | +| Unreal/runtime health | uptime, tick count, average/max tick time, long-tick count, long-tick threshold | +| Process/system health | processor count, process physical memory, process virtual memory, available physical memory, available virtual memory | + +## Telemetry volume controls + +The sample avoids high-volume telemetry patterns: + +- No per-tick telemetry. +- No per-packet telemetry. +- Runtime samples are aggregated locally. +- Events are batched before calling PlayFab. +- Event names and payload fields are stable and low-cardinality. +- The default cadence is one summary event per 60 seconds, plus one final metrics event when `BeginShutdown` is called. + +Tune the constants in `Source/UnrealMpsTelemetry/Public/MpsTelemetrySampleConfig.h` if your title needs a different event cadence. + +With the default cadence, each running server sends: + +- one `server_metrics_summary` event every 60 seconds; +- one `server_metrics_final` event when `BeginShutdown` is called. + +By default, no other telemetry event names are emitted. Custom metrics providers add fields to those same events rather than creating new event names. + +## Add game or network metrics + +This sample does not create a network listener. Your Unreal game server already owns its MPS game port and networking stack, so network metrics should come from your server code. + +Register a custom metrics provider before calling `InitializeForMps`. The provider is called each time the sample builds a telemetry summary, and its fields are merged into the same PlayFab telemetry event as the built-in Unreal/process metrics. + +```cpp +#include "Dom/JsonObject.h" +#include "MpsTelemetrySubsystem.h" + +void UMyGsdkServerBootstrap::OnGSDKServerStarted(const FString& TitleId) +{ + UMpsTelemetrySubsystem* Telemetry = GetGameInstance()->GetSubsystem(); + Telemetry->RegisterCustomMetricsProvider( + [this](TSharedRef Payload) + { + Payload->SetNumberField(TEXT("networkActiveConnections"), NetworkServer->GetActiveConnectionCount()); + Payload->SetNumberField(TEXT("networkBytesReceived"), NetworkServer->GetAndResetBytesReceived()); + Payload->SetNumberField(TEXT("networkBytesSent"), NetworkServer->GetAndResetBytesSent()); + Payload->SetNumberField(TEXT("networkPacketErrorCount"), NetworkServer->GetAndResetPacketErrorCount()); + Payload->SetNumberField(TEXT("networkReplicationBacklog"), NetworkServer->GetReplicationBacklogCount()); + }); + + Telemetry->InitializeForMps(TitleId); + + // Start your own networking stack on the MPS-assigned port, then call ReadyForPlayers. +} + +void UMyGsdkServerBootstrap::OnGSDKShutdown() +{ + GetGameInstance()->GetSubsystem()->BeginShutdown(); + + // Continue with your own server shutdown after giving the HTTP module a chance to flush. +} +``` + +Use stable, low-cardinality field names. For interval counters such as bytes or packet errors, reset the counter inside your provider after writing the current value. +Replace `NetworkServer` in the example with your Unreal networking, replication graph, Online Services, custom socket, or gameplay server component. + +## Integrate with MPS + +This sample does not take a dependency on GSDK. In a real MPS game server: + +1. Download or clone the official PlayFab GSDK repository: `https://github.com/PlayFab/gsdk`. +2. Import the Unreal GSDK plugin into your game server project. +3. Copy this `UnrealMpsTelemetry` folder into your Unreal project's `Plugins` folder. +4. Enable the plugin for your server target and regenerate project files if needed. +5. Use your GSDK bootstrap to read MPS config such as title ID and assigned game port. +6. Register any custom metrics providers before initialization. +7. Call `InitializeForMps(TitleId)` after GSDK startup. +8. Start your own networking stack on the assigned game port and call `ReadyForPlayers` when ready. +9. Wire your GSDK shutdown callback to `BeginShutdown` before completing process shutdown. + +## Configure PlayFab telemetry + +1. In PlayFab Game Manager, open your title. +2. Go to **Data > Telemetry Keys**. +3. Create a telemetry key. +4. Recommended for MPS: upload the key as a managed secret named `TelemetryKey` and reference it from your build. MPS exposes it to the server as `PF_MPS_SECRET_TelemetryKey`. +5. Alternative: set the `PLAYFAB_TELEMETRY_KEY` environment variable before starting the server. +6. Pass the PlayFab title ID from GSDK config into `UMpsTelemetrySubsystem::InitializeForMps`. +7. For temporary validation only, you can replace the value returned by `TelemetryKeySourceOverride()` in `Source/UnrealMpsTelemetry/Public/MpsTelemetrySampleConfig.h`, but do not commit a real key. + +The sample checks for telemetry keys in this order: `PF_MPS_SECRET_TelemetryKey`, `PLAYFAB_TELEMETRY_KEY`, then the source override placeholder. + +Telemetry is sent to: + +```text +https://.playfabapi.com/Event/WriteTelemetryEvents +``` + +The request uses the `X-TelemetryKey` header and the namespace `custom.mps.unrealserver`. + +## Unreal version + +The sample is written as a small runtime plugin intended for Unreal dedicated server projects that already build with the official Unreal GSDK plugin. It uses standard Unreal Engine modules: `Core`, `CoreUObject`, `Engine`, `HTTP`, and `Json`. + +## Build and package + +If you copy the plugin into an existing game, build and package that game using your normal Unreal dedicated-server pipeline. The sample does not prescribe a build output path, container image, or packaging flow; configure those the same way you package your own MPS game servers. + +## Test with LocalMultiplayerAgent in container mode + +To test in LocalMultiplayerAgent or MPS, first integrate the official Unreal GSDK plugin in your game server project. This sample does not call `ReadyForPlayers` or perform MPS state transitions by itself. + +Configure LocalMultiplayerAgent for the build/package layout you chose. Use the port name, protocol, and start command your title already defines. For example, make sure the settings include: + +- game port name: your configured MPS game port name +- protocol: your configured protocol, such as TCP or UDP +- server listening port: the port your Unreal server binds inside the process or container +- start command: your Unreal dedicated server executable with the arguments your build pipeline requires + +In your GSDK-enabled server bootstrap: + +1. Read title ID and assigned game port from GSDK config. +2. Configure your telemetry key through environment or deployment configuration. +3. Initialize this telemetry sample with the title ID. +4. Start your own networking stack on the assigned game port. +5. Call `ReadyForPlayers` when your server is ready. +6. Call `BeginShutdown` from your shutdown callback before completing process shutdown. + +Check the LocalMultiplayerAgent output for state transitions from initializing to standing by to active, and check server logs for telemetry flush messages. + +## Verify events in PlayFab + +In PlayFab Game Manager: + +1. Open your title. +2. Go to **Data > Data Explorer**. +3. Open the **Advanced** query view. +4. Run a query for the sample namespace and event names: + +```kusto +['events.all'] +| where FullName_Namespace == "custom.mps.unrealserver" +| where FullName_Name in ("server_metrics_summary", "server_metrics_final") +| project Timestamp, FullName_Name, Entity_Id, EventData +| order by Timestamp desc +| limit 100 +``` + +The payload values will vary by server, but a `server_metrics_summary` event ingested into PlayFab telemetry looks like this when inspected in Data Explorer (title-specific identifiers redacted): + +```json +{ + "Timestamp": "2024-01-15T22:14:55.2968902Z", + "EntityLineage": { + "namespace": "", + "title": "<your title id>", + "external": "unrealmpsserver" + }, + "SchemaVersion": "2.0.1", + "FullName": { + "Namespace": "custom.mps.unrealserver", + "Name": "server_metrics_summary" + }, + "Id": "<event id>", + "Entity": { + "Id": "unrealmpsserver", + "Type": "external" + }, + "Originator": { + "Id": "<your title id>", + "Type": "external" + }, + "OriginInfo": { + "Timestamp": "2024-01-15T22:14:55.6800000Z", + "Id": "<originator event id>", + "CustomTags": { + "sample": "UnrealMpsTelemetry", + "sampleVersion": "1" + }, + "Key": "test" + }, + "Payload": { + "uptimeSeconds": 90.394, + "tickCount": 111731, + "averageTickMs": 0.537, + "maxTickMs": 1229.641, + "longTickCount": 1, + "longTickThresholdMs": 100, + "processorCount": 20, + "processPhysicalMemoryBytes": 1615429632, + "processVirtualMemoryBytes": 1475825664, + "availablePhysicalMemoryBytes": 31376830464, + "availableVirtualMemoryBytes": 21841702912 + }, + "PayloadContentType": "Json" +} +``` + +The `Payload` object holds the metrics fields the sample emits. The surrounding envelope (`Timestamp`, `EntityLineage`, `FullName`, `Entity`, `Originator`, `OriginInfo`, `PayloadContentType`) is added by PlayFab telemetry ingestion and is the same shape for every event. + +If you register a custom metrics provider, your own fields appear in the same `Payload` object alongside the built-in ones. For example, a network metrics provider might add: + +```json +{ + "Payload": { + "uptimeSeconds": 90.394, + "...": "(built-in fields above)", + "networkActiveConnections": 12, + "networkBytesReceived": 98304, + "networkBytesSent": 147456, + "networkPacketErrorCount": 0, + "networkReplicationBacklog": 3 + } +} +``` + +## Production notes + +- Prefer MPS managed secrets for telemetry keys; avoid storing real keys in source, Unreal assets, command-line arguments, or container images. +- Move telemetry intervals into build metadata, environment configuration, or your deployment pipeline if different titles or fleets need different cadences. +- Keep per-server identifiers in payload fields rather than creating many event names. +- Add game-specific counters from your networking stack, gameplay loop, match state, replication graph, or Online Services integration. +- Consider sampling or longer intervals for very large fleets. +- Use PlayFab Data Connections if you need to export telemetry to your own storage or analytics pipeline. diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/MpsTelemetrySubsystem.cpp b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/MpsTelemetrySubsystem.cpp new file mode 100644 index 0000000..8b8b8fe --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/MpsTelemetrySubsystem.cpp @@ -0,0 +1,176 @@ +#include "MpsTelemetrySubsystem.h" + +#include "Dom/JsonObject.h" +#include "MpsTelemetrySampleConfig.h" +#include "PlayFabTelemetryClient.h" +#include "UnrealServerMetricsCollector.h" + +using namespace PlayFab::Samples::UnrealMpsTelemetry; + +void UMpsTelemetrySubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + TelemetryClient = MakeShared<FPlayFabTelemetryClient, ESPMode::ThreadSafe>(); + MetricsCollector = MakePimpl<FUnrealServerMetricsCollector>(); + MetricsCollector->Initialize(); +} + +void UMpsTelemetrySubsystem::Deinitialize() +{ + // Ensure the final event is enqueued even if the host's GSDK shutdown hook + // never called BeginShutdown. Strong refs captured by in-flight HTTP + // completion lambdas keep the underlying client alive after we release our + // own pointer, and completion chaining inside the client keeps draining + // remaining events until the queue is empty (or HTTP module is torn down). + if (bInitializedForMps && !bShutdownStarted) + { + BeginShutdown(); + } + else + { + RemoveTickers(); + if (TelemetryClient.IsValid() && TelemetryClient->GetPendingEventCount() > 0) + { + TelemetryClient->FlushAsync(); + } + } + + TelemetryClient.Reset(); + MetricsCollector.Reset(); + Super::Deinitialize(); +} + +void UMpsTelemetrySubsystem::Tick(float DeltaTime) +{ + if (MetricsCollector.IsValid() && bInitializedForMps && !bShutdownStarted) + { + MetricsCollector->Tick(DeltaTime); + } +} + +TStatId UMpsTelemetrySubsystem::GetStatId() const +{ + RETURN_QUICK_DECLARE_CYCLE_STAT(UMpsTelemetrySubsystem, STATGROUP_Tickables); +} + +bool UMpsTelemetrySubsystem::IsTickable() const +{ + return !IsTemplate() && bInitializedForMps && !bShutdownStarted; +} + +void UMpsTelemetrySubsystem::InitializeForMps(const FString& TitleId, const FString& ExternalEntityId) +{ + if (bInitializedForMps) + { + UE_LOG(LogMpsTelemetrySample, Warning, TEXT("MPS telemetry sample is already initialized.")); + return; + } + + if (TitleId.IsEmpty()) + { + UE_LOG(LogMpsTelemetrySample, Error, TEXT("Pass the PlayFab title ID from GSDK config when initializing telemetry.")); + return; + } + + if (!TelemetryClient.IsValid()) + { + TelemetryClient = MakeShared<FPlayFabTelemetryClient, ESPMode::ThreadSafe>(); + } + + if (!MetricsCollector.IsValid()) + { + MetricsCollector = MakePimpl<FUnrealServerMetricsCollector>(); + MetricsCollector->Initialize(); + } + + TelemetryClient->Configure( + TitleId, + FMpsTelemetrySampleConfig::GetTelemetryKey(), + FMpsTelemetrySampleConfig::EventNamespace(), + ExternalEntityId); + + // FTSTicker fires on the game thread driven by real elapsed time, so the + // summary cadence is unaffected by world pause or time dilation. Weak lambdas + // are no-ops if this subsystem has been destroyed before RemoveTickers ran. + MetricsSummaryTickerHandle = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateWeakLambda(this, [this](float) + { + EnqueuePeriodicMetricsSummary(); + return true; + }), + FMpsTelemetrySampleConfig::MetricsSummaryIntervalSeconds); + + TelemetryFlushTickerHandle = FTSTicker::GetCoreTicker().AddTicker( + FTickerDelegate::CreateWeakLambda(this, [this](float) + { + FlushTelemetry(); + return true; + }), + FMpsTelemetrySampleConfig::TelemetryFlushIntervalSeconds); + + bInitializedForMps = true; +} + +void UMpsTelemetrySubsystem::BeginShutdown() +{ + if (!bInitializedForMps || bShutdownStarted) + { + return; + } + + bShutdownStarted = true; + RemoveTickers(); + + UE_LOG(LogMpsTelemetrySample, Log, TEXT("MPS telemetry sample server is shutting down.")); + EnqueueMetricsSummary(TEXT("server_metrics_final")); + FlushTelemetry(); +} + +void UMpsTelemetrySubsystem::RegisterCustomMetricsProvider(TFunction<void(TSharedRef<FJsonObject> Payload)> Provider) +{ + if (!MetricsCollector.IsValid()) + { + MetricsCollector = MakePimpl<FUnrealServerMetricsCollector>(); + MetricsCollector->Initialize(); + } + + MetricsCollector->RegisterCustomMetricsProvider(MoveTemp(Provider)); +} + +void UMpsTelemetrySubsystem::EnqueuePeriodicMetricsSummary() +{ + EnqueueMetricsSummary(TEXT("server_metrics_summary")); +} + +void UMpsTelemetrySubsystem::EnqueueMetricsSummary(const FString& EventName) +{ + if (!TelemetryClient.IsValid() || !MetricsCollector.IsValid()) + { + return; + } + + TelemetryClient->Enqueue(EventName, MetricsCollector->CaptureSummaryAndReset()); +} + +void UMpsTelemetrySubsystem::FlushTelemetry() +{ + if (TelemetryClient.IsValid()) + { + TelemetryClient->FlushAsync(); + } +} + +void UMpsTelemetrySubsystem::RemoveTickers() +{ + if (MetricsSummaryTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(MetricsSummaryTickerHandle); + MetricsSummaryTickerHandle.Reset(); + } + + if (TelemetryFlushTickerHandle.IsValid()) + { + FTSTicker::GetCoreTicker().RemoveTicker(TelemetryFlushTickerHandle); + TelemetryFlushTickerHandle.Reset(); + } +} diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/PlayFabTelemetryClient.cpp b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/PlayFabTelemetryClient.cpp new file mode 100644 index 0000000..6aed8d2 --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/PlayFabTelemetryClient.cpp @@ -0,0 +1,265 @@ +#include "PlayFabTelemetryClient.h" + +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "HttpModule.h" +#include "MpsTelemetrySampleConfig.h" +#include "MpsTelemetrySubsystem.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +using namespace PlayFab::Samples::UnrealMpsTelemetry; + +void FPlayFabTelemetryClient::Configure(const FString& TitleId, const FString& TelemetryKey, const FString& EventNamespace, const FString& ExternalEntityId) +{ + if (TitleId.IsEmpty()) + { + UE_LOG(LogMpsTelemetrySample, Error, TEXT("A PlayFab title ID is required to configure telemetry.")); + return; + } + + TelemetryKeyValue = TelemetryKey; + EventNamespaceName = EventNamespace.IsEmpty() ? FString(FMpsTelemetrySampleConfig::EventNamespace()) : EventNamespace; + ExternalEntityIdValue = NormalizeExternalEntityId(ExternalEntityId); + Endpoint = FString::Printf(TEXT("https://%s.playfabapi.com/Event/WriteTelemetryEvents"), *TitleId); + + CustomTags = MakeShared<FJsonObject>(); + CustomTags->SetStringField(TEXT("sample"), TEXT("UnrealMpsTelemetry")); + CustomTags->SetStringField(TEXT("sampleVersion"), TEXT("1")); + bConfigured = true; + + if (!FMpsTelemetrySampleConfig::IsConfiguredTelemetryKey(TelemetryKeyValue)) + { + LogMissingTelemetryKey(); + } +} + +void FPlayFabTelemetryClient::SetExternalEntityId(const FString& ExternalEntityId) +{ + ExternalEntityIdValue = NormalizeExternalEntityId(ExternalEntityId); +} + +bool FPlayFabTelemetryClient::Enqueue(const FString& EventName, TSharedPtr<FJsonObject> Payload) +{ + if (!bConfigured) + { + UE_LOG(LogMpsTelemetrySample, Error, TEXT("Telemetry event was not queued because the telemetry client is not configured.")); + return false; + } + + if (!FMpsTelemetrySampleConfig::IsConfiguredTelemetryKey(TelemetryKeyValue)) + { + LogMissingTelemetryKey(); + return false; + } + + if (EventName.IsEmpty()) + { + UE_LOG(LogMpsTelemetrySample, Error, TEXT("Telemetry event was not queued because the event name is empty.")); + return false; + } + + if (!Payload.IsValid()) + { + Payload = MakeShared<FJsonObject>(); + Payload->SetNumberField(TEXT("schemaVersion"), 1); + } + + TSharedPtr<FJsonObject> Entity = MakeShared<FJsonObject>(); + Entity->SetStringField(TEXT("Type"), TEXT("external")); + Entity->SetStringField(TEXT("Id"), ExternalEntityIdValue); + + TSharedPtr<FJsonObject> TelemetryEvent = MakeShared<FJsonObject>(); + TelemetryEvent->SetStringField(TEXT("EventNamespace"), EventNamespaceName); + TelemetryEvent->SetStringField(TEXT("Name"), EventName); + TelemetryEvent->SetStringField(TEXT("OriginalId"), FGuid::NewGuid().ToString(EGuidFormats::Digits)); + TelemetryEvent->SetStringField(TEXT("OriginalTimestamp"), FDateTime::UtcNow().ToIso8601()); + TelemetryEvent->SetObjectField(TEXT("Entity"), Entity); + TelemetryEvent->SetObjectField(TEXT("Payload"), Payload); + + FScopeLock Lock(&SyncRoot); + if (PendingEvents.Num() >= FMpsTelemetrySampleConfig::MaxQueuedEvents) + { + PendingEvents.RemoveAt(0); + UE_LOG(LogMpsTelemetrySample, Warning, TEXT("Telemetry queue is full. Dropped the oldest pending telemetry event.")); + } + + PendingEvents.Add(TelemetryEvent); + return true; +} + +void FPlayFabTelemetryClient::FlushAsync() +{ + TArray<TSharedPtr<FJsonObject>> Batch; + { + // Hold SyncRoot across the in-progress check, queue dequeue, and the + // bFlushInProgress flip so two concurrent callers can never both pull a + // batch and start parallel HTTP requests. + FScopeLock Lock(&SyncRoot); + + if (bFlushInProgress || !bConfigured || !FMpsTelemetrySampleConfig::IsConfiguredTelemetryKey(TelemetryKeyValue)) + { + return; + } + + const int32 BatchCount = FMath::Min(PendingEvents.Num(), FMpsTelemetrySampleConfig::MaxEventsPerBatch); + if (BatchCount == 0) + { + return; + } + + Batch.Append(PendingEvents.GetData(), BatchCount); + PendingEvents.RemoveAt(0, BatchCount); + bFlushInProgress = true; + } + + TArray<TSharedPtr<FJsonValue>> EventValues; + EventValues.Reserve(Batch.Num()); + for (const TSharedPtr<FJsonObject>& Event : Batch) + { + EventValues.Add(MakeShared<FJsonValueObject>(Event)); + } + + TSharedPtr<FJsonObject> RequestBody = MakeShared<FJsonObject>(); + RequestBody->SetArrayField(TEXT("Events"), EventValues); + RequestBody->SetObjectField(TEXT("CustomTags"), CustomTags); + + FString RequestPayload; + TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&RequestPayload); + if (!FJsonSerializer::Serialize(RequestBody.ToSharedRef(), Writer)) + { + RequeueBatch(Batch); + ClearFlushInProgress(); + UE_LOG(LogMpsTelemetrySample, Error, TEXT("Failed to serialize PlayFab telemetry payload.")); + return; + } + + if (FMpsTelemetrySampleConfig::LogTelemetryPayloads) + { + UE_LOG(LogMpsTelemetrySample, Log, TEXT("%s"), *RequestPayload); + } + + TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest(); + Request->SetURL(Endpoint); + Request->SetVerb(TEXT("POST")); + Request->SetHeader(TEXT("Accept"), TEXT("application/json")); + Request->SetHeader(TEXT("Content-Type"), TEXT("application/json")); + Request->SetHeader(TEXT("X-TelemetryKey"), TelemetryKeyValue); + Request->SetContentAsString(RequestPayload); + + // Capture a strong ref so the client outlives the in-flight request even if + // the owning subsystem releases its TSharedPtr (for example, during + // UMpsTelemetrySubsystem::Deinitialize). Without this, the dequeued batch + // would be silently dropped on shutdown. + TSharedRef<FPlayFabTelemetryClient, ESPMode::ThreadSafe> StrongClient = AsShared(); + Request->OnProcessRequestComplete().BindLambda( + [StrongClient, Batch](FHttpRequestPtr RequestPtr, FHttpResponsePtr Response, bool bSucceeded) + { + const int32 ResponseCode = Response.IsValid() ? Response->GetResponseCode() : 0; + if (!bSucceeded || !Response.IsValid() || ResponseCode < 200 || ResponseCode >= 300) + { + StrongClient->RequeueBatch(Batch); + FString ResponseBody = Response.IsValid() ? Response->GetContentAsString() : FString(); + // Cap the logged body so a sustained 5xx storm cannot flood logs. + const int32 MaxLoggedBodyLength = 512; + if (ResponseBody.Len() > MaxLoggedBodyLength) + { + ResponseBody = ResponseBody.Left(MaxLoggedBodyLength) + TEXT("... [truncated]"); + } + UE_LOG( + LogMpsTelemetrySample, + Error, + TEXT("PlayFab telemetry flush failed. HttpCode=%d, Body=%s"), + ResponseCode, + *ResponseBody); + } + else + { + UE_LOG(LogMpsTelemetrySample, Log, TEXT("Flushed %d telemetry event(s) to PlayFab."), Batch.Num()); + } + + StrongClient->ClearFlushInProgress(); + + // Completion chaining: if more events accumulated (or were requeued) + // while this request was in flight, kick off another flush. This is + // what drains the queue during shutdown when the periodic ticker is + // already gone. + if (StrongClient->GetPendingEventCount() > 0) + { + StrongClient->FlushAsync(); + } + }); + + if (!Request->ProcessRequest()) + { + RequeueBatch(Batch); + ClearFlushInProgress(); + UE_LOG(LogMpsTelemetrySample, Error, TEXT("Failed to start PlayFab telemetry HTTP request.")); + } +} + +int32 FPlayFabTelemetryClient::GetPendingEventCount() const +{ + FScopeLock Lock(&SyncRoot); + return PendingEvents.Num(); +} + +void FPlayFabTelemetryClient::ClearFlushInProgress() +{ + FScopeLock Lock(&SyncRoot); + bFlushInProgress = false; +} + +void FPlayFabTelemetryClient::RequeueBatch(const TArray<TSharedPtr<FJsonObject>>& Batch) +{ + FScopeLock Lock(&SyncRoot); + + // The failed batch contains the oldest pending events (FlushAsync pulls from + // the front of PendingEvents). Anything still in PendingEvents is newer than + // the batch. To match the Unity sample we always drop the oldest events + // first, so when the queue is over capacity we trim the front of the batch + // rather than the newer events that arrived while the flush was in flight. + const int32 AvailableSlots = FMpsTelemetrySampleConfig::MaxQueuedEvents - PendingEvents.Num(); + if (AvailableSlots <= 0) + { + UE_LOG( + LogMpsTelemetrySample, + Warning, + TEXT("Telemetry queue is full. Dropped %d event(s) after a failed flush."), + Batch.Num()); + return; + } + + TArray<TSharedPtr<FJsonObject>> EventsToRequeue = Batch; + if (EventsToRequeue.Num() > AvailableSlots) + { + const int32 DroppedEventCount = EventsToRequeue.Num() - AvailableSlots; + EventsToRequeue.RemoveAt(0, DroppedEventCount); + UE_LOG( + LogMpsTelemetrySample, + Warning, + TEXT("Telemetry queue is near capacity. Dropped %d oldest event(s) after a failed flush."), + DroppedEventCount); + } + + PendingEvents.Insert(EventsToRequeue, 0); +} + +void FPlayFabTelemetryClient::LogMissingTelemetryKey() +{ + if (bMissingKeyLogged) + { + return; + } + + bMissingKeyLogged = true; + UE_LOG( + LogMpsTelemetrySample, + Warning, + TEXT("PlayFab telemetry key is not configured. Set PF_MPS_SECRET_TelemetryKey or PLAYFAB_TELEMETRY_KEY.")); +} + +FString FPlayFabTelemetryClient::NormalizeExternalEntityId(const FString& ExternalEntityId) const +{ + return ExternalEntityId.IsEmpty() ? FString(TEXT("unrealmpsserver")) : ExternalEntityId; +} diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/PlayFabTelemetryClient.h b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/PlayFabTelemetryClient.h new file mode 100644 index 0000000..6676538 --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/PlayFabTelemetryClient.h @@ -0,0 +1,33 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Http.h" + +class FJsonObject; + +class FPlayFabTelemetryClient : public TSharedFromThis<FPlayFabTelemetryClient, ESPMode::ThreadSafe> +{ +public: + void Configure(const FString& TitleId, const FString& TelemetryKey, const FString& EventNamespace, const FString& ExternalEntityId); + void SetExternalEntityId(const FString& ExternalEntityId); + bool Enqueue(const FString& EventName, TSharedPtr<FJsonObject> Payload); + void FlushAsync(); + int32 GetPendingEventCount() const; + +private: + void RequeueBatch(const TArray<TSharedPtr<FJsonObject>>& Batch); + void ClearFlushInProgress(); + void LogMissingTelemetryKey(); + FString NormalizeExternalEntityId(const FString& ExternalEntityId) const; + + mutable FCriticalSection SyncRoot; + TArray<TSharedPtr<FJsonObject>> PendingEvents; + TSharedPtr<FJsonObject> CustomTags; + FString Endpoint; + FString EventNamespaceName; + FString ExternalEntityIdValue; + FString TelemetryKeyValue; + bool bConfigured = false; + bool bFlushInProgress = false; + bool bMissingKeyLogged = false; +}; diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealMpsTelemetryModule.cpp b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealMpsTelemetryModule.cpp new file mode 100644 index 0000000..3c91e61 --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealMpsTelemetryModule.cpp @@ -0,0 +1,10 @@ +#include "Modules/ModuleManager.h" +#include "MpsTelemetrySubsystem.h" + +DEFINE_LOG_CATEGORY(LogMpsTelemetrySample); + +class FUnrealMpsTelemetryModule final : public IModuleInterface +{ +}; + +IMPLEMENT_MODULE(FUnrealMpsTelemetryModule, UnrealMpsTelemetry) diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealServerMetricsCollector.cpp b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealServerMetricsCollector.cpp new file mode 100644 index 0000000..d7d64ca --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealServerMetricsCollector.cpp @@ -0,0 +1,80 @@ +#include "UnrealServerMetricsCollector.h" + +#include "Dom/JsonObject.h" +#include "HAL/PlatformMemory.h" +#include "HAL/PlatformMisc.h" +#include "HAL/PlatformTime.h" +#include "MpsTelemetrySampleConfig.h" +#include "MpsTelemetrySubsystem.h" + +using namespace PlayFab::Samples::UnrealMpsTelemetry; + +void FUnrealServerMetricsCollector::Initialize() +{ + StartedSeconds = FPlatformTime::Seconds(); + ResetIntervalCounters(); +} + +void FUnrealServerMetricsCollector::Tick(float DeltaTime) +{ + TickCount++; + TickDeltaSecondsTotal += DeltaTime; + MaxTickDeltaSeconds = FMath::Max(MaxTickDeltaSeconds, static_cast<double>(DeltaTime)); + + if (DeltaTime * 1000.0 >= FMpsTelemetrySampleConfig::LongTickThresholdMilliseconds) + { + LongTickCount++; + } +} + +void FUnrealServerMetricsCollector::RegisterCustomMetricsProvider(TFunction<void(TSharedRef<FJsonObject> Payload)> Provider) +{ + if (!Provider) + { + UE_LOG(LogMpsTelemetrySample, Warning, TEXT("Ignoring empty custom metrics provider.")); + return; + } + + CustomMetricsProviders.Add(MoveTemp(Provider)); +} + +TSharedRef<FJsonObject> FUnrealServerMetricsCollector::CaptureSummaryAndReset() +{ + const double AverageTickMs = TickCount == 0 + ? 0.0 + : (TickDeltaSecondsTotal / static_cast<double>(TickCount)) * 1000.0; + const FPlatformMemoryStats MemoryStats = FPlatformMemory::GetStats(); + + TSharedRef<FJsonObject> Payload = MakeShared<FJsonObject>(); + Payload->SetNumberField(TEXT("uptimeSeconds"), static_cast<double>(FMath::RoundToInt((FPlatformTime::Seconds() - StartedSeconds) * 1000.0)) / 1000.0); + Payload->SetNumberField(TEXT("tickCount"), TickCount); + Payload->SetNumberField(TEXT("averageTickMs"), static_cast<double>(FMath::RoundToInt(AverageTickMs * 1000.0)) / 1000.0); + Payload->SetNumberField(TEXT("maxTickMs"), static_cast<double>(FMath::RoundToInt(MaxTickDeltaSeconds * 1000000.0)) / 1000.0); + Payload->SetNumberField(TEXT("longTickCount"), LongTickCount); + Payload->SetNumberField(TEXT("longTickThresholdMs"), FMpsTelemetrySampleConfig::LongTickThresholdMilliseconds); + Payload->SetNumberField(TEXT("processorCount"), FPlatformMisc::NumberOfCoresIncludingHyperthreads()); + Payload->SetNumberField(TEXT("processPhysicalMemoryBytes"), static_cast<double>(MemoryStats.UsedPhysical)); + Payload->SetNumberField(TEXT("processVirtualMemoryBytes"), static_cast<double>(MemoryStats.UsedVirtual)); + Payload->SetNumberField(TEXT("availablePhysicalMemoryBytes"), static_cast<double>(MemoryStats.AvailablePhysical)); + Payload->SetNumberField(TEXT("availableVirtualMemoryBytes"), static_cast<double>(MemoryStats.AvailableVirtual)); + + AddCustomMetrics(Payload); + ResetIntervalCounters(); + return Payload; +} + +void FUnrealServerMetricsCollector::ResetIntervalCounters() +{ + TickDeltaSecondsTotal = 0.0; + MaxTickDeltaSeconds = 0.0; + TickCount = 0; + LongTickCount = 0; +} + +void FUnrealServerMetricsCollector::AddCustomMetrics(TSharedRef<FJsonObject> Payload) +{ + for (const TFunction<void(TSharedRef<FJsonObject> Payload)>& Provider : CustomMetricsProviders) + { + Provider(Payload); + } +} diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealServerMetricsCollector.h b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealServerMetricsCollector.h new file mode 100644 index 0000000..87e9a3a --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Private/UnrealServerMetricsCollector.h @@ -0,0 +1,25 @@ +#pragma once + +#include "CoreMinimal.h" + +class FJsonObject; + +class FUnrealServerMetricsCollector +{ +public: + void Initialize(); + void Tick(float DeltaTime); + void RegisterCustomMetricsProvider(TFunction<void(TSharedRef<FJsonObject> Payload)> Provider); + TSharedRef<FJsonObject> CaptureSummaryAndReset(); + +private: + void ResetIntervalCounters(); + void AddCustomMetrics(TSharedRef<FJsonObject> Payload); + + TArray<TFunction<void(TSharedRef<FJsonObject> Payload)>> CustomMetricsProviders; + double StartedSeconds = 0.0; + double TickDeltaSecondsTotal = 0.0; + double MaxTickDeltaSeconds = 0.0; + int32 TickCount = 0; + int32 LongTickCount = 0; +}; diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Public/MpsTelemetrySampleConfig.h b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Public/MpsTelemetrySampleConfig.h new file mode 100644 index 0000000..0418312 --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Public/MpsTelemetrySampleConfig.h @@ -0,0 +1,66 @@ +#pragma once + +#include "CoreMinimal.h" + +namespace PlayFab +{ +namespace Samples +{ +namespace UnrealMpsTelemetry +{ + struct FMpsTelemetrySampleConfig + { + static constexpr double MetricsSummaryIntervalSeconds = 60.0; + static constexpr double TelemetryFlushIntervalSeconds = 60.0; + static constexpr double LongTickThresholdMilliseconds = 100.0; + static constexpr int32 MaxEventsPerBatch = 200; + static constexpr int32 MaxQueuedEvents = 1000; + static constexpr bool LogTelemetryPayloads = false; + + static const TCHAR* TelemetryKeyPlaceholder() + { + return TEXT("PASTE_YOUR_PLAYFAB_TELEMETRY_KEY_HERE"); + } + + static const TCHAR* MpsSecretTelemetryKeyEnvironmentVariable() + { + return TEXT("PF_MPS_SECRET_TelemetryKey"); + } + + static const TCHAR* TelemetryKeyEnvironmentVariable() + { + return TEXT("PLAYFAB_TELEMETRY_KEY"); + } + + static const TCHAR* EventNamespace() + { + return TEXT("custom.mps.unrealserver"); + } + + static const TCHAR* TelemetryKeySourceOverride() + { + return TelemetryKeyPlaceholder(); + } + + static FString GetTelemetryKey() + { + FString MpsSecretTelemetryKey = FPlatformMisc::GetEnvironmentVariable(MpsSecretTelemetryKeyEnvironmentVariable()); + if (IsConfiguredTelemetryKey(MpsSecretTelemetryKey)) + { + return MpsSecretTelemetryKey; + } + + FString EnvironmentTelemetryKey = FPlatformMisc::GetEnvironmentVariable(TelemetryKeyEnvironmentVariable()); + return IsConfiguredTelemetryKey(EnvironmentTelemetryKey) + ? EnvironmentTelemetryKey + : FString(TelemetryKeySourceOverride()); + } + + static bool IsConfiguredTelemetryKey(const FString& TelemetryKey) + { + return !TelemetryKey.IsEmpty() && TelemetryKey != TelemetryKeyPlaceholder(); + } + }; +} +} +} diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Public/MpsTelemetrySubsystem.h b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Public/MpsTelemetrySubsystem.h new file mode 100644 index 0000000..6cb3fb6 --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/Public/MpsTelemetrySubsystem.h @@ -0,0 +1,57 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Containers/Ticker.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "Templates/PimplPtr.h" +#include "Tickable.h" +#include "MpsTelemetrySubsystem.generated.h" + +class FJsonObject; +class FPlayFabTelemetryClient; +class FUnrealServerMetricsCollector; + +DECLARE_LOG_CATEGORY_EXTERN(LogMpsTelemetrySample, Log, All); + +UCLASS() +class UNREALMPSTELEMETRY_API UMpsTelemetrySubsystem : public UGameInstanceSubsystem, public FTickableGameObject +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + virtual void Tick(float DeltaTime) override; + virtual TStatId GetStatId() const override; + virtual bool IsTickable() const override; + + UFUNCTION(BlueprintCallable, Category = "PlayFab|MPS Telemetry") + void InitializeForMps(const FString& TitleId, const FString& ExternalEntityId = TEXT("unrealmpsserver")); + + UFUNCTION(BlueprintCallable, Category = "PlayFab|MPS Telemetry") + void BeginShutdown(); + + void RegisterCustomMetricsProvider(TFunction<void(TSharedRef<FJsonObject> Payload)> Provider); + +private: + void EnqueuePeriodicMetricsSummary(); + void EnqueueMetricsSummary(const FString& EventName); + void FlushTelemetry(); + void RemoveTickers(); + + TSharedPtr<FPlayFabTelemetryClient, ESPMode::ThreadSafe> TelemetryClient; + // TPimplPtr type-erases the deleter so the metrics collector header (which is + // private to the plugin) does not have to be visible to consumers of this header. + // TUniquePtr<ForwardDecl> would otherwise force UHT-generated code to instantiate + // the destructor for an incomplete type. + TPimplPtr<FUnrealServerMetricsCollector> MetricsCollector; + + // Use FTSTicker instead of FTimerManager so the periodic summary and flush keep + // firing at real time, even if the world is paused or time-dilated. This matches + // the Unity sample, which intentionally uses WaitForSecondsRealtime. + FTSTicker::FDelegateHandle MetricsSummaryTickerHandle; + FTSTicker::FDelegateHandle TelemetryFlushTickerHandle; + bool bInitializedForMps = false; + bool bShutdownStarted = false; +}; diff --git a/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/UnrealMpsTelemetry.Build.cs b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/UnrealMpsTelemetry.Build.cs new file mode 100644 index 0000000..5a22fa2 --- /dev/null +++ b/UnrealMpsTelemetry/Source/UnrealMpsTelemetry/UnrealMpsTelemetry.Build.cs @@ -0,0 +1,19 @@ +using UnrealBuildTool; + +public class UnrealMpsTelemetry : ModuleRules +{ + public UnrealMpsTelemetry(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange( + new[] + { + "Core", + "CoreUObject", + "Engine", + "HTTP", + "Json" + }); + } +} diff --git a/UnrealMpsTelemetry/UnrealMpsTelemetry.uplugin b/UnrealMpsTelemetry/UnrealMpsTelemetry.uplugin new file mode 100644 index 0000000..3cdb995 --- /dev/null +++ b/UnrealMpsTelemetry/UnrealMpsTelemetry.uplugin @@ -0,0 +1,19 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0.0", + "FriendlyName": "PlayFab MPS Telemetry Sample", + "Description": "Drop-in Unreal dedicated server telemetry sample for PlayFab Multiplayer Servers.", + "Category": "PlayFab", + "CreatedBy": "Microsoft PlayFab", + "CanContainContent": false, + "IsBetaVersion": true, + "Installed": false, + "Modules": [ + { + "Name": "UnrealMpsTelemetry", + "Type": "Runtime", + "LoadingPhase": "Default" + } + ] +}