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
7 changes: 4 additions & 3 deletions src/Observability/Runtime/Common/ExportFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ public string FormatLogData(IDictionary<string, object?> data)
Kind = data.TryGetValue("SpanKind", out var spanKindObj) && spanKindObj != null ? spanKindObj : SpanKindConstants.Client,
Status = data.TryGetValue("Status", out var statusObj) && statusObj != null
? statusObj
: new Dictionary<string, object> { { "code", 0 }, { "message", "" } }
: new Dictionary<string, object> { { "code", 0 }, { "message", "" } },
Events = data.TryGetValue("Events", out var eventsObj) ? eventsObj : null
};

return SerializePayload(payload);
Expand Down Expand Up @@ -367,7 +368,7 @@ internal sealed class EtwResourceSpan
{
[JsonPropertyName("resource")]
public OtlpResource? Resource { get; set; }

[JsonPropertyName("scopeSpan")]
public EtwScopeSpan ScopeSpan { get; set; } = new EtwScopeSpan();
}
Expand All @@ -376,7 +377,7 @@ internal sealed class EtwScopeSpan
{
[JsonPropertyName("scope")]
public InstrumentationScope? Scope { get; set; }

[JsonPropertyName("span")]
public OtlpSpan Span { get; set; } = new OtlpSpan();
}
Expand Down
12 changes: 12 additions & 0 deletions src/Observability/Runtime/DTOs/BaseData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ public BaseData(
/// </summary>
public string? StatusMessage { get; set; }

/// <summary>
/// Gets the span events for the operation. Each event is a dictionary with OTLP-style keys
/// (<c>timeUnixNano</c>, <c>name</c>, <c>attributes</c>), matching the Activity export path.
/// Empty by default; populated by builders that emit events (e.g. apply_guardrail findings).
/// </summary>
public List<Dictionary<string, object?>> Events { get; } = new List<Dictionary<string, object?>>();

/// <summary>
/// Gets the duration of the operation if both start and end times are provided.
/// </summary>
Expand Down Expand Up @@ -124,6 +131,11 @@ public BaseData(
}
};

if (Events.Count > 0)
{
dict["Events"] = Events;
}

return dict;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public class ApplyGuardrailDataBuilder : BaseDataBuilder<ApplyGuardrailData>
/// <param name="spanKind">Optional span kind override.</param>
/// <param name="traceId">Optional trace ID for distributed tracing.</param>
/// <param name="error">Optional exception describing a failure; sets an OTel error status and the <c>error.type</c> attribute.</param>
/// <param name="findings">Optional security findings to emit as span events on the apply_guardrail span.</param>
/// <returns>An ApplyGuardrailData object containing all telemetry data.</returns>
public static ApplyGuardrailData Build(
GuardrailDetails guardrailDetails,
Expand All @@ -44,11 +45,55 @@ public static ApplyGuardrailData Build(
IDictionary<string, object?>? extraAttributes = null,
string? spanKind = null,
string? traceId = null,
Exception? error = null)
Exception? error = null,
IEnumerable<GuardrailFinding>? findings = null)
{
var attributes = BuildAttributes(guardrailDetails, agentDetails, conversationId, channel, callerDetails, extraAttributes);

return ApplyStatus(new ApplyGuardrailData(parentSpanId, attributes, startTime, endTime, spanId, spanKind, traceId), error);
var data = ApplyStatus(new ApplyGuardrailData(parentSpanId, attributes, startTime, endTime, spanId, spanKind, traceId), error);

AddFindingEvents(data, findings);

return data;
}

private static void AddFindingEvents(ApplyGuardrailData data, IEnumerable<GuardrailFinding>? findings)
{
if (findings == null)
{
return;
}

foreach (var finding in findings)
{
if (finding == null)
{
continue;
}

var eventAttributes = new Dictionary<string, object?>
{
{ OpenTelemetryConstants.GenAiSecurityRiskCategoryKey, finding.RiskCategory },
{ OpenTelemetryConstants.GenAiSecurityRiskSeverityKey, finding.RiskSeverity }
};

AddIfNotNull(eventAttributes, OpenTelemetryConstants.GenAiSecurityPolicyDecisionTypeKey, finding.PolicyDecisionType);
AddIfNotNull(eventAttributes, OpenTelemetryConstants.GenAiSecurityPolicyIdKey, finding.PolicyId);
AddIfNotNull(eventAttributes, OpenTelemetryConstants.GenAiSecurityPolicyNameKey, finding.PolicyName);
AddIfNotNull(eventAttributes, OpenTelemetryConstants.GenAiSecurityPolicyVersionKey, finding.PolicyVersion);
if (finding.RiskScore.HasValue)
{
eventAttributes[OpenTelemetryConstants.GenAiSecurityRiskScoreKey] = finding.RiskScore.Value;
}
AddIfNotNull(eventAttributes, OpenTelemetryConstants.GenAiSecurityRiskMetadataKey, finding.RiskMetadata);

data.Events.Add(new Dictionary<string, object?>
{
{ "timeUnixNano", ToUnixNanos(DateTimeOffset.UtcNow) },
{ "name", OpenTelemetryConstants.GenAiSecurityFindingEventName },
{ "attributes", eventAttributes }
});
}
}

