Skip to content
Merged
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
2 changes: 1 addition & 1 deletion deps/cloudxr/.env.default
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# CloudXR Docker Images and Configs
###########################################################
CXR_RUNTIME_SDK_VERSION=6.2.1
CXR_WEB_SDK_VERSION=6.2.0
CXR_WEB_SDK_VERSION=6.3.0-rc2
CXR_HOST_VOLUME_PATH=$HOME/.cloudxr

###########################################################
Expand Down
146 changes: 146 additions & 0 deletions deps/cloudxr/webxr_client/helpers/metricsAccumulator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @jest-environment jsdom
*
* Jest only reads this pragma from the file's first comment block, hence its placement
* here. The CloudXR SDK bundle touches `window` at import time, and this suite imports it
* (transitively, via metricsAccumulator) to assert against the real MetricsName values
* rather than a mock that could drift from them.
*/

import * as CloudXR from '@nvidia/cloudxr';

import { MetricsAccumulator } from './metricsAccumulator';

/** Finds one cadence in a snapshot list. */
const cadence = (snapshots: ReturnType<MetricsAccumulator['takeSnapshot']>, name: string) =>
snapshots.find(s => s.cadence === name);

describe('MetricsAccumulator', () => {
it('reports nothing before any metric arrives', () => {
expect(new MetricsAccumulator().takeSnapshot()).toEqual([]);
});

it('merges partial updates instead of replacing them', () => {
const acc = new MetricsAccumulator();
// The send-begin and render-begin paths each emit their own PerRender tick.
acc.recordRender({ framerate: 72 });
acc.recordRender({ sendFramerate: 60 });

const render = cadence(acc.takeSnapshot(), 'render');
expect(render?.metrics).toEqual({
[CloudXR.MetricsName.RenderFramerate]: 72,
[CloudXR.MetricsName.PoseSendFramerate]: 60,
});
});

it('overwrites a metric when a later tick carries it again', () => {
const acc = new MetricsAccumulator();
acc.recordRender({ framerate: 72 });
acc.recordRender({ framerate: 71 });

expect(cadence(acc.takeSnapshot(), 'render')?.metrics).toEqual({
[CloudXR.MetricsName.RenderFramerate]: 71,
});
});

it('ignores undefined and non-finite values rather than reporting them as zero', () => {
const acc = new MetricsAccumulator();
acc.recordRender({ framerate: 72, sendFramerate: undefined });
acc.recordNetwork({ rttMs: NaN, packetLoss: Infinity });

expect(cadence(acc.takeSnapshot(), 'render')?.metrics).toEqual({
[CloudXR.MetricsName.RenderFramerate]: 72,
});
// An all-invalid update leaves the cadence empty, so it is omitted entirely.
expect(cadence(acc.takeSnapshot(), 'network')).toBeUndefined();
});

it('keeps a zero value, which is meaningful for loss and quality metrics', () => {
const acc = new MetricsAccumulator();
acc.recordNetwork({ packetLoss: 0, sessionQuality: 0 });

expect(cadence(acc.takeSnapshot(), 'network')?.metrics).toEqual({
[CloudXR.MetricsName.NetworkPacketLoss]: 0,
[CloudXR.MetricsName.SessionQuality]: 0,
});
});

it('separates the three cadences and omits empty ones', () => {
const acc = new MetricsAccumulator();
acc.recordRender({ framerate: 72 });
acc.recordNetwork({ sessionQuality: 3 });

const snapshots = acc.takeSnapshot();
expect(snapshots.map(s => s.cadence).sort()).toEqual(['network', 'render']);
});

it('reports every field of every cadence under its SDK metric name', () => {
const acc = new MetricsAccumulator();
acc.recordRender({ framerate: 1, sendFramerate: 2, xrPoseAgeMs: 3 });
acc.recordFrame({
framerate: 4,
frameCount: 5,
poseToRenderTimeMs: 6,
poseUploadMs: 7,
poseToFrameReceivedMs: 8,
compositorSkippedPercent: 9,
outOfOrderPercent: 10,
mismatchedPercent: 11,
});
acc.recordNetwork({
streamingRateMbps: 12,
availableBandwidthMbps: 13,
rttMs: 14,
packetLoss: 15,
avgDecodeTimeMs: 16,
qualityScore: 17,
bandwidthScore: 18,
lossScore: 19,
latencyScore: 20,
sessionQuality: 21,
});

const snapshots = acc.takeSnapshot();
expect(Object.keys(cadence(snapshots, 'render')!.metrics)).toHaveLength(3);
expect(Object.keys(cadence(snapshots, 'frame')!.metrics)).toHaveLength(8);
expect(Object.keys(cadence(snapshots, 'network')!.metrics)).toHaveLength(10);
// Pin the wire names the OOB hub and its dashboards consume, not just the enum
// reference: renaming a key here is a breaking protocol change, not a refactor.
expect(cadence(snapshots, 'render')!.metrics['render.framerate']).toBe(1);
expect(cadence(snapshots, 'render')!.metrics['pose.send_framerate']).toBe(2);
expect(cadence(snapshots, 'network')!.metrics[CloudXR.MetricsName.SessionQuality]).toBe(21);
});

it('snapshots are detached, so later ticks do not mutate an emitted payload', () => {
const acc = new MetricsAccumulator();
acc.recordRender({ framerate: 72 });
const first = cadence(acc.takeSnapshot(), 'render')!.metrics;
acc.recordRender({ framerate: 30 });

expect(first[CloudXR.MetricsName.RenderFramerate]).toBe(72);
});

it('reset drops everything so a new session cannot inherit stale values', () => {
const acc = new MetricsAccumulator();
acc.recordRender({ framerate: 72 });
acc.recordNetwork({ sessionQuality: 4 });
acc.reset();

expect(acc.takeSnapshot()).toEqual([]);
});
});
153 changes: 153 additions & 0 deletions deps/cloudxr/webxr_client/helpers/metricsAccumulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* MetricsAccumulator - last-known value per CloudXR metric, grouped by cadence.
*
* The in-XR HUD only needs a handful of metrics, but the OOB teleop hub wants
* everything the SDK reports. The SDK's `onMetrics` callbacks are *partial*: one
* tick may carry only `RenderFramerate`, the next only `PoseSendFramerate`.
* Replacing the stored record on each tick would make metrics flicker in and out
* of hub snapshots, so this merges instead, and `HeadsetControlChannel` polls the
* merged state on its own timer (default 500 ms).
*
* Keys are the SDK's `CloudXR.MetricsName` string values, so the hub and any
* downstream dashboards see the same names the SDK documents. The hub side
* (`oob_teleop_hub.py`) stores metrics as an arbitrary `{str: float}` map per
* cadence, so new metric names flow through without a hub change.
*/

