Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/Trax.Core/Exceptions/TrainExceptionData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,11 @@ public class TrainExceptionData
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; set; }

/// <summary>
/// The original stack trace from where the exception was thrown.
/// Nullable for backwards compatibility with previously serialized data.
/// </summary>
[JsonPropertyName("stackTrace")]
public string? StackTrace { get; set; }
}
19 changes: 5 additions & 14 deletions src/Trax.Core/Junction/Junction.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Trax.Core.Exceptions;
Expand Down Expand Up @@ -96,30 +94,23 @@ Train<TTrainIn, TTrainOut> train
}
catch (Exception e)
{
// Enrich the exception with junction information for better debugging
var messageField = typeof(Exception).GetField(
"_message",
BindingFlags.Instance | BindingFlags.NonPublic
);

if (messageField is null)
return e;

var exceptionData = new TrainExceptionData
{
TrainName = train.GetType().Name,
TrainExternalId = train.ExternalId,
Junction = GetType().Name,
Type = e.GetType().Name,
Message = e.Message,
StackTrace = e.StackTrace,
};

ExceptionData = exceptionData;

var serializedMessage = JsonSerializer.Serialize(exceptionData);
messageField.SetValue(e, serializedMessage);
// Store structured data on the exception without mutating its message.
// This preserves the original exception for callers outside Trax,
// while still making junction context available to Metadata.AddException().
e.Data["TrainExceptionData"] = exceptionData;

// Return the exception as Left
return e;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/Trax.Core/Monad/Monad.Resolve.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.ExceptionServices;
using LanguageExt;
using Trax.Core.Exceptions;
using Trax.Core.Extensions;
Expand Down Expand Up @@ -50,7 +51,7 @@ public Either<Exception, TReturn> Resolve()
internal TReturn ResolveOrThrow()
{
if (Exception is not null)
throw Exception;
ExceptionDispatchInfo.Capture(Exception).Throw();

if (ShortCircuitValueSet)
return ShortCircuitValue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,93 @@ public async Task Deserialize_ValidJson_CreatesCorrectObject()
data.Junction.Should().Be("ParseJunction");
data.Message.Should().Be("bad arg");
}

#region StackTrace Serialization

[Theory]
public async Task Serialize_RoundTrip_PreservesStackTrace()
{
// Arrange
var data = new TrainExceptionData
{
TrainName = "MyTrain",
TrainExternalId = "ext-123",
Type = "InvalidOperationException",
Junction = "ValidateInput",
Message = "Input was null",
StackTrace = " at MyApp.ValidateInput.Run() in /app/Validate.cs:line 42",
};

// Act
var json = JsonSerializer.Serialize(data);
var deserialized = JsonSerializer.Deserialize<TrainExceptionData>(json);

// Assert
deserialized.Should().NotBeNull();
deserialized!.StackTrace.Should().Be(data.StackTrace);
}

[Theory]
public async Task Serialize_NullStackTrace_HandledCorrectly()
{
// Arrange
var data = new TrainExceptionData
{
TrainName = "MyTrain",
TrainExternalId = "ext-123",
Type = "Exception",
Junction = "Junction",
Message = "msg",
StackTrace = null,
};

// Act
var json = JsonSerializer.Serialize(data);
var deserialized = JsonSerializer.Deserialize<TrainExceptionData>(json);

// Assert
deserialized.Should().NotBeNull();
deserialized!.StackTrace.Should().BeNull();
}

[Theory]
public async Task Deserialize_WithoutStackTraceField_BackwardsCompatible()
{
// Arrange — JSON from an older version that doesn't include stackTrace
var json =
"""{"trainName":"Test","trainExternalId":"id","type":"Exception","junction":"J","message":"msg"}""";

// Act
var data = JsonSerializer.Deserialize<TrainExceptionData>(json);

// Assert — should deserialize successfully with null StackTrace
data.Should().NotBeNull();
data!.StackTrace.Should().BeNull();
data.Message.Should().Be("msg");
}

[Theory]
public async Task Serialize_StackTraceWithSpecialCharacters_PreservedCorrectly()
{
// Arrange
var data = new TrainExceptionData
{
TrainName = "Test",
TrainExternalId = "id",
Type = "Exception",
Junction = "J",
Message = "msg",
StackTrace =
" at MyApp.Run() in C:\\Users\\dev\\src\\App.cs:line 10\n at System.Threading.Tasks.Task.Execute()",
};

// Act
var json = JsonSerializer.Serialize(data);
var deserialized = JsonSerializer.Deserialize<TrainExceptionData>(json);

// Assert
deserialized!.StackTrace.Should().Be(data.StackTrace);
}

#endregion
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using FluentAssertions;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Trax.Core.Exceptions;
using Trax.Core.Junction;
using Trax.Core.Train;

Expand Down Expand Up @@ -105,6 +106,84 @@ public async Task RailwayStep_NonCancellationException_StillWrapsInExceptionData
junction.ExceptionData!.Junction.Should().Be(nameof(ThrowingStep));
}

[Theory]
public async Task RailwayJunction_ExceptionThrown_OriginalMessagePreserved()
{
// Arrange
var junction = new ThrowingStep();
var train = new TestTrain();

Either<Exception, string> input = "hello";

// Act
var result = await junction.RailwayJunction(input, train);

// Assert — the exception message must be the original, not JSON
var exception = result.Swap().ValueUnsafe();
exception.Message.Should().Be("test error");
}

[Theory]
public async Task RailwayJunction_ExceptionThrown_ExceptionDataAttachedViaDataDictionary()
{
// Arrange
var junction = new ThrowingStep();
var train = new TestTrain();

Either<Exception, string> input = "hello";

// Act
var result = await junction.RailwayJunction(input, train);

// Assert — TrainExceptionData stored in Exception.Data dictionary
var exception = result.Swap().ValueUnsafe();
exception.Data["TrainExceptionData"].Should().NotBeNull();
var data = exception.Data["TrainExceptionData"] as TrainExceptionData;
data.Should().NotBeNull();
data!.Junction.Should().Be(nameof(ThrowingStep));
data.Type.Should().Be("InvalidOperationException");
data.Message.Should().Be("test error");
data.TrainName.Should().Be(nameof(TestTrain));
}

[Theory]
public async Task RailwayJunction_ExceptionThrown_OriginalStackTraceInExceptionData()
{
// Arrange
var junction = new ThrowingStep();
var train = new TestTrain();

Either<Exception, string> input = "hello";

// Act
var result = await junction.RailwayJunction(input, train);

// Assert — the captured StackTrace should contain the junction's Run method
var exception = result.Swap().ValueUnsafe();
var data = exception.Data["TrainExceptionData"] as TrainExceptionData;
data.Should().NotBeNull();
data!.StackTrace.Should().NotBeNullOrEmpty();
data.StackTrace.Should().Contain(nameof(ThrowingStep));
}

[Theory]
public async Task RailwayJunction_ExceptionThrown_ExceptionDataAndPropertyAreConsistent()
{
// Arrange
var junction = new ThrowingStep();
var train = new TestTrain();

Either<Exception, string> input = "hello";

// Act
var result = await junction.RailwayJunction(input, train);

// Assert — Junction.ExceptionData property and Exception.Data dictionary should match
var exception = result.Swap().ValueUnsafe();
var dictionaryData = exception.Data["TrainExceptionData"] as TrainExceptionData;
junction.ExceptionData.Should().BeSameAs(dictionaryData);
}

#region Test Helpers

private class TokenVerifyingStep : Junction<string, string>
Expand Down
Loading
Loading