Skip to content

Commit ec2e9b6

Browse files
add span links (#229)
* add span links * address pr comments --------- Co-authored-by: Nikhil Chitlur Navakiran (from Dev Box) <nikhilc@microsoft.com>
1 parent c4db584 commit ec2e9b6

File tree

7 files changed

+180
-10
lines changed

7 files changed

+180
-10
lines changed

src/Observability/Runtime/Tracing/Contracts/SpanDetails.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,20 @@ public sealed class SpanDetails
2020
/// to obtain an <see cref="ActivityContext"/> from HTTP headers containing a W3C traceparent.</param>
2121
/// <param name="startTime">Optional explicit start time as a <see cref="DateTimeOffset"/>.</param>
2222
/// <param name="endTime">Optional explicit end time as a <see cref="DateTimeOffset"/>.</param>
23+
/// <param name="spanLinks">Optional span links to associate with this span, establishing causal
24+
/// relationships to other spans (e.g. linking a batch operation to individual trigger spans).</param>
2325
public SpanDetails(
2426
ActivityKind? spanKind = null,
2527
ActivityContext? parentContext = null,
2628
DateTimeOffset? startTime = null,
27-
DateTimeOffset? endTime = null)
29+
DateTimeOffset? endTime = null,
30+
ActivityLink[]? spanLinks = null)
2831
{
2932
SpanKind = spanKind;
3033
ParentContext = parentContext;
3134
StartTime = startTime;
3235
EndTime = endTime;
36+
SpanLinks = spanLinks;
3337
}
3438

3539
/// <summary>
@@ -51,5 +55,10 @@ public SpanDetails(
5155
/// Gets the optional explicit end time.
5256
/// </summary>
5357
public DateTimeOffset? EndTime { get; }
58+
59+
/// <summary>
60+
/// Gets the optional span links to associate with this span.
61+
/// </summary>
62+
public ActivityLink[]? SpanLinks { get; }
5463
}
5564
}