import * as CloudXR from '@nvidia/cloudxr';

import type {
FrameMetricsUpdate,
NetworkMetricsUpdate,
RenderMetricsUpdate,
} from './metricsUpdates';

/** Cadence labels used in `clientMetrics` payloads sent to the OOB hub. */
export type MetricsCadenceLabel = 'render' | 'frame' | 'network';

/** One cadence's worth of metrics, ready to place in a `clientMetrics` payload. */
export interface MetricsSnapshot {
cadence: string;
metrics: Record<string, number>;
}

/**
* Copies the defined entries of `update` into `target` under their SDK metric names.
*
* @param target - Accumulated metrics for one cadence; mutated in place.
* @param update - Partial callback payload; `undefined` fields are left untouched.
* @param names - Maps each update field to the `CloudXR.MetricsName` to store it under.
*/
function mergeDefined<T extends object>(
target: Record<string, number>,
update: T,
names: { [K in keyof T]-?: string }
): void {
for (const key of Object.keys(names) as Array<keyof T>) {
const value = update[key];
// Guard on the value, not the key: absent and explicitly-undefined must behave alike.
if (typeof value === 'number' && Number.isFinite(value)) {
target[names[key]] = value;
}
}
}

/** Field-to-MetricsName maps, one per cadence. Kept beside the interfaces they mirror. */
const RENDER_NAMES: { [K in keyof Required<RenderMetricsUpdate>]: string } = {
framerate: CloudXR.MetricsName.RenderFramerate,
sendFramerate: CloudXR.MetricsName.PoseSendFramerate,
xrPoseAgeMs: CloudXR.MetricsName.LatencyXrPoseAgeMs,
};

const FRAME_NAMES: { [K in keyof Required<FrameMetricsUpdate>]: string } = {
framerate: CloudXR.MetricsName.StreamingFramerate,
frameCount: CloudXR.MetricsName.StreamingFrameCount,
poseToRenderTimeMs: CloudXR.MetricsName.PoseToRenderTime,
poseUploadMs: CloudXR.MetricsName.LatencyPoseUploadMs,
poseToFrameReceivedMs: CloudXR.MetricsName.LatencyPoseToFrameReceivedMs,
compositorSkippedPercent: CloudXR.MetricsName.FramePipelineCompositorSkippedPercent,
outOfOrderPercent: CloudXR.MetricsName.FramePipelineOutOfOrderPercent,
mismatchedPercent: CloudXR.MetricsName.FramePipelineMismatchedPercent,
};

const NETWORK_NAMES: { [K in keyof Required<NetworkMetricsUpdate>]: string } = {
streamingRateMbps: CloudXR.MetricsName.NetworkStreamingRateMbps,
availableBandwidthMbps: CloudXR.MetricsName.NetworkAvailableBandwidthMbps,
rttMs: CloudXR.MetricsName.NetworkRttMs,
packetLoss: CloudXR.MetricsName.NetworkPacketLoss,
avgDecodeTimeMs: CloudXR.MetricsName.NetworkAvgDecodeTimeMs,
qualityScore: CloudXR.MetricsName.NetworkQualityScore,
bandwidthScore: CloudXR.MetricsName.NetworkBandwidthScore,
lossScore: CloudXR.MetricsName.NetworkLossScore,
latencyScore: CloudXR.MetricsName.NetworkLatencyScore,
sessionQuality: CloudXR.MetricsName.SessionQuality,
};

