Skip to content
Draft
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
30 changes: 30 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1929,6 +1929,36 @@ editing the `conf` file in a text editor. Use the examples as reference.
</tr>
</table>

### metrics_path

<table>
<tr>
<td>Description</td>
<td colspan="2">
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.}
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">@code{}
(empty — disabled)
@endcode</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
metrics_path = /var/log/sunshine/metrics
@endcode</td>
</tr>
</table>

### qp

<table>
Expand Down
4 changes: 4 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions src/stream.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
*/

// standard includes
#include <cstring>
#include <filesystem>
#include <format>
#include <fstream>
#include <future>
#include <queue>
#include <sstream>

// lib includes
#include <boost/endian/arithmetic.hpp>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<bool> shutdown_event;
safe::signal_t controlEnd;

Expand Down Expand Up @@ -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<int>(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.
Expand Down Expand Up @@ -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<std::chrono::milliseconds>(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];
Expand Down
Loading