diff --git a/CodingWithCalvin.Otel4Vsix.slnx b/CodingWithCalvin.Otel4Vsix.slnx
index 8a7a973..e7e5385 100644
--- a/CodingWithCalvin.Otel4Vsix.slnx
+++ b/CodingWithCalvin.Otel4Vsix.slnx
@@ -1,3 +1,8 @@
-
+
+
+
+
+
+
diff --git a/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs b/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs
index 210de9c..d4a7838 100644
--- a/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs
+++ b/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs
@@ -1,5 +1,6 @@
using System;
using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("Otel4Vsix")]
@@ -19,3 +20,5 @@
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0")]
+
+[assembly: InternalsVisibleTo("CodingWithCalvin.Otel4Vsix.Tests")]
diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/ActivitySourceProviderTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/ActivitySourceProviderTests.cs
new file mode 100644
index 0000000..949fe69
--- /dev/null
+++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/ActivitySourceProviderTests.cs
@@ -0,0 +1,246 @@
+using System;
+using System.Diagnostics;
+using FluentAssertions;
+using Otel4Vsix.Tracing;
+using Xunit;
+
+namespace CodingWithCalvin.Otel4Vsix.Tests
+{
+ public class ActivitySourceProviderTests : IDisposable
+ {
+ private ActivitySourceProvider _provider;
+ private ActivityListener _listener;
+
+ public ActivitySourceProviderTests()
+ {
+ // Set up an activity listener so activities are actually created
+ _listener = new ActivityListener
+ {
+ ShouldListenTo = _ => true,
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData
+ };
+ ActivitySource.AddActivityListener(_listener);
+ }
+
+ public void Dispose()
+ {
+ _provider?.Dispose();
+ _listener?.Dispose();
+ }
+
+ [Fact]
+ public void Constructor_WithNullServiceName_ThrowsArgumentNullException()
+ {
+ Action act = () => new ActivitySourceProvider(null, "1.0.0");
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("serviceName");
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyServiceName_ThrowsArgumentNullException()
+ {
+ Action act = () => new ActivitySourceProvider(string.Empty, "1.0.0");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_WithWhitespaceServiceName_ThrowsArgumentNullException()
+ {
+ Action act = () => new ActivitySourceProvider(" ", "1.0.0");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_WithValidName_CreatesActivitySource()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ _provider.ActivitySource.Should().NotBeNull();
+ _provider.ActivitySource.Name.Should().Be("TestService");
+ _provider.ActivitySource.Version.Should().Be("1.0.0");
+ }
+
+ [Fact]
+ public void Constructor_WithNullVersion_UsesDefaultVersion()
+ {
+ _provider = new ActivitySourceProvider("TestService", null);
+
+ _provider.ActivitySource.Should().NotBeNull();
+ _provider.ActivitySource.Version.Should().Be("1.0.0");
+ }
+
+ [Fact]
+ public void ActivitySource_BeforeDispose_ReturnsActivitySource()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ var activitySource = _provider.ActivitySource;
+
+ activitySource.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void ActivitySource_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => { var _ = _provider.ActivitySource; };
+
+ act.Should().Throw()
+ .And.ObjectName.Should().Be(nameof(ActivitySourceProvider));
+ }
+
+ [Fact]
+ public void StartActivity_CreatesActivity()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartActivity("TestActivity");
+
+ activity.Should().NotBeNull();
+ activity.OperationName.Should().Be("TestActivity");
+ }
+
+ [Fact]
+ public void StartActivity_WithKind_SetsActivityKind()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartActivity("TestActivity", ActivityKind.Client);
+
+ activity.Should().NotBeNull();
+ activity.Kind.Should().Be(ActivityKind.Client);
+ }
+
+ [Fact]
+ public void StartActivity_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => _provider.StartActivity("TestActivity");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void StartCommandActivity_CreatesActivityWithCorrectName()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartCommandActivity("MyCommand");
+
+ activity.Should().NotBeNull();
+ activity.OperationName.Should().Be("Command: MyCommand");
+ }
+
+ [Fact]
+ public void StartCommandActivity_SetsCommandNameTag()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartCommandActivity("MyCommand");
+
+ activity.Should().NotBeNull();
+ activity.GetTagItem("vs.command.name").Should().Be("MyCommand");
+ }
+
+ [Fact]
+ public void StartCommandActivity_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => _provider.StartCommandActivity("MyCommand");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void StartToolWindowActivity_CreatesActivityWithCorrectName()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartToolWindowActivity("SolutionExplorer", "Open");
+
+ activity.Should().NotBeNull();
+ activity.OperationName.Should().Be("ToolWindow: SolutionExplorer.Open");
+ }
+
+ [Fact]
+ public void StartToolWindowActivity_SetsCorrectTags()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartToolWindowActivity("SolutionExplorer", "Open");
+
+ activity.Should().NotBeNull();
+ activity.GetTagItem("vs.toolwindow.name").Should().Be("SolutionExplorer");
+ activity.GetTagItem("vs.toolwindow.operation").Should().Be("Open");
+ }
+
+ [Fact]
+ public void StartToolWindowActivity_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => _provider.StartToolWindowActivity("Window", "Op");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void StartDocumentActivity_CreatesActivityWithCorrectName()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartDocumentActivity("C:\\file.cs", "Open");
+
+ activity.Should().NotBeNull();
+ activity.OperationName.Should().Be("Document: Open");
+ }
+
+ [Fact]
+ public void StartDocumentActivity_SetsCorrectTags()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ using var activity = _provider.StartDocumentActivity("C:\\file.cs", "Save");
+
+ activity.Should().NotBeNull();
+ activity.GetTagItem("vs.document.path").Should().Be("C:\\file.cs");
+ activity.GetTagItem("vs.document.operation").Should().Be("Save");
+ }
+
+ [Fact]
+ public void StartDocumentActivity_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => _provider.StartDocumentActivity("file.cs", "Open");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Dispose_MultipleTimes_DoesNotThrow()
+ {
+ _provider = new ActivitySourceProvider("TestService", "1.0.0");
+
+ Action act = () =>
+ {
+ _provider.Dispose();
+ _provider.Dispose();
+ _provider.Dispose();
+ };
+
+ act.Should().NotThrow();
+ }
+ }
+}
diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/CodingWithCalvin.Otel4Vsix.Tests.csproj b/tests/CodingWithCalvin.Otel4Vsix.Tests/CodingWithCalvin.Otel4Vsix.Tests.csproj
new file mode 100644
index 0000000..133ed98
--- /dev/null
+++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/CodingWithCalvin.Otel4Vsix.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+
+ net48
+ latest
+ false
+ true
+ CodingWithCalvin.Otel4Vsix.Tests
+ CodingWithCalvin.Otel4Vsix.Tests
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/ExceptionTrackerTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/ExceptionTrackerTests.cs
new file mode 100644
index 0000000..6bd30f3
--- /dev/null
+++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/ExceptionTrackerTests.cs
@@ -0,0 +1,501 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Otel4Vsix.Exceptions;
+using Xunit;
+
+namespace CodingWithCalvin.Otel4Vsix.Tests
+{
+ public class ExceptionTrackerTests : IDisposable
+ {
+ private ExceptionTracker _tracker;
+ private Mock _mockLogger;
+ private ActivityListener _listener;
+
+ public ExceptionTrackerTests()
+ {
+ _mockLogger = new Mock();
+ _mockLogger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true);
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny())).Returns(Mock.Of());
+
+ // Set up an activity listener for activity-related tests
+ _listener = new ActivityListener
+ {
+ ShouldListenTo = _ => true,
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData
+ };
+ ActivitySource.AddActivityListener(_listener);
+ }
+
+ public void Dispose()
+ {
+ _tracker?.Dispose();
+ _listener?.Dispose();
+ }
+
+ [Fact]
+ public void Constructor_WithNullLogger_ThrowsArgumentNullException()
+ {
+ Action act = () => new ExceptionTracker(null);
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("logger");
+ }
+
+ [Fact]
+ public void Constructor_WithValidLogger_Succeeds()
+ {
+ Action act = () => _tracker = new ExceptionTracker(_mockLogger.Object);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Constructor_WithExceptionFilter_Succeeds()
+ {
+ Func filter = ex => ex is InvalidOperationException;
+
+ Action act = () => _tracker = new ExceptionTracker(_mockLogger.Object, filter);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void TrackException_WithNullException_DoesNotThrow()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+
+ Action act = () => _tracker.TrackException(null);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void TrackException_WithNullException_DoesNotLog()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+
+ _tracker.TrackException(null);
+
+ _mockLogger.Verify(
+ l => l.Log(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public void TrackException_WithValidException_LogsException()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ var exception = new InvalidOperationException("Test exception");
+
+ _tracker.TrackException(exception);
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.IsAny(),
+ exception,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void TrackException_WithFilterReturningTrue_LogsException()
+ {
+ Func filter = ex => true;
+ _tracker = new ExceptionTracker(_mockLogger.Object, filter);
+ var exception = new InvalidOperationException("Test exception");
+
+ _tracker.TrackException(exception);
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.IsAny(),
+ exception,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void TrackException_WithFilterReturningFalse_DoesNotLog()
+ {
+ Func filter = ex => false;
+ _tracker = new ExceptionTracker(_mockLogger.Object, filter);
+ var exception = new InvalidOperationException("Test exception");
+
+ _tracker.TrackException(exception);
+
+ _mockLogger.Verify(
+ l => l.Log(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public void TrackException_WithSelectiveFilter_FiltersCorrectly()
+ {
+ Func filter = ex => ex is InvalidOperationException;
+ _tracker = new ExceptionTracker(_mockLogger.Object, filter);
+ var allowedException = new InvalidOperationException("Allowed");
+ var filteredOutException = new ArgumentException("Filtered");
+
+ _tracker.TrackException(allowedException);
+ _tracker.TrackException(filteredOutException);
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.IsAny(),
+ allowedException,
+ It.IsAny>()),
+ Times.Once);
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.IsAny(),
+ filteredOutException,
+ It.IsAny>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public void TrackException_BeginsScope()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ var exception = new InvalidOperationException("Test exception");
+
+ _tracker.TrackException(exception);
+
+ _mockLogger.Verify(l => l.BeginScope(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public void TrackException_WithAdditionalAttributes_IncludesAttributes()
+ {
+ Dictionary capturedScope = null;
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny>()))
+ .Callback>(scope => capturedScope = scope)
+ .Returns(Mock.Of());
+
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ var exception = new InvalidOperationException("Test exception");
+ var additionalAttributes = new Dictionary
+ {
+ { "custom.key", "custom.value" },
+ { "custom.number", 42 }
+ };
+
+ _tracker.TrackException(exception, additionalAttributes);
+
+ capturedScope.Should().NotBeNull();
+ capturedScope.Should().ContainKey("custom.key");
+ capturedScope["custom.key"].Should().Be("custom.value");
+ capturedScope.Should().ContainKey("custom.number");
+ capturedScope["custom.number"].Should().Be(42);
+ }
+
+ [Fact]
+ public void TrackException_IncludesExceptionType()
+ {
+ Dictionary capturedScope = null;
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny>()))
+ .Callback>(scope => capturedScope = scope)
+ .Returns(Mock.Of());
+
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ var exception = new InvalidOperationException("Test");
+
+ _tracker.TrackException(exception);
+
+ capturedScope.Should().ContainKey("exception.type");
+ capturedScope["exception.type"].Should().Be(typeof(InvalidOperationException).FullName);
+ }
+
+ [Fact]
+ public void TrackException_IncludesExceptionMessage()
+ {
+ Dictionary capturedScope = null;
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny>()))
+ .Callback>(scope => capturedScope = scope)
+ .Returns(Mock.Of());
+
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ var exception = new InvalidOperationException("Test message");
+
+ _tracker.TrackException(exception);
+
+ capturedScope.Should().ContainKey("exception.message");
+ capturedScope["exception.message"].Should().Be("Test message");
+ }
+
+ [Fact]
+ public void TrackException_IncludesStackTrace()
+ {
+ Dictionary capturedScope = null;
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny>()))
+ .Callback>(scope => capturedScope = scope)
+ .Returns(Mock.Of());
+
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ Exception exception;
+ try
+ {
+ throw new InvalidOperationException("Test");
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+
+ _tracker.TrackException(exception);
+
+ capturedScope.Should().ContainKey("exception.stacktrace");
+ capturedScope["exception.stacktrace"].Should().NotBeNull();
+ ((string)capturedScope["exception.stacktrace"]).Should().Contain("TrackException_IncludesStackTrace");
+ }
+
+ [Fact]
+ public void TrackException_WithInnerException_IncludesInnerExceptionInfo()
+ {
+ Dictionary capturedScope = null;
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny>()))
+ .Callback>(scope => capturedScope = scope)
+ .Returns(Mock.Of());
+
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ var innerException = new ArgumentException("Inner message");
+ var exception = new InvalidOperationException("Outer message", innerException);
+
+ _tracker.TrackException(exception);
+
+ capturedScope.Should().ContainKey("exception.inner.type");
+ capturedScope["exception.inner.type"].Should().Be(typeof(ArgumentException).FullName);
+ capturedScope.Should().ContainKey("exception.inner.message");
+ capturedScope["exception.inner.message"].Should().Be("Inner message");
+ }
+
+ [Fact]
+ public void TrackException_WithExceptionData_IncludesDataEntries()
+ {
+ Dictionary capturedScope = null;
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny>()))
+ .Callback>(scope => capturedScope = scope)
+ .Returns(Mock.Of());
+
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ var exception = new InvalidOperationException("Test");
+ exception.Data["userId"] = "user123";
+ exception.Data["operation"] = "save";
+
+ _tracker.TrackException(exception);
+
+ capturedScope.Should().ContainKey("exception.data.userId");
+ capturedScope["exception.data.userId"].Should().Be("user123");
+ capturedScope.Should().ContainKey("exception.data.operation");
+ capturedScope["exception.data.operation"].Should().Be("save");
+ }
+
+ [Fact]
+ public void TrackException_WithVsContextEnabled_IncludesVsContext()
+ {
+ Dictionary capturedScope = null;
+ _mockLogger.Setup(l => l.BeginScope(It.IsAny>()))
+ .Callback>(scope => capturedScope = scope)
+ .Returns(Mock.Of());
+
+ _tracker = new ExceptionTracker(_mockLogger.Object, includeVsContext: true);
+ var exception = new InvalidOperationException("Test");
+
+ _tracker.TrackException(exception);
+
+ capturedScope.Should().ContainKey("vs.context.available");
+ }
+
+ [Fact]
+ public void TrackException_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ _tracker.Dispose();
+
+ Action act = () => _tracker.TrackException(new InvalidOperationException("Test"));
+
+ act.Should().Throw()
+ .And.ObjectName.Should().Be(nameof(ExceptionTracker));
+ }
+
+ [Fact]
+ public void RecordExceptionOnActivity_WithNullActivity_DoesNotThrow()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+
+ Action act = () => _tracker.RecordExceptionOnActivity(null, new InvalidOperationException("Test"));
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void RecordExceptionOnActivity_WithNullException_DoesNotThrow()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ using var activitySource = new ActivitySource("Test");
+ using var activity = activitySource.StartActivity("TestActivity");
+
+ Action act = () => _tracker.RecordExceptionOnActivity(activity, null);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void RecordExceptionOnActivity_SetsActivityStatusToError()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ using var activitySource = new ActivitySource("Test");
+ using var activity = activitySource.StartActivity("TestActivity");
+ var exception = new InvalidOperationException("Test error");
+
+ _tracker.RecordExceptionOnActivity(activity, exception);
+
+ activity.Status.Should().Be(ActivityStatusCode.Error);
+ activity.StatusDescription.Should().Be("Test error");
+ }
+
+ [Fact]
+ public void RecordExceptionOnActivity_AddsExceptionEvent()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ using var activitySource = new ActivitySource("Test");
+ using var activity = activitySource.StartActivity("TestActivity");
+ var exception = new InvalidOperationException("Test error");
+
+ _tracker.RecordExceptionOnActivity(activity, exception);
+
+ activity.Events.Should().ContainSingle(e => e.Name == "exception");
+ }
+
+ [Fact]
+ public void RecordExceptionOnActivity_WithFilter_RespectsFilter()
+ {
+ Func filter = ex => ex is InvalidOperationException;
+ _tracker = new ExceptionTracker(_mockLogger.Object, filter);
+ using var activitySource = new ActivitySource("Test");
+ using var activity = activitySource.StartActivity("TestActivity");
+ var filteredOutException = new ArgumentException("Filtered");
+
+ _tracker.RecordExceptionOnActivity(activity, filteredOutException);
+
+ activity.Status.Should().Be(ActivityStatusCode.Unset);
+ activity.Events.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void RecordExceptionOnActivity_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ _tracker.Dispose();
+ using var activitySource = new ActivitySource("Test");
+ using var activity = activitySource.StartActivity("TestActivity");
+
+ Action act = () => _tracker.RecordExceptionOnActivity(activity, new InvalidOperationException());
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void RegisterGlobalExceptionHandler_IsIdempotent()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+
+ Action act = () =>
+ {
+ _tracker.RegisterGlobalExceptionHandler();
+ _tracker.RegisterGlobalExceptionHandler();
+ _tracker.RegisterGlobalExceptionHandler();
+ };
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void RegisterGlobalExceptionHandler_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ _tracker.Dispose();
+
+ Action act = () => _tracker.RegisterGlobalExceptionHandler();
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void UnregisterGlobalExceptionHandler_IsIdempotent()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ _tracker.RegisterGlobalExceptionHandler();
+
+ Action act = () =>
+ {
+ _tracker.UnregisterGlobalExceptionHandler();
+ _tracker.UnregisterGlobalExceptionHandler();
+ _tracker.UnregisterGlobalExceptionHandler();
+ };
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void UnregisterGlobalExceptionHandler_WithoutRegistration_DoesNotThrow()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+
+ Action act = () => _tracker.UnregisterGlobalExceptionHandler();
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Dispose_UnregistersGlobalHandler()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ _tracker.RegisterGlobalExceptionHandler();
+
+ Action act = () => _tracker.Dispose();
+
+ act.Should().NotThrow();
+ // The handler is unregistered, subsequent operations will throw ObjectDisposedException
+ }
+
+ [Fact]
+ public void Dispose_MultipleTimes_DoesNotThrow()
+ {
+ _tracker = new ExceptionTracker(_mockLogger.Object);
+ _tracker.RegisterGlobalExceptionHandler();
+
+ Action act = () =>
+ {
+ _tracker.Dispose();
+ _tracker.Dispose();
+ _tracker.Dispose();
+ };
+
+ act.Should().NotThrow();
+ }
+ }
+}
diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/LoggerProviderTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/LoggerProviderTests.cs
new file mode 100644
index 0000000..3317c27
--- /dev/null
+++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/LoggerProviderTests.cs
@@ -0,0 +1,379 @@
+using System;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Otel4Vsix.Logging;
+using Xunit;
+
+namespace CodingWithCalvin.Otel4Vsix.Tests
+{
+ public class LoggerProviderTests : IDisposable
+ {
+ private LoggerProvider _provider;
+ private Mock _mockLoggerFactory;
+ private Mock _mockLogger;
+
+ public LoggerProviderTests()
+ {
+ _mockLogger = new Mock();
+ _mockLoggerFactory = new Mock();
+ _mockLoggerFactory
+ .Setup(f => f.CreateLogger(It.IsAny()))
+ .Returns(_mockLogger.Object);
+ }
+
+ public void Dispose()
+ {
+ _provider?.Dispose();
+ }
+
+ [Fact]
+ public void Constructor_WithNullLoggerFactory_ThrowsArgumentNullException()
+ {
+ Action act = () => new LoggerProvider(null, "TestCategory");
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("loggerFactory");
+ }
+
+ [Fact]
+ public void Constructor_WithValidLoggerFactory_Succeeds()
+ {
+ Action act = () => _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Constructor_WithNullCategoryName_UsesDefaultCategory()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, null);
+
+ _mockLoggerFactory.Verify(f => f.CreateLogger("Otel4Vsix"), Times.Once);
+ }
+
+ [Fact]
+ public void Constructor_WithCategoryName_UsesProvidedCategory()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "CustomCategory");
+
+ _mockLoggerFactory.Verify(f => f.CreateLogger("CustomCategory"), Times.Once);
+ }
+
+ [Fact]
+ public void Logger_BeforeDispose_ReturnsLogger()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ var logger = _provider.Logger;
+
+ logger.Should().NotBeNull();
+ logger.Should().BeSameAs(_mockLogger.Object);
+ }
+
+ [Fact]
+ public void Logger_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => { var _ = _provider.Logger; };
+
+ act.Should().Throw()
+ .And.ObjectName.Should().Be(nameof(LoggerProvider));
+ }
+
+ [Fact]
+ public void LoggerFactory_BeforeDispose_ReturnsFactory()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ var factory = _provider.LoggerFactory;
+
+ factory.Should().NotBeNull();
+ factory.Should().BeSameAs(_mockLoggerFactory.Object);
+ }
+
+ [Fact]
+ public void LoggerFactory_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => { var _ = _provider.LoggerFactory; };
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void CreateLogger_DelegatesToFactory()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.CreateLogger("NewCategory");
+
+ _mockLoggerFactory.Verify(f => f.CreateLogger("NewCategory"), Times.Once);
+ }
+
+ [Fact]
+ public void CreateLogger_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.CreateLogger("NewCategory");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void CreateLoggerGeneric_DelegatesToFactory()
+ {
+ var mockGenericLogger = new Mock>();
+ _mockLoggerFactory
+ .Setup(f => f.CreateLogger(typeof(LoggerProviderTests).FullName))
+ .Returns(mockGenericLogger.Object);
+
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ var logger = _provider.CreateLogger();
+
+ logger.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void CreateLoggerGeneric_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.CreateLogger();
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void LogInformation_DelegatesToUnderlyingLogger()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.LogInformation("Test message {Param}", "value");
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.IsAny(),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void LogInformation_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.LogInformation("Test");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void LogWarning_DelegatesToUnderlyingLogger()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.LogWarning("Warning message");
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.IsAny(),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void LogWarning_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.LogWarning("Test");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void LogError_DelegatesToUnderlyingLogger()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.LogError("Error message");
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.IsAny(),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void LogError_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.LogError("Test");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void LogErrorWithException_DelegatesToUnderlyingLogger()
+ {
+ var exception = new InvalidOperationException("Test exception");
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.LogError(exception, "Error with exception");
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.IsAny(),
+ exception,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void LogErrorWithException_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.LogError(new Exception(), "Test");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void LogDebug_DelegatesToUnderlyingLogger()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.LogDebug("Debug message");
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Debug,
+ It.IsAny(),
+ It.IsAny(),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void LogDebug_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.LogDebug("Test");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void LogCritical_DelegatesToUnderlyingLogger()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.LogCritical("Critical message");
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Critical,
+ It.IsAny(),
+ It.IsAny(),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void LogCritical_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.LogCritical("Test");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void LogCriticalWithException_DelegatesToUnderlyingLogger()
+ {
+ var exception = new InvalidOperationException("Critical exception");
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.LogCritical(exception, "Critical with exception");
+
+ _mockLogger.Verify(
+ l => l.Log(
+ LogLevel.Critical,
+ It.IsAny(),
+ It.IsAny(),
+ exception,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void LogCriticalWithException_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+ _provider.Dispose();
+
+ Action act = () => _provider.LogCritical(new Exception(), "Test");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Dispose_DisposesUnderlyingFactory()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ _provider.Dispose();
+
+ _mockLoggerFactory.Verify(f => f.Dispose(), Times.Once);
+ }
+
+ [Fact]
+ public void Dispose_MultipleTimes_DoesNotThrow()
+ {
+ _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory");
+
+ Action act = () =>
+ {
+ _provider.Dispose();
+ _provider.Dispose();
+ _provider.Dispose();
+ };
+
+ act.Should().NotThrow();
+ _mockLoggerFactory.Verify(f => f.Dispose(), Times.Once);
+ }
+ }
+}
diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/MetricsProviderTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/MetricsProviderTests.cs
new file mode 100644
index 0000000..6347dea
--- /dev/null
+++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/MetricsProviderTests.cs
@@ -0,0 +1,258 @@
+using System;
+using System.Diagnostics.Metrics;
+using FluentAssertions;
+using Otel4Vsix.Metrics;
+using Xunit;
+
+namespace CodingWithCalvin.Otel4Vsix.Tests
+{
+ public class MetricsProviderTests : IDisposable
+ {
+ private MetricsProvider _provider;
+
+ public void Dispose()
+ {
+ _provider?.Dispose();
+ }
+
+ [Fact]
+ public void Constructor_WithNullServiceName_ThrowsArgumentNullException()
+ {
+ Action act = () => new MetricsProvider(null, "1.0.0");
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("serviceName");
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyServiceName_ThrowsArgumentNullException()
+ {
+ Action act = () => new MetricsProvider(string.Empty, "1.0.0");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_WithWhitespaceServiceName_ThrowsArgumentNullException()
+ {
+ Action act = () => new MetricsProvider(" ", "1.0.0");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_WithValidName_CreatesMeter()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ _provider.Meter.Should().NotBeNull();
+ _provider.Meter.Name.Should().Be("TestService");
+ _provider.Meter.Version.Should().Be("1.0.0");
+ }
+
+ [Fact]
+ public void Constructor_WithNullVersion_UsesDefaultVersion()
+ {
+ _provider = new MetricsProvider("TestService", null);
+
+ _provider.Meter.Should().NotBeNull();
+ _provider.Meter.Version.Should().Be("1.0.0");
+ }
+
+ [Fact]
+ public void Meter_BeforeDispose_ReturnsMeter()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var meter = _provider.Meter;
+
+ meter.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void Meter_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => { var _ = _provider.Meter; };
+
+ act.Should().Throw()
+ .And.ObjectName.Should().Be(nameof(MetricsProvider));
+ }
+
+ [Fact]
+ public void GetOrCreateCounter_ReturnsCounter()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var counter = _provider.GetOrCreateCounter("test.counter", "items", "A test counter");
+
+ counter.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void GetOrCreateCounter_ReturnsCachedInstance()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var counter1 = _provider.GetOrCreateCounter("test.counter");
+ var counter2 = _provider.GetOrCreateCounter("test.counter");
+
+ counter1.Should().BeSameAs(counter2);
+ }
+
+ [Fact]
+ public void GetOrCreateCounter_DifferentNames_ReturnsDifferentInstances()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var counter1 = _provider.GetOrCreateCounter("counter.one");
+ var counter2 = _provider.GetOrCreateCounter("counter.two");
+
+ counter1.Should().NotBeSameAs(counter2);
+ }
+
+ [Fact]
+ public void GetOrCreateCounter_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => _provider.GetOrCreateCounter("test.counter");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void GetOrCreateHistogram_ReturnsHistogram()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var histogram = _provider.GetOrCreateHistogram("test.histogram", "ms", "A test histogram");
+
+ histogram.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void GetOrCreateHistogram_ReturnsCachedInstance()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var histogram1 = _provider.GetOrCreateHistogram("test.histogram");
+ var histogram2 = _provider.GetOrCreateHistogram("test.histogram");
+
+ histogram1.Should().BeSameAs(histogram2);
+ }
+
+ [Fact]
+ public void GetOrCreateHistogram_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => _provider.GetOrCreateHistogram("test.histogram");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void GetOrCreateUpDownCounter_ReturnsUpDownCounter()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var counter = _provider.GetOrCreateUpDownCounter("test.updown", "items", "A test up-down counter");
+
+ counter.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void GetOrCreateUpDownCounter_ReturnsCachedInstance()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var counter1 = _provider.GetOrCreateUpDownCounter("test.updown");
+ var counter2 = _provider.GetOrCreateUpDownCounter("test.updown");
+
+ counter1.Should().BeSameAs(counter2);
+ }
+
+ [Fact]
+ public void GetOrCreateUpDownCounter_AfterDispose_ThrowsObjectDisposedException()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+ _provider.Dispose();
+
+ Action act = () => _provider.GetOrCreateUpDownCounter("test.updown");
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void CreateCommandExecutionCounter_ReturnsCounterWithCorrectName()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var counter = _provider.CreateCommandExecutionCounter();
+
+ counter.Should().NotBeNull();
+ // The counter is created, we can verify it's cached
+ var counter2 = _provider.GetOrCreateCounter("vs.extension.commands.executed");
+ counter.Should().BeSameAs(counter2);
+ }
+
+ [Fact]
+ public void CreateOperationDurationHistogram_ReturnsHistogramWithCorrectName()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var histogram = _provider.CreateOperationDurationHistogram();
+
+ histogram.Should().NotBeNull();
+ // The histogram is created, we can verify it's cached
+ var histogram2 = _provider.GetOrCreateHistogram("vs.extension.operation.duration");
+ histogram.Should().BeSameAs(histogram2);
+ }
+
+ [Fact]
+ public void CreateErrorCounter_ReturnsCounterWithCorrectName()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ var counter = _provider.CreateErrorCounter();
+
+ counter.Should().NotBeNull();
+ // The counter is created, we can verify it's cached
+ var counter2 = _provider.GetOrCreateCounter("vs.extension.errors");
+ counter.Should().BeSameAs(counter2);
+ }
+
+ [Fact]
+ public void Dispose_MultipleTimes_DoesNotThrow()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+
+ Action act = () =>
+ {
+ _provider.Dispose();
+ _provider.Dispose();
+ _provider.Dispose();
+ };
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Dispose_ClearsInstrumentCache()
+ {
+ _provider = new MetricsProvider("TestService", "1.0.0");
+ _provider.GetOrCreateCounter("test.counter");
+
+ _provider.Dispose();
+
+ // After dispose, accessing anything should throw
+ Action act = () => _provider.GetOrCreateCounter("test.counter");
+ act.Should().Throw();
+ }
+ }
+}
diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/TelemetryConfigurationTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/TelemetryConfigurationTests.cs
new file mode 100644
index 0000000..ce24907
--- /dev/null
+++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/TelemetryConfigurationTests.cs
@@ -0,0 +1,241 @@
+using System;
+using FluentAssertions;
+using Otel4Vsix;
+using Xunit;
+
+namespace CodingWithCalvin.Otel4Vsix.Tests
+{
+ public class TelemetryConfigurationTests
+ {
+ [Fact]
+ public void Defaults_AreSetCorrectly()
+ {
+ var config = new TelemetryConfiguration();
+
+ config.ServiceName.Should().Be("VsixExtension");
+ config.ServiceVersion.Should().Be("1.0.0");
+ config.OtlpEndpoint.Should().BeNull();
+ config.UseOtlpHttp.Should().BeFalse();
+ config.EnableConsoleExporter.Should().BeFalse();
+ config.EnableTracing.Should().BeTrue();
+ config.EnableMetrics.Should().BeTrue();
+ config.EnableLogging.Should().BeTrue();
+ config.EnableGlobalExceptionHandler.Should().BeTrue();
+ config.TraceSamplingRatio.Should().Be(1.0);
+ config.ExceptionFilter.Should().BeNull();
+ config.IncludeVisualStudioContext.Should().BeTrue();
+ config.ExportTimeoutMilliseconds.Should().Be(30000);
+ config.BatchExportScheduledDelayMilliseconds.Should().Be(5000);
+ }
+
+ [Fact]
+ public void OtlpHeaders_InitializedAsEmptyDictionary()
+ {
+ var config = new TelemetryConfiguration();
+
+ config.OtlpHeaders.Should().NotBeNull();
+ config.OtlpHeaders.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ResourceAttributes_InitializedAsEmptyDictionary()
+ {
+ var config = new TelemetryConfiguration();
+
+ config.ResourceAttributes.Should().NotBeNull();
+ config.ResourceAttributes.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Validate_WithValidConfig_Succeeds()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ ServiceVersion = "2.0.0",
+ TraceSamplingRatio = 0.5,
+ ExportTimeoutMilliseconds = 10000,
+ BatchExportScheduledDelayMilliseconds = 1000
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeNull();
+ }
+
+ [Fact]
+ public void Validate_WithNullServiceName_ThrowsArgumentException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = null
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ exception.Message.Should().Contain("ServiceName");
+ }
+
+ [Fact]
+ public void Validate_WithEmptyServiceName_ThrowsArgumentException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = string.Empty
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ }
+
+ [Fact]
+ public void Validate_WithWhitespaceServiceName_ThrowsArgumentException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = " "
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ }
+
+ [Fact]
+ public void Validate_WithNegativeSamplingRatio_ThrowsArgumentOutOfRangeException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ TraceSamplingRatio = -0.1
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("TraceSamplingRatio");
+ }
+
+ [Fact]
+ public void Validate_WithSamplingRatioAboveOne_ThrowsArgumentOutOfRangeException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ TraceSamplingRatio = 1.1
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("TraceSamplingRatio");
+ }
+
+ [Theory]
+ [InlineData(0.0)]
+ [InlineData(0.5)]
+ [InlineData(1.0)]
+ public void Validate_WithValidSamplingRatio_Succeeds(double samplingRatio)
+ {
+ var config = new TelemetryConfiguration
+ {
+ TraceSamplingRatio = samplingRatio
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeNull();
+ }
+
+ [Fact]
+ public void Validate_WithZeroExportTimeout_ThrowsArgumentOutOfRangeException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ExportTimeoutMilliseconds = 0
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("ExportTimeoutMilliseconds");
+ }
+
+ [Fact]
+ public void Validate_WithNegativeExportTimeout_ThrowsArgumentOutOfRangeException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ExportTimeoutMilliseconds = -1
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ }
+
+ [Fact]
+ public void Validate_WithZeroBatchDelay_ThrowsArgumentOutOfRangeException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ BatchExportScheduledDelayMilliseconds = 0
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("BatchExportScheduledDelayMilliseconds");
+ }
+
+ [Fact]
+ public void Validate_WithNegativeBatchDelay_ThrowsArgumentOutOfRangeException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ BatchExportScheduledDelayMilliseconds = -100
+ };
+
+ var exception = Record.Exception(() => config.Validate());
+
+ exception.Should().BeOfType();
+ }
+
+ [Fact]
+ public void OtlpHeaders_CanAddHeaders()
+ {
+ var config = new TelemetryConfiguration();
+
+ config.OtlpHeaders["x-api-key"] = "test-key";
+ config.OtlpHeaders["Authorization"] = "Bearer token";
+
+ config.OtlpHeaders.Should().HaveCount(2);
+ config.OtlpHeaders["x-api-key"].Should().Be("test-key");
+ }
+
+ [Fact]
+ public void ResourceAttributes_CanAddAttributes()
+ {
+ var config = new TelemetryConfiguration();
+
+ config.ResourceAttributes["environment"] = "test";
+ config.ResourceAttributes["instance.id"] = 123;
+
+ config.ResourceAttributes.Should().HaveCount(2);
+ config.ResourceAttributes["environment"].Should().Be("test");
+ }
+
+ [Fact]
+ public void ExceptionFilter_CanBeSet()
+ {
+ var config = new TelemetryConfiguration();
+ Func filter = ex => ex is InvalidOperationException;
+
+ config.ExceptionFilter = filter;
+
+ config.ExceptionFilter.Should().NotBeNull();
+ config.ExceptionFilter(new InvalidOperationException()).Should().BeTrue();
+ config.ExceptionFilter(new ArgumentException()).Should().BeFalse();
+ }
+ }
+}
diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/VsixTelemetryTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/VsixTelemetryTests.cs
new file mode 100644
index 0000000..38198fb
--- /dev/null
+++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/VsixTelemetryTests.cs
@@ -0,0 +1,516 @@
+using System;
+using System.Diagnostics;
+using FluentAssertions;
+using Otel4Vsix;
+using Xunit;
+
+namespace CodingWithCalvin.Otel4Vsix.Tests
+{
+ ///
+ /// Tests for VsixTelemetry static class.
+ /// These tests must run sequentially due to shared static state.
+ ///
+ [Collection("VsixTelemetry")]
+ public class VsixTelemetryTests : IDisposable
+ {
+ public VsixTelemetryTests()
+ {
+ // Ensure clean state before each test
+ VsixTelemetry.Shutdown();
+ }
+
+ public void Dispose()
+ {
+ // Clean up after each test
+ VsixTelemetry.Shutdown();
+ }
+
+ [Fact]
+ public void IsInitialized_BeforeInitialize_ReturnsFalse()
+ {
+ VsixTelemetry.IsInitialized.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Initialize_WithNullConfig_ThrowsArgumentNullException()
+ {
+ Action act = () => VsixTelemetry.Initialize(null);
+
+ act.Should().Throw()
+ .And.ParamName.Should().Be("configuration");
+ }
+
+ [Fact]
+ public void Initialize_WithInvalidConfig_ThrowsArgumentException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = null
+ };
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Initialize_WithValidConfig_SetsIsInitializedTrue()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableConsoleExporter = false
+ };
+
+ VsixTelemetry.Initialize(config);
+
+ VsixTelemetry.IsInitialized.Should().BeTrue();
+ }
+
+ [Fact]
+ public void Initialize_WhenAlreadyInitialized_ThrowsInvalidOperationException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService"
+ };
+ VsixTelemetry.Initialize(config);
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().Throw()
+ .WithMessage("*already initialized*");
+ }
+
+ [Fact]
+ public void Tracer_BeforeInitialize_ThrowsInvalidOperationException()
+ {
+ Action act = () => { var _ = VsixTelemetry.Tracer; };
+
+ act.Should().Throw()
+ .WithMessage("*not initialized*");
+ }
+
+ [Fact]
+ public void Tracer_AfterInitialize_ReturnsActivitySource()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableTracing = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var tracer = VsixTelemetry.Tracer;
+
+ tracer.Should().NotBeNull();
+ tracer.Name.Should().Be("TestService");
+ }
+
+ [Fact]
+ public void Meter_BeforeInitialize_ThrowsInvalidOperationException()
+ {
+ Action act = () => { var _ = VsixTelemetry.Meter; };
+
+ act.Should().Throw()
+ .WithMessage("*not initialized*");
+ }
+
+ [Fact]
+ public void Meter_AfterInitialize_ReturnsMeter()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableMetrics = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var meter = VsixTelemetry.Meter;
+
+ meter.Should().NotBeNull();
+ meter.Name.Should().Be("TestService");
+ }
+
+ [Fact]
+ public void Logger_BeforeInitialize_ThrowsInvalidOperationException()
+ {
+ Action act = () => { var _ = VsixTelemetry.Logger; };
+
+ act.Should().Throw()
+ .WithMessage("*not initialized*");
+ }
+
+ [Fact]
+ public void Logger_AfterInitialize_ReturnsLogger()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableLogging = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var logger = VsixTelemetry.Logger;
+
+ logger.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void LoggerFactory_BeforeInitialize_ThrowsInvalidOperationException()
+ {
+ Action act = () => { var _ = VsixTelemetry.LoggerFactory; };
+
+ act.Should().Throw()
+ .WithMessage("*not initialized*");
+ }
+
+ [Fact]
+ public void LoggerFactory_AfterInitialize_ReturnsLoggerFactory()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableLogging = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var loggerFactory = VsixTelemetry.LoggerFactory;
+
+ loggerFactory.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void Shutdown_ResetsIsInitializedToFalse()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService"
+ };
+ VsixTelemetry.Initialize(config);
+ VsixTelemetry.IsInitialized.Should().BeTrue();
+
+ VsixTelemetry.Shutdown();
+
+ VsixTelemetry.IsInitialized.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Shutdown_WhenNotInitialized_DoesNotThrow()
+ {
+ Action act = () => VsixTelemetry.Shutdown();
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Shutdown_AllowsReinitialization()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService"
+ };
+ VsixTelemetry.Initialize(config);
+ VsixTelemetry.Shutdown();
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().NotThrow();
+ VsixTelemetry.IsInitialized.Should().BeTrue();
+ }
+
+ [Fact]
+ public void TrackException_BeforeInitialize_DoesNotThrow()
+ {
+ Action act = () => VsixTelemetry.TrackException(new InvalidOperationException("Test"));
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void TrackException_AfterInitialize_DoesNotThrow()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService"
+ };
+ VsixTelemetry.Initialize(config);
+
+ Action act = () => VsixTelemetry.TrackException(new InvalidOperationException("Test"));
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void TrackException_WithNullException_DoesNotThrow()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService"
+ };
+ VsixTelemetry.Initialize(config);
+
+ Action act = () => VsixTelemetry.TrackException(null);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void StartActivity_BeforeInitialize_ReturnsNull()
+ {
+ var activity = VsixTelemetry.StartActivity("TestActivity");
+
+ activity.Should().BeNull();
+ }
+
+ [Fact]
+ public void StartCommandActivity_BeforeInitialize_ReturnsNull()
+ {
+ var activity = VsixTelemetry.StartCommandActivity("TestCommand");
+
+ activity.Should().BeNull();
+ }
+
+ [Fact]
+ public void CreateLoggerGeneric_BeforeInitialize_ReturnsNull()
+ {
+ var logger = VsixTelemetry.CreateLogger();
+
+ logger.Should().BeNull();
+ }
+
+ [Fact]
+ public void CreateLogger_BeforeInitialize_ReturnsNull()
+ {
+ var logger = VsixTelemetry.CreateLogger("TestCategory");
+
+ logger.Should().BeNull();
+ }
+
+ [Fact]
+ public void CreateLoggerGeneric_AfterInitialize_ReturnsLogger()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableLogging = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var logger = VsixTelemetry.CreateLogger();
+
+ logger.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void CreateLogger_AfterInitialize_ReturnsLogger()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableLogging = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var logger = VsixTelemetry.CreateLogger("TestCategory");
+
+ logger.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void GetOrCreateCounter_BeforeInitialize_ReturnsNull()
+ {
+ var counter = VsixTelemetry.GetOrCreateCounter("test.counter");
+
+ counter.Should().BeNull();
+ }
+
+ [Fact]
+ public void GetOrCreateCounter_AfterInitialize_ReturnsCounter()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableMetrics = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var counter = VsixTelemetry.GetOrCreateCounter("test.counter");
+
+ counter.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void GetOrCreateHistogram_BeforeInitialize_ReturnsNull()
+ {
+ var histogram = VsixTelemetry.GetOrCreateHistogram("test.histogram");
+
+ histogram.Should().BeNull();
+ }
+
+ [Fact]
+ public void GetOrCreateHistogram_AfterInitialize_ReturnsHistogram()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableMetrics = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ var histogram = VsixTelemetry.GetOrCreateHistogram("test.histogram");
+
+ histogram.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void Initialize_WithTracingDisabled_TracerThrowsInvalidOperationException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableTracing = false,
+ EnableMetrics = true,
+ EnableLogging = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ // When tracing is disabled, the activity source provider is null
+ // Accessing Tracer should throw because the provider is null
+ Action act = () => { var _ = VsixTelemetry.Tracer; };
+
+ // This will throw NullReferenceException internally, wrapped in InvalidOperationException
+ // or throw directly based on implementation
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Initialize_WithMetricsDisabled_MeterThrowsException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableTracing = true,
+ EnableMetrics = false,
+ EnableLogging = true
+ };
+ VsixTelemetry.Initialize(config);
+
+ Action act = () => { var _ = VsixTelemetry.Meter; };
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Initialize_WithLoggingDisabled_LoggerThrowsException()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableTracing = true,
+ EnableMetrics = true,
+ EnableLogging = false
+ };
+ VsixTelemetry.Initialize(config);
+
+ Action act = () => { var _ = VsixTelemetry.Logger; };
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Initialize_WithOtlpEndpoint_Succeeds()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ OtlpEndpoint = "http://localhost:4317"
+ };
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Initialize_WithOtlpHeaders_Succeeds()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ OtlpEndpoint = "http://localhost:4317"
+ };
+ config.OtlpHeaders["x-api-key"] = "test-key";
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Initialize_WithResourceAttributes_Succeeds()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService"
+ };
+ config.ResourceAttributes["environment"] = "test";
+ config.ResourceAttributes["instance.id"] = "123";
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Initialize_WithExceptionFilter_Succeeds()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ ExceptionFilter = ex => ex is InvalidOperationException
+ };
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Initialize_WithGlobalExceptionHandlerDisabled_Succeeds()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ EnableGlobalExceptionHandler = false
+ };
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().NotThrow();
+ }
+
+ [Fact]
+ public void Initialize_WithHttpProtocol_Succeeds()
+ {
+ var config = new TelemetryConfiguration
+ {
+ ServiceName = "TestService",
+ OtlpEndpoint = "http://localhost:4318",
+ UseOtlpHttp = true
+ };
+
+ Action act = () => VsixTelemetry.Initialize(config);
+
+ act.Should().NotThrow();
+ }
+ }
+
+ ///
+ /// Collection definition to ensure VsixTelemetry tests run sequentially.
+ ///
+ [CollectionDefinition("VsixTelemetry", DisableParallelization = true)]
+ public class VsixTelemetryCollection
+ {
+ }
+}