export class MetricsAccumulator {
private readonly byCadence: Record<MetricsCadenceLabel, Record<string, number>> = {
render: {},
frame: {},
network: {},
};

/** Merge a PerRender callback payload. */
recordRender(update: RenderMetricsUpdate): void {
mergeDefined(this.byCadence.render, update, RENDER_NAMES);
}

/** Merge a PerFrame callback payload. */
recordFrame(update: FrameMetricsUpdate): void {
mergeDefined(this.byCadence.frame, update, FRAME_NAMES);
}

/** Merge a PerNetwork callback payload. */
recordNetwork(update: NetworkMetricsUpdate): void {
mergeDefined(this.byCadence.network, update, NETWORK_NAMES);
}

/**
* Drop everything recorded so far.
*
* Called when a session ends so the next session's hub snapshots cannot report
* the previous session's last-known values as if they were live.
*/
reset(): void {
for (const cadence of Object.keys(this.byCadence) as MetricsCadenceLabel[]) {
this.byCadence[cadence] = {};
}
}

/**
* Snapshot the accumulated metrics for transmission.
*
* @returns One entry per cadence that has at least one metric; empty cadences
* are omitted so the hub is not sent empty `clientMetrics` frames.
*/
takeSnapshot(): MetricsSnapshot[] {
const snapshots: MetricsSnapshot[] = [];
for (const cadence of Object.keys(this.byCadence) as MetricsCadenceLabel[]) {
const metrics = this.byCadence[cadence];
if (Object.keys(metrics).length === 0) continue;
// Copy: the caller serializes asynchronously, and callbacks keep mutating.
snapshots.push({ cadence, metrics: { ...metrics } });
}
return snapshots;
}
}
81 changes: 81 additions & 0 deletions deps/cloudxr/webxr_client/helpers/metricsUpdates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Shapes for the metrics CloudXRComponent forwards to the app, one per SDK
* MetricsCadence.
*
* The SDK delivers `onMetrics` as a partial `Record<MetricsName, number>`: a
* single tick carries only the keys that were sampled on that path (the
* send-begin and render-begin paths, for example, each emit their own PerRender
* callback). Every field here is therefore optional, and consumers must merge
* rather than replace — see `MetricsAccumulator` in metricsAccumulator.ts.
*/

/** Metrics delivered at {@link CloudXR.MetricsCadence.PerRender} (once per client render). */
export interface RenderMetricsUpdate {
/** Client render rate, rolling average (FPS). */
framerate?: number;
/** Rate at which poses are sent upstream, rolling average (FPS). */
sendFramerate?: number;
/** Age of the XR pose at send time (ms). */
xrPoseAgeMs?: number;
}

/** Metrics delivered at {@link CloudXR.MetricsCadence.PerFrame} (once per streamed video frame). */
export interface FrameMetricsUpdate {
/** Streaming rate, rolling average (FPS). */
framerate?: number;
/** Monotonic count of streamed frames. */
frameCount?: number;
/** Pose-to-render latency, rolling average (ms). */
poseToRenderTimeMs?: number;
/** Time spent uploading the pose (ms). */
poseUploadMs?: number;
/** Time from pose send to frame received (ms). */
poseToFrameReceivedMs?: number;
/** Share of frames the compositor skipped (%). */
compositorSkippedPercent?: number;
/** Share of frames that arrived out of order (%). */
outOfOrderPercent?: number;
/** Share of frames whose pose did not match the rendered pixels (%). */
mismatchedPercent?: number;
}

/** Metrics delivered at {@link CloudXR.MetricsCadence.PerNetwork} (network sampling cadence). */
export interface NetworkMetricsUpdate {
/** Current streaming rate (Mbps). */
streamingRateMbps?: number;
/** Estimated available bandwidth (Mbps). */
availableBandwidthMbps?: number;
/** Round-trip time (ms). */
rttMs?: number;
/** Packet loss ratio. */
packetLoss?: number;
/** Average video decode time (ms). */
avgDecodeTimeMs?: number;
/** Composite network quality, a {@link CloudXR.QualityScore}. */
qualityScore?: number;
/** Bandwidth component of the quality score, a {@link CloudXR.QualityScore}. */
bandwidthScore?: number;
/** Loss component of the quality score, a {@link CloudXR.QualityScore}. */
lossScore?: number;
/** Latency component of the quality score, a {@link CloudXR.QualityScore}. */
latencyScore?: number;
/** Overall session quality 0-4, a {@link CloudXR.QualityScore}; drives the HUD indicator. */
sessionQuality?: number;
}
Loading
Loading