private static Dictionary<string, object?> BuildAttributes(
Expand Down
11 changes: 11 additions & 0 deletions src/Observability/Runtime/DTOs/Builders/BaseDataBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ protected static void AddIfNotNull(IDictionary<string, object?> attributes, stri
}
}

/// <summary>
/// Converts a timestamp to Unix epoch nanoseconds, matching the Activity export path's
/// conversion in <c>ExportFormatter</c>. Used to stamp span events on the ETW DTO path.
/// </summary>
protected static ulong ToUnixNanos(DateTimeOffset timestamp)
{
var unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var ns = (timestamp.UtcDateTime - unixEpoch).Ticks * 100;
return (ulong)ns;
}

/// <summary>
/// Adds extra attributes to the attributes dictionary.
/// Extra attributes cannot override keys already set by the builder on this span.
Expand Down
56 changes: 29 additions & 27 deletions src/Observability/Runtime/Etw/A365EtwLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ public A365EtwLogger(ILoggerFactory factory)

/// <inheritdoc/>
public void LogInferenceCall(
InferenceCallDetails inferenceCallDetails,
AgentDetails agentDetails,
string conversationId,
string[]? inputMessages,
string[]? outputMessages,
DateTimeOffset? startTime,
DateTimeOffset? endTime,
string? spanId,
InferenceCallDetails inferenceCallDetails,
AgentDetails agentDetails,
string conversationId,
string[]? inputMessages,
string[]? outputMessages,
DateTimeOffset? startTime,
DateTimeOffset? endTime,
string? spanId,
string? parentSpanId,
Channel? channel,
CallerDetails? callerDetails,
Expand Down Expand Up @@ -77,16 +77,16 @@ public void LogInferenceCall(

/// <inheritdoc/>
public void LogInvokeAgent(
InvokeAgentScopeDetails invokeAgentScopeDetails,
AgentDetails agentDetails,
string conversationId,
Request? request,
CallerDetails? callerDetails,
string[]? inputMessages,
string[]? outputMessages,
DateTimeOffset? startTime,
DateTimeOffset? endTime,
string? spanId,
InvokeAgentScopeDetails invokeAgentScopeDetails,
AgentDetails agentDetails,
string conversationId,
Request? request,
CallerDetails? callerDetails,
string[]? inputMessages,
string[]? outputMessages,
DateTimeOffset? startTime,
DateTimeOffset? endTime,
string? spanId,
string? parentSpanId,
string? traceId,
Exception? error = null)
Expand Down Expand Up @@ -117,13 +117,13 @@ public void LogInvokeAgent(

/// <inheritdoc/>
public void LogToolCall(
ToolCallDetails toolCallDetails,
AgentDetails agentDetails,
string conversationId,
string? responseContent,
DateTimeOffset? startTime,
DateTimeOffset? endTime,
string? spanId,
ToolCallDetails toolCallDetails,
AgentDetails agentDetails,
string conversationId,
string? responseContent,
DateTimeOffset? startTime,
DateTimeOffset? endTime,
string? spanId,
string? parentSpanId,
Channel? channel,
CallerDetails? callerDetails,
Expand Down Expand Up @@ -201,7 +201,8 @@ public void LogApplyGuardrail(
Channel? channel = null,
CallerDetails? callerDetails = null,
string? traceId = null,
Exception? error = null)
Exception? error = null,
IEnumerable<GuardrailFinding>? findings = null)
{
var data = ApplyGuardrailDataBuilder.Build(
guardrailDetails,
Expand All @@ -214,7 +215,8 @@ public void LogApplyGuardrail(
channel,
callerDetails: callerDetails,
traceId: traceId,
error: error);
error: error,
findings: findings);

logger.Log(
LogLevel.Information,
Expand Down
5 changes: 4 additions & 1 deletion src/Observability/Runtime/Etw/IA365EtwLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts;
using System;
using System.Collections.Generic;

namespace Microsoft.Agents.A365.Observability.Runtime.Etw
{
Expand Down Expand Up @@ -143,6 +144,7 @@ public void LogOutput(
/// <param name="callerDetails">Optional details of the caller.</param>
/// <param name="traceId">Optional trace ID for distributed tracing.</param>
/// <param name="error">Optional exception describing a failure; sets an OTel error status and the <c>error.type</c> attribute.</param>
/// <param name="findings">Optional security findings to emit as span events on the apply_guardrail span.</param>
public void LogApplyGuardrail(
GuardrailDetails guardrailDetails,
AgentDetails agentDetails,
Expand All @@ -154,6 +156,7 @@ public void LogApplyGuardrail(
Channel? channel = null,
CallerDetails? callerDetails = null,
string? traceId = null,
Exception? error = null);
Exception? error = null,
IEnumerable<GuardrailFinding>? findings = null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Linq;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Agents.A365.Observability.Runtime.DTOs;
using Microsoft.Agents.A365.Observability.Runtime.DTOs.Builders;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes;

namespace Microsoft.Agents.A365.Observability.Runtime.Tests.Common;

public partial class ExportFormatterTests
{
[TestMethod]
public void FormatLogData_NoEvents_OmitsEventsField()
{
// Arrange
var data = new InvokeAgentData(
new Dictionary<string, object?> { { "key", "val" } },
spanId: "span-1");
var formatter = CreateFormatter();

// Act
var json = formatter.FormatLogData(data.ToDictionary());

// Assert
using var doc = JsonDocument.Parse(json);
doc.RootElement.TryGetProperty("Events", out _).Should().BeFalse();
}

[TestMethod]
public void FormatLogData_WithFindings_EmitsEventsArray()
{
// Arrange
var details = new GuardrailDetails(
targetType: GuardrailTargetType.LlmInput,
decisionType: GuardrailDecisionType.Deny);
var findings = new[]
{
new GuardrailFinding("prompt_injection", GuardrailRiskSeverity.High, policyDecisionType: "deny")
};
var data = ApplyGuardrailDataBuilder.Build(details, TestAgentDetails, "conv-1", "parent-1", findings: findings);
var formatter = CreateFormatter();

// Act
var json = formatter.FormatLogData(data.ToDictionary());

// Assert
using var doc = JsonDocument.Parse(json);
var events = doc.RootElement.GetProperty("Events");
events.GetArrayLength().Should().Be(1);
var evt = events[0];
evt.GetProperty("name").GetString().Should().Be(OpenTelemetryConstants.GenAiSecurityFindingEventName);
evt.GetProperty("timeUnixNano").GetUInt64().Should().BeGreaterThan(0);
var attrs = evt.GetProperty("attributes");
attrs.GetProperty(OpenTelemetryConstants.GenAiSecurityRiskCategoryKey).GetString().Should().Be("prompt_injection");
attrs.GetProperty(OpenTelemetryConstants.GenAiSecurityRiskSeverityKey).GetString().Should().Be("high");
attrs.GetProperty(OpenTelemetryConstants.GenAiSecurityPolicyDecisionTypeKey).GetString().Should().Be("deny");
}

[TestMethod]
public void FindingEvent_DtoPath_StructurallyMatches_ActivityPath()
{
// Arrange — one finding with all fields populated.
var finding = new GuardrailFinding(
riskCategory: "sensitive_info_disclosure",
riskSeverity: GuardrailRiskSeverity.High,
policyDecisionType: "deny",
policyId: "policy_pii_v2",
policyName: "PII Policy",
policyVersion: "2.0",
riskScore: 0.92,
riskMetadata: new[] { "pattern:ssn", "count:2" });

var details = new GuardrailDetails(
targetType: GuardrailTargetType.LlmInput,
decisionType: GuardrailDecisionType.Deny);

var resource = CreateResource();
var formatter = CreateFormatter();

// Activity path: RecordFinding -> FormatSingle
var activity = ListenForActivity(() =>
{
using var scope = ApplyGuardrailScope.Start(details, TestAgentDetails);
scope.RecordFinding(finding);
});
using var activityDoc = JsonDocument.Parse(formatter.FormatSingle(activity, resource));
var activityEvent = activityDoc.RootElement
.GetProperty("resourceSpan")
.GetProperty("scopeSpan")
.GetProperty("span")
.GetProperty("events")[0];

// DTO path: ApplyGuardrailDataBuilder -> FormatLogData
var data = ApplyGuardrailDataBuilder.Build(details, TestAgentDetails, "conv-1", "parent-1", findings: new[] { finding });
using var dtoDoc = JsonDocument.Parse(formatter.FormatLogData(data.ToDictionary()));
var dtoEvent = dtoDoc.RootElement.GetProperty("Events")[0];

// Assert — same event name and same attributes (timeUnixNano intentionally ignored).
dtoEvent.GetProperty("name").GetString()
.Should().Be(activityEvent.GetProperty("name").GetString());

AttributeMap(dtoEvent.GetProperty("attributes"))
.Should().BeEquivalentTo(AttributeMap(activityEvent.GetProperty("attributes")));
}

private static Dictionary<string, string> AttributeMap(JsonElement attributes) =>
attributes.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetRawText());
}
Loading
Loading