Network Tracing¶
Audience
This document is written to be readable by both human integrators and coding-agent LLMs (e.g. Claude Code). The recipe sections follow a fixed shape — file, function, insertion point, literal macro line, why — so an agent can apply each site without ambiguity. Human readers can safely skim subsections that don't apply to their engine configuration.
Engine version
This recipe was authored from a UE 5.7 codebase but is expected to apply across UE 5.x releases. Function names referenced below have been stable across recent 5.x versions, but line numbers and surrounding code will differ in your fork — anchor on the function name and landmark statement, not on line numbers. The rest of the page refers to the target as "UE 5".
For macro signatures, parameters, and semantics, see Instrumentation API → Network Tracing.
1. Overview¶
Micromegas net tracing captures per-connection replication traffic at the UE engine layer and lands it in the lakehouse as a structured event stream. Unlike generic spans or logs, net events carry a bit-size attribution model: each root object or RPC records how many bits of content it contributed to a connection, letting you answer "which actors/properties dominate per-connection bandwidth?" directly in SQL.
Net tracing is separate from spans/logs/metrics because:
- It needs tight coupling to replication internals (bit stream positions, bunch writers, content blocks) that generic spans can't observe.
- It has its own verbosity ladder (packets / root objects / objects / properties) independent of span sampling.
- It produces a nested event hierarchy —
Connection → Object → Subobject → PropertyorConnection → RPC → Property— that mirrors the wire format rather than call stacks.
Event hierarchy. Net events stream into a dedicated net tagged stream. The wire shape depends on whether the project uses classic replication or Iris.
Classic incoming example (ProcessBunch path):
NetConnectionBeginEvent connection="127.0.0.1:7777" is_outgoing=false
NetObjectBeginEvent name="PlayerPawn_C_0"
NetPropertyEvent name="Health" bit_size=16
NetPropertyEvent name="ReplicatedMovement" bit_size=112
NetObjectEndEvent bit_size=512
NetObjectBeginEvent name="InventoryComponent" # peer, not nested
NetPropertyEvent name="GoldCount" bit_size=24
NetObjectEndEvent bit_size=88
NetRPCBeginEvent name="ClientNotifyHit"
NetPropertyEvent name="HitLocation" bit_size=96
NetRPCEndEvent bit_size=160
NetConnectionEndEvent bit_size=760 # sum of root Object/RPC bits
Iris outgoing example (WriteObjectAndSubObjects path):
NetConnectionBeginEvent connection="127.0.0.1:7777" is_outgoing=true
NetObjectBeginEvent name="PlayerPawn_C_0"
NetPropertyEvent name="Health" bit_size=16
NetObjectBeginEvent name="WeaponComponent" # nested inside parent
NetPropertyEvent name="AmmoCount" bit_size=24
NetObjectEndEvent bit_size=88
NetObjectEndEvent bit_size=520
NetConnectionEndEvent bit_size=520
The classic shape emits subobjects as peers at depth 0; Iris emits them nested at depth 1+. Both are correct — they mirror the actual replication traversal order in each system. See Architecture → Classic vs Iris hierarchy.
2. Architecture¶
This section is load-bearing: the recipe in §3 makes judgment calls (which macro form, which bit source, when to suspend) that only make sense once the architecture is understood. When the implementer faces a novel call site or a renamed function, this section is the fallback.
Data flow¶
flowchart LR
A[Engine code<br/>replication path] -->|MICROMEGAS_NET_* macros| B[Dispatch]
B --> C[NetTraceWriter<br/>depth counters,<br/>verbosity gate]
C --> D[NetStream<br/>tagged 'net']
D --> E[HttpEventSink]
E --> F[Telemetry ingestion<br/>→ object store]
F --> G[Lakehouse<br/>parse_block, views]
Verbosity model¶
Runtime verbosity is a 0–4 enum:
| Level | Name | Emits |
|---|---|---|
| 0 | Off |
Nothing |
| 1 | Packets |
Connection scopes only |
| 2 | RootObjects |
+ root object scopes (depth 0) |
| 3 | Objects |
+ nested object scopes (depth 1+) |
| 4 | Properties |
+ per-property leaf events, + RPC scopes (root and nested) |
Depth-based gating rule. Inside NetTraceWriter:
- Object scopes: depth 0 = at least level
RootObjects; depth 1+ = at least levelObjects - Property leaves = level
Properties - RPC scopes (Begin/End events) = level
Properties
Default: level 2 (RootObjects) — production setting, cheap enough to run continuously.
Note that root RPC bit_size (ObjectDepth == 0 at EndRPC) still accumulates into NetConnectionEndEvent.bit_size at every verbosity ≥ Packets, regardless of whether the NetRPC* events themselves are emitted. Only the per-RPC event records require level 4.
Snapshot invariant (Decision 6). The writer captures EffectiveVerbosity at the outermost BeginConnection and uses that snapshot for every gating decision in the scope. CVar-driven changes take effect at the next outer connection scope, never mid-scope. This eliminates orphaned Begin/End pairs and partially-gated subtrees.
Content-attribution vs wire-bit semantics¶
Net tracing emits two distinct families of data. Pick the right one for your question:
| Question | Use |
|---|---|
| Which actors/properties dominate per-connection bandwidth? | NetObjectEndEvent.bit_size / NetPropertyEvent.bit_size |
| What's the connection's true bandwidth on the wire? | sum(net.packet_sent_bits) filtered by connection_name |
| What fraction is framing/overhead? | 1 − sum(NetConnectionEndEvent.bit_size) / sum(net.packet_*_bits) |
bit_size on the NetConnectionEndEvent is the content sum: root-level NetObjectEndEvent.bit_size + root-level NetRPCEndEvent.bit_size. It deliberately excludes packet headers, bunch headers, control bunches, NetGUID exports, voice, and anything outside an OBJECT_SCOPE or RPC_SCOPE.
The ObjectDepth == 0 gate in EndObject / EndRPC prevents nested-scope double-counting: a receive RPC inside a subobject scope measures the same Reader delta as the outer object, so only the outer contribution is accumulated. Don't try to defeat this — see Pitfalls → RPC bits double-count.
Classic vs Iris hierarchy¶
Classic — UActorChannel::ReplicateActor opens a root-actor scope, replicates the actor, closes the scope, then calls DoSubObjectReplication, which opens a peer scope at depth 0 for each subobject. The two scopes are siblings in the event stream.
Why: classic's actor/subobject split is serialized into separate scopes because they run sequentially in different bunches.
Iris — FReplicationWriter::WriteObjectAndSubObjects is recursive: the parent scope stays open while child scopes run inside it, producing a nested tree in the event stream.
Why: Iris serializes an object and all its subobjects into a single batch, so the scope structure follows the recursion.
Consequence for queries. Queries that walk hierarchy must handle both shapes — or filter on a per-process flag that identifies Iris vs classic processes.
Non-nesting invariant (Decision 6)¶
Connection scopes do not nest. Only the outermost BeginConnection emits and resets writer state; nested ones are absorbed as no-ops and logged once via LogMicromegasNet with the inner/outer (name, direction) pair.
Consequence:
- If a nested scope closes while
ObjectDepth == 0, itsAccumulatedBitsroll into the outer's total. - If it closes while inside an outer object scope, the bits are dropped.
Don't try to defeat this by changing the writer. Instead:
- Instrument so scopes don't overlap naturally (one per replication entry point).
- For paths that process packets/bunches but shouldn't contribute to attribution (demo, replay), use
MICROMEGAS_NET_SUSPEND_SCOPE()instead.
Suspend mechanism¶
MICROMEGAS_NET_SUSPEND_SCOPE() zeroes out every MICROMEGAS_NET_* call inside its lifetime without touching depth counters. Safe to nest under an active live scope — the live scope resumes unchanged when the suspend scope exits.
Use it for:
- Demo recording / replay scrubbing
- Server-side simulation that re-runs replication
- Any synchronous engine callback that can fire from inside a live packet/replication scope and shouldn't be attributed
RAII everywhere¶
Every MICROMEGAS_NET_* macro is RAII. There are no public BEGIN_* / END_* macros. Do not call Dispatch::NetEndConnection / NetEndObject / NetEndRPC directly — only the scope guard destructors are authorized callers.
Early returns are safe: the destructors close scopes automatically when the enclosing block exits.
3. Engine instrumentation recipe¶
Every site below follows the same shape:
- File — UE 5 path (
Engine/Source/...orEngine/Plugins/...). - Function — qualified signature.
- Insertion point — a named landmark statement in the existing code.
- Insert — the literal macro line (copy-pasteable C++).
- Why — one sentence on what breaks or is misattributed if skipped.
Line numbers will differ in your fork. Anchor on the function name and landmark statement.
Connection-name resolution snippet. Every connection-scope site uses the same two-liner to pick a stable FName for the connection. Defined once here, referenced by <conn-name> below:
FName MmConnectionName = Connection->GetPlayerOnlinePlatformName();
if (MmConnectionName == NAME_None) { MmConnectionName = Connection->GetFName(); }
Adjust Connection-> if the local handle is named differently (e.g. Params.Connection, NetConnection).
3.1 Incoming packet scope¶
- File:
Engine/Source/Runtime/Engine/Private/NetConnection.cpp - Function:
UNetConnection::ReceivedPacket - Insertion point: after the
if (PacketNotify.ReadHeader(Reader))success branch, beforeInTraceCollectorsetup. -
Insert:
-
Why: natural function-scoped boundary for one received UDP packet; RAII closes the scope on every early-return path.
Then, near the existing UE_NET_TRACE_PACKET_RECV(...) line at the end of the function, add the wire-bit metric:
-
Insert:
3.2 Outgoing packet metric¶
- File:
Engine/Source/Runtime/Engine/Private/NetConnection.cpp - Function:
UNetConnection::FlushNet - Insertion point: immediately after the
UE_NET_TRACE_PACKET_SEND(...)line, before theLowLevelSendcall. -
Insert:
-
Why: physical wire-bit counter. Do not open a
MICROMEGAS_NET_CONNECTION_SCOPEhere — outgoing scopes live at higher-level replication entry points (§3.3, §3.4, §3.5).
3.3 Classic server replication scope¶
- File:
Engine/Source/Runtime/Engine/Private/NetDriver.cpp - Function:
UNetDriver::ServerReplicateActors_ForConnection - Insertion point: first executable line of the function body, inside the
#if WITH_SERVER_CODEguard. -
Insert:
-
Why: wraps the per-connection actor-replication walk on a vanilla server (no RepGraph). Skipped entirely when
UReplicationDriveris set — that path needs §3.4 instead.
3.4 RepGraph server replication scope¶
- File:
Engine/Plugins/Runtime/ReplicationGraph/Source/Private/ReplicationGraph.cpp - Function:
UReplicationGraph::ServerReplicateActors - Insertion point: inside the
for (UNetReplicationGraphConnection* ConnectionManager : Connections)loop, after the early-continuechecks (saturation, invalid connection) and before the gather phase. -
Insert:
-
Why: RepGraph short-circuits
UNetDriver::ServerReplicateActors, so the §3.3 scope never opens for RepGraph traffic. Symptom of missing this site: object/property events appear outside anyBeginConnectionevent — especially ~120 actors per tick withbit_size:0from the relevancy walk.
3.5 Iris outgoing scope¶
- File:
Engine/Source/Runtime/Engine/Private/Net/Experimental/Iris/DataStreamChannel.cpp - Function:
UDataStreamChannel::WriteData - Insertion point: after the
if (Result == EWriteResult::NoData) return;early return, before the main write loop. -
Insert:
-
Why: opens only when there's actual work — empty ticks don't produce empty scopes. Iris bypasses both classic scopes (§3.3, §3.4).
3.6 Queued-bunch deferred-flush scope¶
- File:
Engine/Source/Runtime/Engine/Private/DataChannel.cpp - Function:
UActorChannel::ProcessQueuedBunches - Insertion point: inside the
bHasTimeToProcess && PendingGuidResolves.Num() == 0branch, immediately before thewhile ((BunchIndex < QueuedBunches.Num()) && ...)drain loop. -
Insert:
-
Why: queued bunches (parked waiting for NetGUID resolution) are flushed from
UActorChannel::Tick(), outside the §3.1ReceivedPacketscope. Without this, multi-kilobitInventoryManagerComponentand similar large initial-state objects orphan ~1 s after the packet arrives.
3.7 Classic object scopes¶
Four sites.
3.7.1 UActorChannel::ReplicateActor (root actor, send)¶
- File:
Engine/Source/Runtime/Engine/Private/DataChannel.cpp - Insertion point: inside the
// The Actorblock, immediately after the existingUE_NET_TRACE_OBJECT_SCOPE(ActorReplicator->ObjectNetGUID, Bunch, ...)line. -
Insert:
-
Why: root actor body. Bit source is the bunch writer because that's the wire stream the actor's bits land in.
3.7.2 UActorChannel::WriteSubObjectInBunch (subobject, send)¶
- File:
Engine/Source/Runtime/Engine/Private/DataChannel.cpp - Insertion point: immediately after the existing
UE_NET_TRACE_OBJECT_SCOPE(ObjectReplicator->ObjectNetGUID, Bunch, ...)line, before thebWroteSomething = ObjectReplicator.Get().ReplicateProperties(Bunch, ObjRepFlags);call. -
Insert:
-
Why: subobject body. In classic, this opens at depth 0 (peer to the root actor's scope, not nested) — the §3.7.1 scope already closed by the time
WriteSubObjectInBunchis called fromDoSubObjectReplication.
3.7.3 UActorChannel::ProcessBunch content-block receive loop¶
- File:
Engine/Source/Runtime/Engine/Private/DataChannel.cpp - Insertion point: immediately after
TSharedRef<FObjectReplicator>& Replicator = FindOrCreateReplicator(RepObj);, beforebool bHasUnmapped = false;. -
Insert:
-
Why: covers both root actors and subobjects on receive —
ProcessBunch's loop body handles both. Bit source isReader(the localFNetBitReadercontaining the content-block payload), notBunch(the outer packet reader — usingBunchhere would measure the wrong delta).
3.7.4 FObjectReplicator::ReplicateCustomDeltaProperties (fast arrays / custom delta)¶
- File:
Engine/Source/Runtime/Engine/Private/DataReplication.cpp - Insertion point: inside the per-property loop, after the trace-collection reset block, before the
TSharedPtr<INetDeltaBaseState>& OldState = ...assignment. -
Insert:
-
Why: outer container for fast-array / custom-delta properties. Opens at depth 1 (nested inside the root-actor scope, since
ReplicateCustomDeltaPropertiesis called fromReplicateProperties_rinsideReplicateActor's scope) — the one classic exception to the peer-not-nested rule.
3.8 Classic property scopes¶
Five sites in Engine/Source/Runtime/Engine/Private/RepLayout.cpp. The macro form (flat vs scope) is dictated by what's already available; pick wrong and you create unnecessary divergence.
3.8.1 SendProperties_r, shared-property branch¶
- Insertion point: immediately after
NETWORK_PROFILER(GNetworkProfiler.TrackReplicateProperty(ParentCmd.Property, SharedPropInfo->PropBitLength, nullptr));. -
Insert:
-
Why flat:
SharedPropInfo->PropBitLengthis pre-computed, no delta needed.
3.8.2 SendProperties_r, regular-property branch¶
- Insertion point: immediately after
NETWORK_PROFILER(GNetworkProfiler.TrackReplicateProperty(ParentCmd.Property, NumEndBits - NumStartBits, nullptr));. -
Insert:
-
Why flat:
NumStartBits/NumEndBitsare pre-existing engine locals captured forNETWORK_PROFILER— reuse them in a single line. Switching to the scope form would require wrappingNetSerializeItemin a new brace-bounded block to avoid measuring the trailingENABLE_PROPERTY_CHECKSUMScode, increasing divergence.
3.8.3 SendProperties_BackwardsCompatible_r¶
- Insertion point: immediately after
NETWORK_PROFILER(GNetworkProfiler.TrackReplicateProperty(Cmd.Property, NumEndBits - NumStartBits, nullptr));. -
Insert:
-
Why flat: same reasoning as §3.8.2.
3.8.4 ReceiveProperties_r¶
- Insertion point: inside the per-property block, immediately after the existing
UE_NET_TRACE_DYNAMIC_NAME_SCOPE(Cmd.Property->GetFName(), Params.Bunch, ...)line, before theif (ReceivePropertyHelper(...))call. -
Insert:
-
Why scope: there's no pre-existing bit capture here, and the scope form absorbs what would otherwise be a dedicated
MmStartBitscapture — one line instead of three.
3.8.5 ReceiveProperties_BackwardsCompatible_r¶
- Insertion point: after the
TempReader.ResetData(Reader, NumBits)and error-check block, beforeif (NetFieldExportGroup->NetFieldExports[NetFieldExportHandle].bIncompatible) continue;. -
Insert:
-
Why flat:
NumBitsis read directly from the stream as a packed int — no delta needed.
3.9 Classic RPCs¶
Three sites.
3.9.1 ProcessRemoteFunctionForChannelPrivate, send-side¶
- File:
Engine/Source/Runtime/Engine/Private/NetDriver.cpp - Function:
UNetDriver::ProcessRemoteFunctionForChannelPrivate - Insertion point: after
TSharedPtr<FRepLayout> RepLayout = GetFunctionRepLayout(Function);, immediately beforeRepLayout->SendPropertiesForRPC(Function, Ch, TempWriter, Parms);. -
Insert:
-
Why:
TempWriteris fresh per-RPC, soGetNumBits()gives the full payload size via the destructor delta afterSendPropertiesForRPCreturns.
3.9.2 ProcessRemoteFunctionForChannelPrivate, RPC-forced ReplicateActor block¶
- File:
Engine/Source/Runtime/Engine/Private/NetDriver.cpp - Function:
UNetDriver::ProcessRemoteFunctionForChannelPrivate - Insertion point: earlier in the same function — the
Ch->SetForcedSerializeFromRPC(true); Ch->ReplicateActor(); Ch->SetForcedSerializeFromRPC(false);triple. Wrap it in a brace-bounded block with a connection scope: -
Insert (replaces the triple):
// micromegas net trace { FName MmConnectionName = Connection->GetPlayerOnlinePlatformName(); if (MmConnectionName == NAME_None) { MmConnectionName = Connection->GetFName(); } MICROMEGAS_NET_CONNECTION_SCOPE(MmConnectionName, /*bIsOutgoing=*/ true); Ch->SetForcedSerializeFromRPC(true); Ch->ReplicateActor(); Ch->SetForcedSerializeFromRPC(false); } -
Why: when an RPC fires for an actor whose channel isn't up to date,
ReplicateActoris forced — emitting a full actor + subobject + fast-array walk that needs connection attribution. The brace bounds the RAII lifetime soEndConnectionfires immediately afterSetForcedSerializeFromRPC(false). If reached while another scope is open (e.g. inside §3.3), Decision-6 absorbs the nested Begin/End as no-ops.
3.9.3 FObjectReplicator::ReceivedRPC¶
- File:
Engine/Source/Runtime/Engine/Private/DataReplication.cpp - Insertion point: immediately before
FuncRepLayout->ReceivePropertiesForRPC(Object, LayoutFunction, OwningChannel, Reader, Parms, UnmappedGuids);. -
Insert:
-
Why: receive-side RPC body.
Readeris the bit reader the RPC parameters are deserialized from.
3.10 Iris properties¶
Eight sites in Engine/Source/Runtime/Net/Iris/Private/Iris/ReplicationSystem/ReplicationOperations.cpp — one per Serialize / Deserialize variant.
Pattern (identical at every site): inside the for (uint32 MemberIt = 0; MemberIt < MemberCount; ++MemberIt) loop, immediately before the MemberSerializerDescriptor.Serializer->{Serialize,Deserialize,SerializeDelta,DeserializeDelta}(Context, Args); call. The macro line is:
// micromegas net trace — Serialize variants
MICROMEGAS_NET_PROPERTY_SCOPE(MemberDebugDescriptors[MemberIt].DebugName->Name,
Context.GetBitStreamWriter()->GetPosBits());
with GetBitStreamWriter() for Serialize* variants, GetBitStreamReader() for Deserialize* variants.
The eight functions:
| Function | Bit stream | Serializer call |
|---|---|---|
Serialize |
GetBitStreamWriter() |
Serializer->Serialize |
Deserialize |
GetBitStreamReader() |
Serializer->Deserialize |
SerializeDelta |
GetBitStreamWriter() |
Serializer->SerializeDelta |
DeserializeDelta |
GetBitStreamReader() |
Serializer->DeserializeDelta |
SerializeWithMask |
GetBitStreamWriter() |
Serializer->Serialize |
DeserializeWithMask |
GetBitStreamReader() |
Serializer->Deserialize |
SerializeDeltaWithMask |
GetBitStreamWriter() |
Serializer->SerializeDelta |
DeserializeDeltaWithMask |
GetBitStreamReader() |
Serializer->DeserializeDelta |
For the four WithMask variants, the macro lives inside the if (ChangeMask.IsAnyBitSet(...)) (or equivalent changemask-check) block so only dirty properties trace.
Why: Iris has no NETWORK_PROFILER capture to reuse, and the bit position is a GetPosBits delta across the serializer call — the scope form is the right tool. DebugName->Name is const TCHAR* (process-lifetime stable, allocated by CreatePersistentNetDebugName) — pass directly; the macro wraps it in StaticStringRef internally.
3.11 Iris objects¶
Two sites.
3.11.1 FReplicationWriter::SerializeObjectStateDelta¶
- File:
Engine/Source/Runtime/Net/Iris/Private/Iris/ReplicationSystem/ReplicationWriter.cpp - Insertion point: first executable line of the function body.
-
Insert:
-
Why: per-object Iris send entry point, called for both root objects and subobjects within a batch. Null-guard on
Protocol/DebugNamefalls back to the literalTEXT("Unknown")(also process-lifetime stable). Depth-based gating inNetTraceWriterdecides which level emits.
3.11.2 FReplicationReader::DeserializeObjectStateDelta¶
- File:
Engine/Source/Runtime/Net/Iris/Private/Iris/ReplicationSystem/ReplicationReader.cpp - Insertion point: first executable line of the function body.
-
Insert:
-
Why: symmetric receive path with the same null-guard pattern.
3.12 Iris RPCs¶
Four sites in Engine/Source/Runtime/Net/Iris/Private/Iris/ReplicationSystem/NetBlob/NetRPC.cpp.
3.12.1 FNetRPC::SerializeWithObject¶
- Insertion point: after
FNetBitStreamWriter& Writer = *Context.GetBitStreamWriter();, beforeconst uint32 HeaderPos = Writer.GetPosBits();. -
Insert:
-
Why:
BlobDescriptoris a member field, valid at function entry — open the scope as early as possible to capture the full RPC including header.
3.12.2 FNetRPC::Serialize¶
- Insertion point: same shape — after the
FNetBitStreamWriter& Writer = ...line, before theHeaderPoscapture. -
Insert:
3.12.3 FNetRPC::DeserializeWithObject¶
- Insertion point: after
ResolveFunctionAndObject(...)succeeds andUE_NET_TRACE_SET_SCOPE_NAME(TraceScope, BlobDescriptor->DebugName);runs, immediately beforeInternalDeserializeBlob(Context);. -
Insert:
-
Why: the RPC name isn't valid until after
ResolveFunctionAndObject— opening the scope earlier would dereference an invalidBlobDescriptor.
3.12.4 FNetRPC::Deserialize¶
- Insertion point: same post-resolve placement as §3.12.3.
-
Insert:
3.13 Demo recording suspend¶
Three sites in Engine/Source/Runtime/Engine/Private/DemoNetDriver.cpp, all using MICROMEGAS_NET_SUSPEND_SCOPE(); (no arguments) immediately before the demo operation.
3.13.1 UDemoNetDriver::ProcessRemoteFunction¶
- Insertion point: immediately before
InternalProcessRemoteFunction(Actor, SubObject, ClientConnections[0], Function, Parameters, OutParms, Stack, IsServer());. -
Insert:
-
Why: a multicast RPC processed by
DemoNetDriverduring live-gameReceivedPacketwould otherwise callBeginConnection/EndConnectionand clobber the live packet's depth counters andAccumulatedBits.
3.13.2 UDemoNetDriver::TickDemoRecordFrame¶
- Insertion point: immediately before the demo actor-replication block (around the
STAT_ReplayReplicateActorscycle counter /Replay actor replication timescope). -
Insert:
-
Why: demo recording isn't real network bandwidth — including its 1-bit "no changes" fast-array markers and 0-dirty-bits objects would be misleading.
3.13.3 UDemoNetDriver::NotifyActorTornOff¶
- Insertion point: immediately before
ReplayHelper.ReplicateActor(Actor, ClientConnections[0], true);. -
Insert:
-
Why: event-driven, same re-entrancy risk as
ProcessRemoteFunction.
4. Pitfalls¶
Each entry below gives a symptom you'd see in the lakehouse or the log, the underlying cause, and the structural fix. Together these encode the rules an implementer needs to extrapolate to call sites the recipe doesn't explicitly cover.
Object events orphan outside any connection scope (server traces)¶
- Symptom: root
NetObjectBeginEventevents appear in the stream with no precedingNetConnectionBeginEvent, often ~120 actors per tick withbit_size:0. - Cause: the project sets a
UReplicationDriver(typicallyUReplicationGraph), which makesUNetDriver::ServerReplicateActorsshort-circuit into the driver — the §3.3 scope never opens. - Fix: add the §3.4 RepGraph scope.
- Generalization: any custom replication driver needs its own per-connection scope at its top-level per-connection loop.
Multi-kilobit objects appear ~1 s after their packet (client traces, initial state)¶
- Symptom: large initial-state subobjects (e.g.
InventoryManagerComponent) orphan outside aReceivedPacketscope, ~1 s late. - Cause: bunches blocked on unresolved NetGUIDs are parked in
UActorChannel::QueuedBunchesand flushed later fromTick(), outsideReceivedPacket. - Fix: add the §3.6
ProcessQueuedBunchesscope. - Generalization: any code path that calls
ProcessBunchfrom outsideReceivedPacketneeds its own incoming connection scope.
LogMicromegasNet fires repeatedly with the same (inner, outer) pair¶
- Symptom: a diagnostic log line repeats at runtime with the same inner/outer connection name+direction.
- Cause: a real re-entry path — the inner scope opens while the outer is still active.
- Fix: identify the call path. If the inner work shouldn't be attributed (demo, replay), add
MICROMEGAS_NET_SUSPEND_SCOPE()at the inner call site instead of a connection scope. If it should be attributed, accept the Decision-6 absorption: inner bits roll into the outer'sAccumulatedBitsif they close at root depth, or are dropped if they close inside an outer object scope. - Generalization: any synchronous engine callback that can fire from inside a packet/replication scope (e.g.
PostLogin,OnRep_*calling into game code) is a re-entry candidate.
NetConnectionEndEvent.bit_size much smaller than sum(net.packet_*_bits)¶
- Symptom: content bits look way off from wire bits for the same connection and window.
- Cause: expected —
bit_sizeis content attribution (sum of rootNetObjectEndEvent.bit_sizeand rootNetRPCEndEvent.bit_sizeonly); the metric is wire bits including bunch headers, packet headers, NetGUID exports, control bunches, and voice. - Fix: use
net.packet_*_bitsfor wire totals,NetConnectionEndEvent.bit_sizefor content attribution. The gap is the framing overhead — that's a feature, not a bug.
Demo recording bits show up as live network bandwidth¶
- Symptom: unusual spikes in content attribution during replay recording; depth counters behave oddly in live packets near demo events.
- Cause: missing
SUSPEND_SCOPEon one of the §3.13DemoNetDriversites — the demo'sBeginConnectionclobbered the live packet's depth counters. - Fix: add the missing suspend.
- Generalization: any system that does its own packet/bunch processing while the live game is also doing so (replay, server-side simulation, ghost recording) should suspend.
EndConnection events with no preceding BeginConnection¶
- Symptom: unbalanced Begin/End in the event stream.
- Cause: a removed-then-reintroduced manual Begin/End pair, or a scope opened in one frame's block and closed after a flush boundary.
- Fix: only use
MICROMEGAS_NET_CONNECTION_SCOPE(RAII) at function-scoped boundaries — there are no public Begin/End macros. If this surfaces in a fresh integration, confirm no helper is callingDispatch::NetEndConnection()directly (FNetConnectionScopeis the only authorized caller).
RPC bits double-count into NetConnectionEndEvent.bit_size¶
- Symptom: content bits appear inflated when RPCs are involved.
- Cause: an RPC scope nested inside an object scope on the receive path (an RPC fires inside
ReceivedBunchwhich is inside §3.7.3'sProcessBunchOBJECT_SCOPE). Both scopes would measure deltas of the sameReader. - Fix: already gated automatically by
if (ObjectDepth == 0)inEndRPC— nested RPC bits are silently absorbed into the parent object's measurement, not double-counted. Don't try to "unfix" the gate.
Subobject hierarchy differs between classic and Iris¶
- Symptom: queries that walk
NetObject*nesting return different shapes from classic vs Iris processes. - Cause: classic emits subobjects as peers at depth 0 (because
WriteSubObjectInBunchis called fromDoSubObjectReplicationafter the root actor's scope closed). Iris emits them nested at depth 1+ (becauseWriteObjectAndSubObjectsrecurses inside the parent scope). - Fix: both shapes are correct — they reflect how each system actually replicates. Queries that traverse hierarchy must handle both, or filter on a per-process Iris/classic flag.
5. Verifying instrumentation¶
Scope
A dedicated net_events view has not yet been added to rust/analytics/src/lakehouse/. For now, per-event inspection goes through the generic parse_block(block_id) table function, which returns (object_index, type_name, value as JSONB) rows. For net blocks the type_name values are NetConnectionBeginEvent, NetConnectionEndEvent, NetObjectBeginEvent, NetObjectEndEvent, NetPropertyEvent, NetRPCBeginEvent, NetRPCEndEvent. Once a first-class net_events view ships, these queries shorten; the verification shape stays the same.
Stream registration¶
Confirms the net stream is created and tagged.
SELECT process_id, tags, properties
FROM streams
WHERE array_has(tags, 'net')
ORDER BY insert_time DESC
LIMIT 5;
If this returns zero rows, the writer isn't initializing or the sink isn't uploading — no point checking anything else.
Wire-bit metrics¶
Confirms §3.1 and §3.2 are firing. Independent of any net-events view — these go through the standard metric pipeline.
SELECT time, name, value, properties
FROM measures
WHERE name IN ('net.packet_sent_bits', 'net.packet_received_bits')
ORDER BY time DESC
LIMIT 20;
Find a net block to inspect¶
The blocks view joins stream tags under "streams.tags"; filter with array_has:
SELECT block_id, stream_id, "processes.exe", begin_time, end_time,
nb_objects, payload_size, "streams.tags"
FROM blocks
WHERE array_has("streams.tags", 'net')
ORDER BY end_time DESC
LIMIT 5;
Decode events in a block¶
Pick a block_id from the query above and pass it to parse_block:
SELECT object_index, type_name, jsonb_format_json(value) AS event
FROM parse_block('<block_id>')
LIMIT 1000;
Or summarize what event types a block contains:
A NetConnectionBeginEvent without a matching EndEvent in the same block signals unbalanced instrumentation.
Notebook-friendly
In the Analytics Web App, the three queries above compose naturally into a notebook: a net-blocks table with a row selection → two parse_block cells bound to $blocks.selected.block_id. Ready-made inspection workflow.
Connection names and bit sizes¶
Extract specific fields with jsonb_get / jsonb_as_string:
SELECT object_index,
type_name,
jsonb_as_string(jsonb_get(value, 'connection_name')) AS connection_name,
jsonb_as_string(jsonb_get(value, 'is_outgoing')) AS is_outgoing,
jsonb_as_string(jsonb_get(value, 'bit_size')) AS bit_size
FROM parse_block('<block_id>')
WHERE type_name IN ('NetConnectionBeginEvent', 'NetConnectionEndEvent')
ORDER BY object_index;
connection_name should be a non-empty FName-backed string. bit_size on the End event should be a plausible content-bit total.
Content-vs-wire reconciliation¶
Pick one connection and one time window. Sum NetConnectionEndEvent.bit_size from parse_block across that connection's net blocks, and sum net.packet_sent_bits from measures for the same connection over the same window. Content should be 0.7–0.95× wire. Wild divergence indicates a missing OBJECT_SCOPE somewhere in the replication path. This query is verbose against parse_block — once a first-class net_events view lands, it collapses to a single JOIN.
No orphaned ends¶
Over a single block, every NetConnectionEndEvent should follow a preceding NetConnectionBeginEvent (same for NetObject* and NetRPC*). Walk the parse_block output and maintain a depth counter per event family — it must never go negative.
CVar runtime change¶
Confirm net-block insertion rate scales with verbosity by re-running the blocks filter query at each level. The change takes effect at the next outermost BeginConnection (verbosity is snapshotted per scope), not mid-scope.
6. Build configuration¶
MICROMEGAS_NET_TRACE_ENABLED defaults to !UE_BUILD_SHIPPING && !WITH_EDITOR:
- Editor → off
- Development / Test → on
- Shipping → off
To enable in shipping for a focused investigation, add to your Target.cs:
To force-off in non-shipping for a perf bake-off:
When the macro is 0, every MICROMEGAS_NET_* call expands to nothing — zero overhead, zero divergence cost beyond the insertion lines themselves.