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" + ); +}