Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
12 changes: 11 additions & 1 deletion src/Observability/Runtime/Tracing/Contracts/SpanDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts
Expand All @@ -20,16 +21,20 @@ public sealed class SpanDetails
/// to obtain an <see cref="ActivityContext"/> from HTTP headers containing a W3C traceparent.</param>
/// <param name="startTime">Optional explicit start time as a <see cref="DateTimeOffset"/>.</param>
/// <param name="endTime">Optional explicit end time as a <see cref="DateTimeOffset"/>.</param>
/// <param name="spanLinks">Optional span links to associate with this span, establishing causal
/// relationships to other spans (e.g. linking a batch operation to individual trigger spans).</param>
public SpanDetails(
ActivityKind? spanKind = null,
ActivityContext? parentContext = null,
DateTimeOffset? startTime = null,
DateTimeOffset? endTime = null)
DateTimeOffset? endTime = null,
ActivityLink[]? spanLinks = null)
{
SpanKind = spanKind;
ParentContext = parentContext;
StartTime = startTime;
EndTime = endTime;
SpanLinks = spanLinks;
}

/// <summary>
Expand All @@ -51,5 +56,10 @@ public SpanDetails(
/// Gets the optional explicit end time.
/// </summary>
public DateTimeOffset? EndTime { get; }

/// <summary>
/// Gets the optional span links to associate with this span.
/// </summary>
public ActivityLink[]? SpanLinks { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public sealed class ExecuteToolScope : OpenTelemetryScope
/// <param name="details">Details of the tool call (name, args, type, call ID, description, endpoint).</param>
/// <param name="agentDetails">Information about the agent executing the tool (service, version, identifiers).</param>
/// <param name="userDetails">Optional human user details.</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind).</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind, span links).</param>
/// <param name="threatDiagnosticsSummary">Optional threat diagnostics summary containing security-related information about blocked actions.</param>
/// <returns>A new ExecuteToolScope instance.</returns>
/// <remarks>
Expand All @@ -49,7 +49,7 @@ private ExecuteToolScope(Request request, ToolCallDetails details, AgentDetails
operationName: OperationName,
activityName: $"{OperationName} {details.ToolName}",
agentDetails: agentDetails,
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Internal, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime),
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Internal, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime, spanDetails?.SpanLinks),
userDetails: userDetails)
{
var (toolName, arguments, toolCallId, description, toolType, endpoint, toolServerName) = details;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public sealed class InferenceScope : OpenTelemetryScope
/// <param name="details">Details of the inference call (operation name, model, provider, token usage, finish reasons, response ID).</param>
/// <param name="agentDetails">Information about the agent executing the inference (service, version, identifiers).</param>
/// <param name="userDetails">Optional human user details.</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing).</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing, span links).</param>
/// <returns>A new InferenceScope instance.</returns>
/// <remarks>
/// <para>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public sealed class InvokeAgentScope : OpenTelemetryScope
/// <param name="scopeDetails">Scope-level configuration (endpoint).</param>
/// <param name="agentDetails">The details of the agent being invoked.</param>
/// <param name="callerDetails">Optional composite caller details (human user and/or calling agent for A2A scenarios).</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind).</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing, kind, span links).</param>
/// <param name="threatDiagnosticsSummary">Optional threat diagnostics summary containing security-related information about blocked actions.</param>
/// <returns>A new InvokeAgentScope instance.</returns>
/// <remarks>
Expand Down Expand Up @@ -66,7 +66,7 @@ private InvokeAgentScope(
? OperationName
: $"invoke_agent {agentDetails!.AgentName}",
agentDetails: agentDetails!,
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Client, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime),
spanDetails: new SpanDetails(spanDetails?.SpanKind ?? ActivityKind.Client, spanDetails?.ParentContext, spanDetails?.StartTime, spanDetails?.EndTime, spanDetails?.SpanLinks),
userDetails: callerDetails?.UserDetails)
{
SetTagMaybe(OpenTelemetryConstants.SessionIdKey, request?.SessionId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public abstract class OpenTelemetryScope : IDisposable
/// <param name="activityName">The name of the activity for display purposes.</param>
/// <param name="agentDetails">Optional agent details. Tenant ID is read from <see cref="AgentDetails.TenantId"/>.</param>
/// <param name="spanDetails">Optional span configuration including parent context, start/end times,
/// and span kind. Subclasses may override <see cref="SpanDetails.SpanKind"/> before calling this constructor;
/// span kind, and span links. Subclasses may override <see cref="SpanDetails.SpanKind"/> before calling this constructor;
/// defaults to <see cref="ActivityKind.Client"/>.</param>
/// <param name="userDetails">Optional human caller identity details (id, email, name, client IP).</param>
protected OpenTelemetryScope(string operationName, string activityName, AgentDetails agentDetails, SpanDetails? spanDetails = null, UserDetails? userDetails = null)
Expand All @@ -50,12 +50,13 @@ protected OpenTelemetryScope(string operationName, string activityName, AgentDet
var parentContext = spanDetails?.ParentContext;
var startTime = spanDetails?.StartTime;
var endTime = spanDetails?.EndTime;
var spanLinks = spanDetails?.SpanLinks;

customStartTime = startTime;
customEndTime = endTime;
activity = parentContext.HasValue && parentContext.Value.TraceId != default
? ActivitySource.CreateActivity(activityName, kind, parentContext.Value)
: ActivitySource.CreateActivity(activityName, kind, default(ActivityContext));
? ActivitySource.CreateActivity(activityName, kind, parentContext.Value, links: spanLinks)
: ActivitySource.CreateActivity(activityName, kind, default(ActivityContext), links: spanLinks);

if (startTime != null)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Observability/Runtime/Tracing/Scopes/OutputScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public sealed class OutputScope : OpenTelemetryScope
/// <param name="response">Response containing output messages.</param>
/// <param name="agentDetails">Information about the agent producing the output.</param>
/// <param name="userDetails">Optional human user details.</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing).</param>
/// <param name="spanDetails">Optional span configuration (parent context, timing, span links).</param>
/// <returns>A new OutputScope instance.</returns>
public static OutputScope Start(Request request, Response response, AgentDetails agentDetails, UserDetails? userDetails = null, SpanDetails? spanDetails = null)
=> new OutputScope(request, response, agentDetails, userDetails, spanDetails);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.Agents.A365.Observability.Tests.Tracing.Scopes;

using System.Diagnostics;
using FluentAssertions;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes;

[TestClass]
public sealed class SpanLinksTest : ActivityTest
{
private static readonly ActivityLink[] SampleLinks = new[]
{
new ActivityLink(
new ActivityContext(
ActivityTraceId.CreateFromString("0aa4621e5ae09963a3de354f3d18aa65"),
ActivitySpanId.CreateFromString("c1aaa519600b1bf0"),
ActivityTraceFlags.Recorded)),
new ActivityLink(
new ActivityContext(
ActivityTraceId.CreateFromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
ActivitySpanId.CreateFromString("aaaaaaaaaaaaaaaa"),
ActivityTraceFlags.None),
new ActivityTagsCollection(new[] { new KeyValuePair<string, object?>("link.reason", "retry") })),
};

[TestMethod]
public void ExecuteToolScope_RecordsSpanLinks_WithFullContextAndAttributes()
{
var activity = ListenForActivity(() =>
{
using var scope = ExecuteToolScope.Start(
Util.GetDefaultRequest(),
new ToolCallDetails("my-tool", "args"),
Util.GetAgentDetails(),
spanDetails: new SpanDetails(spanLinks: SampleLinks));
});

var links = activity.Links.ToList();
links.Should().HaveCount(2);
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
links[0].Context.TraceFlags.Should().Be(ActivityTraceFlags.Recorded);
links[1].Context.TraceId.ToHexString().Should().Be("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
links[1].Context.SpanId.ToHexString().Should().Be("aaaaaaaaaaaaaaaa");
links[1].Tags.Should().Contain(new KeyValuePair<string, object?>("link.reason", "retry"));
}

[TestMethod]
public void ExecuteToolScope_HasEmptyLinks_WhenSpanLinksOmitted()
{
var activity = ListenForActivity(() =>
{
using var scope = ExecuteToolScope.Start(
Util.GetDefaultRequest(),
new ToolCallDetails("my-tool", "args"),
Util.GetAgentDetails());
});

activity.Links.Should().BeEmpty();
}

[TestMethod]
public void InvokeAgentScope_ForwardsSpanLinks()
{
var activity = ListenForActivity(() =>
{
using var scope = InvokeAgentScope.Start(
Util.GetDefaultRequest(),
ScopeDetails,
TestAgentDetails,
spanDetails: new SpanDetails(spanLinks: SampleLinks));
});

var links = activity.Links.ToList();
links.Should().HaveCount(2);
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
links[0].Context.TraceFlags.Should().Be(ActivityTraceFlags.Recorded);
links[1].Context.TraceId.ToHexString().Should().Be("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
links[1].Context.SpanId.ToHexString().Should().Be("aaaaaaaaaaaaaaaa");
links[1].Context.TraceFlags.Should().Be(ActivityTraceFlags.None);
links[1].Tags.Should().Contain(new KeyValuePair<string, object?>("link.reason", "retry"));
}

[TestMethod]
public void InferenceScope_ForwardsSpanLinks()
{
var details = new InferenceCallDetails(
InferenceOperationType.Chat, "gpt-4", "openai");

var activity = ListenForActivity(() =>
{
using var scope = InferenceScope.Start(
Util.GetDefaultRequest(),
details,
Util.GetAgentDetails(),
spanDetails: new SpanDetails(spanLinks: SampleLinks));
});

var links = activity.Links.ToList();
links.Should().HaveCount(2);
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
}

[TestMethod]
public void OutputScope_ForwardsSpanLinks()
{
var response = new Response(new[] { "hello" });

var activity = ListenForActivity(() =>
{
using var scope = OutputScope.Start(
Util.GetDefaultRequest(),
response,
Util.GetAgentDetails(),
spanDetails: new SpanDetails(spanLinks: SampleLinks));
});

var links = activity.Links.ToList();
links.Should().HaveCount(2);
links[0].Context.TraceId.ToHexString().Should().Be("0aa4621e5ae09963a3de354f3d18aa65");
links[0].Context.SpanId.ToHexString().Should().Be("c1aaa519600b1bf0");
}

[TestMethod]
public void SpanLinks_PreservesTypedAttributes()
{
var linksWithAttrs = new[]
{
new ActivityLink(
new ActivityContext(
ActivityTraceId.CreateFromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
ActivitySpanId.CreateFromString("bbbbbbbbbbbbbbbb"),
ActivityTraceFlags.Recorded),
new ActivityTagsCollection(new[]
{
new KeyValuePair<string, object?>("link.type", "causal"),
new KeyValuePair<string, object?>("link.index", 0),
})),
};

var activity = ListenForActivity(() =>
{
using var scope = InvokeAgentScope.Start(
Util.GetDefaultRequest(),
ScopeDetails,
new AgentDetails("attr-agent"),
spanDetails: new SpanDetails(spanLinks: linksWithAttrs));
});

var links = activity.Links.ToList();
links.Should().HaveCount(1);
links[0].Tags.Should().Contain(new KeyValuePair<string, object?>("link.type", "causal"));
links[0].Tags.Should().Contain(new KeyValuePair<string, object?>("link.index", 0));
}
}
Loading