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)); }