From cf667e60f0fc03fd84e4c7ed3b9cd33bd807dcbc Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 30 Oct 2025 16:45:37 +0800 Subject: [PATCH] Add ExcludeFromMcp() resource extension --- .../Mcp/AspireResourceMcpTools.cs | 30 +++- .../Mcp/AspireTelemetryMcpTools.cs | 49 ++++++- .../Model/Assistant/AIHelpers.cs | 5 + src/Aspire.Dashboard/Utils/ValueExtensions.cs | 16 +++ .../ExcludeFromMcpAnnotation.cs | 8 ++ .../ResourceNotificationService.cs | 9 ++ .../ResourceBuilderExtensions.cs | 13 ++ src/Shared/Model/KnownProperties.cs | 1 + .../Mcp/AspireResourceMcpToolsTests.cs | 64 ++++++++- .../Mcp/AspireTelemetryMcpToolsTests.cs | 132 +++++++++++++++++- 10 files changed, 311 insertions(+), 16 deletions(-) create mode 100644 src/Aspire.Hosting/ApplicationModel/ExcludeFromMcpAnnotation.cs diff --git a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs index 51723b45efa..9848a000fe8 100644 --- a/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireResourceMcpTools.cs @@ -20,23 +20,30 @@ internal sealed class AspireResourceMcpTools { private readonly IDashboardClient _dashboardClient; private readonly IOptionsMonitor _dashboardOptions; + private readonly ILogger _logger; - public AspireResourceMcpTools(IDashboardClient dashboardClient, IOptionsMonitor dashboardOptions) + public AspireResourceMcpTools(IDashboardClient dashboardClient, + IOptionsMonitor dashboardOptions, + ILogger logger) { _dashboardClient = dashboardClient; _dashboardOptions = dashboardOptions; + _logger = logger; } [McpServerTool(Name = "list_resources")] [Description("List the application resources. Includes information about their type (.NET project, container, executable), running state, source, HTTP endpoints, health status, commands, and relationships.")] public string ListResources() { + _logger.LogDebug("MCP tool list_resources called"); + try { var resources = _dashboardClient.GetResources().ToList(); + var filteredResources = GetFilteredResources(resources); var resourceGraphData = AIHelpers.GetResponseGraphJson( - resources, + filteredResources, _dashboardOptions.CurrentValue, includeDashboardUrl: true, getResourceName: r => ResourceViewModel.GetResourceName(r, resources)); @@ -57,6 +64,11 @@ public string ListResources() return "No resources found."; } + private static List GetFilteredResources(List resources) + { + return resources.Where(r => !AIHelpers.IsResourceAIOptOut(r)).ToList(); + } + [McpServerTool(Name = "list_console_logs")] [Description("List console logs for a resource. The console logs includes standard output from resources and resource commands. Known resource commands are 'resource-start', 'resource-stop' and 'resource-restart' which are used to start and stop resources. Don't print the full console logs in the response to the user. Console logs should be examined when determining why a resource isn't running.")] public async Task ListConsoleLogsAsync( @@ -64,9 +76,12 @@ public async Task ListConsoleLogsAsync( string resourceName, CancellationToken cancellationToken) { - var resources = _dashboardClient.GetResources(); + _logger.LogDebug("MCP tool list_console_logs called with resource '{ResourceName}'.", resourceName); + + var resources = _dashboardClient.GetResources().ToList(); + var filteredResources = GetFilteredResources(resources); - if (AIHelpers.TryGetResource(resources, resourceName, out var resource)) + if (AIHelpers.TryGetResource(filteredResources, resourceName, out var resource)) { resourceName = resource.Name; } @@ -125,9 +140,12 @@ public async Task ListConsoleLogsAsync( [Description("Executes a command on a resource. If a resource needs to be restarted and is currently stopped, use the start command instead.")] public async Task ExecuteResourceCommand([Description("The resource name")] string resourceName, [Description("The command name")] string commandName) { - var resources = _dashboardClient.GetResources(); + _logger.LogDebug("MCP tool execute_resource_command called with resource '{ResourceName}' and command '{CommandName}'.", resourceName, commandName); + + var resources = _dashboardClient.GetResources().ToList(); + var filteredResources = GetFilteredResources(resources); - if (!AIHelpers.TryGetResource(resources, resourceName, out var resource)) + if (!AIHelpers.TryGetResource(filteredResources, resourceName, out var resource)) { throw new McpProtocolException($"Resource '{resourceName}' not found.", McpErrorCode.InvalidParams); } diff --git a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs index 913f3e8ce08..f3cef5d4b83 100644 --- a/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs +++ b/src/Aspire.Dashboard/Mcp/AspireTelemetryMcpTools.cs @@ -22,12 +22,20 @@ internal sealed class AspireTelemetryMcpTools private readonly TelemetryRepository _telemetryRepository; private readonly IEnumerable _outgoingPeerResolvers; private readonly IOptionsMonitor _dashboardOptions; - - public AspireTelemetryMcpTools(TelemetryRepository telemetryRepository, IEnumerable outgoingPeerResolvers, IOptionsMonitor dashboardOptions) + private readonly IDashboardClient _dashboardClient; + private readonly ILogger _logger; + + public AspireTelemetryMcpTools(TelemetryRepository telemetryRepository, + IEnumerable outgoingPeerResolvers, + IOptionsMonitor dashboardOptions, + IDashboardClient dashboardClient, + ILogger logger) { _telemetryRepository = telemetryRepository; _outgoingPeerResolvers = outgoingPeerResolvers; _dashboardOptions = dashboardOptions; + _dashboardClient = dashboardClient; + _logger = logger; } [McpServerTool(Name = "list_structured_logs")] @@ -36,6 +44,8 @@ public string ListStructuredLogs( [Description("The resource name. This limits logs returned to the specified resource. If no resource name is specified then structured logs for all resources are returned.")] string? resourceName = null) { + _logger.LogDebug("MCP tool list_structured_logs called with resource '{ResourceName}'.", resourceName); + if (!TryResolveResourceNameForTelemetry(resourceName, out var message, out var resourceKey)) { return message; @@ -49,12 +59,21 @@ public string ListStructuredLogs( StartIndex = 0, Count = int.MaxValue, Filters = [] - }); + }).Items; + + if (_dashboardClient.IsEnabled) + { + var optOutResources = GetOptOutResources(_dashboardClient.GetResources()); + if (optOutResources.Count > 0) + { + logs = logs.Where(l => !optOutResources.Any(r => l.ResourceView.ResourceKey.EqualsCompositeName(r.Name))).ToList(); + } + } var resources = _telemetryRepository.GetResources(); var (logsData, limitMessage) = AIHelpers.GetStructuredLogsJson( - logs.Items, + logs, _dashboardOptions.CurrentValue, includeDashboardUrl: true, getResourceName: r => OtlpResource.GetResourceName(r, resources)); @@ -77,6 +96,8 @@ public string ListTraces( [Description("The resource name. This limits traces returned to the specified resource. If no resource name is specified then distributed traces for all resources are returned.")] string? resourceName = null) { + _logger.LogDebug("MCP tool list_traces called with resource '{ResourceName}'.", resourceName); + if (!TryResolveResourceNameForTelemetry(resourceName, out var message, out var resourceKey)) { return message; @@ -89,12 +110,21 @@ public string ListTraces( Count = int.MaxValue, Filters = [], FilterText = string.Empty - }); + }).PagedResult.Items; + + if (_dashboardClient.IsEnabled) + { + var optOutResources = GetOptOutResources(_dashboardClient.GetResources()); + if (optOutResources.Count > 0) + { + traces = traces.Where(t => !optOutResources.Any(r => t.Spans.Any(s => s.Source.ResourceKey.EqualsCompositeName(r.Name)))).ToList(); + } + } var resources = _telemetryRepository.GetResources(); var (tracesData, limitMessage) = AIHelpers.GetTracesJson( - traces.PagedResult.Items, + traces, _outgoingPeerResolvers, _dashboardOptions.CurrentValue, includeDashboardUrl: true, @@ -117,6 +147,8 @@ public string ListTraceStructuredLogs( [Description("The trace id of the distributed trace.")] string traceId) { + _logger.LogDebug("MCP tool list_trace_structured_logs called with trace '{TraceId}'.", traceId); + // Condition of filter should be contains because a substring of the traceId might be provided. var traceIdFilter = new FieldTelemetryFilter { @@ -177,4 +209,9 @@ private bool TryResolveResourceNameForTelemetry([NotNullWhen(false)] string? res resourceKey = resource.ResourceKey; return true; } + + private static List GetOptOutResources(IEnumerable resources) + { + return resources.Where(AIHelpers.IsResourceAIOptOut).ToList(); + } } diff --git a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs index ad6021681b7..c077167f9f2 100644 --- a/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs +++ b/src/Aspire.Dashboard/Model/Assistant/AIHelpers.cs @@ -581,4 +581,9 @@ private static string GetLimitSummary(int totalValues, int returnedCount, string return $"Returned latest {itemName.ToQuantity(returnedCount, formatProvider: CultureInfo.InvariantCulture)}. Earlier {itemName.ToQuantity(totalValues - returnedCount, formatProvider: CultureInfo.InvariantCulture)} not returned because of size limits."; } + + public static bool IsResourceAIOptOut(ResourceViewModel r) + { + return r.Properties.TryGetValue(KnownProperties.Resource.ExcludeFromMcp, out var v) && v.Value.TryConvertToBool(out var b) && b; + } } diff --git a/src/Aspire.Dashboard/Utils/ValueExtensions.cs b/src/Aspire.Dashboard/Utils/ValueExtensions.cs index 5b499f19208..8e7df55b439 100644 --- a/src/Aspire.Dashboard/Utils/ValueExtensions.cs +++ b/src/Aspire.Dashboard/Utils/ValueExtensions.cs @@ -28,6 +28,22 @@ public static bool TryConvertToInt(this Value value, out int i) return false; } + public static bool TryConvertToBool(this Value value, out bool b) + { + if (value.HasStringValue && bool.TryParse(value.StringValue, out b)) + { + return true; + } + else if (value.HasBoolValue) + { + b = value.BoolValue; + return true; + } + + b = false; + return false; + } + public static bool TryConvertToString(this Value value, [NotNullWhen(returnValue: true)] out string? s) { if (value.HasStringValue) diff --git a/src/Aspire.Hosting/ApplicationModel/ExcludeFromMcpAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ExcludeFromMcpAnnotation.cs new file mode 100644 index 00000000000..04060e976cd --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ExcludeFromMcpAnnotation.cs @@ -0,0 +1,8 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +internal sealed class ExcludeFromMcpAnnotation : IResourceAnnotation +{ +} diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs index 07cf8d70df8..7621c57919d 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceNotificationService.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Threading.Channels; +using Aspire.Dashboard.Model; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; @@ -605,6 +606,14 @@ public Task PublishUpdateAsync(IResource resource, string resourceId, Func(out _)) + { + newState = newState with + { + Properties = newState.Properties.SetResourceProperty(KnownProperties.Resource.ExcludeFromMcp, true) + }; + } + notificationState.LastSnapshot = newState; OnResourceUpdated?.Invoke(new ResourceEvent(resource, resourceId, newState)); diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index f348907e5aa..acaac444d96 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -2876,4 +2876,17 @@ private static IResourceBuilder WithProbe(this IResourceBuilder builder return builder.WithAnnotation(probeAnnotation); } + + /// + /// Exclude the resource from MCP operations using the Aspire MCP server. The resource is excluded from results that return resources, console logs and telemetry. + /// + /// The resource type. + /// The resource builder. + /// The . + public static IResourceBuilder ExcludeFromMcp(this IResourceBuilder builder) where T : IResource + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithAnnotation(new ExcludeFromMcpAnnotation()); + } } diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs index d583b592dc4..769cef8f6c0 100644 --- a/src/Shared/Model/KnownProperties.cs +++ b/src/Shared/Model/KnownProperties.cs @@ -29,6 +29,7 @@ public static class Resource public const string ParentName = "resource.parentName"; public const string AppArgs = "resource.appArgs"; public const string AppArgsSensitivity = "resource.appArgsSensitivity"; + public const string ExcludeFromMcp = "resource.excludeFromMcp"; } public static class Container diff --git a/tests/Aspire.Dashboard.Tests/Mcp/AspireResourceMcpToolsTests.cs b/tests/Aspire.Dashboard.Tests/Mcp/AspireResourceMcpToolsTests.cs index 72b31cf2af1..9d1e932f2bb 100644 --- a/tests/Aspire.Dashboard.Tests/Mcp/AspireResourceMcpToolsTests.cs +++ b/tests/Aspire.Dashboard.Tests/Mcp/AspireResourceMcpToolsTests.cs @@ -9,12 +9,16 @@ using Aspire.Dashboard.Tests.Model; using Aspire.Dashboard.Tests.Shared; using Aspire.Tests.Shared.DashboardModel; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Aspire.Dashboard.Tests.Mcp; public class AspireResourceMcpToolsTests { + private static readonly ResourcePropertyViewModel s_excludeFromMcpProperty = new ResourcePropertyViewModel(KnownProperties.Resource.ExcludeFromMcp, Value.ForBool(true), isValueSensitive: false, knownProperty: null, priority: 0); + [Fact] public void ListResources_NoResources_ReturnsResourceData() { @@ -66,6 +70,27 @@ public void ListResources_MultipleResources_ReturnsAllResources() Assert.Contains("app2", result); } + [Fact] + public void ListResources_OptOutResources_FiltersOptOutResources() + { + // Arrange + var resource1 = ModelTestHelpers.CreateResource(resourceName: "app1"); + var resource2 = ModelTestHelpers.CreateResource( + resourceName: "app2", + properties: new Dictionary { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty }); + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource1, resource2]); + var tools = CreateTools(dashboardClient); + + // Act + var result = tools.ListResources(); + + // Assert + Assert.NotNull(result); + Assert.Contains("# RESOURCE DATA", result); + Assert.Contains("app1", result); + Assert.DoesNotContain("app2", result); + } + [Fact] public async Task ListConsoleLogsAsync_ResourceNotFound_ReturnsErrorMessage() { @@ -82,6 +107,24 @@ public async Task ListConsoleLogsAsync_ResourceNotFound_ReturnsErrorMessage() Assert.Contains("Unable to find a resource named 'nonexistent'", result); } + [Fact] + public async Task ListConsoleLogsAsync_ResourceOptOut_ReturnsErrorMessage() + { + // Arrange + var resource = ModelTestHelpers.CreateResource( + resourceName: "app1", + properties: new Dictionary { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty }); + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource]); + var tools = CreateTools(dashboardClient); + + // Act + var result = await tools.ListConsoleLogsAsync("app1", CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Contains("Unable to find a resource named 'app1'", result); + } + [Fact] public async Task ListConsoleLogsAsync_ResourceFound_ReturnsLogs() { @@ -138,6 +181,24 @@ public async Task ExecuteResourceCommand_ResourceNotFound_ThrowsMcpProtocolExcep Assert.Contains("Resource 'nonexistent' not found", exception.Message); } + [Fact] + public async Task ExecuteResourceCommand_ResourceOptOut_ThrowsMcpProtocolException() + { + // Arrange + var resource = ModelTestHelpers.CreateResource( + resourceName: "app1", + commands: ImmutableArray.Empty, + properties: new Dictionary { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty }); + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource]); + var tools = CreateTools(dashboardClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await tools.ExecuteResourceCommand("app1", "start")); + + Assert.Contains("Resource 'app1' not found", exception.Message); + } + [Fact] public async Task ExecuteResourceCommand_CommandNotFound_ThrowsMcpProtocolException() { @@ -162,6 +223,7 @@ private static AspireResourceMcpTools CreateTools(IDashboardClient dashboardClie return new AspireResourceMcpTools( dashboardClient, - new TestOptionsMonitor(options)); + new TestOptionsMonitor(options), + NullLogger.Instance); } } diff --git a/tests/Aspire.Dashboard.Tests/Mcp/AspireTelemetryMcpToolsTests.cs b/tests/Aspire.Dashboard.Tests/Mcp/AspireTelemetryMcpToolsTests.cs index 74da20549b8..54e65477c1e 100644 --- a/tests/Aspire.Dashboard.Tests/Mcp/AspireTelemetryMcpToolsTests.cs +++ b/tests/Aspire.Dashboard.Tests/Mcp/AspireTelemetryMcpToolsTests.cs @@ -3,11 +3,17 @@ using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Mcp; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Model; +using Aspire.Dashboard.Tests.Shared; +using Aspire.Tests.Shared.DashboardModel; using Aspire.Tests.Shared.Telemetry; using Google.Protobuf.Collections; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Trace.V1; using Xunit; using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; @@ -16,6 +22,9 @@ namespace Aspire.Dashboard.Tests.Mcp; public class AspireTelemetryMcpToolsTests { + private static readonly ResourcePropertyViewModel s_excludeFromMcpProperty = new ResourcePropertyViewModel(KnownProperties.Resource.ExcludeFromMcp, Value.ForBool(true), isValueSensitive: false, knownProperty: null, priority: 0); + private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + [Fact] public void ListTraces_NoResources_ReturnsEmptyResult() { @@ -45,6 +54,33 @@ public void ListTraces_SingleResource_ReturnsTraces() // Assert Assert.NotNull(result); Assert.Contains("# TRACES DATA", result); + Assert.Contains("app1", result); + } + + [Fact] + public void ListTraces_ResourceOptOut_FilterTraces() + { + // Arrange + var repository = CreateRepository(); + AddResource(repository, "app1", "instance1"); + AddResource(repository, "app2", "instance1"); + + var resource = ModelTestHelpers.CreateResource( + resourceName: "app1-instance1", + displayName: "app1", + properties: new Dictionary { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty }); + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource]); + + var tools = CreateTools(repository, dashboardClient); + + // Act + var result = tools.ListTraces(); + + // Assert + Assert.NotNull(result); + Assert.Contains("# TRACES DATA", result); + Assert.DoesNotContain("app1", result); + Assert.Contains("app2", result); } [Fact] @@ -97,6 +133,49 @@ public void ListStructuredLogs_NoResources_ReturnsEmptyResult() Assert.Contains("# STRUCTURED LOGS DATA", result); } + [Fact] + public void ListStructuredLogs_HasResource_ReturnsLogs() + { + // Arrange + var repository = CreateRepository(); + AddResource(repository, "app1"); + var tools = CreateTools(repository); + + // Act + var result = tools.ListStructuredLogs(); + + // Assert + Assert.NotNull(result); + Assert.Contains("# STRUCTURED LOGS DATA", result); + Assert.Contains("app1", result); + } + + [Fact] + public void ListStructuredLogs_ResourceOptOut_FiltersLogs() + { + // Arrange + var repository = CreateRepository(); + AddResource(repository, "app1", "instance1"); + AddResource(repository, "app2", "instance1"); + + var resource = ModelTestHelpers.CreateResource( + resourceName: "app1-instance1", + displayName: "app1", + properties: new Dictionary { [KnownProperties.Resource.ExcludeFromMcp] = s_excludeFromMcpProperty }); + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [resource]); + + var tools = CreateTools(repository, dashboardClient); + + // Act + var result = tools.ListStructuredLogs(); + + // Assert + Assert.NotNull(result); + Assert.Contains("# STRUCTURED LOGS DATA", result); + Assert.DoesNotContain("app1", result); + Assert.Contains("app2", result); + } + [Fact] public void ListStructuredLogs_SingleResource_ReturnsLogs() { @@ -111,6 +190,7 @@ public void ListStructuredLogs_SingleResource_ReturnsLogs() // Assert Assert.NotNull(result); Assert.Contains("# STRUCTURED LOGS DATA", result); + Assert.Contains("app1", result); } [Fact] @@ -148,9 +228,19 @@ public void ListTraceStructuredLogs_WithTraceId_ReturnsLogs() Assert.Contains("# STRUCTURED LOGS DATA", result); } - private static AspireTelemetryMcpTools CreateTools(TelemetryRepository repository) + private static AspireTelemetryMcpTools CreateTools(TelemetryRepository repository, IDashboardClient? dashboardClient = null) { - return new AspireTelemetryMcpTools(repository, [], new TestOptionsMonitor(new DashboardOptions())); + var options = new DashboardOptions(); + options.Frontend.EndpointUrls = "https://localhost:1234"; + options.Frontend.PublicUrl = "https://localhost:8080"; + Assert.True(options.Frontend.TryParseOptions(out _)); + + return new AspireTelemetryMcpTools( + repository, + [], + new TestOptionsMonitor(options), + dashboardClient ?? new TestDashboardClient(), + NullLogger.Instance); } private static TelemetryRepository CreateRepository() @@ -160,12 +250,48 @@ private static TelemetryRepository CreateRepository() private static void AddResource(TelemetryRepository repository, string name, string? instanceId = null) { + var idPrefix = instanceId != null ? $"{name}-{instanceId}" : name; + var addContext = new AddContext(); repository.AddTraces(addContext, new RepeatedField() { new ResourceSpans { - Resource = CreateResource(name: name, instanceId: instanceId) + Resource = CreateResource(name: name, instanceId: instanceId), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: idPrefix + "1", spanId: idPrefix + "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(10)), + CreateSpan(traceId: idPrefix + "1", spanId: idPrefix + "1-2", startTime: s_testTime.AddMinutes(5), endTime: s_testTime.AddMinutes(10), parentSpanId: idPrefix + "1-1"), + CreateSpan(traceId: idPrefix + "2", spanId: idPrefix + "2-1", startTime: s_testTime.AddMinutes(6), endTime: s_testTime.AddMinutes(10)) + } + } + } + } + }); + + Assert.Equal(0, addContext.FailureCount); + + repository.AddLogs(addContext, new RepeatedField() + { + new ResourceLogs + { + Resource = CreateResource(name: name, instanceId: instanceId), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope(), + LogRecords = + { + CreateLogRecord(time: s_testTime, message: "Log entry!") + } + } + } } });