diff --git a/docs/configuration.md b/docs/configuration.md
index 5ef85dc92a3..ccae8a16d13 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1929,6 +1929,36 @@ editing the `conf` file in a text editor. Use the examples as reference.
+### metrics_path
+
+
+
+ | Description |
+
+ Directory where Sunshine writes a CSV file with per-session network metrics.
+ One file per session is created lazily on the first FEC status report from
+ the client (typically only when the network experiences packet loss). Each
+ row records the timestamp, session id, configured bitrate, frame index,
+ packet counts, FEC percentage, and accumulated IDR requests since the
+ previous row.
+ @note{Leave empty to disable metrics export. The directory is created if
+ it does not exist.}
+ |
+
+
+ | Default |
+ @code{}
+ (empty — disabled)
+ @endcode |
+
+
+ | Example |
+ @code{}
+ metrics_path = /var/log/sunshine/metrics
+ @endcode |
+
+
+
### qp
diff --git a/src/config.cpp b/src/config.cpp
index a79f7d88151..dd522bf3a01 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -544,6 +544,8 @@ namespace config {
ENCRYPTION_MODE_NEVER, // lan_encryption_mode
ENCRYPTION_MODE_OPPORTUNISTIC, // wan_encryption_mode
+
+ {}, // metrics_path (disabled by default)
};
nvhttp_t nvhttp {
@@ -1268,6 +1270,8 @@ namespace config {
int_between_f(vars, "fec_percentage", stream.fec_percentage, {1, 255});
+ string_f(vars, "metrics_path", stream.metrics_path);
+
map_int_int_f(vars, "keybindings"s, input.keybindings);
// This config option will only be used by the UI
diff --git a/src/config.h b/src/config.h
index eb778a3ac68..54bd098542d 100644
--- a/src/config.h
+++ b/src/config.h
@@ -177,6 +177,9 @@ namespace config {
// Video encryption settings for LAN and WAN streams
int lan_encryption_mode;
int wan_encryption_mode;
+
+ // Directory to write per-session metrics CSV files. Empty = disabled.
+ std::string metrics_path;
};
struct nvhttp_t {
diff --git a/src/stream.cpp b/src/stream.cpp
index 8b303ef24da..d3e202d8d8c 100644
--- a/src/stream.cpp
+++ b/src/stream.cpp
@@ -4,9 +4,13 @@
*/
// standard includes
+#include
+#include
+#include
#include
#include
#include
+#include
// lib includes
#include
@@ -231,6 +235,27 @@ namespace stream {
AUDIO_FEC_HEADER fecHeader;
};
+ // Wire layout of SS_FRAME_FEC_STATUS (Moonlight → Sunshine, big-endian on the wire).
+ // Mirrors the struct in moonlight-common-c/src/Video.h with explicit endian decoding.
+ struct ss_frame_fec_status_wire_t {
+ boost::endian::big_uint32_at frameIndex;
+ boost::endian::big_uint16_at highestReceivedSequenceNumber;
+ boost::endian::big_uint16_at nextContiguousSequenceNumber;
+ boost::endian::big_uint16_at missingPacketsBeforeHighestReceived;
+ boost::endian::big_uint16_at totalDataPackets;
+ boost::endian::big_uint16_at totalParityPackets;
+ boost::endian::big_uint16_at receivedDataPackets;
+ boost::endian::big_uint16_at receivedParityPackets;
+ std::uint8_t fecPercentage;
+ std::uint8_t multiFecBlockIndex;
+ std::uint8_t multiFecBlockCount;
+ };
+
+ static_assert(
+ sizeof(ss_frame_fec_status_wire_t) == 21,
+ "SS_FRAME_FEC_STATUS wire format must be 21 bytes"
+ );
+
#pragma pack(pop)
constexpr std::size_t round_to_pkcs7_padded(std::size_t size) {
@@ -406,6 +431,14 @@ namespace stream {
std::uint32_t launch_session_id;
+ // Per-session metrics CSV state. Populated lazily on the first
+ // SS_FRAME_FEC_STATUS event when config::stream.metrics_path is set.
+ // Both fields are accessed only from the control thread, so no locking.
+ struct {
+ std::ofstream csv_file;
+ int idr_count = 0;
+ } metrics;
+
safe::mail_raw_t::event_t shutdown_event;
safe::signal_t controlEnd;
@@ -779,6 +812,93 @@ namespace stream {
return replaced;
}
+ /**
+ * @brief Returns the header line for the per-session metrics CSV.
+ * Pure function — exposed for testability.
+ */
+ std::string_view metrics_csv_header() {
+ return "timestamp_ms,session_id,bitrate_kbps,frame_index,"
+ "missing_packets,total_data_packets,received_data_packets,"
+ "total_parity_packets,received_parity_packets,fec_percentage,"
+ "idr_request_count";
+ }
+
+ /**
+ * @brief Decodes a SS_FRAME_FEC_STATUS payload and formats one CSV row.
+ * Returns an empty string if the payload is too small to decode.
+ * Pure function — exposed for testability.
+ * @param payload Raw bytes of the SS_FRAME_FEC_STATUS message (big-endian).
+ * @param timestamp_ms Wall-clock timestamp in milliseconds since Unix epoch.
+ * @param session_id The launch session identifier.
+ * @param bitrate_kbps Bitrate negotiated for the session, in kbps.
+ * @param idr_count Accumulated IDR requests since the previous row.
+ */
+ std::string format_metrics_csv_row(
+ const std::string_view &payload,
+ int64_t timestamp_ms,
+ uint32_t session_id,
+ int bitrate_kbps,
+ int idr_count
+ ) {
+ if (payload.size() < sizeof(ss_frame_fec_status_wire_t)) {
+ return {};
+ }
+
+ ss_frame_fec_status_wire_t fec;
+ std::memcpy(&fec, payload.data(), sizeof(fec));
+
+ std::ostringstream os;
+ os << timestamp_ms << ','
+ << session_id << ','
+ << bitrate_kbps << ','
+ << fec.frameIndex << ','
+ << fec.missingPacketsBeforeHighestReceived << ','
+ << fec.totalDataPackets << ','
+ << fec.receivedDataPackets << ','
+ << fec.totalParityPackets << ','
+ << fec.receivedParityPackets << ','
+ << static_cast(fec.fecPercentage) << ','
+ << idr_count;
+ return os.str();
+ }
+
+ /**
+ * @brief Builds the per-session metrics CSV filename (without directory).
+ * Pure function — exposed for testability.
+ */
+ std::string make_metrics_csv_filename(uint32_t session_id, int64_t timestamp_ms) {
+ return std::format("sunshine_metrics_{}_{}.csv", session_id, timestamp_ms);
+ }
+
+ /**
+ * @brief Lazily opens the per-session metrics CSV file and writes the header.
+ * No-op on subsequent calls. Returns true if the file is open and ready.
+ */
+ bool ensure_metrics_csv_open(session_t *session, int64_t wall_ms) {
+ if (session->metrics.csv_file.is_open()) {
+ return true;
+ }
+
+ std::filesystem::path dir {config::stream.metrics_path};
+ std::error_code ec;
+ std::filesystem::create_directories(dir, ec);
+ if (ec) {
+ BOOST_LOG(error) << "metrics_path: failed to create directory "sv << dir << ": "sv << ec.message();
+ return false;
+ }
+
+ auto path = dir / make_metrics_csv_filename(session->launch_session_id, wall_ms);
+ session->metrics.csv_file.open(path);
+ if (!session->metrics.csv_file.is_open()) {
+ BOOST_LOG(error) << "metrics_path: failed to open "sv << path;
+ return false;
+ }
+
+ BOOST_LOG(info) << "metrics: writing session metrics to "sv << path;
+ session->metrics.csv_file << metrics_csv_header() << '\n';
+ return true;
+ }
+
/**
* @brief Pass gamepad feedback data back to the client.
* @param session The session object.
@@ -951,9 +1071,40 @@ namespace stream {
server->map(packetTypes[IDX_REQUEST_IDR_FRAME], [&](session_t *session, const std::string_view &payload) {
BOOST_LOG(debug) << "type [IDX_REQUEST_IDR_FRAME]"sv;
+ ++session->metrics.idr_count;
+
session->video.idr_events->raise(true);
});
+ // SS_FRAME_FEC_STATUS (0x5502) is sent by Moonlight per-frame whenever there
+ // is FEC recovery activity or a frame drop. Note: the same packet type is
+ // sent by Sunshine TO clients as "Set RGB LED" — the protocol multiplexes
+ // 0x5502 by direction. The receive handler is registered only here.
+ server->map(SS_FRAME_FEC_PTYPE, [&](session_t *session, const std::string_view &payload) {
+ if (config::stream.metrics_path.empty()) {
+ return;
+ }
+
+ auto wall_now = std::chrono::system_clock::now().time_since_epoch();
+ auto wall_ms = std::chrono::duration_cast(wall_now).count();
+
+ auto row = format_metrics_csv_row(
+ payload, wall_ms, session->launch_session_id,
+ session->config.monitor.bitrate, session->metrics.idr_count
+ );
+ if (row.empty()) {
+ BOOST_LOG(warning) << "SS_FRAME_FEC_STATUS payload too small: " << payload.size();
+ return;
+ }
+
+ if (!ensure_metrics_csv_open(session, wall_ms)) {
+ return;
+ }
+
+ session->metrics.csv_file << row << '\n';
+ session->metrics.idr_count = 0;
+ });
+
server->map(packetTypes[IDX_INVALIDATE_REF_FRAMES], [&](session_t *session, const std::string_view &payload) {
auto frames = (std::int64_t *) payload.data();
auto firstFrame = frames[0];
diff --git a/tests/unit/test_stream.cpp b/tests/unit/test_stream.cpp
index fdc444cf023..28e7ba1183e 100644
--- a/tests/unit/test_stream.cpp
+++ b/tests/unit/test_stream.cpp
@@ -10,7 +10,17 @@
namespace stream {
std::vector concat_and_insert(uint64_t insert_size, uint64_t slice_size, const std::string_view &data1, const std::string_view &data2);
-}
+
+ std::string_view metrics_csv_header();
+ std::string format_metrics_csv_row(
+ const std::string_view &payload,
+ int64_t timestamp_ms,
+ uint32_t session_id,
+ int bitrate_kbps,
+ int idr_count
+ );
+ std::string make_metrics_csv_filename(uint32_t session_id, int64_t timestamp_ms);
+} // namespace stream
#include "../tests_common.h"
@@ -37,3 +47,124 @@ TEST(ConcatAndInsertTests, ConcatSmallStrideTest) {
auto expected = std::vector {0, 'a', 0, 'b', 0, 'c', 0, 'd', 0, 'e'};
ASSERT_EQ(res, expected);
}
+
+namespace {
+ struct fec_status_values_t {
+ uint32_t frame_index = 0;
+ uint16_t highest_received = 0;
+ uint16_t next_contiguous = 0;
+ uint16_t missing = 0;
+ uint16_t total_data = 0;
+ uint16_t total_parity = 0;
+ uint16_t received_data = 0;
+ uint16_t received_parity = 0;
+ uint8_t fec_percentage = 0;
+ uint8_t multi_fec_index = 0;
+ uint8_t multi_fec_count = 0;
+ };
+
+ // Builds a 21-byte SS_FRAME_FEC_STATUS payload in the wire (big-endian) format.
+ std::array build_fec_status_payload(const fec_status_values_t &v) {
+ auto put_u16_be = [](std::array &out, size_t off, uint16_t value) {
+ out[off] = static_cast((value >> 8) & 0xFF);
+ out[off + 1] = static_cast(value & 0xFF);
+ };
+
+ std::array payload {};
+ payload[0] = static_cast((v.frame_index >> 24) & 0xFF);
+ payload[1] = static_cast((v.frame_index >> 16) & 0xFF);
+ payload[2] = static_cast((v.frame_index >> 8) & 0xFF);
+ payload[3] = static_cast(v.frame_index & 0xFF);
+ put_u16_be(payload, 4, v.highest_received);
+ put_u16_be(payload, 6, v.next_contiguous);
+ put_u16_be(payload, 8, v.missing);
+ put_u16_be(payload, 10, v.total_data);
+ put_u16_be(payload, 12, v.total_parity);
+ put_u16_be(payload, 14, v.received_data);
+ put_u16_be(payload, 16, v.received_parity);
+ payload[18] = v.fec_percentage;
+ payload[19] = v.multi_fec_index;
+ payload[20] = v.multi_fec_count;
+ return payload;
+ }
+} // namespace
+
+TEST(MetricsCsvTests, HeaderHasAllColumns) {
+ auto header = stream::metrics_csv_header();
+ EXPECT_NE(header.find("timestamp_ms"), std::string_view::npos);
+ EXPECT_NE(header.find("session_id"), std::string_view::npos);
+ EXPECT_NE(header.find("bitrate_kbps"), std::string_view::npos);
+ EXPECT_NE(header.find("frame_index"), std::string_view::npos);
+ EXPECT_NE(header.find("missing_packets"), std::string_view::npos);
+ EXPECT_NE(header.find("total_data_packets"), std::string_view::npos);
+ EXPECT_NE(header.find("received_data_packets"), std::string_view::npos);
+ EXPECT_NE(header.find("total_parity_packets"), std::string_view::npos);
+ EXPECT_NE(header.find("received_parity_packets"), std::string_view::npos);
+ EXPECT_NE(header.find("fec_percentage"), std::string_view::npos);
+ EXPECT_NE(header.find("idr_request_count"), std::string_view::npos);
+ EXPECT_EQ(header.find('\n'), std::string_view::npos); // header has no trailing newline
+}
+
+TEST(MetricsCsvTests, RowDecodesBigEndianFields) {
+ auto payload = build_fec_status_payload({
+ .frame_index = 0x12345678u, // 305419896
+ .highest_received = 100,
+ .next_contiguous = 95,
+ .missing = 5,
+ .total_data = 50,
+ .total_parity = 10,
+ .received_data = 45,
+ .received_parity = 9,
+ .fec_percentage = 20,
+ .multi_fec_index = 0,
+ .multi_fec_count = 1,
+ });
+
+ auto row = stream::format_metrics_csv_row(
+ std::string_view {reinterpret_cast(payload.data()), payload.size()},
+ /*timestamp_ms=*/1000,
+ /*session_id=*/42,
+ /*bitrate_kbps=*/20000,
+ /*idr_count=*/3
+ );
+
+ EXPECT_EQ(row, "1000,42,20000,305419896,5,50,45,10,9,20,3");
+}
+
+TEST(MetricsCsvTests, RowReturnsEmptyOnShortPayload) {
+ std::array short_payload {};
+ auto row = stream::format_metrics_csv_row(
+ std::string_view {reinterpret_cast(short_payload.data()), short_payload.size()},
+ 1000,
+ 42,
+ 20000,
+ 0
+ );
+ EXPECT_TRUE(row.empty());
+}
+
+TEST(MetricsCsvTests, RowEmbedsSessionMetadata) {
+ auto payload = build_fec_status_payload({});
+
+ auto row = stream::format_metrics_csv_row(
+ std::string_view {reinterpret_cast(payload.data()), payload.size()},
+ /*timestamp_ms=*/1713045600000LL,
+ /*session_id=*/7,
+ /*bitrate_kbps=*/55388,
+ /*idr_count=*/12
+ );
+
+ // Leading fields come from session metadata, not from the FEC payload.
+ EXPECT_EQ(row.substr(0, row.find(',')), "1713045600000");
+ EXPECT_NE(row.find(",7,"), std::string::npos);
+ EXPECT_NE(row.find(",55388,"), std::string::npos);
+ // idr_count is the trailing field
+ EXPECT_EQ(row.substr(row.rfind(',') + 1), "12");
+}
+
+TEST(MetricsCsvTests, FilenameFormat) {
+ EXPECT_EQ(
+ stream::make_metrics_csv_filename(42, 1713045600000LL),
+ "sunshine_metrics_42_1713045600000.csv"
+ );
+}