Skip to content
Open
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
75 changes: 75 additions & 0 deletions src/Sentry/HubExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/// <summary>
/// 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 <see cref="StartTransaction(IHub, string, string)"/> there is no stopwatch, idle timer,
/// or sampling decision involved; the transaction is materialized and captured once when
/// <paramref name="configure"/> returns.
/// </summary>
/// <param name="hub">The hub.</param>
/// <param name="name">The transaction name.</param>
/// <param name="operation">The transaction operation.</param>
/// <param name="startTimestamp">When the transaction started.</param>
/// <param name="duration">How long the transaction ran. Must not be negative.</param>
/// <param name="traceId">Optional trace id to preserve from the originating system; generated when omitted.</param>
/// <param name="spanId">Optional root span id to preserve; generated when omitted.</param>
/// <param name="parentSpanId">Optional parent span id, when continuing a trace from another service.</param>
/// <param name="configure">
/// Optional callback to set metadata, configure the capture scope, and record the span tree via
/// <see cref="ISpanRecorder.RecordSpan(string, DateTimeOffset, TimeSpan, SpanId?, Action{ISpanRecorder}?)"/>.
/// </param>
/// <returns>The event id of the captured transaction.</returns>
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<ITransactionRecorder>? 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;
}

/// <summary>
/// Adds a breadcrumb to the current scope.
/// </summary>
Expand Down
87 changes: 87 additions & 0 deletions src/Sentry/ISpanRecorder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace Sentry;

/// <summary>
/// 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 <see cref="ISpan"/>, timing is supplied
/// up-front rather than measured live, so a recorded span can never be left half-specified.
/// </summary>
/// <remarks>
/// Obtained from <see cref="ITransactionRecorder"/> or a parent <see cref="ISpanRecorder"/> via
/// <see cref="RecordSpan(string, DateTimeOffset, TimeSpan, SpanId?, Action{ISpanRecorder}?)"/>.
/// See <see cref="HubExtensions.RecordTransaction"/>.
/// </remarks>
public interface ISpanRecorder
{
/// <summary>
/// The span's id. When not overridden at creation, one is generated.
/// </summary>
public SpanId SpanId { get; }

/// <summary>
/// Span description.
/// </summary>
public string? Description { get; set; }

/// <summary>
/// Span status. Defaults to <see cref="SpanStatus.Ok"/> when not set.
/// </summary>
public SpanStatus? Status { get; set; }

/// <summary>
/// Sets a tag on the span.
/// </summary>
public void SetTag(string key, string value);

/// <summary>
/// Sets arbitrary data on the span.
/// </summary>
public void SetData(string key, object? value);

/// <summary>
/// Records a child span nested under this one. The parent is structural (this span), so no parent id
/// needs to be supplied. Pass <paramref name="spanId"/> to preserve an id from the originating system.
/// </summary>
/// <param name="operation">The span operation.</param>
/// <param name="startTimestamp">When the child span started.</param>
/// <param name="duration">How long the child span ran. Must not be negative.</param>
/// <param name="spanId">Optional span id to preserve; generated when omitted.</param>
/// <param name="configure">Optional callback to set metadata and record further nested spans.</param>
/// <returns>The recorder for the child span.</returns>
public ISpanRecorder RecordSpan(
string operation,
DateTimeOffset startTimestamp,
TimeSpan duration,
SpanId? spanId = null,
Action<ISpanRecorder>? configure = null);
}

/// <summary>
/// Builds a transaction (and its span tree) that has already completed elsewhere. Obtained inside the
/// <c>configure</c> callback of <see cref="HubExtensions.RecordTransaction"/>. When the callback returns, the
/// whole tree is materialized and captured once — no live tracing, stopwatch, or sampling roll is involved.
/// </summary>
public interface ITransactionRecorder : ISpanRecorder
Comment thread
jamescrosswell marked this conversation as resolved.
{
/// <summary>
/// The trace this transaction belongs to. Set at creation (via <see cref="HubExtensions.RecordTransaction"/>)
/// and inherited by every recorded span.
/// </summary>
public SentryId TraceId { get; }

/// <summary>
/// The release that produced this transaction. Useful when the origin system differs from this process.
/// </summary>
public string? Release { get; set; }

/// <summary>
/// The environment the transaction ran in. Useful when the origin system differs from this process.
/// </summary>
public string? Environment { get; set; }

/// <summary>
/// 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.
/// </summary>
public void ConfigureScope(Action<Scope> configureScope);
}
114 changes: 114 additions & 0 deletions src/Sentry/Internal/SpanRecorder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
namespace Sentry.Internal;

