diff --git a/Directory.Packages.props b/Directory.Packages.props
index 574007a020..6253a1e046 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -101,6 +101,7 @@
+
diff --git a/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt b/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt
index 4d171bbb50..8ebfbb459a 100644
--- a/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Adapter/MSTest.TestAdapter/InternalAPI/InternalAPI.Unshipped.txt
@@ -60,3 +60,10 @@ static Microsoft.Testing.Extensions.RunSettingsProviderHelper.TryLoadRunSettings
static Microsoft.VisualStudio.TestTools.UnitTesting.MSTestTestNodeConverter.ToEmptyResultTestNode(Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.UnitTestElement! element, bool isTrxEnabled) -> Microsoft.Testing.Platform.Extensions.Messages.TestNode!
static readonly Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.AdapterTestProperties.DependenciesProperty -> Microsoft.VisualStudio.TestPlatform.ObjectModel.TestProperty!
static readonly Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.AdapterTestProperties.ResourceLocksProperty -> Microsoft.VisualStudio.TestPlatform.ObjectModel.TestProperty!
+Microsoft.VisualStudio.TestTools.UnitTesting.MSTestPlatformActivity
+Microsoft.VisualStudio.TestTools.UnitTesting.MSTestPlatformActivity.Dispose() -> void
+Microsoft.VisualStudio.TestTools.UnitTesting.MSTestPlatformActivity.MSTestPlatformActivity(Microsoft.Testing.Platform.Telemetry.IPlatformActivity! activity) -> void
+Microsoft.VisualStudio.TestTools.UnitTesting.MSTestPlatformActivity.RecordException(System.Exception! exception) -> void
+Microsoft.VisualStudio.TestTools.UnitTesting.MSTestPlatformActivity.SetFailed(string? description) -> void
+Microsoft.VisualStudio.TestTools.UnitTesting.MSTestPlatformActivity.SetTag(string! key, object? value) -> void
+static Microsoft.VisualStudio.TestTools.UnitTesting.MSTestPlatformActivity.TryEnable(System.IServiceProvider! serviceProvider) -> void
diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestPlatformActivity.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestPlatformActivity.cs
new file mode 100644
index 0000000000..1684f25d0e
--- /dev/null
+++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestPlatformActivity.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#if !WINDOWS_UWP
+using Microsoft.Testing.Platform.Telemetry;
+using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
+
+namespace Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Bridges the engine's dependency-free seam onto the platform's OpenTelemetry
+/// service, so MSTest fixture and test-method spans nest under the platform's test-case spans.
+///
+[SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")]
+internal sealed class MSTestPlatformActivity(IPlatformActivity activity) : IMSTestActivity
+{
+ public void SetTag(string key, object? value)
+ => activity.SetTag(key, value);
+
+ public void RecordException(Exception exception)
+ => activity.RecordException(exception);
+
+ public void SetFailed(string? description)
+ => activity.SetStatus(PlatformActivityStatusCode.Error, description);
+
+ public void Dispose()
+ => activity.Dispose();
+
+ ///
+ /// Installs the tracing factory on when the platform has an OpenTelemetry
+ /// service registered. Does nothing (leaving MSTest tracing disabled and free) otherwise.
+ ///
+ internal static void TryEnable(IServiceProvider serviceProvider)
+ {
+ if (serviceProvider.GetService(typeof(IPlatformOpenTelemetryService)) is not IPlatformOpenTelemetryService otelService)
+ {
+ MSTestInstrumentation.SetActivityFactory(null);
+ return;
+ }
+
+ MSTestInstrumentation.SetActivityFactory((name, tags)
+ // A non-ambient span is required, not an optimization - see the remarks on MSTestInstrumentation.
+ // Because the span is not ambient it cannot pick a parent up from the ambient context either, so it
+ // is parented explicitly to the same test-framework span the platform's test-case spans use, which
+ // keeps everything in one trace.
+ => otelService.StartNonAmbientActivity(name, tags, otelService.TestFrameworkActivity?.Id) is { } platformActivity
+ ? new MSTestPlatformActivity(platformActivity)
+ : null);
+ }
+}
+#endif
diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs
index 6f1169b68c..7633bcf5b2 100644
--- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs
+++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestTestFramework.cs
@@ -55,6 +55,10 @@ public MSTestTestFramework(MSTestExtension extension, Func
_configuration = new(serviceProvider.GetConfiguration());
_loggerFactory = serviceProvider.GetRequiredService();
PlatformServiceProvider.Instance.AdapterTraceLogger = new MTPTraceLogger(_loggerFactory.CreateLogger("mstest-trace"));
+
+ // Let the engine emit fixture/test-method spans that nest under the platform's test-case spans. This is a
+ // no-op unless the OpenTelemetry extension is registered.
+ MSTestPlatformActivity.TryEnable(serviceProvider);
}
public string Uid => _extension.Uid;
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs
index a66542a7df..00329abe31 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs
@@ -186,6 +186,16 @@ public async Task RunAssemblyInitializeAsync(TestContext testContext
// Perform a check again.
if (!IsAssemblyInitializeExecuted)
{
+ // Assembly initialize can dominate the wall-clock time of a run (spinning up a database,
+ // a browser, ...). Giving it its own span is what lets you tell "the test is slow" apart
+ // from "the assembly fixture is slow".
+ using IMSTestActivity? activity = MSTestInstrumentation.IsEnabled
+ ? MSTestInstrumentation.StartFixtureActivity(
+ MSTestInstrumentation.ActivityNames.AssemblyInitialize,
+ "assembly_initialize",
+ AssemblyInitializeMethod.DeclaringType?.FullName,
+ Assembly.GetName().Name)
+ : null;
try
{
AssemblyInitializationException = await FixtureMethodRunner.RunWithTimeoutAndCancellationAsync(
@@ -223,6 +233,13 @@ public async Task RunAssemblyInitializeAsync(TestContext testContext
}
finally
{
+ // RunWithTimeoutAndCancellationAsync *returns* (rather than throws) the failure for the
+ // timeout and cancellation paths, so the catch above is not enough to mark the span failed.
+ if (AssemblyInitializationException is not null)
+ {
+ activity?.RecordException(AssemblyInitializationException);
+ }
+
IsAssemblyInitializeExecuted = true;
}
}
@@ -273,6 +290,13 @@ private static TestFailedException GetTestFailedExceptionFromAssemblyInitializeE
try
{
await _assemblyInfoExecuteSyncSemaphore.WaitAsync().ConfigureAwait(false);
+ using IMSTestActivity? activity = MSTestInstrumentation.IsEnabled
+ ? MSTestInstrumentation.StartFixtureActivity(
+ MSTestInstrumentation.ActivityNames.AssemblyCleanup,
+ "assembly_cleanup",
+ AssemblyCleanupMethod.DeclaringType?.FullName,
+ Assembly.GetName().Name)
+ : null;
AssemblyCleanupException = await FixtureMethodRunner.RunWithTimeoutAndCancellationAsync(
() => AssemblyCleanupMethod.InvokeAsFixtureMethodAsync(
testContext,
@@ -283,6 +307,13 @@ private static TestFailedException GetTestFailedExceptionFromAssemblyInitializeE
ExecutionContext,
Resource.AssemblyCleanupWasCancelled,
Resource.AssemblyCleanupTimedOut).ConfigureAwait(false);
+
+ // RunWithTimeoutAndCancellationAsync returns (rather than throws) the failure for the timeout and
+ // cancellation paths, so record it here while the span is still open.
+ if (AssemblyCleanupException is not null)
+ {
+ activity?.RecordException(AssemblyCleanupException);
+ }
}
catch (Exception ex)
{
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Cleanup.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Cleanup.cs
index 25dcb2b9c4..f4b31b7f5c 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Cleanup.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Cleanup.cs
@@ -197,6 +197,14 @@ internal sealed partial class TestClassInfo
timeout = localTimeout;
}
+ using IMSTestActivity? activity = MSTestInstrumentation.IsEnabled
+ ? MSTestInstrumentation.StartFixtureActivity(
+ MSTestInstrumentation.ActivityNames.ClassCleanup,
+ "class_cleanup",
+ methodInfo.DeclaringType?.FullName,
+ methodInfo.DeclaringType?.Assembly.GetName().Name)
+ : null;
+
TestFailedException? result = await FixtureMethodRunner.RunWithTimeoutAndCancellationAsync(
() => methodInfo.InvokeAsFixtureMethodAsync(
testContext,
@@ -208,6 +216,11 @@ internal sealed partial class TestClassInfo
Resource.ClassCleanupWasCancelled,
Resource.ClassCleanupTimedOut).ConfigureAwait(false);
+ if (result is not null)
+ {
+ activity?.RecordException(result);
+ }
+
return result;
}
}
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Initializer.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Initializer.cs
index fb8581646d..16c9d5fb12 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Initializer.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.Initializer.cs
@@ -279,6 +279,16 @@ async Task DoRunAsync()
timeout = localTimeout;
}
+ // One span per class-initialize method (including inherited ones), so a slow or throwing class fixture is
+ // attributable in the trace instead of being folded into the first test of the class.
+ using IMSTestActivity? activity = MSTestInstrumentation.IsEnabled
+ ? MSTestInstrumentation.StartFixtureActivity(
+ MSTestInstrumentation.ActivityNames.ClassInitialize,
+ "class_initialize",
+ methodInfo.DeclaringType?.FullName,
+ methodInfo.DeclaringType?.Assembly.GetName().Name)
+ : null;
+
TestFailedException? result = await FixtureMethodRunner.RunWithTimeoutAndCancellationAsync(
() => methodInfo.InvokeAsFixtureMethodAsync(
testContext,
@@ -290,6 +300,11 @@ async Task DoRunAsync()
Resource.ClassInitializeWasCancelled,
Resource.ClassInitializeTimedOut).ConfigureAwait(false);
+ if (result is not null)
+ {
+ activity?.RecordException(result);
+ }
+
return result;
}
}
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Lifecycle.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Lifecycle.cs
index 0889d7ad5e..1741aaae08 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Lifecycle.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Lifecycle.cs
@@ -260,7 +260,15 @@ private async SynchronizationContextPreservingTask RunTestInitializeMethod
timeout = localTimeout;
}
- return await InvokeFixtureMethodAsync(
+ using IMSTestActivity? activity = MSTestInstrumentation.IsEnabled
+ ? MSTestInstrumentation.StartFixtureActivity(
+ MSTestInstrumentation.ActivityNames.TestInitialize,
+ "test_initialize",
+ methodInfo.DeclaringType?.FullName,
+ methodInfo.DeclaringType?.Assembly.GetName().Name)
+ : null;
+
+ TestFailedException? result = await InvokeFixtureMethodAsync(
methodInfo,
classInstance,
arguments: null,
@@ -268,6 +276,13 @@ private async SynchronizationContextPreservingTask RunTestInitializeMethod
timeoutTokenSource,
Resource.TestInitializeWasCancelled,
Resource.TestInitializeTimedOut).ConfigureAwait(false);
+
+ if (result is not null)
+ {
+ activity?.RecordException(result);
+ }
+
+ return result;
}
private async SynchronizationContextPreservingTask InvokeGlobalInitializeMethodAsync(MethodInfo methodInfo, TimeoutInfo? timeoutInfo, CancellationTokenSource? timeoutTokenSource)
@@ -288,7 +303,15 @@ private async SynchronizationContextPreservingTask RunTestInitializeMethod
timeout = localTimeout;
}
- return await InvokeFixtureMethodAsync(
+ using IMSTestActivity? activity = MSTestInstrumentation.IsEnabled
+ ? MSTestInstrumentation.StartFixtureActivity(
+ MSTestInstrumentation.ActivityNames.TestCleanup,
+ "test_cleanup",
+ methodInfo.DeclaringType?.FullName,
+ methodInfo.DeclaringType?.Assembly.GetName().Name)
+ : null;
+
+ TestFailedException? result = await InvokeFixtureMethodAsync(
methodInfo,
classInstance,
arguments: null,
@@ -296,6 +319,13 @@ private async SynchronizationContextPreservingTask RunTestInitializeMethod
timeoutTokenSource,
Resource.TestCleanupWasCancelled,
Resource.TestCleanupTimedOut).ConfigureAwait(false);
+
+ if (result is not null)
+ {
+ activity?.RecordException(result);
+ }
+
+ return result;
}
private async SynchronizationContextPreservingTask InvokeGlobalCleanupMethodAsync(MethodInfo methodInfo, TimeoutInfo? timeoutInfo, CancellationTokenSource? timeoutTokenSource)
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs
index aba0af1dab..8ede15e79e 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs
@@ -19,6 +19,14 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution;
[StackTraceHidden]
internal sealed partial class TestMethodRunner
{
+ ///
+ /// The aggregate outcome names reported on the span, matching the OpenTelemetry
+ /// test.case.result.status enum.
+ ///
+ private const string FailedOutcomeName = "fail";
+ private const string PassedOutcomeName = "pass";
+ private const string SkippedOutcomeName = "skipped";
+
///
/// Test context which needs to be passed to the various methods of the test.
///
@@ -73,7 +81,23 @@ internal async Task ExecuteAsync(string? initializationLogs, strin
{
_testContext.Context.TestRunCount++;
- TestResult[]? result = null;
+ // Never null once the try/catch below has run, but the compiler cannot prove that inside finally.
+ // Starting from an empty array keeps the null analysis honest without changing behavior on any
+ // reachable path (see the earlier review thread on this line).
+ TestResult[] result = [];
+ bool exceptionRecorded = false;
+
+ // The engine-level span for the whole test method: it wraps test initialize, the body, test cleanup and any
+ // data-row expansion, so the platform's test-case span gains an explanation of where the time went.
+ using IMSTestActivity? activity = MSTestInstrumentation.IsEnabled
+ ? MSTestInstrumentation.StartActivity(
+ MSTestInstrumentation.ActivityNames.TestMethod,
+ [
+ new(MSTestInstrumentation.Attributes.TestMethod, _test.DisplayName ?? _test.Name),
+ new(MSTestInstrumentation.Attributes.TestClass, _test.FullClassName),
+ new(MSTestInstrumentation.Attributes.TestAssembly, _test.AssemblyName),
+ ])
+ : null;
try
{
@@ -84,6 +108,12 @@ internal async Task ExecuteAsync(string? initializationLogs, strin
// NOTE: We intentionally don't have any special casing for TestFailedException in this code path.
// It's handled down by TestMethodInfo which also unwraps TargetInvocationException.
// RunTestMethodAsync is not supposed to throw any exceptions. So it's always an **error** if we got an exception here.
+ if (activity is not null)
+ {
+ activity.RecordException(ex);
+ exceptionRecorded = true;
+ }
+
result =
[
new TestResult
@@ -96,16 +126,103 @@ internal async Task ExecuteAsync(string? initializationLogs, strin
finally
{
// Assembly initialize and class initialize logs are pre-pended to the first result.
- TestResult firstResult = result![0];
+ TestResult firstResult = result[0];
firstResult.LogOutput = initializationLogs + firstResult.LogOutput;
firstResult.LogError = initializationErrorLogs + firstResult.LogError;
firstResult.DebugTrace = initializationTrace + firstResult.DebugTrace;
firstResult.TestContextMessages = initializationTestContextMessages + firstResult.TestContextMessages;
+
+ if (activity is not null)
+ {
+ // A folded data-driven test produces several results for a single method invocation; reporting the
+ // count makes that visible in the trace instead of looking like a single test.
+ // Note the name deliberately differs from the platform's `test.case.result.count` *metric*, which
+ // counts test cases per outcome across the run.
+ activity.SetTag("test.case.data_row.count", result.Length);
+ string aggregateOutcomeName = GetAggregateOutcomeName(result);
+ activity.SetTag("test.case.result.status", aggregateOutcomeName);
+
+ // An ordinary assertion failure is returned in the TestResult rather than thrown, so nothing above
+ // recorded it. Without this the span for a failed test would stay Unset and look green in a trace.
+ if (!exceptionRecorded && aggregateOutcomeName == FailedOutcomeName)
+ {
+ MarkActivityFailed(activity, result);
+ }
+ }
}
return result;
}
+ ///
+ /// Marks the span for a test whose failure was returned rather than thrown, preferring the recorded exception
+ /// so the trace carries the assertion message.
+ ///
+ private static void MarkActivityFailed(IMSTestActivity activity, TestResult[] results)
+ {
+ MSTestSettings settings = MSTestSettings.CurrentSettings;
+ foreach (TestResult testResult in results)
+ {
+ if (MapOutcomeName(testResult.Outcome, settings) != FailedOutcomeName)
+ {
+ continue;
+ }
+
+ if (testResult.TestFailureException is { } failureException)
+ {
+ activity.RecordException(failureException);
+ }
+ else
+ {
+ activity.SetFailed(testResult.Outcome.ToString());
+ }
+
+ return;
+ }
+ }
+
+ private static string GetAggregateOutcomeName(TestResult[] results)
+ {
+ bool anyFailed = false;
+ bool allSkipped = true;
+ MSTestSettings settings = MSTestSettings.CurrentSettings;
+ foreach (TestResult testResult in results)
+ {
+ switch (MapOutcomeName(testResult.Outcome, settings))
+ {
+ case FailedOutcomeName:
+ anyFailed = true;
+ allSkipped = false;
+ break;
+ case SkippedOutcomeName:
+ break;
+ default:
+ allSkipped = false;
+ break;
+ }
+ }
+
+ return anyFailed ? FailedOutcomeName : allSkipped ? SkippedOutcomeName : PassedOutcomeName;
+ }
+
+ ///
+ /// Maps a single outcome onto the span's result status using the same rules the adapter applies when it
+ /// reports the result, so a span never disagrees with the reported outcome.
+ ///
+ ///
+ /// Mirrors UnitTestOutcomeHelper.ToTestOutcome, which lives in the adapter layer above and cannot be
+ /// referenced from here: this assembly is deliberately free of the VSTest object model. Keep the two in sync.
+ ///
+ private static string MapOutcomeName(UnitTestOutcome outcome, MSTestSettings settings)
+ => outcome switch
+ {
+ UnitTestOutcome.Passed => PassedOutcomeName,
+ UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Timeout or UnitTestOutcome.Aborted or UnitTestOutcome.Unknown => FailedOutcomeName,
+ UnitTestOutcome.NotRunnable => settings.MapNotRunnableToFailed ? FailedOutcomeName : SkippedOutcomeName,
+ UnitTestOutcome.Inconclusive => settings.MapInconclusiveToFailed ? FailedOutcomeName : SkippedOutcomeName,
+ _ => SkippedOutcomeName,
+ };
+
///
/// Runs the test method.
///
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/InternalAPI/InternalAPI.Unshipped.txt b/src/Adapter/MSTestAdapter.PlatformServices/InternalAPI/InternalAPI.Unshipped.txt
index f1cbbca2d5..e9f456399b 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Adapter/MSTestAdapter.PlatformServices/InternalAPI/InternalAPI.Unshipped.txt
@@ -12,6 +12,21 @@
*REMOVED*Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.TestContextImplementation.WriteTrace(char value) -> void
*REMOVED*Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.TestContextImplementation.WriteTrace(string? value) -> void
#nullable enable
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.AssemblyCleanup = "MSTest.AssemblyCleanup" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.AssemblyInitialize = "MSTest.AssemblyInitialize" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.ClassCleanup = "MSTest.ClassCleanup" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.ClassInitialize = "MSTest.ClassInitialize" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.Discovery = "MSTest.Discovery" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.TestCleanup = "MSTest.TestCleanup" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.TestInitialize = "MSTest.TestInitialize" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames.TestMethod = "MSTest.TestMethod" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes.DataRowIndex = "test.case.data_row.index" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes.FixtureKind = "test.fixture.kind" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes.RetryAttempt = "test.case.retry.attempt" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes.TestAssembly = "test.assembly.name" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes.TestClass = "test.suite.name" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes.TestMethod = "test.case.name" -> string!
+const Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes.TimeoutMilliseconds = "test.case.timeout" -> string!
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.AsyncReaderWriterLock
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.AsyncReaderWriterLock.AcquireReaderAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.AsyncReaderWriterLock.AcquireWriterAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
@@ -53,6 +68,13 @@ Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.UnitTestRunner.
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.UnitTestRunner.RunSingleTestAsync(Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.UnitTestElement! unitTestElement, System.Collections.Generic.IDictionary! testContextProperties, System.Collections.Generic.IDictionary! lifecycleContextProperties, Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface.IAdapterMessageLogger! messageLogger) -> System.Threading.Tasks.Task!
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Helpers.FixtureKind.GlobalTestCleanup = 7 -> Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Helpers.FixtureKind
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Helpers.FixtureKind.GlobalTestInitialize = 6 -> Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Helpers.FixtureKind
+Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.IMSTestActivity
+Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.IMSTestActivity.RecordException(System.Exception! exception) -> void
+Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.IMSTestActivity.SetTag(string! key, object? value) -> void
+Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.IMSTestActivity.SetFailed(string? description) -> void
+Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation
+Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.ActivityNames
+Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.Attributes
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestSettings.DeclaredDependencies.get -> Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestDependencyDeclaration![]?
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestSettings.GlobalTestCleanupTimeout.get -> int
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestSettings.GlobalTestInitializeTimeout.get -> int
@@ -96,6 +118,10 @@ static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.TestMeth
static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution.UnitTestRunner.CreateLiveOutputWriter(System.IO.Stream! standardOutput, System.Text.Encoding! encoding) -> System.IO.TextWriter!
static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Helpers.RuntimeContext.IsMultiThreaded.get -> bool
static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestDiscovererHelpers.GetSettingsExceptionMessage(Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.AdapterSettingsException! ex) -> string!
+static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.IsEnabled.get -> bool
+static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.SetActivityFactory(System.Func>?, Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.IMSTestActivity?>? activityFactory) -> void
+static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.StartActivity(string! name, System.Collections.Generic.IEnumerable>? tags = null) -> Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.IMSTestActivity?
+static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestInstrumentation.StartFixtureActivity(string! name, string! fixtureKind, string? owningType, string? assemblyName = null) -> Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.IMSTestActivity?
static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.ResourceLockInfo.Decode(string! encoded) -> Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.ResourceLockInfo!
static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.ResourceLockInfo.Encode(Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.ResourceLockInfo! info) -> string!
static Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.TestDependencyInfo.Decode(string! encoded) -> Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel.TestDependencyInfo!
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestInstrumentation.cs b/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestInstrumentation.cs
new file mode 100644
index 0000000000..de79ec3bac
--- /dev/null
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestInstrumentation.cs
@@ -0,0 +1,125 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
+
+///
+/// A distributed-tracing span opened by the MSTest engine.
+///
+internal interface IMSTestActivity : IDisposable
+{
+ ///
+ /// Adds an attribute to the span.
+ ///
+ void SetTag(string key, object? value);
+
+ ///
+ /// Marks the span as failed without recording an exception event.
+ ///
+ void SetFailed(string? description);
+
+ ///
+ /// Records an exception on the span and marks it as failed.
+ ///
+ void RecordException(Exception exception);
+}
+
+///
+/// The MSTest engine's tracing seam.
+///
+///
+///
+/// The engine (this assembly) is deliberately free of any Microsoft.Testing.Platform or
+/// System.Diagnostics.DiagnosticSource dependency: it also runs under VSTest and under target frameworks where
+/// those are not available. So instead of creating activities itself, it asks a factory that the hosting adapter
+/// installs at start-up (see MSTestTestFramework, which forwards to the platform's OpenTelemetry service).
+///
+///
+/// When nothing installs a factory - which is the case for VSTest, or when the OpenTelemetry extension is not
+/// registered - returns and every call site degrades to a null
+/// check, so the cost is a single static field read.
+///
+///
+/// This is what makes the *inside* of a test observable: without it a trace shows one span per test case and no
+/// explanation for a slow test, whereas with it you can see that, say, AssemblyInitialize took 8 of the 9
+/// seconds, or that a fixture and not the test body threw.
+///
+///
+/// Spans created here are never ambient. MSTest captures the
+/// after AssemblyInitialize, ClassInitialize and the test-level fixtures so that async-locals set there
+/// flow to every subsequent test (see TestMethodRunner.ExecuteTestAsync). The ambient activity is itself an
+/// async-local, so an ambient span would be captured into that context and restored for the rest of the run -
+/// parenting every later span, and any activity the user's own code starts, to a span that has long since ended.
+/// The factory therefore creates spans that are timed and exported but never published as the current activity, and
+/// parents them explicitly instead.
+///
+///
+internal static class MSTestInstrumentation
+{
+ ///
+ /// Span names, kept here so the tracing vocabulary is defined in one place.
+ ///
+ internal static class ActivityNames
+ {
+ internal const string AssemblyInitialize = "MSTest.AssemblyInitialize";
+ internal const string AssemblyCleanup = "MSTest.AssemblyCleanup";
+ internal const string ClassInitialize = "MSTest.ClassInitialize";
+ internal const string ClassCleanup = "MSTest.ClassCleanup";
+ internal const string TestInitialize = "MSTest.TestInitialize";
+ internal const string TestCleanup = "MSTest.TestCleanup";
+ internal const string TestMethod = "MSTest.TestMethod";
+ internal const string Discovery = "MSTest.Discovery";
+ }
+
+ ///
+ /// Attribute names emitted by the MSTest engine, on top of the platform's test.* conventions.
+ ///
+ internal static class Attributes
+ {
+ internal const string FixtureKind = "test.fixture.kind";
+ internal const string TestClass = "test.suite.name";
+ internal const string TestMethod = "test.case.name";
+ internal const string TestAssembly = "test.assembly.name";
+ internal const string RetryAttempt = "test.case.retry.attempt";
+ internal const string DataRowIndex = "test.case.data_row.index";
+ internal const string TimeoutMilliseconds = "test.case.timeout";
+ }
+
+ private static volatile Func>?, IMSTestActivity?>? s_activityFactory;
+
+ ///
+ /// Gets a value indicating whether a tracing factory has been installed. Call sites should use this to avoid
+ /// building tag payloads when tracing is off.
+ ///
+ internal static bool IsEnabled => s_activityFactory is not null;
+
+ ///
+ /// Installs (or, with , removes) the factory used to create spans.
+ ///
+ internal static void SetActivityFactory(Func>?, IMSTestActivity?>? activityFactory)
+ => s_activityFactory = activityFactory;
+
+ ///
+ /// Starts a span, or returns when tracing is not configured.
+ ///
+ internal static IMSTestActivity? StartActivity(string name, IEnumerable>? tags = null)
+ => s_activityFactory?.Invoke(name, tags);
+
+ ///
+ /// Starts a span describing a fixture method (assembly/class/test initialize or cleanup).
+ ///
+ internal static IMSTestActivity? StartFixtureActivity(string name, string fixtureKind, string? owningType, string? assemblyName = null)
+ {
+ // Read the volatile field once: the tag array must not be built when tracing is off.
+ Func>?, IMSTestActivity?>? factory = s_activityFactory;
+ return factory is null
+ ? null
+ : factory(
+ name,
+ [
+ new(Attributes.FixtureKind, fixtureKind),
+ new(Attributes.TestClass, owningType),
+ new(Attributes.TestAssembly, assemblyName),
+ ]);
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/ActivityWrapper.cs b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/ActivityWrapper.cs
index f7bc97c329..07a677eecf 100644
--- a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/ActivityWrapper.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/ActivityWrapper.cs
@@ -5,15 +5,92 @@
namespace Microsoft.Testing.Extensions.OpenTelemetry;
-internal sealed class ActivityWrapper(Activity activity) : IPlatformActivity
+internal sealed class ActivityWrapper(Activity activity, bool isAmbient = true) : IPlatformActivity
{
+ private const string ExceptionEventName = "exception";
+ private const string ExceptionTypeTag = "exception.type";
+ private const string ExceptionMessageTag = "exception.message";
+ private const string ExceptionStackTraceTag = "exception.stacktrace";
+
public string? Id => activity.Id;
+ public string? TraceId => activity.IdFormat == ActivityIdFormat.W3C ? activity.TraceId.ToHexString() : null;
+
+ public string? SpanId => activity.IdFormat == ActivityIdFormat.W3C ? activity.SpanId.ToHexString() : null;
+
+ public bool IsRecording => activity.IsAllDataRequested;
+
public IPlatformActivity SetTag(string key, object? value)
{
activity.SetTag(key, value);
return this;
}
- public void Dispose() => activity.Dispose();
+ public IPlatformActivity SetStatus(PlatformActivityStatusCode statusCode, string? description = null)
+ {
+ activity.SetStatus(
+ statusCode switch
+ {
+ PlatformActivityStatusCode.Ok => ActivityStatusCode.Ok,
+ PlatformActivityStatusCode.Error => ActivityStatusCode.Error,
+ _ => ActivityStatusCode.Unset,
+ },
+ description);
+ return this;
+ }
+
+ public IPlatformActivity AddEvent(string name, IEnumerable>? tags = null, DateTimeOffset timestamp = default)
+ {
+ ActivityTagsCollection? tagsCollection = null;
+ if (tags is not null)
+ {
+ tagsCollection = [];
+ foreach (KeyValuePair tag in tags)
+ {
+ tagsCollection[tag.Key] = tag.Value;
+ }
+ }
+
+ activity.AddEvent(new ActivityEvent(name, timestamp, tagsCollection));
+ return this;
+ }
+
+ public IPlatformActivity RecordException(Exception exception, IEnumerable>? additionalTags = null)
+ {
+ // exception.escaped is deliberately omitted: it is deprecated upstream, and a test failure is reported
+ // through the result attributes rather than by letting the exception escape the span.
+ ActivityTagsCollection tags = new()
+ {
+ [ExceptionTypeTag] = exception.GetType().FullName,
+ [ExceptionMessageTag] = exception.Message,
+ [ExceptionStackTraceTag] = exception.ToString(),
+ };
+
+ if (additionalTags is not null)
+ {
+ foreach (KeyValuePair tag in additionalTags)
+ {
+ tags[tag.Key] = tag.Value;
+ }
+ }
+
+ activity.AddEvent(new ActivityEvent(ExceptionEventName, tags: tags));
+ activity.SetStatus(ActivityStatusCode.Error, exception.Message);
+ return this;
+ }
+
+ public void Dispose()
+ {
+ if (isAmbient)
+ {
+ activity.Dispose();
+ return;
+ }
+
+ // A non-ambient activity never became Activity.Current, so stopping it must not touch the ambient
+ // activity either. Activity.Stop() reassigns Activity.Current to its parent, so save and restore around it.
+ Activity? current = Activity.Current;
+ activity.Dispose();
+ Activity.Current = current;
+ }
}
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/CounterWrapper.cs b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/CounterWrapper.cs
index 1cde3b1643..6717ab3ff9 100644
--- a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/CounterWrapper.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/CounterWrapper.cs
@@ -17,4 +17,15 @@ public CounterWrapper(Counter counter)
?? throw new ArgumentNullException(nameof(counter));
public void Add(T delta) => _counter.Add(delta);
+
+ public void Add(T delta, IEnumerable>? tags)
+ {
+ if (tags is null)
+ {
+ _counter.Add(delta);
+ return;
+ }
+
+ _counter.Add(delta, MeasurementTags.ToArray(tags));
+ }
}
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/HistogramWrapper.cs b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/HistogramWrapper.cs
index 8c6f2062b0..59fe4a3dca 100644
--- a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/HistogramWrapper.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/HistogramWrapper.cs
@@ -18,4 +18,15 @@ public HistogramWrapper(Histogram histogram)
public void Record(T value)
=> _histogram.Record(value);
+
+ public void Record(T value, IEnumerable>? tags)
+ {
+ if (tags is null)
+ {
+ _histogram.Record(value);
+ return;
+ }
+
+ _histogram.Record(value, MeasurementTags.ToArray(tags));
+ }
}
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/InternalAPI/InternalAPI.Unshipped.txt
index 7dc5c58110..58d983df1b 100644
--- a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/InternalAPI/InternalAPI.Unshipped.txt
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/InternalAPI/InternalAPI.Unshipped.txt
@@ -1 +1,33 @@
#nullable enable
+Microsoft.Testing.Extensions.OpenTelemetry.ActivityWrapper.AddEvent(string! name, System.Collections.Generic.IEnumerable>? tags = null, System.DateTimeOffset timestamp = default(System.DateTimeOffset)) -> Microsoft.Testing.Platform.Telemetry.IPlatformActivity!
+Microsoft.Testing.Extensions.OpenTelemetry.ActivityWrapper.IsRecording.get -> bool
+Microsoft.Testing.Extensions.OpenTelemetry.ActivityWrapper.RecordException(System.Exception! exception, System.Collections.Generic.IEnumerable>? additionalTags = null) -> Microsoft.Testing.Platform.Telemetry.IPlatformActivity!
+Microsoft.Testing.Extensions.OpenTelemetry.ActivityWrapper.SetStatus(Microsoft.Testing.Platform.Telemetry.PlatformActivityStatusCode statusCode, string? description = null) -> Microsoft.Testing.Platform.Telemetry.IPlatformActivity!
+Microsoft.Testing.Extensions.OpenTelemetry.ActivityWrapper.SpanId.get -> string?
+Microsoft.Testing.Extensions.OpenTelemetry.ActivityWrapper.TraceId.get -> string?
+Microsoft.Testing.Extensions.OpenTelemetry.CounterWrapper.Add(T delta, System.Collections.Generic.IEnumerable>? tags) -> void
+Microsoft.Testing.Extensions.OpenTelemetry.HistogramWrapper.Record(T value, System.Collections.Generic.IEnumerable>? tags) -> void
+Microsoft.Testing.Extensions.OpenTelemetry.MeasurementTags
+Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryPlatformService.CreateObservableGauge(string! name, System.Func! observeValue, string? unit = null, string? description = null) -> void
+Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryPlatformService.CreateUpDownCounter(string! name, string? unit = null, string? description = null, System.Collections.Generic.IEnumerable>? tags = null) -> Microsoft.Testing.Platform.Telemetry.IUpDownCounter!
+Microsoft.Testing.Extensions.OpenTelemetry.UpDownCounterWrapper
+Microsoft.Testing.Extensions.OpenTelemetry.UpDownCounterWrapper.Add(T delta, System.Collections.Generic.IEnumerable>? tags = null) -> void
+Microsoft.Testing.Extensions.OpenTelemetry.UpDownCounterWrapper.UpDownCounterWrapper(System.Diagnostics.Metrics.UpDownCounter! counter) -> void
+static Microsoft.Testing.Extensions.OpenTelemetry.MeasurementTags.ToArray(System.Collections.Generic.IEnumerable>? tags) -> System.Collections.Generic.KeyValuePair[]!
+const Microsoft.Testing.Extensions.OpenTelemetry.TestingPlatformResourceDetector.UnknownServiceName = "unknown_test_service" -> string!
+Microsoft.Testing.Extensions.OpenTelemetry.TestingPlatformResourceDetector
+static Microsoft.Testing.Extensions.OpenTelemetry.TestingPlatformResourceDetector.GetResourceAttributes() -> System.Collections.Generic.IEnumerable>!
+static Microsoft.Testing.Extensions.OpenTelemetry.TestingPlatformResourceDetector.GetServiceName() -> string!
+static Microsoft.Testing.Extensions.OpenTelemetry.TestingPlatformResourceDetector.GetServiceVersion() -> string?
+const Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryEnvironmentVariables.ExporterOtlpEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" -> string!
+const Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryEnvironmentVariables.MetricsExporter = "OTEL_METRICS_EXPORTER" -> string!
+const Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryEnvironmentVariables.SdkDisabled = "OTEL_SDK_DISABLED" -> string!
+const Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryEnvironmentVariables.ServiceName = "OTEL_SERVICE_NAME" -> string!
+const Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryEnvironmentVariables.TracesExporter = "OTEL_TRACES_EXPORTER" -> string!
+Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryEnvironmentVariables
+static Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryEnvironmentVariables.IsNullOrWhiteSpace(string? value) -> bool
+Microsoft.Testing.Extensions.OpenTelemetry.ActivityWrapper.ActivityWrapper(System.Diagnostics.Activity! activity, bool isAmbient = true) -> void
+Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryPlatformService.HasCurrentActivity.get -> bool
+Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryPlatformService.RootTraceState.get -> string?
+Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryPlatformService.RootTraceState.set -> void
+Microsoft.Testing.Extensions.OpenTelemetry.OpenTelemetryPlatformService.StartNonAmbientActivity(string! name, System.Collections.Generic.IEnumerable>? tags = null, string? parentId = null) -> Microsoft.Testing.Platform.Telemetry.IPlatformActivity?
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/MeasurementTags.cs b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/MeasurementTags.cs
new file mode 100644
index 0000000000..af1edf17cf
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/MeasurementTags.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.Testing.Extensions.OpenTelemetry;
+
+///
+/// Converts the platform's dependency-free tag representation into the array shape accepted by
+/// System.Diagnostics.Metrics instruments.
+///
+internal static class MeasurementTags
+{
+ private static readonly KeyValuePair[] Empty = [];
+
+ public static KeyValuePair[] ToArray(IEnumerable>? tags)
+ => tags switch
+ {
+ null => Empty,
+ KeyValuePair[] array => array,
+ ICollection> { Count: 0 } => Empty,
+ _ => CopyToArray(tags),
+ };
+
+ private static KeyValuePair[] CopyToArray(IEnumerable> tags)
+ {
+ List> buffer = [];
+ foreach (KeyValuePair tag in tags)
+ {
+ buffer.Add(tag);
+ }
+
+ return buffer.ToArray();
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/Microsoft.Testing.Extensions.OpenTelemetry.csproj b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/Microsoft.Testing.Extensions.OpenTelemetry.csproj
index f219d33a8c..189b851529 100644
--- a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/Microsoft.Testing.Extensions.OpenTelemetry.csproj
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/Microsoft.Testing.Extensions.OpenTelemetry.csproj
@@ -33,6 +33,7 @@ This package provides Open Telemetry for the platform.]]>
+
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/OpenTelemetryEnvironmentVariables.cs b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/OpenTelemetryEnvironmentVariables.cs
new file mode 100644
index 0000000000..e827dd1720
--- /dev/null
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/OpenTelemetryEnvironmentVariables.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Microsoft.Testing.Extensions.OpenTelemetry;
+
+///
+/// The subset of the standard OpenTelemetry SDK environment variables honored by the turnkey configuration.
+///
+///
+/// These names are defined by the OpenTelemetry specification rather than by Microsoft.Testing.Platform, which is
+/// why they live in the extension rather than in the platform's own environment variable constants.
+///
+internal static class OpenTelemetryEnvironmentVariables
+{
+ internal const string ServiceName = "OTEL_SERVICE_NAME";
+ internal const string ExporterOtlpEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT";
+ internal const string TracesExporter = "OTEL_TRACES_EXPORTER";
+ internal const string MetricsExporter = "OTEL_METRICS_EXPORTER";
+ internal const string SdkDisabled = "OTEL_SDK_DISABLED";
+
+ internal static bool IsNullOrWhiteSpace([NotNullWhen(false)] string? value)
+ {
+ if (value is null)
+ {
+ return true;
+ }
+
+ for (int i = 0; i < value.Length; i++)
+ {
+ if (!char.IsWhiteSpace(value[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/OpenTelemetryPlatformService.cs b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/OpenTelemetryPlatformService.cs
index b18553a00c..9c64a7178e 100644
--- a/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/OpenTelemetryPlatformService.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.OpenTelemetry/OpenTelemetryPlatformService.cs
@@ -14,22 +14,80 @@ internal sealed class OpenTelemetryPlatformService : IPlatformOpenTelemetryServi
private readonly ActivitySource _activitySource = new(ActivitySourceName, ExtensionVersion.DefaultSemVer);
private readonly Meter _meter = new(MeterName, ExtensionVersion.DefaultSemVer);
+ private readonly List