mirror of
https://github.com/Facepunch/sbox-public.git
synced 2025-12-23 22:48:07 -05:00
Managed CrashReporter (#3595)
Remove C++ crashreporter and reimplement in C#, add various session id tags and send payload to our own API
This commit is contained in:
14
engine/Launcher/CrashReporter/CrashReporter.csproj
Normal file
14
engine/Launcher/CrashReporter/CrashReporter.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<OutputPath>..\..\..\game\bin\managed</OutputPath>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
302
engine/Launcher/CrashReporter/Envelope.cs
Normal file
302
engine/Launcher/CrashReporter/Envelope.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CrashReporter;
|
||||
|
||||
public record EnvelopeException( string? Type, string? Value );
|
||||
public record FormattedEnvelopeItem( string Header, string Payload );
|
||||
public record FormattedEnvelope( string Header, List<FormattedEnvelopeItem> Items );
|
||||
public record Attachment( string Filename, byte[] Data );
|
||||
|
||||
public sealed class EnvelopeItem( JsonObject header, byte[] payload )
|
||||
{
|
||||
public JsonObject Header { get; } = header;
|
||||
public byte[] Payload { get; } = payload;
|
||||
|
||||
public string? TryGetType()
|
||||
{
|
||||
return TryGetHeader( "type" );
|
||||
}
|
||||
|
||||
public string? TryGetHeader( string key )
|
||||
{
|
||||
if ( Header.TryGetPropertyValue( key, out var node ) && node is JsonValue value &&
|
||||
value.TryGetValue( out string? result ) )
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public string? TryGetTag( string tag )
|
||||
{
|
||||
if ( !Header.TryGetPropertyValue( "tags", out var tagsNode ) || tagsNode is not JsonObject tags )
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( tags.TryGetPropertyValue( tag, out var tagNode ) && tagNode is JsonValue value && value.TryGetValue( out string? result ) )
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public JsonObject? TryParseAsJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse( Payload )?.AsObject();
|
||||
}
|
||||
catch ( JsonException )
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public FormattedEnvelopeItem Format( JsonSerializerOptions? options = null )
|
||||
{
|
||||
options ??= new JsonSerializerOptions { WriteIndented = true };
|
||||
var header = Header.ToJsonString( options );
|
||||
try
|
||||
{
|
||||
var json = JsonNode.Parse( Payload );
|
||||
var payload = json?.AsObject().ToJsonString( options );
|
||||
return new FormattedEnvelopeItem( header, payload ?? string.Empty );
|
||||
}
|
||||
catch ( JsonException )
|
||||
{
|
||||
const int maxLen = 32;
|
||||
var hex = BitConverter.ToString( Payload.Take( maxLen ).ToArray() ).Replace( "-", " " );
|
||||
if ( Payload.Length > maxLen )
|
||||
{
|
||||
hex += "...";
|
||||
}
|
||||
return new FormattedEnvelopeItem( header, hex );
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task SerializeAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default )
|
||||
{
|
||||
var json = Encoding.UTF8.GetBytes( Header.ToJsonString() );
|
||||
await stream.WriteLineAsync( json.AsMemory(), cancellationToken ).ConfigureAwait( false );
|
||||
|
||||
await stream.WriteLineAsync( Payload.AsMemory(), cancellationToken ).ConfigureAwait( false );
|
||||
}
|
||||
|
||||
internal static async Task<EnvelopeItem> DeserializeAsync(
|
||||
Stream stream, CancellationToken cancellationToken = default )
|
||||
{
|
||||
var buffer = await stream.ReadLineAsync( cancellationToken ).ConfigureAwait( false ) ??
|
||||
throw new InvalidOperationException( "Envelope item is malformed." );
|
||||
var header = JsonNode.Parse( buffer )?.AsObject() ?? throw new InvalidOperationException( "Envelope item is malformed." );
|
||||
|
||||
if ( header.TryGetPropertyValue( "length", out var node ) && node?.AsValue()?.TryGetValue( out long length ) == true )
|
||||
{
|
||||
var payload = new byte[length];
|
||||
var pos = 0;
|
||||
while ( pos < payload.Length )
|
||||
{
|
||||
var read = await stream.ReadAsync( payload, pos, payload.Length - pos, cancellationToken ).ConfigureAwait( false );
|
||||
if ( read == 0 )
|
||||
{
|
||||
throw new InvalidOperationException( "Envelope item payload is malformed." );
|
||||
}
|
||||
pos += read;
|
||||
}
|
||||
return new EnvelopeItem( header, payload );
|
||||
}
|
||||
else
|
||||
{
|
||||
var payload = await stream.ReadLineAsync( cancellationToken ).ConfigureAwait( false ) ??
|
||||
throw new InvalidOperationException( "Envelope item payload is malformed." );
|
||||
return new EnvelopeItem( header, Encoding.UTF8.GetBytes( payload ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Envelope( JsonObject header, IReadOnlyList<EnvelopeItem> items )
|
||||
{
|
||||
public string? FilePath { get; internal set; }
|
||||
public JsonObject Header { get; } = header;
|
||||
public IReadOnlyList<EnvelopeItem> Items { get; } = items;
|
||||
|
||||
public string? TryGetDsn()
|
||||
{
|
||||
return TryGetHeader( "dsn" );
|
||||
}
|
||||
|
||||
public string? TryGetEventId()
|
||||
{
|
||||
return TryGetHeader( "event_id" );
|
||||
}
|
||||
|
||||
public string? TryGetHeader( string key )
|
||||
{
|
||||
if ( Header.TryGetPropertyValue( key, out var node ) && node is JsonValue value &&
|
||||
value.TryGetValue( out string? result ) )
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public EnvelopeItem? TryGetEvent()
|
||||
{
|
||||
return Items.FirstOrDefault( i => i.TryGetType() == "event" );
|
||||
}
|
||||
|
||||
/*
|
||||
public Minidump? TryGetMinidump()
|
||||
{
|
||||
var item = Items.FirstOrDefault( i => i.TryGetHeader( "attachment_type" ) == "event.minidump" );
|
||||
if ( item is null )
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Minidump.FromBytes( item.Payload );
|
||||
}
|
||||
|
||||
public List<Attachment> TryGetAttachments()
|
||||
{
|
||||
return Items
|
||||
.Where( s => s.TryGetType() == "attachment" )
|
||||
.Select( s => new Attachment( s.Header.TryGetString( "filename" ) ?? string.Empty, s.Payload ) )
|
||||
.Where( a => !string.IsNullOrEmpty( a.Filename ) )
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public EnvelopeException? TryGetException()
|
||||
{
|
||||
var payload = TryGetEvent()?.TryParseAsJson();
|
||||
var os = payload?.TryGetString( "contexts.os.name" );
|
||||
|
||||
if ( payload?.TryGetProperty( "exception.values" )?.AsArray().FirstOrDefault()?.AsObject() is { } inproc )
|
||||
{
|
||||
return new EnvelopeException( inproc.TryGetString( "type" ), inproc.TryGetString( "value" ) );
|
||||
}
|
||||
|
||||
if ( TryGetMinidump()?.Streams.Select( s => s.Data )
|
||||
.OfType<Minidump.ExceptionStream>()
|
||||
.FirstOrDefault() is { } minidump )
|
||||
{
|
||||
var code = minidump.ExceptionRec.Code.AsExceptionCode( os ?? string.Empty );
|
||||
return new EnvelopeException( code?.Type, code?.Value );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
*/
|
||||
|
||||
public FormattedEnvelope Format( JsonSerializerOptions? options = null )
|
||||
{
|
||||
options ??= new JsonSerializerOptions { WriteIndented = true };
|
||||
var header = Header.ToJsonString( options );
|
||||
|
||||
var items = new List<FormattedEnvelopeItem>();
|
||||
foreach ( var item in Items )
|
||||
{
|
||||
items.Add( item.Format( options ) );
|
||||
}
|
||||
|
||||
return new FormattedEnvelope( header, items );
|
||||
}
|
||||
|
||||
public async Task SerializeAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default )
|
||||
{
|
||||
var json = Encoding.UTF8.GetBytes( Header.ToJsonString() );
|
||||
await stream.WriteLineAsync( json.AsMemory(), cancellationToken ).ConfigureAwait( false );
|
||||
|
||||
foreach ( var item in Items )
|
||||
{
|
||||
await item.SerializeAsync( stream, cancellationToken ).ConfigureAwait( false );
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<Envelope> FromFileStreamAsync(
|
||||
FileStream fileStream,
|
||||
CancellationToken cancellationToken = default )
|
||||
{
|
||||
var envelope = await DeserializeAsync( fileStream, cancellationToken );
|
||||
envelope.FilePath = fileStream.Name;
|
||||
return envelope;
|
||||
}
|
||||
|
||||
public static async Task<Envelope> DeserializeAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default )
|
||||
{
|
||||
var buffer = await stream.ReadLineAsync( cancellationToken ).ConfigureAwait( false ) ??
|
||||
throw new InvalidOperationException( "Envelope header is malformed." );
|
||||
var header = JsonNode.Parse( buffer )?.AsObject() ?? throw new InvalidOperationException( "Envelope header is malformed." );
|
||||
|
||||
var items = new List<EnvelopeItem>();
|
||||
while ( stream.Position < stream.Length )
|
||||
{
|
||||
var item = await EnvelopeItem.DeserializeAsync( stream, cancellationToken ).ConfigureAwait( false );
|
||||
items.Add( item );
|
||||
await stream.ConsumeEmptyLinesAsync( cancellationToken );
|
||||
}
|
||||
|
||||
return new Envelope( header, items );
|
||||
}
|
||||
|
||||
public static Envelope FromJson( JsonObject header, IEnumerable<(JsonObject Header, JsonObject Payload)> items )
|
||||
{
|
||||
var envelopeItems = items.Select( item => new EnvelopeItem( item.Header, Encoding.UTF8.GetBytes( item.Payload.ToJsonString() ) ) );
|
||||
return new Envelope( header, envelopeItems.ToList() );
|
||||
}
|
||||
}
|
||||
|
||||
internal static class StreamExtensions
|
||||
{
|
||||
private const byte NewLine = (byte)'\n';
|
||||
|
||||
public static async Task<string?> ReadLineAsync( this Stream stream, CancellationToken cancellationToken = default )
|
||||
{
|
||||
var line = new List<byte>();
|
||||
var buffer = new byte[1];
|
||||
while ( true )
|
||||
{
|
||||
var read = await stream.ReadAsync( buffer, 0, 1, cancellationToken ).ConfigureAwait( false );
|
||||
if ( read == 0 || buffer[0] == NewLine )
|
||||
{
|
||||
break;
|
||||
}
|
||||
line.Add( buffer[0] );
|
||||
}
|
||||
return Encoding.UTF8.GetString( line.ToArray() );
|
||||
}
|
||||
|
||||
public static async Task WriteLineAsync( this Stream stream, ReadOnlyMemory<byte> line, CancellationToken cancellationToken = default )
|
||||
{
|
||||
await stream.WriteAsync( line, cancellationToken ).ConfigureAwait( false );
|
||||
await stream.WriteAsync( new byte[] { NewLine }, cancellationToken ).ConfigureAwait( false );
|
||||
}
|
||||
|
||||
public static async Task ConsumeEmptyLinesAsync( this Stream stream, CancellationToken cancellationToken = default )
|
||||
{
|
||||
while ( await stream.PeekAsync( cancellationToken ) == NewLine )
|
||||
{
|
||||
await stream.ReadLineAsync( cancellationToken ).ConfigureAwait( false );
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> PeekAsync( this Stream stream, CancellationToken cancellationToken = default )
|
||||
{
|
||||
var pos = stream.Position;
|
||||
var buffer = new byte[1];
|
||||
var read = await stream.ReadAsync( buffer, 0, 1, cancellationToken ).ConfigureAwait( false );
|
||||
stream.Position = pos;
|
||||
return read == 0 ? -1 : buffer[0];
|
||||
}
|
||||
}
|
||||
57
engine/Launcher/CrashReporter/Program.cs
Normal file
57
engine/Launcher/CrashReporter/Program.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CrashReporter;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task<int> Main( string[] args )
|
||||
{
|
||||
if ( args.Length < 1 )
|
||||
{
|
||||
Console.WriteLine( "Usage: CrashReporter.exe <path to envelope>" );
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead( args[0] );
|
||||
var envelope = await Envelope.FromFileStreamAsync( stream );
|
||||
|
||||
var dsn = envelope.TryGetDsn();
|
||||
var event_id = envelope.TryGetEventId();
|
||||
|
||||
// Submit to Sentry
|
||||
await SentryClient.SubmitEnvelopeAsync( dsn!, envelope );
|
||||
|
||||
// Submit to our own API
|
||||
var sentryEvent = envelope.TryGetEvent()?.TryParseAsJson();
|
||||
|
||||
if ( sentryEvent is not null )
|
||||
{
|
||||
var tags = sentryEvent["tags"];
|
||||
|
||||
var payload = new
|
||||
{
|
||||
sentry_event_id = event_id,
|
||||
timestamp = sentryEvent["timestamp"],
|
||||
version = sentryEvent["release"],
|
||||
session_id = tags?["session_id"],
|
||||
activity_session_id = tags?["activity_session_id"],
|
||||
launch_guid = tags?["launch_guid"],
|
||||
gpu = tags?["gpu"],
|
||||
cpu = tags?["cpu"],
|
||||
mode = tags?["mode"],
|
||||
};
|
||||
|
||||
// Submit to our API
|
||||
using var client = new HttpClient();
|
||||
await client.PostAsJsonAsync( "https://services.facepunch.com/sbox/event/crash/1/", payload );
|
||||
}
|
||||
|
||||
// Open browser to crash report page
|
||||
Process.Start( new ProcessStartInfo( $"https://sbox.game/crashes/{event_id}" ) { UseShellExecute = true } );
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
38
engine/Launcher/CrashReporter/SentryClient.cs
Normal file
38
engine/Launcher/CrashReporter/SentryClient.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace CrashReporter;
|
||||
|
||||
public class SentryClient
|
||||
{
|
||||
public static async Task SubmitEnvelopeAsync( string dsn, Envelope envelope, CancellationToken cancellationToken = default )
|
||||
{
|
||||
// <scheme>://<key>@<host>:<port>/<project-id> ->
|
||||
// <scheme>://<host>:<port>/api/<project-id>/envelope
|
||||
var uri = new Uri( dsn );
|
||||
var projectId = uri.LocalPath.Trim( '/' );
|
||||
var uriBuilder = new UriBuilder()
|
||||
{
|
||||
Scheme = uri.Scheme,
|
||||
Host = uri.Host,
|
||||
Port = uri.Port,
|
||||
Path = $"/api/{projectId}/envelope/"
|
||||
};
|
||||
|
||||
var stream = new MemoryStream();
|
||||
await envelope.SerializeAsync( stream, cancellationToken ).ConfigureAwait( false );
|
||||
await stream.FlushAsync( cancellationToken ).ConfigureAwait( false );
|
||||
stream.Seek( 0, SeekOrigin.Begin );
|
||||
|
||||
var request = new HttpRequestMessage( HttpMethod.Post, uriBuilder.Uri )
|
||||
{
|
||||
Content = new StreamContent( stream )
|
||||
};
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
using var response = await httpClient.SendAsync( request, cancellationToken ).ConfigureAwait( false );
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync( cancellationToken ).ConfigureAwait( false );
|
||||
|
||||
Console.WriteLine( $"content: {content}" );
|
||||
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
<Project Path="Tools/ShaderCompiler/ShaderCompiler.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Engine/Exe/">
|
||||
<Project Path="Launcher/CrashReporter/CrashReporter.csproj" Id="652c4b4b-7f21-4884-9ad5-6a9032ce4ef9" />
|
||||
<Project Path="Launcher/Sbox/Sbox.csproj" />
|
||||
<Project Path="Launcher/SboxBench/SboxBench.csproj" />
|
||||
<Project Path="Launcher/SboxDev/Sbox-Dev.csproj" />
|
||||
|
||||
@@ -76,6 +76,9 @@ internal class AccountInformation
|
||||
return;
|
||||
}
|
||||
|
||||
NativeErrorReporter.SetTag( "session_id", login.Session );
|
||||
NativeErrorReporter.SetTag( "launch_guid", Api.LaunchGuid );
|
||||
|
||||
SteamId = login.Id;
|
||||
Session = login.Session;
|
||||
Links = login.Links?.Select( x => (StreamService)x ).ToList() ?? new();
|
||||
|
||||
@@ -54,6 +54,8 @@ internal static partial class Api
|
||||
|
||||
SessionId = Guid.NewGuid();
|
||||
|
||||
NativeErrorReporter.SetTag( "activity_session_id", SessionId.ToString() );
|
||||
|
||||
SessionTimer = FastTimer.StartNew();
|
||||
ActivityCount = 0;
|
||||
performanceData = null;
|
||||
|
||||
Reference in New Issue
Block a user