diff --git a/crash-handler-process/CMakeLists.txt b/crash-handler-process/CMakeLists.txt index e3d5b87..bb3553e 100644 --- a/crash-handler-process/CMakeLists.txt +++ b/crash-handler-process/CMakeLists.txt @@ -1,22 +1,66 @@ CMAKE_MINIMUM_REQUIRED(VERSION 3.1) PROJECT(crash-handler-process VERSION 0.0.1) +include(FetchContent) + +# Nlohmann JSON (modern JSON for C++) +FetchContent_Declare( + nlohmannjson + GIT_REPOSITORY https://github.com/nlohmann/json +) + +add_compile_definitions(CURL_STATICLIB) + +# Curl for people +FetchContent_Declare( + cpr + GIT_REPOSITORY https://github.com/whoshuu/cpr/ +) + +FetchContent_GetProperties(nlohmannjson) +if(NOT nlohmannjson_POPULATED) + FetchContent_Populate(nlohmannjson) +endif() + +set(BUILD_CURL_EXE false CACHE BOOL "" FORCE) +set(CURL_STATIC_CRT true CACHE BOOL "" FORCE) +set(CURL_STATICLIB true CACHE BOOL "" FORCE) +set(BUILD_CPR_TESTS false CACHE BOOL "" FORCE) +set(BUILD_TESTING false CACHE BOOL "" FORCE) +set(CMAKE_USE_OPENSSL false CACHE BOOL "" FORCE) +set(CMAKE_USE_WINSSL true CACHE BOOL "" FORCE) + +FetchContent_GetProperties(cpr) +if(NOT cpr_POPULATED) + FetchContent_Populate(cpr) + add_subdirectory(${cpr_SOURCE_DIR} ${cpr_BINARY_DIR}) +endif() + ############################# # Source, Libraries & Directories ############################# SET(PROJECT_SOURCE "${PROJECT_SOURCE_DIR}/process.cpp" "${PROJECT_SOURCE_DIR}/process.hpp" + "${PROJECT_SOURCE_DIR}/metricsprovider.cpp" "${PROJECT_SOURCE_DIR}/metricsprovider.hpp" "${PROJECT_SOURCE_DIR}/message.cpp" "${PROJECT_SOURCE_DIR}/message.hpp" "${PROJECT_SOURCE_DIR}/namedsocket-win.cpp" "${PROJECT_SOURCE_DIR}/namedsocket-win.hpp" "${PROJECT_SOURCE_DIR}/namedsocket.cpp" "${PROJECT_SOURCE_DIR}/namedsocket.hpp" "${PROJECT_SOURCE_DIR}/main.cpp" ) + ############################# # Building ############################# ADD_EXECUTABLE(crash-handler-process ${PROJECT_SOURCE}) +# Include/link crash manager dependencies +target_include_directories(crash-handler-process PUBLIC + "${nlohmannjson_SOURCE_DIR}/single_include" + "${cpr_SOURCE_DIR}/include") +target_link_libraries(crash-handler-process cpr) +target_link_libraries(crash-handler-process libcurl) + ############################# # Distribute ############################# diff --git a/crash-handler-process/main.cpp b/crash-handler-process/main.cpp index 76be12d..a7e32f2 100644 --- a/crash-handler-process/main.cpp +++ b/crash-handler-process/main.cpp @@ -4,7 +4,9 @@ #include #include #include "namedsocket-win.hpp" +#include "metricsprovider.hpp" +#include #include #include #include @@ -20,6 +22,7 @@ bool doRestartApp = false; bool monitoring = false; bool closeAll = false; std::mutex* mu = new std::mutex(); +MetricsProvider metricsServer; static thread_local std::wstring_convert> converter; std::string from_utf16_wide_to_utf8(const wchar_t* from, size_t length = -1) @@ -217,6 +220,10 @@ void checkProcesses(std::mutex* m) { criticalProcessAlive = processes.at(i)->getAlive(); } if (!processes.at(index)->getCritical() && criticalProcessAlive) { + + // Metrics + metricsServer.BlameFrontend(); + int code = MessageBox( NULL, "An error occurred which has caused Streamlabs OBS to close. Don't worry! If you were streaming or recording, that is still happening in the background." @@ -250,6 +257,10 @@ void checkProcesses(std::mutex* m) { closeAll = true; } else { + + // Metrics + metricsServer.BlameServer(); + closeAll = true; } *exitApp = true; @@ -327,6 +338,13 @@ int main(int argc, char** argv) std::thread processManager(checkProcesses, mu); + std::thread metricsPipe([&]() + { + metricsServer.Initialize("\\\\.\\pipe\\metrics_pipe"); + metricsServer.ConnectToClient(); + metricsServer.StartPollingEvent(); + }); + std::unique_ptr sock = NamedSocket::create(); while (!(*exitApp) && !sock->read(&processes, mu, exitApp)) @@ -337,11 +355,28 @@ int main(int argc, char** argv) *exitApp = true; if (processManager.joinable()) processManager.join(); + + metricsServer.KillPendingIO(); + if (metricsPipe.joinable()) + metricsPipe.join(); close(closeAll); if (doRestartApp) { restartApp(path); } + + // Wait until the server process dies or the metrics provider signals that we can shutdown + while (metricsServer.ServerIsActive() && !metricsServer.ServerExitedSuccessfully()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + // Only perform the shutdown for the metrics server if it exited successfully + if (metricsServer.ServerExitedSuccessfully()) + { + metricsServer.Shutdown(); + } + return 0; } diff --git a/crash-handler-process/metricsprovider.cpp b/crash-handler-process/metricsprovider.cpp new file mode 100644 index 0000000..b40ca3d --- /dev/null +++ b/crash-handler-process/metricsprovider.cpp @@ -0,0 +1,454 @@ +#include "metricsprovider.hpp" +#include +#include +#include "Shlobj.h" +#include +#include +#include +#include +#include +#include +#include + +class curl_wrapper +{ +public: + class response + { + public: + response(std::string d, int sc) : data(std::move(d)), status_code(sc) {} + + std::string data; + int status_code; + + nlohmann::json json() + { + return nlohmann::json::parse(data); + } + }; + + public: + curl_wrapper(std::string dns) : m_curl(curl_easy_init()) + { + assert(m_curl); + + setup_sentry_dns(dns); + + //set_option(CURLOPT_VERBOSE, 1L); + set_option(CURLOPT_SSL_VERIFYPEER, 0L); + } + + ~curl_wrapper() + { + curl_slist_free_all(m_headers); + curl_easy_cleanup(m_curl); + } + + void config_secure_header() + { + // add security header + std::string security_header = "X-Sentry-Auth: Sentry sentry_version=5,sentry_client=Streamlabs Crash Handler/"; + security_header += std::string("1.0.0") + ",sentry_timestamp="; + security_header += std::to_string(34546456); + security_header += ",sentry_key=" + m_public_key; + security_header += ",sentry_secret=" + m_secret_key; + set_header(security_header.c_str()); + } + + response post(const nlohmann::json& payload, const bool compress = false) + { + config_secure_header(); + + set_header("Content-Type: application/json"); + + return post(m_store_url, payload.dump(), compress); + } + + private: + response post(const std::string& url, const std::string& data, const bool compress = false) + { + std::string c_data; + + set_option(CURLOPT_POSTFIELDS, data.c_str()); + set_option(CURLOPT_POSTFIELDSIZE, data.size()); + + set_option(CURLOPT_URL, url.c_str()); + set_option(CURLOPT_POST, 1); + set_option(CURLOPT_WRITEFUNCTION, &write_callback); + set_option(CURLOPT_WRITEDATA, &string_buffer); + + auto res = curl_easy_perform(m_curl); + + if (res != CURLE_OK) { + std::string error_msg = std::string("curl_easy_perform() failed: ") + curl_easy_strerror(res); + throw std::runtime_error(error_msg); + } + + int status_code; + curl_easy_getinfo(m_curl, CURLINFO_RESPONSE_CODE, &status_code); + + return { std::move(string_buffer), status_code }; + } + + template + CURLcode set_option(CURLoption option, T parameter) + { + return curl_easy_setopt(m_curl, option, parameter); + } + + void set_header(const char* header) + { + m_headers = curl_slist_append(m_headers, header); + set_option(CURLOPT_HTTPHEADER, m_headers); + } + + private: + static size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) + { + assert(userdata); + ((std::string*)userdata)->append(ptr, size * nmemb); + return size * nmemb; + } + + void setup_sentry_dns(const std::string& dsn) + { + if (!dsn.empty()) { + const std::regex dsn_regex("(http[s]?)://([^:]+):([^@]+)@([^/]+)/([0-9]+)"); + std::smatch pieces_match; + + if (std::regex_match(dsn, pieces_match, dsn_regex) and pieces_match.size() == 6) { + const auto scheme = pieces_match.str(1); + m_public_key = pieces_match.str(2); + m_secret_key = pieces_match.str(3); + const auto host = pieces_match.str(4); + const auto project_id = pieces_match.str(5); + m_store_url = scheme + "://" + host + "/api/" + project_id + "/store/"; + } + else { + throw std::invalid_argument("DNS " + dsn + " is invalid"); + } + } + else { + throw std::invalid_argument("DNS is empty"); + } + } + + private: + CURL* const m_curl; + struct curl_slist* m_headers = nullptr; + std::string string_buffer; + std::string m_public_key; + std::string m_secret_key; + std::string m_store_url; +}; + +MetricsProvider::MetricsProvider() +{ + // Start with a Frontend Crash status (if SLOBS crash we know that it happened before receiving the first message) + m_LastStatus = "Frontend Crash"; +} + +MetricsProvider::~MetricsProvider() +{ + m_StopPolling = true; + if (m_PollingThread.joinable()) + { + m_PollingThread.join(); + } + + if (m_PipeActive) + { + m_PipeActive = false; + CloseHandle(m_Pipe); + } + + // Check if we should report the last status + if (m_LastStatus != "shutdown") + { + SendMetricsReport(m_LastStatus); + } + + MetricsFileClose(); +} + +bool MetricsProvider::Initialize(std::string name) +{ + m_Pipe = CreateNamedPipe( + name.c_str(), // name of the pipe // "\\\\.\\pipe\\my_pipe" + PIPE_ACCESS_INBOUND, // 1-way pipe -- receive only + PIPE_TYPE_BYTE, // send data as a byte stream + 1, // only allow 1 instance of this pipe + 0, // no outbound buffer + 0, // no inbound buffer + 0, // use default wait time + NULL // use default security attributes + ); + + if (m_Pipe == NULL || m_Pipe == INVALID_HANDLE_VALUE) { + return false; + } + + // Determine the metrics file path + { + HRESULT hResult; + PWSTR ppszPath; + + hResult = SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, NULL, &ppszPath); + + std::wstring appdata_path; + + appdata_path.assign(ppszPath); + appdata_path.append(L"\\obs-studio-node-server"); + + CoTaskMemFree(ppszPath); + + m_MetricsFilePath = appdata_path + L"\\" + std::wstring(CrashMetricsFilename); + } + + m_PipeActive = true; + + InitializeMetricsFile(); + + return true; +} + +void MetricsProvider::Shutdown() +{ + MetricsFileClose(); +} + +DWORD WINAPI ConnectToClientPipe(LPVOID lpParam) +{ + HANDLE pipe = *static_cast(lpParam); + + BOOL result = ConnectNamedPipe(pipe, NULL); + if (!result) { + return 1; + } + + return 0; +} + +bool MetricsProvider::ConnectToClient() +{ + // Necessary to create a windows thread so we can use the CancelSynchronousIo() method in case we need to + // exit without waiting for the client connection + m_ClientConnectionThread = CreateThread( + NULL, + 0, + (LPTHREAD_START_ROUTINE)ConnectToClientPipe, + &m_Pipe, + 0, + NULL); + + WaitForMultipleObjects(1, &m_ClientConnectionThread, TRUE, INFINITE); + + return true; +} + +void MetricsProvider::StartPollingEvent() +{ + m_PollingThread = std::thread([&]() + { + while (!m_StopPolling) + { + static const int BufferSize = sizeof(MetricsMessage); + MetricsMessage message; + DWORD numBytesRead = 0; + BOOL result = ReadFile( + m_Pipe, + &message, // the data from the pipe will be put here + BufferSize, // number of bytes allocated + &numBytesRead, // this will store number of bytes actually read + NULL // not using overlapped IO + ); + + if (!result) + continue; + + if (message.type == MessageType::Status) + { + // Write to the file + MetricsFileSetStatus(std::string(message.param1)); + } + else if (message.type == MessageType::Blame) + { + // Write to the file + MetricsFileSetStatus(std::string(message.param1)); + + // Set blame active + m_ClientBlameActive = true; + } + else if (message.type == MessageType::Shutdown) + { + m_ServerExitedSuccessfully = true; + m_StopPolling = true; + } + else if (message.type == MessageType::Pid) + { + m_ServerPid = *reinterpret_cast(message.param1); + } + + else if (message.type == MessageType::Tag) + { + m_ReportTags.insert({std::string(message.param1), std::string(message.param2)}); + + // Now that we received the tag from the server, if we don't receive any other message + // from it and a crash happens, we should blame the backend and not the frontend anymore + m_LastStatus = "Backend Crash"; + } + } + }); +} + +void MetricsProvider::KillPendingIO() +{ + CancelSynchronousIo(m_ClientConnectionThread); +} + +bool MetricsProvider::ServerIsActive() +{ + auto IsProcessRunning = [](DWORD pid) + { + HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, pid); + DWORD ret = WaitForSingleObject(process, 0); + CloseHandle(process); + return ret == WAIT_TIMEOUT; + }; + + // If we can't check by the server pid, better set that it exited + return m_ServerPid != 0 && IsProcessRunning(m_ServerPid); +} + +bool MetricsProvider::ServerExitedSuccessfully() +{ + return m_ServerExitedSuccessfully; +} + +void MetricsProvider::BlameFrontend() +{ + MetricsFileSetStatus("Frontend Crash"); +} + +void MetricsProvider::BlameServer() +{ + MetricsFileSetStatus("Backend Crash"); +} + +void MetricsProvider::InitializeMetricsFile() +{ + try { + std::string metrics_string = GetMetricsFileStatus(); + + // Check if we should send a report + if (metrics_string != "shutdown") { + SendMetricsReport(metrics_string); + } + + m_MetricsFile = std::ofstream(m_MetricsFilePath, std::ios::trunc | std::ios::ate); + } + catch (...) { + } +} + +std::string MetricsProvider::GetMetricsFileStatus() +{ + std::string metrics_string; + + try + { + auto metrics_file_read = std::ifstream(m_MetricsFilePath); + if (metrics_file_read.is_open()) { + + getline(metrics_file_read, metrics_string); + + // Check if the string is empty, in that case SLOBS crashed before initializing + if (metrics_string.length() == 0) { + metrics_string = "Frontend Crash"; + } + + metrics_file_read.close(); + } + } + catch (...) { + } + + return metrics_string; +} + +void MetricsProvider::SendMetricsReport(std::string status) +{ + curl_wrapper curl( + "https://b1db4ea934a649bca50701198f29b501:22e46ee0fbe54b53ba8571a4c10e31c0@sentry.io/1439562"); + + nlohmann::json j; + nlohmann::json tags; + char name[UNLEN + 1]; + DWORD name_len = UNLEN + 1; + using convert_typeX = std::codecvt_utf8; + std::wstring_convert converterX; + + j["message"] = status; + j["level"] = "info"; + + // User name + if (GetUserName(name, &name_len) != 0) { + tags["user.name"] = std::string(name); + } + + // Computer name + name_len = UNLEN + 1; + if (GetComputerName(name, &name_len) != 0) { + tags["computer.name"] = std::string(name); + } + + // Set the client blame tag + m_ReportTags.insert({ "user.blame", m_ClientBlameActive ? "true" : "false" }); + + // For each tag + for (auto& tag : m_ReportTags) + { + tags[tag.first] = tag.second; + + // If we have a version tag, use it as a release too + if (tag.first == "version") + { + j["release"] = "backend." + tag.second; + } + } + + j["tags"] = tags; + + curl.post(j, true).data; +} + +void MetricsProvider::MetricsFileSetStatus(std::string status) +{ + // If the client blame is active, don't do anything to prevent overwriting the blame source + // that the client told us, ofc proceed if the status is the shutdown one + if (m_ClientBlameActive && status != "shutdown") + { + return; + } + + if (m_MetricsFile.is_open()) { + // Don't do anything if the last status is 'Handled Crash' since this means that the server + // crashed but it handled the crash, we should propagate the report as it is to correctly + // address the number of handled crashes + if (m_LastStatus != "Handled Crash") + { + m_LastStatus = status; + m_MetricsFile.seekp(0, std::ios::beg); + m_MetricsFile << status << std::endl; + } + } +} + +void MetricsProvider::MetricsFileClose() +{ + if (m_MetricsFile.is_open()) { + m_LastStatus = "shutdown"; + MetricsFileSetStatus(m_LastStatus); + m_MetricsFile.close(); + } +} \ No newline at end of file diff --git a/crash-handler-process/metricsprovider.hpp b/crash-handler-process/metricsprovider.hpp new file mode 100644 index 0000000..8e36b22 --- /dev/null +++ b/crash-handler-process/metricsprovider.hpp @@ -0,0 +1,69 @@ +#include +#include +#include +#include +#include +#include + +class MetricsProvider +{ + const wchar_t* CrashMetricsFilename = L"backend-metrics.doc"; + const static int StringSize = 64; + + enum class MessageType + { + Pid, + Tag, + Status, + Blame, + Shutdown + }; + + struct MetricsMessage + { + MessageType type; + char param1[StringSize]; + char param2[StringSize]; + }; + + public: + + MetricsProvider(); + ~MetricsProvider(); + + bool Initialize(std::string name); + void Shutdown(); + bool ConnectToClient(); + void StartPollingEvent(); + + void KillPendingIO(); + + bool ServerIsActive(); + bool ServerExitedSuccessfully(); + + void BlameFrontend(); + void BlameServer(); + + private: + + void InitializeMetricsFile(); + void MetricsFileSetStatus(std::string status); + void MetricsFileClose(); + void SendMetricsReport(std::string status); + std::string GetMetricsFileStatus(); + + private: + + HANDLE m_Pipe; + HANDLE m_ClientConnectionThread = nullptr; + bool m_PipeActive = false; + bool m_StopPolling = false; + bool m_ServerExitedSuccessfully = false; + bool m_ClientBlameActive = false; + std::thread m_PollingThread; + std::ofstream m_MetricsFile; + DWORD m_ServerPid = 0; + std::wstring m_MetricsFilePath; + std::string m_LastStatus; + std::map m_ReportTags; +}; \ No newline at end of file