diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs
index 07639f4eea..061378afb5 100644
--- a/src/Sentry/HubExtensions.cs
+++ b/src/Sentry/HubExtensions.cs
@@ -61,6 +61,81 @@ public static ISpan StartSpan(this IHub hub, string operation, string descriptio
: hub.StartTransaction(operation, description); // this is actually in the wrong order but changing it may break other things
}
+ ///
+ /// Records a transaction that has already completed elsewhere — for example, spans measured on another
+ /// machine or process and replayed through a proxy. Timing is supplied explicitly rather than measured
+ /// live, so unlike there is no stopwatch, idle timer,
+ /// or sampling decision involved; the transaction is materialized and captured once when
+ /// returns.
+ ///
+ /// The hub.
+ /// The transaction name.
+ /// The transaction operation.
+ /// When the transaction started.
+ /// How long the transaction ran. Must not be negative.
+ /// Optional trace id to preserve from the originating system; generated when omitted.
+ /// Optional root span id to preserve; generated when omitted.
+ /// Optional parent span id, when continuing a trace from another service.
+ ///
+ /// Optional callback to set metadata, configure the capture scope, and record the span tree via
+ /// .
+ ///
+ /// The event id of the captured transaction.
+ public static SentryId RecordTransaction(
+ this IHub hub,
+ string name,
+ string operation,
+ DateTimeOffset startTimestamp,
+ TimeSpan duration,
+ SentryId? traceId = null,
+ SpanId? spanId = null,
+ SpanId? parentSpanId = null,
+ Action? configure = null)
+ {
+ if (duration < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(duration), duration, "Transaction duration cannot be negative.");
+ }
+
+ var context = new TransactionContext(
+ name,
+ operation,
+ spanId: spanId,
+ parentSpanId: parentSpanId,
+ traceId: traceId,
+ isSampled: true);
+
+ var tracer = new TransactionTracer(hub, context)
+ {
+ StartTimestamp = startTimestamp,
+ EndTimestamp = startTimestamp + duration,
+ };
+ tracer.Status ??= SpanStatus.Ok;
+
+ // A recorded transaction represents work that happened elsewhere, so we capture it against a clean
+ // scope rather than the current (live) one — this stops the current process's breadcrumbs/user/tags/
+ // contexts from leaking onto it. The scope is exposed via ITransactionRecorder.ConfigureScope so the
+ // caller can still set data that was captured alongside the original trace.
+ var options = hub.GetSentryOptions();
+ var scope = options is not null ? new Scope(options) : null;
+
+ var recorder = new TransactionRecorder(tracer, scope);
+ configure?.Invoke(recorder);
+
+ var transaction = new SentryTransaction(tracer);
+ if (scope is not null)
+ {
+ hub.CaptureTransaction(transaction, scope, null);
+ }
+ else
+ {
+ // No options available (e.g. a disabled hub); this is effectively a no-op capture.
+ hub.CaptureTransaction(transaction);
+ }
+
+ return transaction.EventId;
+ }
+
///
/// Adds a breadcrumb to the current scope.
///
diff --git a/src/Sentry/ISpanRecorder.cs b/src/Sentry/ISpanRecorder.cs
new file mode 100644
index 0000000000..f62f9f0349
--- /dev/null
+++ b/src/Sentry/ISpanRecorder.cs
@@ -0,0 +1,87 @@
+namespace Sentry;
+
+///
+/// Builds a span while recording a transaction that has already completed elsewhere (for example, work
+/// measured on another machine and replayed through a proxy). Unlike , timing is supplied
+/// up-front rather than measured live, so a recorded span can never be left half-specified.
+///
+///
+/// Obtained from or a parent via
+/// .
+/// See .
+///
+public interface ISpanRecorder
+{
+ ///
+ /// The span's id. When not overridden at creation, one is generated.
+ ///
+ public SpanId SpanId { get; }
+
+ ///
+ /// Span description.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Span status. Defaults to when not set.
+ ///
+ public SpanStatus? Status { get; set; }
+
+ ///
+ /// Sets a tag on the span.
+ ///
+ public void SetTag(string key, string value);
+
+ ///
+ /// Sets arbitrary data on the span.
+ ///
+ public void SetData(string key, object? value);
+
+ ///
+ /// Records a child span nested under this one. The parent is structural (this span), so no parent id
+ /// needs to be supplied. Pass to preserve an id from the originating system.
+ ///
+ /// The span operation.
+ /// When the child span started.
+ /// How long the child span ran. Must not be negative.
+ /// Optional span id to preserve; generated when omitted.
+ /// Optional callback to set metadata and record further nested spans.
+ /// The recorder for the child span.
+ public ISpanRecorder RecordSpan(
+ string operation,
+ DateTimeOffset startTimestamp,
+ TimeSpan duration,
+ SpanId? spanId = null,
+ Action? configure = null);
+}
+
+///
+/// Builds a transaction (and its span tree) that has already completed elsewhere. Obtained inside the
+/// configure callback of . When the callback returns, the
+/// whole tree is materialized and captured once — no live tracing, stopwatch, or sampling roll is involved.
+///
+public interface ITransactionRecorder : ISpanRecorder
+{
+ ///
+ /// The trace this transaction belongs to. Set at creation (via )
+ /// and inherited by every recorded span.
+ ///
+ public SentryId TraceId { get; }
+
+ ///
+ /// The release that produced this transaction. Useful when the origin system differs from this process.
+ ///
+ public string? Release { get; set; }
+
+ ///
+ /// The environment the transaction ran in. Useful when the origin system differs from this process.
+ ///
+ public string? Environment { get; set; }
+
+ ///
+ /// Configures the (clean) scope the recorded transaction is captured against. Use this to set data that
+ /// was captured alongside the original trace — user, tags, contexts, breadcrumbs — without inheriting the
+ /// current process's live scope.
+ ///
+ public void ConfigureScope(Action configureScope);
+}
diff --git a/src/Sentry/Internal/SpanRecorder.cs b/src/Sentry/Internal/SpanRecorder.cs
new file mode 100644
index 0000000000..9da6e1107b
--- /dev/null
+++ b/src/Sentry/Internal/SpanRecorder.cs
@@ -0,0 +1,114 @@
+namespace Sentry.Internal;
+
+///
+/// Base for a node in a recorded transaction tree. Holds the owning
+/// (which new child spans are created on) and the node this recorder
+/// represents (a , or the transaction tracer itself for the root), proxying metadata
+/// straight through to it.
+///
+internal abstract class SpanRecorderBase : ISpanRecorder
+{
+ /// The transaction that owns the whole tree; child spans are created on it.
+ protected TransactionTracer Owner { get; }
+
+ private readonly ISpan _node;
+
+ protected SpanRecorderBase(TransactionTracer owner, ISpan node)
+ {
+ Owner = owner;
+ _node = node;
+ }
+
+ public SpanId SpanId => _node.SpanId;
+
+ public string? Description
+ {
+ get => _node.Description;
+ set => _node.Description = value;
+ }
+
+ public SpanStatus? Status
+ {
+ get => _node.Status;
+ set => _node.Status = value;
+ }
+
+ public void SetTag(string key, string value) => _node.SetTag(key, value);
+
+ public void SetData(string key, object? value) => _node.SetData(key, value);
+
+ public ISpanRecorder RecordSpan(
+ string operation,
+ DateTimeOffset startTimestamp,
+ TimeSpan duration,
+ SpanId? spanId = null,
+ Action? configure = null)
+ {
+ if (duration < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(duration), duration, "Span duration cannot be negative.");
+ }
+
+ // Reuse the existing child-span machinery so the span is added to the transaction's flat span
+ // collection with the correct parent pointer; then override the timing the tracer would otherwise
+ // have measured with a stopwatch.
+ var span = (SpanTracer)Owner.StartChild(spanId, SpanId, operation);
+ span.StartTimestamp = startTimestamp;
+ span.EndTimestamp = startTimestamp + duration;
+ span.Status ??= SpanStatus.Ok;
+
+ var recorder = new SpanRecorder(Owner, span);
+ configure?.Invoke(recorder);
+ return recorder;
+ }
+}
+
+///
+/// backed by a whose timing has been set explicitly.
+///
+internal sealed class SpanRecorder : SpanRecorderBase
+{
+ public SpanRecorder(TransactionTracer owner, SpanTracer span)
+ : base(owner, span)
+ {
+ }
+}
+
+///
+/// backed by a whose timing has been set
+/// explicitly. The tracer is never Finish()-ed (which would reset the live scope); instead the caller
+/// converts it to a and captures it directly.
+///
+internal sealed class TransactionRecorder : SpanRecorderBase, ITransactionRecorder
+{
+ private readonly Scope? _scope;
+
+ public TransactionRecorder(TransactionTracer tracer, Scope? scope)
+ : base(tracer, tracer)
+ {
+ _scope = scope;
+ }
+
+ public SentryId TraceId => Owner.TraceId;
+
+ public string? Release
+ {
+ get => Owner.Release;
+ set => Owner.Release = value;
+ }
+
+ public string? Environment
+ {
+ get => Owner.Environment;
+ set => Owner.Environment = value;
+ }
+
+ public void ConfigureScope(Action configureScope)
+ {
+ // No scope when options are unavailable (e.g. a disabled hub); configuration is a no-op.
+ if (_scope is not null)
+ {
+ configureScope(_scope);
+ }
+ }
+}
diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs
index a6e31c57af..f98f46ecb6 100644
--- a/src/Sentry/SentrySdk.cs
+++ b/src/Sentry/SentrySdk.cs
@@ -703,6 +703,23 @@ public static ITransactionTracer StartTransaction(string name, string operation,
public static ITransactionTracer StartTransaction(string name, string operation, SentryTraceHeader traceHeader)
=> CurrentHub.StartTransaction(name, operation, traceHeader);
+ ///
+ /// Records a transaction that has already completed elsewhere — for example, spans measured on another
+ /// machine or process and replayed through a proxy. See
+ /// .
+ ///
+ [DebuggerStepThrough]
+ public static SentryId RecordTransaction(
+ string name,
+ string operation,
+ DateTimeOffset startTimestamp,
+ TimeSpan duration,
+ SentryId? traceId = null,
+ SpanId? spanId = null,
+ SpanId? parentSpanId = null,
+ Action? configure = null)
+ => CurrentHub.RecordTransaction(name, operation, startTimestamp, duration, traceId, spanId, parentSpanId, configure);
+
///
/// Binds specified exception the specified span.
///
diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt
index 5b0c537022..57a515ea8e 100644
--- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt
+++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt
@@ -151,6 +151,7 @@ namespace Sentry
public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { }
public static void LockScope(this Sentry.IHub hub) { }
public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { }
+ public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { }
@@ -293,6 +294,15 @@ namespace Sentry
Sentry.SentryTraceHeader GetTraceHeader();
void SetMeasurement(string name, Sentry.Protocol.Measurement measurement);
}
+ public interface ISpanRecorder
+ {
+ string? Description { get; set; }
+ Sentry.SpanId SpanId { get; }
+ Sentry.SpanStatus? Status { get; set; }
+ Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null);
+ void SetData(string key, object? value);
+ void SetTag(string key, string value);
+ }
public interface ITransactionContext : Sentry.Protocol.ITraceContext
{
bool? IsParentSampled { get; }
@@ -303,6 +313,13 @@ namespace Sentry
{
string? Platform { get; set; }
}
+ public interface ITransactionRecorder : Sentry.ISpanRecorder
+ {
+ string? Environment { get; set; }
+ string? Release { get; set; }
+ Sentry.SentryId TraceId { get; }
+ void ConfigureScope(System.Action configureScope);
+ }
public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable
{
new bool? IsParentSampled { get; set; }
@@ -986,6 +1003,7 @@ namespace Sentry
public static void PauseSession() { }
public static System.IDisposable PushScope() { }
public static System.IDisposable PushScope(TState state) { }
+ public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static void ResumeSession() { }
public static void SetTag(string key, string value) { }
public static void StartSession() { }
diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt
index 5b0c537022..57a515ea8e 100644
--- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt
+++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt
@@ -151,6 +151,7 @@ namespace Sentry
public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { }
public static void LockScope(this Sentry.IHub hub) { }
public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { }
+ public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { }
@@ -293,6 +294,15 @@ namespace Sentry
Sentry.SentryTraceHeader GetTraceHeader();
void SetMeasurement(string name, Sentry.Protocol.Measurement measurement);
}
+ public interface ISpanRecorder
+ {
+ string? Description { get; set; }
+ Sentry.SpanId SpanId { get; }
+ Sentry.SpanStatus? Status { get; set; }
+ Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null);
+ void SetData(string key, object? value);
+ void SetTag(string key, string value);
+ }
public interface ITransactionContext : Sentry.Protocol.ITraceContext
{
bool? IsParentSampled { get; }
@@ -303,6 +313,13 @@ namespace Sentry
{
string? Platform { get; set; }
}
+ public interface ITransactionRecorder : Sentry.ISpanRecorder
+ {
+ string? Environment { get; set; }
+ string? Release { get; set; }
+ Sentry.SentryId TraceId { get; }
+ void ConfigureScope(System.Action configureScope);
+ }
public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable
{
new bool? IsParentSampled { get; set; }
@@ -986,6 +1003,7 @@ namespace Sentry
public static void PauseSession() { }
public static System.IDisposable PushScope() { }
public static System.IDisposable PushScope(TState state) { }
+ public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static void ResumeSession() { }
public static void SetTag(string key, string value) { }
public static void StartSession() { }
diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
index 5b0c537022..57a515ea8e 100644
--- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
+++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
@@ -151,6 +151,7 @@ namespace Sentry
public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { }
public static void LockScope(this Sentry.IHub hub) { }
public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { }
+ public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { }
@@ -293,6 +294,15 @@ namespace Sentry
Sentry.SentryTraceHeader GetTraceHeader();
void SetMeasurement(string name, Sentry.Protocol.Measurement measurement);
}
+ public interface ISpanRecorder
+ {
+ string? Description { get; set; }
+ Sentry.SpanId SpanId { get; }
+ Sentry.SpanStatus? Status { get; set; }
+ Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null);
+ void SetData(string key, object? value);
+ void SetTag(string key, string value);
+ }
public interface ITransactionContext : Sentry.Protocol.ITraceContext
{
bool? IsParentSampled { get; }
@@ -303,6 +313,13 @@ namespace Sentry
{
string? Platform { get; set; }
}
+ public interface ITransactionRecorder : Sentry.ISpanRecorder
+ {
+ string? Environment { get; set; }
+ string? Release { get; set; }
+ Sentry.SentryId TraceId { get; }
+ void ConfigureScope(System.Action configureScope);
+ }
public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable
{
new bool? IsParentSampled { get; set; }
@@ -986,6 +1003,7 @@ namespace Sentry
public static void PauseSession() { }
public static System.IDisposable PushScope() { }
public static System.IDisposable PushScope(TState state) { }
+ public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static void ResumeSession() { }
public static void SetTag(string key, string value) { }
public static void StartSession() { }
diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
index f9068edea7..ff0e0aa7e2 100644
--- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
+++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
@@ -139,6 +139,7 @@ namespace Sentry
public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action configureScope, Sentry.SentryLevel level = 1) { }
public static void LockScope(this Sentry.IHub hub) { }
public static System.IDisposable PushAndLockScope(this Sentry.IHub hub) { }
+ public static Sentry.SentryId RecordTransaction(this Sentry.IHub hub, string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static Sentry.ISpan StartSpan(this Sentry.IHub hub, string operation, string description) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, Sentry.ITransactionContext context) { }
public static Sentry.ITransactionTracer StartTransaction(this Sentry.IHub hub, string name, string operation) { }
@@ -281,6 +282,15 @@ namespace Sentry
Sentry.SentryTraceHeader GetTraceHeader();
void SetMeasurement(string name, Sentry.Protocol.Measurement measurement);
}
+ public interface ISpanRecorder
+ {
+ string? Description { get; set; }
+ Sentry.SpanId SpanId { get; }
+ Sentry.SpanStatus? Status { get; set; }
+ Sentry.ISpanRecorder RecordSpan(string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SpanId? spanId = default, System.Action? configure = null);
+ void SetData(string key, object? value);
+ void SetTag(string key, string value);
+ }
public interface ITransactionContext : Sentry.Protocol.ITraceContext
{
bool? IsParentSampled { get; }
@@ -291,6 +301,13 @@ namespace Sentry
{
string? Platform { get; set; }
}
+ public interface ITransactionRecorder : Sentry.ISpanRecorder
+ {
+ string? Environment { get; set; }
+ string? Release { get; set; }
+ Sentry.SentryId TraceId { get; }
+ void ConfigureScope(System.Action configureScope);
+ }
public interface ITransactionTracer : Sentry.IEventLike, Sentry.IHasData, Sentry.IHasExtra, Sentry.IHasTags, Sentry.ISpan, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext, System.IDisposable
{
new bool? IsParentSampled { get; set; }
@@ -967,6 +984,7 @@ namespace Sentry
public static void PauseSession() { }
public static System.IDisposable PushScope() { }
public static System.IDisposable PushScope(TState state) { }
+ public static Sentry.SentryId RecordTransaction(string name, string operation, System.DateTimeOffset startTimestamp, System.TimeSpan duration, Sentry.SentryId? traceId = default, Sentry.SpanId? spanId = default, Sentry.SpanId? parentSpanId = default, System.Action? configure = null) { }
public static void ResumeSession() { }
public static void SetTag(string key, string value) { }
public static void StartSession() { }
diff --git a/test/Sentry.Tests/HubExtensionsRecordTests.cs b/test/Sentry.Tests/HubExtensionsRecordTests.cs
new file mode 100644
index 0000000000..cf06c866e8
--- /dev/null
+++ b/test/Sentry.Tests/HubExtensionsRecordTests.cs
@@ -0,0 +1,152 @@
+#nullable enable
+
+namespace Sentry.Tests;
+
+public class HubExtensionsRecordTests
+{
+ private readonly IHub _hub = Substitute.For();
+ private SentryTransaction? _captured;
+
+ public HubExtensionsRecordTests()
+ {
+ // With a substitute hub, GetSentryOptions() returns null, so RecordTransaction falls back to the
+ // single-argument CaptureTransaction overload.
+ _hub.When(h => h.CaptureTransaction(Arg.Any()))
+ .Do(ci => _captured = ci.Arg());
+ _hub.When(h => h.CaptureTransaction(Arg.Any(), Arg.Any(), Arg.Any()))
+ .Do(ci => _captured = ci.Arg());
+ }
+
+ [Fact]
+ public void RecordTransaction_SetsNameOperationAndTiming()
+ {
+ var start = new DateTimeOffset(2023, 09, 28, 10, 00, 00, TimeSpan.Zero);
+ var duration = TimeSpan.FromSeconds(5);
+
+ var eventId = _hub.RecordTransaction("my-transaction", "my-op", start, duration);
+
+ var transaction = Assert.IsType(_captured);
+ Assert.Equal(eventId, transaction.EventId);
+ Assert.Equal("my-transaction", transaction.Name);
+ Assert.Equal("my-op", transaction.Operation);
+ Assert.Equal(start, transaction.StartTimestamp);
+ Assert.Equal(start + duration, transaction.EndTimestamp);
+ Assert.True(transaction.IsFinished);
+ Assert.True(transaction.IsSampled);
+ Assert.Equal(SpanStatus.Ok, transaction.Status);
+ }
+
+ [Fact]
+ public void RecordTransaction_PreservesTraceAndSpanIds()
+ {
+ var traceId = SentryId.Create();
+ var spanId = SpanId.Create();
+ var parentSpanId = SpanId.Create();
+
+ _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1),
+ traceId: traceId, spanId: spanId, parentSpanId: parentSpanId);
+
+ var transaction = Assert.IsType(_captured);
+ Assert.Equal(traceId, transaction.TraceId);
+ Assert.Equal(spanId, transaction.SpanId);
+ Assert.Equal(parentSpanId, transaction.ParentSpanId);
+ }
+
+ [Fact]
+ public void RecordTransaction_AppliesMetadata()
+ {
+ _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1), configure: tx =>
+ {
+ tx.Release = "1.2.3";
+ tx.Environment = "staging";
+ tx.Description = "recorded elsewhere";
+ tx.Status = SpanStatus.InternalError;
+ tx.SetTag("origin", "proxy");
+ tx.SetData("count", 42);
+ });
+
+ var transaction = Assert.IsType(_captured);
+ Assert.Equal("1.2.3", transaction.Release);
+ Assert.Equal("staging", transaction.Environment);
+ Assert.Equal("recorded elsewhere", transaction.Description);
+ Assert.Equal(SpanStatus.InternalError, transaction.Status);
+ Assert.Equal("proxy", transaction.Tags["origin"]);
+ Assert.Equal(42, transaction.Data["count"]);
+ }
+
+ [Fact]
+ public void RecordTransaction_RecordsNestedSpanTree()
+ {
+ var start = new DateTimeOffset(2023, 09, 28, 10, 00, 00, TimeSpan.Zero);
+ var traceId = SentryId.Create();
+ var rootSpanId = SpanId.Create();
+ var childSpanId = SpanId.Create();
+ var grandchildSpanId = SpanId.Create();
+
+ _hub.RecordTransaction("t", "root-op", start, TimeSpan.FromSeconds(10),
+ traceId: traceId, spanId: rootSpanId, configure: tx =>
+ {
+ tx.RecordSpan("child-op", start.AddSeconds(1), TimeSpan.FromSeconds(2), spanId: childSpanId, configure: child =>
+ {
+ child.Description = "child";
+ child.RecordSpan("grandchild-op", start.AddSeconds(1.5), TimeSpan.FromSeconds(0.5), spanId: grandchildSpanId);
+ });
+ });
+
+ var transaction = Assert.IsType(_captured);
+ Assert.Equal(2, transaction.Spans.Count);
+
+ var child = transaction.Spans.Single(s => s.Operation == "child-op");
+ Assert.Equal(childSpanId, child.SpanId);
+ Assert.Equal(rootSpanId, child.ParentSpanId); // structural parent = transaction root
+ Assert.Equal(traceId, child.TraceId); // trace id inherited
+ Assert.Equal("child", child.Description);
+ Assert.Equal(start.AddSeconds(1), child.StartTimestamp);
+ Assert.Equal(start.AddSeconds(3), child.EndTimestamp);
+
+ var grandchild = transaction.Spans.Single(s => s.Operation == "grandchild-op");
+ Assert.Equal(grandchildSpanId, grandchild.SpanId);
+ Assert.Equal(childSpanId, grandchild.ParentSpanId); // nested under child
+ Assert.Equal(traceId, grandchild.TraceId);
+ }
+
+ [Fact]
+ public void RecordTransaction_ConfigureScope_AppliesToCaptureScope()
+ {
+ // With options available, RecordTransaction captures against a fresh scope (via the 3-arg overload)
+ // that ConfigureScope can mutate — rather than the current live scope.
+ var previous = SentryClientExtensions.SentryOptionsForTestingOnly;
+ SentryClientExtensions.SentryOptionsForTestingOnly = new SentryOptions();
+ try
+ {
+ Scope? capturedScope = null;
+ _hub.When(h => h.CaptureTransaction(Arg.Any(), Arg.Any(), Arg.Any()))
+ .Do(ci => capturedScope = ci.Arg());
+
+ _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1), configure: tx =>
+ tx.ConfigureScope(s => s.SetTag("origin", "proxy")));
+
+ Assert.NotNull(capturedScope);
+ Assert.Equal("proxy", capturedScope!.Tags["origin"]);
+ }
+ finally
+ {
+ SentryClientExtensions.SentryOptionsForTestingOnly = previous;
+ }
+ }
+
+ [Fact]
+ public void RecordTransaction_NegativeDuration_Throws()
+ {
+ Assert.Throws(() =>
+ _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(-1)));
+ }
+
+ [Fact]
+ public void RecordSpan_NegativeDuration_Throws()
+ {
+ Assert.Throws(() =>
+ _hub.RecordTransaction("t", "op", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(1), configure: tx =>
+ tx.RecordSpan("child", DateTimeOffset.UtcNow, TimeSpan.FromSeconds(-1))));
+ }
+}