diff --git a/CMakeLists.txt b/CMakeLists.txt index 2c94edd81..b5e50bb0f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -168,6 +168,14 @@ include( CMakePushCheckState ) include( GNUInstallDirs ) include( TestBigEndian ) +cmake_dependent_option( + ${_OPT}has_sentry_reporting + "Build support for sending errors to Sentry" + On + "${_OPT}has_networking;DEFINED SENTRY_DSN_KEY;DEFINED SENTRY_HOST;DEFINED SENTRY_PROJECT" + Off +) + # Determine 32-bit or 64-bit target if( CMAKE_C_COMPILER_ID MATCHES "MSVC" AND CMAKE_VS_PLATFORM_NAME MATCHES "Win64|x64" ) set( IS_64BIT ON ) diff --git a/cmake-proxies/CMakeLists.txt b/cmake-proxies/CMakeLists.txt index 6407ae5a5..5048cf92c 100644 --- a/cmake-proxies/CMakeLists.txt +++ b/cmake-proxies/CMakeLists.txt @@ -144,6 +144,12 @@ if( NOT CMAKE_SYSTEM_NAME MATCHES "Darwin|Windows") ) endif() +add_conan_lib( + RapidJSON + rapidjson/1.1.0 + REQUIRED +) + set_conan_vars_to_parent() # Required libraries diff --git a/libraries/CMakeLists.txt b/libraries/CMakeLists.txt index aa6704ee2..591c14d0f 100644 --- a/libraries/CMakeLists.txt +++ b/libraries/CMakeLists.txt @@ -3,16 +3,21 @@ # The list of modules is ordered so that each library occurs after any others # that it depends on set( LIBRARIES - "lib-string-utils" + lib-string-utils lib-strings lib-utility lib-uuid ) if ( ${_OPT}has_networking ) - list( APPEND LIBRARIES "lib-network-manager") + list( APPEND LIBRARIES lib-network-manager) endif() +# This library depends on lib-network-manager +# If Sentry reporting is disabled, an INTERFACE library +# will be defined +list( APPEND LIBRARIES lib-sentry-reporting) + foreach( LIBRARY ${LIBRARIES} ) add_subdirectory( "${LIBRARY}" ) endforeach() diff --git a/libraries/lib-sentry-reporting/AnonymizedMessage.cpp b/libraries/lib-sentry-reporting/AnonymizedMessage.cpp new file mode 100644 index 000000000..9edd603b0 --- /dev/null +++ b/libraries/lib-sentry-reporting/AnonymizedMessage.cpp @@ -0,0 +1,91 @@ +/*!******************************************************************** + + Audacity: A Digital Audio Editor + + @file AnonymizedMessage.cpp + @brief Define a class to store anonymized messages. + + Dmitry Vedenko + **********************************************************************/ + +#include "AnonymizedMessage.h" + +#include + +#include "CodeConversions.h" + +namespace audacity +{ +namespace sentry +{ + +AnonymizedMessage::AnonymizedMessage(std::string message) + : mMessage(std::move(message)) +{ + CleanupPaths(); +} + +AnonymizedMessage::AnonymizedMessage(const std::wstring& message) + : AnonymizedMessage(ToUTF8(message)) +{ +} + +AnonymizedMessage::AnonymizedMessage(const wxString& message) + : AnonymizedMessage(ToUTF8(message)) +{ +} + +AnonymizedMessage::AnonymizedMessage(const char* message) + : AnonymizedMessage(std::string(message)) +{ +} + +AnonymizedMessage::AnonymizedMessage(const wchar_t* message) + : AnonymizedMessage(ToUTF8(message)) +{ +} + +bool AnonymizedMessage::Empty() const noexcept +{ + return mMessage.empty(); +} + +size_t AnonymizedMessage::Length() const noexcept +{ + return mMessage.size(); +} + +const std::string& AnonymizedMessage::GetString() const noexcept +{ + return mMessage; +} + +wxString AnonymizedMessage::ToWXString() const noexcept +{ + return audacity::ToWXString(mMessage); +} + +const char* AnonymizedMessage::c_str() const noexcept +{ + return mMessage.c_str(); +} + +size_t AnonymizedMessage::length() const noexcept +{ + return mMessage.length(); +} + +void AnonymizedMessage::CleanupPaths() +{ + // Finding the path boundary in the arbitrary text is a hard task. + // We assume that spaces cannot be a part of the path. + // In the worst case - we will get + static const std::regex re( + R"(\b(?:(?:[a-zA-Z]:)?[\\/]?)?(?:[^<>:"/|\\/?\s*]+[\\/]+)*(?:[^<>:"/|\\/?*\s]+\.\w+)?)"); + + mMessage = std::regex_replace( + mMessage, re, "", std::regex_constants::match_not_null); +} + +} // namespace sentry +} // namespace audacity diff --git a/libraries/lib-sentry-reporting/AnonymizedMessage.h b/libraries/lib-sentry-reporting/AnonymizedMessage.h new file mode 100644 index 000000000..b9a5ccab5 --- /dev/null +++ b/libraries/lib-sentry-reporting/AnonymizedMessage.h @@ -0,0 +1,55 @@ +/*!******************************************************************** + + Audacity: A Digital Audio Editor + + @file AnonymizedMessage.h + @brief Declare a class to store anonymized messages. + + Dmitry Vedenko + **********************************************************************/ + +#include +#include + +#pragma once + +namespace audacity +{ +namespace sentry +{ + +class SENTRY_REPORTING_API AnonymizedMessage final +{ +public: + AnonymizedMessage() = default; + + AnonymizedMessage(const AnonymizedMessage&) = default; + AnonymizedMessage(AnonymizedMessage&&) = default; + + AnonymizedMessage& operator=(const AnonymizedMessage&) = default; + AnonymizedMessage& operator=(AnonymizedMessage&&) = default; + + AnonymizedMessage(std::string message); + AnonymizedMessage(const std::wstring& message); + AnonymizedMessage(const wxString& message); + + AnonymizedMessage(const char* message); + AnonymizedMessage(const wchar_t* message); + + bool Empty() const noexcept; + size_t Length() const noexcept; + + const std::string& GetString() const noexcept; + wxString ToWXString() const noexcept; + + // Immitate std::string interface + const char* c_str() const noexcept; + size_t length() const noexcept; +private: + void CleanupPaths(); + + std::string mMessage; +}; + +} // namespace sentry +} // namespace audacity diff --git a/libraries/lib-sentry-reporting/CMakeLists.txt b/libraries/lib-sentry-reporting/CMakeLists.txt new file mode 100644 index 000000000..4d53b01f3 --- /dev/null +++ b/libraries/lib-sentry-reporting/CMakeLists.txt @@ -0,0 +1,48 @@ +#[[ +A library, that allows sending error reports to a Sentry server +using Exception and Message interfaces. +]]# + +set( TARGET lib-sentry-reporting ) +set( TARGET_ROOT ${CMAKE_CURRENT_SOURCE_DIR} ) + +def_vars() + +if(${_OPT}has_sentry_reporting) + set( SOURCES + SentryHelper.h + + AnonymizedMessage.h + AnonymizedMessage.cpp + + SentryReport.h + SentryReport.cpp + + SentryRequestBuilder.h + SentryRequestBuilder.cpp + ) + + + set ( LIBRARIES PRIVATE + lib-network-manager # Required for the networking + lib-string-utils # ToUtf8 + lib-uuid # UUIDs are required as an event identifier. + RapidJSON::RapidJSON # Protocol is JSON based + wxwidgets::base # Required to retreive the OS information + ) + + set ( DEFINES + INTERFACE + HAS_SENTRY_REPORTING=1 + PRIVATE + # The variables below will be used to construct Sentry URL: + # https://${SENTRY_DSN_KEY}@${SENTRY_HOST}/api/${SENTRY_PROJECT}/store + SENTRY_DSN_KEY="${SENTRY_DSN_KEY}" + SENTRY_HOST="${SENTRY_HOST}" + SENTRY_PROJECT="${SENTRY_PROJECT}" + ) + + audacity_library( ${TARGET} "${SOURCES}" "${LIBRARIES}" "${DEFINES}" "" ) +else() + audacity_header_only_library( ${TARGET} "SentryHelper.h" "" "" "" ) +endif() \ No newline at end of file diff --git a/libraries/lib-sentry-reporting/SentryHelper.h b/libraries/lib-sentry-reporting/SentryHelper.h new file mode 100644 index 000000000..0184bcf10 --- /dev/null +++ b/libraries/lib-sentry-reporting/SentryHelper.h @@ -0,0 +1,25 @@ +/********************************************************************** + + Audacity: A Digital Audio Editor + + SentryHelper.h + + Defines a macro ADD_EXCEPTION_CONTEXT, that is a no op if Sentry reporting is disabled. + + Dmitry Vedenko + +**********************************************************************/ + +#ifndef __AUDACITY_SENTRY__ +#define __AUDACITY_SENTRY__ + +#ifdef HAS_SENTRY_REPORTING +# include "SentryReport.h" + +# define ADD_EXCEPTION_CONTEXT(name, value) audacity::sentry::AddExceptionContext(name, value) +#else +# define ADD_EXCEPTION_CONTEXT(name, value) +#endif // HAS_SENTRY_REPORTING + + +#endif /* __AUDACITY_SENTRY__ */ diff --git a/libraries/lib-sentry-reporting/SentryReport.cpp b/libraries/lib-sentry-reporting/SentryReport.cpp new file mode 100644 index 000000000..fb045eca9 --- /dev/null +++ b/libraries/lib-sentry-reporting/SentryReport.cpp @@ -0,0 +1,428 @@ +/*!******************************************************************** + + Audacity: A Digital Audio Editor + + @file SentryReport.cpp + @brief Define a class to report errors to Sentry. + + Dmitry Vedenko + **********************************************************************/ + +#include "SentryReport.h" + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include "CodeConversions.h" +#include "Uuid.h" + +#include "IResponse.h" +#include "NetworkManager.h" + +#include "SentryRequestBuilder.h" + +namespace audacity +{ +namespace sentry +{ +namespace +{ + +//! Helper class to store additional details about the exception +/*! This small class is a thread safe store for the information + we want to add to the exception before the exception occurs. + For example, we may log SQLite3 return codes here, as otherwise + they wont be available when everything fails +*/ +class ExceptionContext final +{ +public: + //! Adds a new item to the exception context + void Add(std::string parameterName, AnonymizedMessage parameterValue) + { + std::lock_guard lock(mDataMutex); + mData.emplace_back(std::move(parameterName), std::move(parameterValue)); + } + + //! Return the current context and reset it + std::vector MoveParameters() + { + std::lock_guard lock(mDataMutex); + + std::vector emptyVector; + + std::swap(mData, emptyVector); + + return emptyVector; + } + + //! Get an instance of the ExceptionContext + static ExceptionContext& Get() + { + static ExceptionContext instance; + return instance; + } + +private: + ExceptionContext() = default; + + std::mutex mDataMutex; + std::vector mData; +}; + +//! Append the data about the operating system to the JSON document +void AddOSContext( + rapidjson::Value& root, rapidjson::Document::AllocatorType& allocator) +{ + rapidjson::Value osContext(rapidjson::kObjectType); + + const wxPlatformInfo platformInfo = wxPlatformInfo::Get(); + + const std::string osName = + ToUTF8(platformInfo.GetOperatingSystemFamilyName()); + + osContext.AddMember("type", rapidjson::Value("os", allocator), allocator); + + osContext.AddMember( + "name", rapidjson::Value(osName.c_str(), osName.length(), allocator), + allocator); + + const std::string osVersion = + std::to_string(platformInfo.GetOSMajorVersion()) + "." + + std::to_string(platformInfo.GetOSMinorVersion()) + "." + + std::to_string(platformInfo.GetOSMicroVersion()); + + osContext.AddMember( + "version", + rapidjson::Value(osVersion.c_str(), osVersion.length(), allocator), + allocator); + + root.AddMember("os", std::move(osContext), allocator); +} + +//! Create the minimal required Sentry JSON document +rapidjson::Document CreateSentryDocument() +{ + using namespace std::chrono; + rapidjson::Document document; + + document.SetObject(); + + document.AddMember( + "timestamp", + rapidjson::Value( + duration_cast(system_clock::now().time_since_epoch()) + .count()), + document.GetAllocator()); + + std::string eventId = Uuid::Generate().ToHexString(); + + document.AddMember( + "event_id", + rapidjson::Value( + eventId.c_str(), eventId.length(), document.GetAllocator()), + document.GetAllocator()); + + constexpr char platform[] = "native"; + + document.AddMember( + "platform", + rapidjson::Value(platform, sizeof(platform) - 1, document.GetAllocator()), + document.GetAllocator()); + + document["platform"].SetString( + platform, sizeof(platform) - 1, document.GetAllocator()); + + const std::string release = std::string("audacity@") + + std::to_string(AUDACITY_VERSION) + "." + + std::to_string(AUDACITY_RELEASE) + "." + + std::to_string(AUDACITY_REVISION); + + document.AddMember( + "release", + rapidjson::Value( + release.c_str(), release.length(), document.GetAllocator()), + document.GetAllocator()); + + rapidjson::Value contexts = rapidjson::Value(rapidjson::kObjectType); + + AddOSContext(contexts, document.GetAllocator()); + + document.AddMember("contexts", contexts, document.GetAllocator()); + + return document; +} + +//! Append the ExceptionData to the Exception JSON object +void AddExceptionDataToJson( + rapidjson::Value& value, rapidjson::Document::AllocatorType& allocator, + const ExceptionData& data) +{ + value.AddMember( + rapidjson::Value(data.first.c_str(), data.first.length(), allocator), + rapidjson::Value(data.second.c_str(), data.second.length(), allocator), + allocator); +} + +//! Serialize the Exception to JSON +void SerializeException( + const Exception& exception, rapidjson::Value& root, + rapidjson::Document::AllocatorType& allocator) +{ + root.AddMember( + "type", + rapidjson::Value( + exception.Type.c_str(), exception.Type.length(), allocator), + allocator); + + root.AddMember( + "value", + rapidjson::Value( + exception.Value.c_str(), exception.Value.length(), allocator), + allocator); + + rapidjson::Value mechanismObject(rapidjson::kObjectType); + + mechanismObject.AddMember( + "type", rapidjson::Value("runtime_error", allocator), allocator); + + mechanismObject.AddMember( + "handled", false, allocator); + + auto contextData = ExceptionContext::Get().MoveParameters(); + + if (!exception.Data.empty() || !contextData.empty()) + { + rapidjson::Value dataObject(rapidjson::kObjectType); + + for (const auto& data : contextData) + AddExceptionDataToJson(dataObject, allocator, data); + + for (const auto& data : exception.Data) + AddExceptionDataToJson(dataObject, allocator, data); + + mechanismObject.AddMember("data", std::move(dataObject), allocator); + } + + root.AddMember("mechanism", std::move(mechanismObject), allocator); +} + +} // namespace + +Exception Exception::Create(std::string type, AnonymizedMessage value) +{ + std::replace_if(type.begin(), type.end(), [](char c) { + return std::isspace(c) != 0; + }, '_'); + + return { std::move(type), std::move(value) }; +} + +Exception Exception::Create(AnonymizedMessage value) +{ + return { "runtime_error", std::move(value) }; +} + +Exception& Exception::AddData(std::string key, AnonymizedMessage value) +{ + Data.emplace_back(std::move(key), std::move(value)); + return *this; +} + +Message Message::Create(AnonymizedMessage message) +{ + return { std::move(message) }; +} + +Message& Message::AddParam(AnonymizedMessage value) +{ + Params.emplace_back(std::move(value)); + return *this; +} + +void AddExceptionContext( + std::string parameterName, AnonymizedMessage parameterValue) +{ + ExceptionContext::Get().Add(std::move (parameterName), std::move (parameterValue)); +} + +class Report::ReportImpl +{ +public: + explicit ReportImpl(const Exception& exception); + explicit ReportImpl(const Message& message); + + void AddUserComment(const std::string& message); + + std::string ToString(bool pretty) const; + + void Send(CompletionHandler completionHandler) const; + +private: + rapidjson::Document mDocument; +}; + + +Report::ReportImpl::ReportImpl(const Exception& exception) + : mDocument(CreateSentryDocument()) +{ + rapidjson::Value exceptionObject(rapidjson::kObjectType); + rapidjson::Value valuesArray(rapidjson::kArrayType); + rapidjson::Value valueObject(rapidjson::kObjectType); + + SerializeException(exception, valueObject, mDocument.GetAllocator()); + + valuesArray.PushBack(std::move(valueObject), mDocument.GetAllocator()); + + exceptionObject.AddMember( + "values", std::move(valuesArray), mDocument.GetAllocator()); + + mDocument.AddMember( + "exception", std::move(exceptionObject), mDocument.GetAllocator()); +} + +Report::ReportImpl::ReportImpl(const Message& message) + : mDocument(CreateSentryDocument()) +{ + rapidjson::Value messageObject(rapidjson::kObjectType); + + messageObject.AddMember( + "message", + rapidjson::Value( + message.Value.c_str(), message.Value.length(), + mDocument.GetAllocator()), + mDocument.GetAllocator()); + + if (!message.Params.empty()) + { + rapidjson::Value paramsArray(rapidjson::kArrayType); + + for (const AnonymizedMessage& param : message.Params) + { + paramsArray.PushBack( + rapidjson::Value( + param.c_str(), param.length(), mDocument.GetAllocator()), + mDocument.GetAllocator()); + } + + messageObject.AddMember( + "params", std::move(paramsArray), mDocument.GetAllocator()); + } + + mDocument.AddMember( + "message", std::move(messageObject), mDocument.GetAllocator()); +} + +void Report::ReportImpl::AddUserComment(const std::string& message) +{ + // We only allow adding comment to exceptions now + if (!mDocument.HasMember("exception") || message.empty()) + return; + + rapidjson::Value& topException = mDocument["exception"]["values"][0]; + + if (!topException.IsObject()) + return; + + rapidjson::Value& mechanism = topException["mechanism"]; + + // Create a data object if it still does not exist + if (!mechanism.HasMember("data")) + { + mechanism.AddMember( + "data", rapidjson::Value(rapidjson::kObjectType), + mDocument.GetAllocator()); + } + + // Add a comment itself + mechanism["data"].AddMember( + "user_comment", + rapidjson::Value( + message.data(), message.length(), mDocument.GetAllocator()), + mDocument.GetAllocator()); +} + + +void Report::ReportImpl::Send(CompletionHandler completionHandler) const +{ + const std::string serializedDocument = ToString(false); + + network_manager::Request request = + SentryRequestBuilder::Get().CreateRequest(); + + auto response = network_manager::NetworkManager::GetInstance().doPost( + request, serializedDocument.data(), serializedDocument.size()); + + response->setRequestFinishedCallback( + [response, handler = std::move(completionHandler)](network_manager::IResponse*) { + const std::string responseData = response->readAll(); + + wxLogDebug(responseData.c_str()); + + if (handler) + handler(response->getHTTPCode(), responseData); + }); +} + +std::string Report::ReportImpl::ToString(bool pretty) const +{ + rapidjson::StringBuffer buffer; + + if (pretty) + { + rapidjson::PrettyWriter writer(buffer); + mDocument.Accept(writer); + } + else + { + rapidjson::Writer writer(buffer); + mDocument.Accept(writer); + } + + return std::string(buffer.GetString()); +} + +Report::~Report() +{ +} + +Report::Report(const Exception& exception) + : mImpl(std::make_unique(exception)) +{ +} + +Report::Report(const Message& message) + : mImpl(std::make_unique(message)) +{ +} + +void Report::AddUserComment(const std::string& comment) +{ + mImpl->AddUserComment(comment); +} + +std::string Report::GetReportPreview() const +{ + return mImpl->ToString(true); +} + +void Report::Send(CompletionHandler completionHandler) const +{ + mImpl->Send(std::move (completionHandler)); +} + + +} // namespace sentry +} // namespace audacity diff --git a/libraries/lib-sentry-reporting/SentryReport.h b/libraries/lib-sentry-reporting/SentryReport.h new file mode 100644 index 000000000..ce2b24338 --- /dev/null +++ b/libraries/lib-sentry-reporting/SentryReport.h @@ -0,0 +1,95 @@ +/*!******************************************************************** + + Audacity: A Digital Audio Editor + + @file SentryReport.h + @brief Declare a class to report errors to Sentry. + + Dmitry Vedenko + **********************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include + +#include "AnonymizedMessage.h" + +namespace audacity +{ +namespace sentry +{ + +//! Additional payload to the exception +using ExceptionData = std::pair; + +//! A DTO for the Sentry Exception interface +struct SENTRY_REPORTING_API Exception final +{ + //! Exception type. Should not have spaces. + std::string Type; + //! Message, associated with the Exception + AnonymizedMessage Value; + //! Arbitrary payload + std::vector Data; + + //! Create a new exception + static Exception Create(std::string type, AnonymizedMessage value); + //! Create a new exception with type runtime_error + static Exception Create(AnonymizedMessage value); + //! Add a payload to the exception + Exception& AddData(std::string key, AnonymizedMessage value); +}; + +//! A DTO for the Sentry Message interface +struct SENTRY_REPORTING_API Message final +{ + //! A string, possibly with %s placeholders, containing the message + AnonymizedMessage Value; + //! Values for the placeholders + std::vector Params; + //! Create a new Message + static Message Create(AnonymizedMessage message); + //! Add a parameter to the Message + Message& AddParam(AnonymizedMessage value); +}; + +//! Saves a parameter, that will be appended to the next Exception report +SENTRY_REPORTING_API void AddExceptionContext( + std::string parameterName, AnonymizedMessage parameterValue); + +//! A report to Sentry +class SENTRY_REPORTING_API Report final +{ +public: + //! A callback, that will be called when Send completes + using CompletionHandler = std::function; + + //! Create a report from the exception and previously added exception context + explicit Report(const Exception& exception); + + //! Create a report with a single log message + explicit Report(const Message& message); + + ~Report(); + + //! Adds a user comment to the exception report + void AddUserComment(const std::string& comment); + + //! Get a pretty printed report preview + std::string GetReportPreview() const; + + //! Send the report to Sentry + void Send(CompletionHandler completionHandler) const; + +private: + class ReportImpl; + + std::unique_ptr mImpl; +}; + +} // namespace sentry +} // namespace audacity diff --git a/libraries/lib-sentry-reporting/SentryRequestBuilder.cpp b/libraries/lib-sentry-reporting/SentryRequestBuilder.cpp new file mode 100644 index 000000000..6bda81603 --- /dev/null +++ b/libraries/lib-sentry-reporting/SentryRequestBuilder.cpp @@ -0,0 +1,53 @@ +/*!******************************************************************** + + Audacity: A Digital Audio Editor + + @file SentryRequestBuilder.h + @brief Define a class to generate the requests to Sentry. + + Dmitry Vedenko + **********************************************************************/ + +#include "SentryRequestBuilder.h" + +#include + +namespace audacity +{ +namespace sentry +{ + +const SentryRequestBuilder& audacity::sentry::SentryRequestBuilder::Get() +{ + static SentryRequestBuilder builder; + + return builder; +} + +network_manager::Request SentryRequestBuilder::CreateRequest() const +{ + using namespace std::chrono; + + const std::string sentryAuth = + std::string("Sentry sentry_version=7,sentry_timestamp=") + + std::to_string( + duration_cast(system_clock::now().time_since_epoch()) + .count()) + + ",sentry_client=sentry-audacity/1.0,sentry_key=" + SENTRY_DSN_KEY; + + network_manager::Request request(mUrl); + + request.setHeader("Content-Type", "application/json"); + request.setHeader("X-Sentry-Auth", sentryAuth); + + return request; +} + +SentryRequestBuilder::SentryRequestBuilder() +{ + mUrl = std::string("https://") + SENTRY_DSN_KEY + "@" + SENTRY_HOST + + "/api/" + SENTRY_PROJECT + "/store/"; +} + +} // namespace sentry +} // namespace audacity diff --git a/libraries/lib-sentry-reporting/SentryRequestBuilder.h b/libraries/lib-sentry-reporting/SentryRequestBuilder.h new file mode 100644 index 000000000..560010e0c --- /dev/null +++ b/libraries/lib-sentry-reporting/SentryRequestBuilder.h @@ -0,0 +1,36 @@ +/*!******************************************************************** + + Audacity: A Digital Audio Editor + + @file SentryRequestBuilder.cpp + @brief Declare a class to generate the requests to Sentry. + + Dmitry Vedenko + **********************************************************************/ + +#pragma once + +#include + +#include "Request.h" + +namespace audacity +{ +namespace sentry +{ +// This is a private class, so it is not exported +//! A helper, that creates a correct Request to Sentry +class SentryRequestBuilder final +{ +public: + static const SentryRequestBuilder& Get(); + + network_manager::Request CreateRequest() const; + +private: + SentryRequestBuilder(); + + std::string mUrl; +}; +} // namespace sentry +} // namespace audacity