src/Observability/Runtime/Tracing/Scopes/ExecuteToolScope.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public sealed class ExecuteToolScope : OpenTelemetryScope
2727
/// <param name="details">Details of the tool call (name, args, type, call ID, description, endpoint).</param>
2828
/// <param name="agentDetails">Information about the agent executing the tool (service, version, identifiers).</param>
2929
/// <param name="userDetails">Optional human user details.</param>
30-
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind).</param>
30+
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind, span links).</param>
3131
/// <param name="threatDiagnosticsSummary">Optional threat diagnostics summary containing security-related information about blocked actions.</param>
3232
/// <returns>A new ExecuteToolScope instance.</returns>
3333
/// <remarks>
@@ -49,7 +49,7 @@ private ExecuteToolScope(Request request, ToolCallDetails details, AgentDetails
4949
operationName: OperationName,
5050
activityName: $"{OperationName} {details.ToolName}",
5151
agentDetails: agentDetails,
52-
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Internal, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime),
52+
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Internal, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime, spanDetails?.SpanLinks),
5353
userDetails: userDetails)
5454
{
5555
var (toolName, arguments, toolCallId, description, toolType, endpoint, toolServerName) = details;

src/Observability/Runtime/Tracing/Scopes/InferenceScope.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public sealed class InferenceScope : OpenTelemetryScope
2323
/// <param name="details">Details of the inference call (operation name, model, provider, token usage, finish reasons, response ID).</param>
2424
/// <param name="agentDetails">Information about the agent executing the inference (service, version, identifiers).</param>
2525
/// <param name="userDetails">Optional human user details.</param>
26-
/// <param name="spanDetails">Optional span configuration (parent context, timing).</param>
26+
/// <param name="spanDetails">Optional span configuration (parent context, timing, span links).</param>
2727
/// <returns>A new InferenceScope instance.</returns>
2828
/// <remarks>
2929
/// <para>

src/Observability/Runtime/Tracing/Scopes/InvokeAgentScope.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public sealed class InvokeAgentScope : OpenTelemetryScope
2929
/// <param name="scopeDetails">Scope-level configuration (endpoint).</param>
3030
/// <param name="agentDetails">The details of the agent being invoked.</param>
3131
/// <param name="callerDetails">Optional composite caller details (human user and/or calling agent for A2A scenarios).</param>
32-
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind).</param>
32+
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind, span links).</param>
3333
/// <param name="threatDiagnosticsSummary">Optional threat diagnostics summary containing security-related information about blocked actions.</param>
3434
/// <returns>A new InvokeAgentScope instance.</returns>
3535
/// <remarks>
@@ -66,7 +66,7 @@ private InvokeAgentScope(
6666
? OperationName
6767
: $"invoke_agent {agentDetails!.AgentName}",
6868
agentDetails: agentDetails!,
69-
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Client, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime),
69+
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Client, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime, spanDetails?.SpanLinks),
7070
userDetails: callerDetails?.UserDetails)
7171
{
7272
SetTagMaybe(OpenTelemetryConstants.SessionIdKey, request?.SessionId);

src/Observability/Runtime/Tracing/Scopes/OpenTelemetryScope.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public abstract class OpenTelemetryScope : IDisposable
4141
/// <param name="activityName">The name of the activity for display purposes.</param>
4242
/// <param name="agentDetails">Optional agent details. Tenant ID is read from <see cref="AgentDetails.TenantId"/>.</param>
4343
/// <param name="spanDetails">Optional span configuration including parent context, start/end times,
44-
/// and span kind. Subclasses may override <see cref="SpanDetails.SpanKind"/> before calling this constructor;
44+
/// span kind, and span links. Subclasses may override <see cref="SpanDetails.SpanKind"/> before calling this constructor;
4545
/// defaults to <see cref="ActivityKind.Client"/>.</param>
4646
/// <param name="userDetails">Optional human caller identity details (id, email, name, client IP).</param>
4747
protected OpenTelemetryScope(string operationName, string activityName, AgentDetails agentDetails, SpanDetails? spanDetails = null, UserDetails? userDetails = null)
@@ -50,12 +50,13 @@ protected OpenTelemetryScope(string operationName, string activityName, AgentDet
5050
var parentContext = spanDetails?.ParentContext;
5151
var startTime = spanDetails?.StartTime;
5252
var endTime = spanDetails?.EndTime;
53+
var spanLinks = spanDetails?.SpanLinks;
5354

5455
customStartTime = startTime;
5556
customEndTime = endTime;
5657
activity = parentContext.HasValue && parentContext.Value.TraceId != default
57-
? ActivitySource.CreateActivity(activityName, kind, parentContext.Value)
58-
: ActivitySource.CreateActivity(activityName, kind, default(ActivityContext));
58+
? ActivitySource.CreateActivity(activityName, kind, parentContext.Value, links: spanLinks)
59+
: ActivitySource.CreateActivity(activityName, kind, default(ActivityContext), links: spanLinks);
5960

6061
if (startTime != null)
6162
{

src/Observability/Runtime/Tracing/Scopes/OutputScope.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public sealed class OutputScope : OpenTelemetryScope
2727
/// <param name="response">Response containing output messages.</param>
2828
/// <param name="agentDetails">Information about the agent producing the output.</param>
2929
/// <param name="userDetails">Optional human user details.</param>
30-
/// <param name="spanDetails">Optional span configuration (parent context, timing).</param>
30+
/// <param name="spanDetails">Optional span configuration (parent context, timing, span links).</param>
3131
/// <returns>A new OutputScope instance.</returns>
3232
public static OutputScope Start(Request request, Response response, AgentDetails agentDetails, UserDetails? userDetails = null, SpanDetails? spanDetails = null)
3333
=> new OutputScope(request, response, agentDetails, userDetails, spanDetails);
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.Agents.A365.Observability.Tests.Tracing.Scopes;
5+
6+
using System.Diagnostics;
7+
using FluentAssertions;
8+
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts;
9+
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes;
10+
11+
[TestClass]
12+
public sealed class SpanLinksTest : ActivityTest
13+
{
14+
private static readonly ActivityLink[] SampleLinks = new[]
15+
{
16+
new ActivityLink(
17+
new ActivityContext(
18+
ActivityTraceId.CreateFromString("0aa4621e5ae09963a3de354f3d18aa65"),
19+
ActivitySpanId.CreateFromString("c1aaa519600b1bf0"),
20+
ActivityTraceFlags.Recorded)),
21+
new ActivityLink(
22+
new ActivityContext(
23+
ActivityTraceId.CreateFromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
24+
ActivitySpanId.CreateFromString("aaaaaaaaaaaaaaaa"),
25+
ActivityTraceFlags.None),
26+
new ActivityTagsCollection(new[] { new KeyValuePair<string, object?>("link.reason", "retry") })),
27+
};
28+
29+
[TestMethod]
30+
public void ExecuteToolScope_RecordsSpanLinks_WithFullContextAndAttributes()
31+
{
32+
var activity = ListenForActivity(() =>
33+
{
34+
using var scope = ExecuteToolScope.Start(
35+
Util.GetDefaultRequest(),
36+
new ToolCallDetails("my-tool", "args"),
37+
Util.GetAgentDetails(),
38+
spanDetails: new SpanDetails(spanLinks: SampleLinks));
39+
});
40+
41+
var links = activity.Links.ToList();
42+
links.Should().HaveCount(2);
43+
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
44+
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
45+
links[0].Context.TraceFlags.Should().Be(ActivityTraceFlags.Recorded);
46+
links[1].Context.TraceId.ToHexString().Should().Be("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
47+
links[1].Context.SpanId.ToHexString().Should().Be("aaaaaaaaaaaaaaaa");
48+
links[1].Tags.Should().Contain(new KeyValuePair<string, object?>("link.reason", "retry"));
49+
}
50+
51+
[TestMethod]
52+
public void ExecuteToolScope_HasEmptyLinks_WhenSpanLinksOmitted()
53+
{
54+
var activity = ListenForActivity(() =>
55+
{
56+
using var scope = ExecuteToolScope.Start(
57+
Util.GetDefaultRequest(),
58+
new ToolCallDetails("my-tool", "args"),
59+
Util.GetAgentDetails());
60+
});
61+
62+
activity.Links.Should().BeEmpty();
63+
}
64+
65+
[TestMethod]
66+
public void InvokeAgentScope_ForwardsSpanLinks()
67+
{
68+
var activity = ListenForActivity(() =>
69+
{
70+
using var scope = InvokeAgentScope.Start(
71+
Util.GetDefaultRequest(),
72+
ScopeDetails,
73+
TestAgentDetails,
74+
spanDetails: new SpanDetails(spanLinks: SampleLinks));
75+
});
76+
77+
var links = activity.Links.ToList();
78+
links.Should().HaveCount(2);
79+
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
80+
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
81+
links[0].Context.TraceFlags.Should().Be(ActivityTraceFlags.Recorded);
82+
links[1].Context.TraceId.ToHexString().Should().Be("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
83+
links[1].Context.SpanId.ToHexString().Should().Be("aaaaaaaaaaaaaaaa");
84+
links[1].Context.TraceFlags.Should().Be(ActivityTraceFlags.None);
85+
links[1].Tags.Should().Contain(new KeyValuePair<string, object?>("link.reason", "retry"));
86+
}
87+
88+
[TestMethod]
89+
public void InferenceScope_ForwardsSpanLinks()
90+
{
91+
var details = new InferenceCallDetails(
92+
InferenceOperationType.Chat, "gpt-4", "openai");
93+
94+
var activity = ListenForActivity(() =>
95+
{
96+
using var scope = InferenceScope.Start(
97+
Util.GetDefaultRequest(),
98+
details,
99+
Util.GetAgentDetails(),
100+
spanDetails: new SpanDetails(spanLinks: SampleLinks));
101+
});
102+
103+
var links = activity.Links.ToList();
104+
links.Should().HaveCount(2);
105+
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
106+
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
107+
}
108+
109+
[TestMethod]
110+
public void OutputScope_ForwardsSpanLinks()
111+
{
112+
var response = new Response(new[] { "hello" });
113+
114+
var activity = ListenForActivity(() =>
115+
{
116+
using var scope = OutputScope.Start(
117+
Util.GetDefaultRequest(),
118+
response,
119+
Util.GetAgentDetails(),
120+
spanDetails: new SpanDetails(spanLinks: SampleLinks));
121+
});
122+
123+
var links = activity.Links.ToList();
124+
links.Should().HaveCount(2);
125+
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
126+
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
127+
}
128+
129+
[TestMethod]
130+
public void SpanLinks_PreservesTypedAttributes()
131+
{
132+
var linksWithAttrs = new[]
133+
{
134+
new ActivityLink(
135+
new ActivityContext(
136+
ActivityTraceId.CreateFromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
137+
ActivitySpanId.CreateFromString("bbbbbbbbbbbbbbbb"),
138+
ActivityTraceFlags.Recorded),
139+
new ActivityTagsCollection(new[]
140+
{
141+
new KeyValuePair<string, object?>("link.type", "causal"),
142+
new KeyValuePair<string, object?>("link.index", 0),
143+
})),
144+
};
145+
146+
var activity = ListenForActivity(() =>
147+
{
148+
using var scope = InvokeAgentScope.Start(
149+
Util.GetDefaultRequest(),
150+
ScopeDetails,
151+
new AgentDetails("attr-agent"),
152+
spanDetails: new SpanDetails(spanLinks: linksWithAttrs));
153+
});
154+
155+
var links = activity.Links.ToList();
156+
links.Should().HaveCount(1);
157+
links[0].Tags.Should().Contain(new KeyValuePair<string, object?>("link.type", "causal"));
158+
links[0].Tags.Should().Contain(new KeyValuePair<string, object?>("link.index", 0));
159+
}
160+
}

0 commit comments

Comments
 (0)