using System.Diagnostics;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Morph.AiKit.Agents;
using Morph.AiKit.Observability;
using Morph.AiKit.Providers;
using Morph.AiKit.Tests.Fakes;
using Xunit;
namespace Morph.AiKit.Tests;
///
/// Canarios da telemetria (T12). O arquivo inteiro mede spans, com
/// — nao propriedades de configuracao. Um guard contra span
/// duplicado so pode ser provado contando spans.
///
///
/// Cada caso usa um SourceName unico, para que um teste nunca conte o span do outro.
///
public sealed class TelemetryTests
{
private const string Document = """
{
"version": "2.0",
"defaultAlias": "text",
"aliases": {
"text": {
"provider": "openai",
"model": "gpt-5.4-mini",
"pricing": { "inputPerMTok": 0.25, "outputPerMTok": 2.0 }
}
}
}
""";
private static AgentSpec Spec(AgentSpecOptions? options = null)
=> new()
{
Alias = "text",
Instructions = "voce e um agente de teste",
Name = "agente-instrumentado",
Options = options ?? new AgentSpecOptions(),
};
// ---------------------------------------------------------------------------------
// O caso normal: um span de agente, um span de chat
// ---------------------------------------------------------------------------------
///
/// A forma que as GenAI conventions descrevem: invoke_agent como raiz e
/// chat como filho. Este teste tambem e a prova de que a escolha da T10 de montar
/// o proprio pipeline nao custou o span de chat — ele so existe porque a T12 o coloca.
///
[Fact]
public async Task AnInstrumentedAgentEmitsOneInvokeAgentSpanAndOneChatSpan()
{
var source = NewSourceName();
var spans = await CollectAsync(source, () => BuildAgent(
new MorphTelemetryOptions { SourceName = source },
leaf: new FakeChatClient().EnqueueText("pronto")));
Assert.Equal(1, CountOf(spans, "invoke_agent"));
Assert.Equal(1, CountOf(spans, "chat"));
Assert.Null(Single(spans, "invoke_agent").Parent);
Assert.NotNull(Single(spans, "chat").Parent);
}
// ---------------------------------------------------------------------------------
// O guard — as duas metades da mesma afirmacao
// ---------------------------------------------------------------------------------
///
/// O guard, no default. Um pipeline que ja carrega instrumentacao (o caso real:
/// um IChatClient que veio do DI ja instrumentado) continua com UM span de chat.
///
[Fact]
public async Task TheDefaultDoesNotAddASecondChatSpanOverAnAlreadyInstrumentedPipeline()
{
var source = NewSourceName();
var spans = await CollectAsync(source, () => BuildAgent(
new MorphTelemetryOptions { SourceName = source },
leaf: AlreadyInstrumented(source)));
Assert.Equal(1, CountOf(spans, "invoke_agent"));
Assert.Equal(1, CountOf(spans, "chat"));
}
///
/// O guard, desligado. Forcar InstrumentChatClient = true sobre o MESMO
/// pipeline ja instrumentado produz o par duplicado. E o canario que documenta o risco:
/// sem o ramo automatico, este e o resultado.
///
[Fact]
public async Task ForcingInstrumentChatClientProducesTheDuplicateChatSpan()
{
var source = NewSourceName();
var spans = await CollectAsync(source, () => BuildAgent(
new MorphTelemetryOptions { SourceName = source, InstrumentChatClient = true },
leaf: AlreadyInstrumented(source)));
Assert.Equal(1, CountOf(spans, "invoke_agent"));
Assert.Equal(2, CountOf(spans, "chat"));
}
///
/// O mesmo guard no nivel do agente: aplicar a telemetria duas vezes no
/// nao produz dois invoke_agent.
///
[Fact]
public async Task TheAgentGuardKeepsASingleInvokeAgentSpanWhenTelemetryIsAppliedTwice()
{
var source = NewSourceName();
var telemetry = new MorphTelemetryOptions { SourceName = source };
var spans = await CollectAsync(source, () =>
{
var agent = BuildAgent(telemetry, new FakeChatClient().EnqueueText("pronto"));
return agent.AsBuilder().UseMorphTelemetry(telemetry).Build();
});
Assert.Equal(1, CountOf(spans, "invoke_agent"));
}
///
/// false nao e o mesmo que o default. Com a AgentFactory — que monta o
/// proprio pipeline — desligar a instrumentacao de chat deixa a invocacao com o span do
/// agente e nenhum span de chat. O teste existe para que essa consequencia seja
/// um fato escrito, e nao uma surpresa em producao.
///
[Fact]
public async Task TurningTheChatInstrumentationOffLeavesTheAgentSpanAndNoChatSpan()
{
var source = NewSourceName();
var spans = await CollectAsync(source, () => BuildAgent(
new MorphTelemetryOptions { SourceName = source, InstrumentChatClient = false },
leaf: new FakeChatClient().EnqueueText("pronto")));
Assert.Equal(1, CountOf(spans, "invoke_agent"));
Assert.Equal(0, CountOf(spans, "chat"));
}
// ---------------------------------------------------------------------------------
// Dado sensivel
// ---------------------------------------------------------------------------------
///
/// Conteudo sensivel nasce desligado nos dois niveis — o standard exige false
/// fora de dev/staging, porque prompt e resposta sao PII do cliente.
///
[Fact]
public void SensitiveDataIsOffByDefaultAtBothLevels()
{
var agent = BuildAgent(
new MorphTelemetryOptions { SourceName = NewSourceName() },
new FakeChatClient());
Assert.False(Assert.IsType(agent).EnableSensitiveData);
Assert.False(Assert.IsType(
agent.GetService(typeof(OpenTelemetryChatClient))).EnableSensitiveData);
}
///
/// A spec pode ligar o dado sensivel para UM agente sem mexer na configuracao global —
/// e o destino que a T7 declarou para EnableSensitiveTelemetry. Nulo na spec
/// ("nao opinei") preserva o valor global; preenchido, vence.
///
[Fact]
public void TheSpecCanOverrideSensitiveDataForASingleAgent()
{
var telemetry = new MorphTelemetryOptions { SourceName = NewSourceName() };
var loud = BuildAgent(
telemetry,
new FakeChatClient(),
spec: Spec(new AgentSpecOptions { EnableSensitiveTelemetry = true }));
Assert.True(Assert.IsType(loud).EnableSensitiveData);
Assert.True(Assert.IsType(
loud.GetService(typeof(OpenTelemetryChatClient))).EnableSensitiveData);
}
// ---------------------------------------------------------------------------------
// Configuracao invalida
// ---------------------------------------------------------------------------------
///
/// Source em branco e recusado na construcao: um ActivitySource sem nome nao pode
/// ser registrado em AddSource(...), e a telemetria sumiria em silencio.
///
[Fact]
public void ABlankSourceNameIsRefusedAtConstruction()
=> Assert.Throws(() => new MorphTelemetryOptions { SourceName = " " });
// ---------------------------------------------------------------------------------
// Apoio dos testes
// ---------------------------------------------------------------------------------
private static string NewSourceName() => "morph.aikit.test." + Guid.CreateVersion7().ToString("N");
/// Um pipeline que ja chega instrumentado — o caso real do cliente vindo do DI.
private static IChatClient AlreadyInstrumented(string sourceName)
=> new FakeChatClient()
.EnqueueText("pronto")
.AsBuilder()
.UseOpenTelemetry(sourceName: sourceName)
.Build();
private static AIAgent BuildAgent(MorphTelemetryOptions telemetry, IChatClient leaf, AgentSpec? spec = null)
{
using var registry = ModelRegistry.FromJson(Document, new FakeProviderClientFactory());
var factory = new AgentFactory(
registry,
new AgentFactoryOptions { ChatClientSelector = (_, _) => leaf }.WithMorphTelemetry(telemetry));
return factory.Create(spec ?? Spec());
}
///
/// Coleta os spans de UMA invocacao. O listener e registrado antes de o agente existir e
/// removido no fim — telemetria e estado global do processo, e um listener vazado
/// contaminaria os outros casos.
///
private static async Task> CollectAsync(string sourceName, Func build)
{
var spans = new List();
using var listener = new ActivityListener
{
ShouldListenTo = candidate => candidate.Name == sourceName,
Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activity =>
{
lock (spans)
{
spans.Add(activity);
}
},
};
ActivitySource.AddActivityListener(listener);
var agent = build();
var session = await agent.CreateSessionAsync(TestContext.Current.CancellationToken);
_ = await agent.RunAsync("oi", session, cancellationToken: TestContext.Current.CancellationToken);
lock (spans)
{
return [.. spans];
}
}
private static int CountOf(IReadOnlyList spans, string operation)
=> spans.Count(span => Equals(span.GetTagItem("gen_ai.operation.name"), operation));
private static Activity Single(IReadOnlyList spans, string operation)
=> spans.Single(span => Equals(span.GetTagItem("gen_ai.operation.name"), operation));
}