/// <summary>
/// Base <see cref="ISpanRecorder"/> for a node in a recorded transaction tree. Holds the owning
/// <see cref="TransactionTracer"/> (which new child spans are created on) and the node this recorder
/// represents (a <see cref="SpanTracer"/>, or the transaction tracer itself for the root), proxying metadata
/// straight through to it.
/// </summary>
internal abstract class SpanRecorderBase : ISpanRecorder
{
/// <summary>The transaction that owns the whole tree; child spans are created on it.</summary>
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<ISpanRecorder>? 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;
}
}

/// <summary>
/// <see cref="ISpanRecorder"/> backed by a <see cref="SpanTracer"/> whose timing has been set explicitly.
/// </summary>
internal sealed class SpanRecorder : SpanRecorderBase

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Arguably we don't need SpanRecorderBase (TransactionRecorder could descend from `SpanRecorder and we just fold all the stuff from the Base class into here). That would give us one less class.

Probably the main reason to do this is so that we can add the sealed keyword to SpanRecorder. Since the cost of doing this is minimal, I've left the base class in for the time being (unless anyone has really strong opinions about this).

{
public SpanRecorder(TransactionTracer owner, SpanTracer span)
: base(owner, span)
{
}
}

/// <summary>
/// <see cref="ITransactionRecorder"/> backed by a <see cref="TransactionTracer"/> whose timing has been set
/// explicitly. The tracer is never <c>Finish()</c>-ed (which would reset the live scope); instead the caller
/// converts it to a <see cref="SentryTransaction"/> and captures it directly.
/// </summary>
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<Scope> configureScope)
{
// No scope when options are unavailable (e.g. a disabled hub); configuration is a no-op.
if (_scope is not null)
{
configureScope(_scope);
}
}
}
17 changes: 17 additions & 0 deletions src/Sentry/SentrySdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Records a transaction that has already completed elsewhere — for example, spans measured on another
/// machine or process and replayed through a proxy. See
/// <see cref="HubExtensions.RecordTransaction(IHub, string, string, DateTimeOffset, TimeSpan, SentryId?, SpanId?, SpanId?, Action{ITransactionRecorder}?)"/>.
/// </summary>
[DebuggerStepThrough]
public static SentryId RecordTransaction(
string name,
string operation,
DateTimeOffset startTimestamp,
TimeSpan duration,
SentryId? traceId = null,
SpanId? spanId = null,
SpanId? parentSpanId = null,
Action<ITransactionRecorder>? configure = null)
=> CurrentHub.RecordTransaction(name, operation, startTimestamp, duration, traceId, spanId, parentSpanId, configure);

/// <summary>
/// Binds specified exception the specified span.
/// </summary>
Expand Down
18 changes: 18 additions & 0 deletions test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ namespace Sentry
public static Sentry.SentryId CaptureMessage(this Sentry.IHub hub, string message, System.Action<Sentry.Scope> 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<Sentry.ITransactionRecorder>? 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) { }
Expand Down Expand Up @@ -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<Sentry.ISpanRecorder>? configure = null);
void SetData(string key, object? value);
void SetTag(string key, string value);
}
public interface ITransactionContext : Sentry.Protocol.ITraceContext
{
bool? IsParentSampled { get; }
Expand All @@ -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<Sentry.Scope> 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; }
Expand Down Expand Up @@ -986,6 +1003,7 @@ namespace Sentry
public static void PauseSession() { }
public static System.IDisposable PushScope() { }
public static System.IDisposable PushScope<TState>(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<Sentry.ITransactionRecorder>? configure = null) { }
public static void ResumeSession() { }
public static void SetTag(string key, string value) { }
public static void StartSession() { }
Expand Down
Loading
Loading