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)))); + } +}