diff --git a/.publicApi/Microsoft.ApplicationInsights.dll/Stable/PublicAPI.Unshipped.txt b/.publicApi/Microsoft.ApplicationInsights.dll/Stable/PublicAPI.Unshipped.txt index e69de29bb..546aede6f 100644 --- a/.publicApi/Microsoft.ApplicationInsights.dll/Stable/PublicAPI.Unshipped.txt +++ b/.publicApi/Microsoft.ApplicationInsights.dll/Stable/PublicAPI.Unshipped.txt @@ -0,0 +1,28 @@ +Microsoft.ApplicationInsights.DataContracts.TelemetryContext.Device.get -> Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext +Microsoft.ApplicationInsights.DataContracts.TelemetryContext.Session.get -> Microsoft.ApplicationInsights.Extensibility.Implementation.SessionContext +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.Id.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.Id.set -> void +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.Model.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.Model.set -> void +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.OperatingSystem.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.OperatingSystem.set -> void +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.Type.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.DeviceContext.Type.set -> void +Microsoft.ApplicationInsights.Extensibility.Implementation.OperationContext.SyntheticSource.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.OperationContext.SyntheticSource.set -> void +Microsoft.ApplicationInsights.Extensibility.Implementation.SessionContext +Microsoft.ApplicationInsights.Extensibility.Implementation.SessionContext.Id.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.SessionContext.Id.set -> void +Microsoft.ApplicationInsights.Extensibility.Implementation.UserContext.AccountId.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.UserContext.AccountId.set -> void +Microsoft.ApplicationInsights.DataContracts.TelemetryContext.Component.get -> Microsoft.ApplicationInsights.Extensibility.Implementation.ComponentContext +Microsoft.ApplicationInsights.Extensibility.Implementation.ComponentContext +Microsoft.ApplicationInsights.Extensibility.Implementation.ComponentContext.Version.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.ComponentContext.Version.set -> void +Microsoft.ApplicationInsights.DataContracts.TelemetryContext.Cloud.get -> Microsoft.ApplicationInsights.Extensibility.Implementation.CloudContext +Microsoft.ApplicationInsights.Extensibility.Implementation.CloudContext +Microsoft.ApplicationInsights.Extensibility.Implementation.CloudContext.RoleName.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.CloudContext.RoleName.set -> void +Microsoft.ApplicationInsights.Extensibility.Implementation.CloudContext.RoleInstance.get -> string +Microsoft.ApplicationInsights.Extensibility.Implementation.CloudContext.RoleInstance.set -> void diff --git a/BASE/README.md b/BASE/README.md index 0e5dc20b0..b3ded39ee 100644 --- a/BASE/README.md +++ b/BASE/README.md @@ -418,66 +418,6 @@ Note: Setting an OpenTelemetry Sampler via `builder.SetSampler()` is currently u -### Setting Context Properties - -In version 3.x, the following properties remain publicly settable on telemetry items: - -**Available Context Properties:** -| Context | Properties | Notes | -|---------|-----------|-------| -| `User` | `Id`, `AuthenticatedUserId`, `UserAgent` | Be mindful of PII | -| `Operation` | `Name`| | -| `Location` | `Ip` | | -| `GlobalProperties` | (dictionary) | Custom key-value pairs | - -**Example: Setting context on a telemetry item** - -```csharp -var request = new RequestTelemetry -{ - Name = "ProcessOrder", - Timestamp = DateTimeOffset.UtcNow, - Duration = TimeSpan.FromMilliseconds(150), - ResponseCode = "200", - Success = true -}; - -// Set user context (be mindful of PII) -request.Context.User.Id = userId; -request.Context.User.AuthenticatedUserId = authenticatedUserId; - -// Set operation context -request.Context.Operation.Name = "ProcessOrder"; - -// Set location context -request.Context.Location.Ip = clientIpAddress; - -// Set custom properties via GlobalProperties -request.Context.GlobalProperties["Environment"] = "Production"; -request.Context.GlobalProperties["DataCenter"] = "WestUS"; - -telemetryClient.TrackRequest(request); -``` - -**Recommended: Use OpenTelemetry Resource Attributes** - -For service-level context (cloud role, version, environment), the recommended approach in 3.x is to use OpenTelemetry Resource attributes, which apply to all telemetry automatically: - -```csharp -configuration.ConfigureOpenTelemetryBuilder(builder => -{ - builder.ConfigureResource(r => r - .AddService( - serviceName: "OrderProcessingService", // Maps to Cloud.RoleName - serviceVersion: "1.2.3", // Maps to Application Version - serviceInstanceId: Environment.MachineName) // Maps to Cloud.RoleInstance - .AddAttributes(new Dictionary - { - ["deployment.environment"] = "Production" - })); -}); -``` - ### Dependency Injection In applications using Microsoft.Extensions.DependencyInjection: diff --git a/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientContextTagsTest.cs b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientContextTagsTest.cs index 3364b55c5..96fadae22 100644 --- a/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientContextTagsTest.cs +++ b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientContextTagsTest.cs @@ -10,7 +10,6 @@ namespace Microsoft.ApplicationInsights using Microsoft.Extensions.Logging; using OpenTelemetry; using OpenTelemetry.Logs; - using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Xunit; @@ -23,7 +22,6 @@ namespace Microsoft.ApplicationInsights public class TelemetryClientContextTagsTest : IDisposable { private readonly List logItems; - private readonly List metricItems; private readonly List activityItems; private readonly TelemetryClient telemetryClient; @@ -32,13 +30,11 @@ public TelemetryClientContextTagsTest() var configuration = new TelemetryConfiguration(); configuration.SamplingRatio = 1.0f; this.logItems = new List(); - this.metricItems = new List(); this.activityItems = new List(); var instrumentationKey = Guid.NewGuid().ToString(); configuration.ConnectionString = "InstrumentationKey=" + instrumentationKey; configuration.ConfigureOpenTelemetryBuilder(b => b .WithLogging(l => l.AddInMemoryExporter(this.logItems)) - .WithMetrics(m => m.AddInMemoryExporter(this.metricItems)) .WithTracing(t => t.AddInMemoryExporter(this.activityItems))); this.telemetryClient = new TelemetryClient(configuration); } @@ -46,205 +42,117 @@ public TelemetryClientContextTagsTest() public void Dispose() { this.activityItems?.Clear(); - this.metricItems?.Clear(); this.logItems?.Clear(); this.telemetryClient?.TelemetryConfiguration?.Dispose(); - } - - #region BuildContextTags — Attribute Key Mapping - - [Fact] - public void BuildContextTags_UserIdMapsToEnduserPseudoId() - { - this.telemetryClient.Context.User.Id = "user-123"; - - var tags = this.telemetryClient.ContextTags; - - Assert.True(tags.ContainsKey("enduser.pseudo.id")); - Assert.Equal("user-123", tags["enduser.pseudo.id"]); - } - - [Fact] - public void BuildContextTags_AuthenticatedUserIdMapsToEnduserId() - { - this.telemetryClient.Context.User.AuthenticatedUserId = "auth-456"; - - var tags = this.telemetryClient.ContextTags; - - Assert.True(tags.ContainsKey("enduser.id")); - Assert.Equal("auth-456", tags["enduser.id"]); - } - - [Fact] - public void BuildContextTags_UserAgentMapsToUserAgentOriginal() - { - this.telemetryClient.Context.User.UserAgent = "Mozilla/5.0"; - - var tags = this.telemetryClient.ContextTags; - - Assert.True(tags.ContainsKey("user_agent.original")); - Assert.Equal("Mozilla/5.0", tags["user_agent.original"]); - } - - [Fact] - public void BuildContextTags_OperationNameMapsToMicrosoftOperationName() - { - this.telemetryClient.Context.Operation.Name = "GET /api/users"; - - var tags = this.telemetryClient.ContextTags; - - Assert.True(tags.ContainsKey("microsoft.operation_name")); - Assert.Equal("GET /api/users", tags["microsoft.operation_name"]); - } - - [Fact] - public void BuildContextTags_LocationIpMapsToMicrosoftClientIp() - { - this.telemetryClient.Context.Location.Ip = "10.0.0.1"; - - var tags = this.telemetryClient.ContextTags; - - Assert.True(tags.ContainsKey("microsoft.client.ip")); - Assert.Equal("10.0.0.1", tags["microsoft.client.ip"]); - } - - [Fact] - public void BuildContextTags_AllPropertiesSet_ContainsAllFiveAttributes() - { - this.telemetryClient.Context.User.Id = "user-1"; - this.telemetryClient.Context.User.AuthenticatedUserId = "auth-1"; - this.telemetryClient.Context.User.UserAgent = "TestAgent/1.0"; - this.telemetryClient.Context.Operation.Name = "TestOp"; - this.telemetryClient.Context.Location.Ip = "192.168.1.1"; - - var tags = this.telemetryClient.ContextTags; - - Assert.Equal(5, tags.Count); - Assert.Equal("user-1", tags["enduser.pseudo.id"]); - Assert.Equal("auth-1", tags["enduser.id"]); - Assert.Equal("TestAgent/1.0", tags["user_agent.original"]); - Assert.Equal("TestOp", tags["microsoft.operation_name"]); - Assert.Equal("192.168.1.1", tags["microsoft.client.ip"]); - } - - #endregion - - #region BuildContextTags — Null/Empty Exclusion - - [Fact] - public void BuildContextTags_NullUserIdExcluded() - { - this.telemetryClient.Context.User.Id = null; - this.telemetryClient.Context.Location.Ip = "10.0.0.1"; - - var tags = this.telemetryClient.ContextTags; - - Assert.False(tags.ContainsKey("enduser.pseudo.id")); - Assert.True(tags.ContainsKey("microsoft.client.ip")); - } - - [Fact] - public void BuildContextTags_EmptyStringExcluded() - { - this.telemetryClient.Context.User.Id = string.Empty; - this.telemetryClient.Context.User.AuthenticatedUserId = ""; - this.telemetryClient.Context.User.UserAgent = ""; - this.telemetryClient.Context.Operation.Name = ""; - this.telemetryClient.Context.Location.Ip = ""; - - var tags = this.telemetryClient.ContextTags; - - Assert.Equal(0, tags.Count); - } - - [Fact] - public void BuildContextTags_MixedSetAndUnset_OnlyIncludesNonEmpty() - { - this.telemetryClient.Context.User.Id = "user-1"; - // AuthenticatedUserId not set (null) - this.telemetryClient.Context.User.UserAgent = ""; // empty - this.telemetryClient.Context.Operation.Name = "MyOp"; - // Location.Ip not set (null) - - var tags = this.telemetryClient.ContextTags; - - Assert.Equal(2, tags.Count); - Assert.True(tags.ContainsKey("enduser.pseudo.id")); - Assert.True(tags.ContainsKey("microsoft.operation_name")); - Assert.False(tags.ContainsKey("enduser.id")); - Assert.False(tags.ContainsKey("user_agent.original")); - Assert.False(tags.ContainsKey("microsoft.client.ip")); - } - - [Fact] - public void BuildContextTags_NoContextSet_ReturnsEmptyDictionary() - { - // Context is default (all properties null) - var tags = this.telemetryClient.ContextTags; - - Assert.NotNull(tags); - Assert.Equal(0, tags.Count); - } - - #endregion - - + } #region Log-based Telemetry — Context Tags Applied [Fact] - public void TrackEvent_String_IncludesClientContextTags() + public void TrackEvent_IncludesAllClientContextTags() { - this.telemetryClient.Context.User.Id = "ctx-user"; - this.telemetryClient.Context.Location.Ip = "10.0.0.1"; + this.telemetryClient.Context.User.Id = "evt-user"; + this.telemetryClient.Context.User.AuthenticatedUserId = "evt-auth"; + this.telemetryClient.Context.User.AccountId = "evt-acct"; + this.telemetryClient.Context.User.UserAgent = "evt-agent"; + this.telemetryClient.Context.Operation.Name = "EvtOp"; + this.telemetryClient.Context.Operation.SyntheticSource = "evt-bot"; + this.telemetryClient.Context.Location.Ip = "10.0.0.2"; + this.telemetryClient.Context.Session.Id = "evt-session"; + this.telemetryClient.Context.Device.Id = "evt-device"; + this.telemetryClient.Context.Device.Model = "Phone"; + this.telemetryClient.Context.Device.Type = "Mobile"; + this.telemetryClient.Context.Device.OperatingSystem = "Android 14"; - this.telemetryClient.TrackEvent("TestEvent"); + this.telemetryClient.TrackEvent("AllContextEvent"); this.telemetryClient.Flush(); var logRecord = this.logItems.FirstOrDefault(l => l.Attributes != null && l.Attributes.Any(a => - a.Key == "microsoft.custom_event.name" && a.Value?.ToString() == "TestEvent")); + a.Key == "microsoft.custom_event.name" && a.Value?.ToString() == "AllContextEvent")); Assert.NotNull(logRecord); var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); - Assert.Equal("ctx-user", attributes["enduser.pseudo.id"]); - Assert.Equal("10.0.0.1", attributes["microsoft.client.ip"]); + Assert.Equal("evt-user", attributes["enduser.pseudo.id"]); + Assert.Equal("evt-auth", attributes["enduser.id"]); + Assert.Equal("EvtOp", attributes["microsoft.operation_name"]); + Assert.Equal("10.0.0.2", attributes["microsoft.client.ip"]); + Assert.Equal("evt-session", attributes["microsoft.session.id"]); + Assert.Equal("evt-device", attributes["ai.device.id"]); + Assert.Equal("Phone", attributes["ai.device.model"]); + Assert.Equal("Mobile", attributes["ai.device.type"]); + Assert.Equal("Android 14", attributes["ai.device.osVersion"]); + Assert.Equal("evt-bot", attributes["microsoft.synthetic_source"]); + Assert.Equal("evt-acct", attributes["microsoft.user.account_id"]); + Assert.Equal("evt-agent", attributes["user_agent.original"]); } [Fact] - public void TrackTrace_String_IncludesClientContextTags() + public void TrackTrace_WithSeverityAndProperties_IncludesClientContextTags() { - this.telemetryClient.Context.User.Id = "trace-user"; + this.telemetryClient.Context.User.AuthenticatedUserId = "auth-trace"; - this.telemetryClient.TrackTrace("hello"); + this.telemetryClient.TrackTrace("msg", SeverityLevel.Warning, new Dictionary { { "key", "val" } }); this.telemetryClient.Flush(); - Assert.True(this.logItems.Count > 0); - var logRecord = this.logItems.Last(); + var logRecord = this.logItems.FirstOrDefault(l => l.LogLevel == LogLevel.Warning); + Assert.NotNull(logRecord); var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); - Assert.Equal("trace-user", attributes["enduser.pseudo.id"]); + Assert.Equal("auth-trace", attributes["enduser.id"]); + Assert.Equal("val", attributes["key"]); } [Fact] - public void TrackTrace_WithSeverityAndProperties_IncludesClientContextTags() + public void TrackTrace_IncludesAllClientContextTags() { - this.telemetryClient.Context.User.AuthenticatedUserId = "auth-trace"; + this.telemetryClient.Context.User.Id = "trc-user"; + this.telemetryClient.Context.User.AuthenticatedUserId = "trc-auth"; + this.telemetryClient.Context.User.AccountId = "trc-acct"; + this.telemetryClient.Context.User.UserAgent = "trc-agent"; + this.telemetryClient.Context.Operation.Name = "TrcOp"; + this.telemetryClient.Context.Operation.SyntheticSource = "trc-bot"; + this.telemetryClient.Context.Location.Ip = "10.0.0.3"; + this.telemetryClient.Context.Session.Id = "trc-session"; + this.telemetryClient.Context.Device.Id = "trc-device"; + this.telemetryClient.Context.Device.Model = "Desktop"; + this.telemetryClient.Context.Device.Type = "PC"; + this.telemetryClient.Context.Device.OperatingSystem = "Windows 10"; - this.telemetryClient.TrackTrace("msg", SeverityLevel.Warning, new Dictionary { { "key", "val" } }); + this.telemetryClient.TrackTrace("AllContextTrace"); this.telemetryClient.Flush(); - var logRecord = this.logItems.FirstOrDefault(l => l.LogLevel == LogLevel.Warning); - Assert.NotNull(logRecord); + Assert.True(this.logItems.Count > 0); + var logRecord = this.logItems.Last(); var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); - Assert.Equal("auth-trace", attributes["enduser.id"]); - Assert.Equal("val", attributes["key"]); + Assert.Equal("trc-user", attributes["enduser.pseudo.id"]); + Assert.Equal("trc-auth", attributes["enduser.id"]); + Assert.Equal("TrcOp", attributes["microsoft.operation_name"]); + Assert.Equal("10.0.0.3", attributes["microsoft.client.ip"]); + Assert.Equal("trc-session", attributes["microsoft.session.id"]); + Assert.Equal("trc-device", attributes["ai.device.id"]); + Assert.Equal("Desktop", attributes["ai.device.model"]); + Assert.Equal("PC", attributes["ai.device.type"]); + Assert.Equal("Windows 10", attributes["ai.device.osVersion"]); + Assert.Equal("trc-bot", attributes["microsoft.synthetic_source"]); + Assert.Equal("trc-acct", attributes["microsoft.user.account_id"]); + Assert.Equal("trc-agent", attributes["user_agent.original"]); } [Fact] - public void TrackException_Exception_IncludesClientContextTags() + public void TrackException_IncludesAllClientContextTags() { this.telemetryClient.Context.User.Id = "exc-user"; + this.telemetryClient.Context.User.AuthenticatedUserId = "exc-auth"; + this.telemetryClient.Context.User.AccountId = "exc-acct"; + this.telemetryClient.Context.User.UserAgent = "exc-agent"; + this.telemetryClient.Context.Operation.Name = "ExcOp"; + this.telemetryClient.Context.Operation.SyntheticSource = "exc-bot"; this.telemetryClient.Context.Location.Ip = "172.16.0.1"; + this.telemetryClient.Context.Session.Id = "exc-session"; + this.telemetryClient.Context.Device.Id = "exc-device"; + this.telemetryClient.Context.Device.Model = "Tablet"; + this.telemetryClient.Context.Device.Type = "Portable"; + this.telemetryClient.Context.Device.OperatingSystem = "Linux 6.1"; this.telemetryClient.TrackException(new InvalidOperationException("boom")); this.telemetryClient.Flush(); @@ -253,7 +161,17 @@ public void TrackException_Exception_IncludesClientContextTags() Assert.NotNull(logRecord); var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); Assert.Equal("exc-user", attributes["enduser.pseudo.id"]); + Assert.Equal("exc-auth", attributes["enduser.id"]); + Assert.Equal("ExcOp", attributes["microsoft.operation_name"]); Assert.Equal("172.16.0.1", attributes["microsoft.client.ip"]); + Assert.Equal("exc-session", attributes["microsoft.session.id"]); + Assert.Equal("exc-device", attributes["ai.device.id"]); + Assert.Equal("Tablet", attributes["ai.device.model"]); + Assert.Equal("Portable", attributes["ai.device.type"]); + Assert.Equal("Linux 6.1", attributes["ai.device.osVersion"]); + Assert.Equal("exc-bot", attributes["microsoft.synthetic_source"]); + Assert.Equal("exc-acct", attributes["microsoft.user.account_id"]); + Assert.Equal("exc-agent", attributes["user_agent.original"]); } [Fact] @@ -277,27 +195,6 @@ public void TrackAvailability_IncludesClientContextTags() #region Log-based Telemetry — 3-Tier Priority Merge - [Fact] - public void TrackEvent_GlobalPropertiesOverrideContextTags() - { - // Client context (lowest priority) - this.telemetryClient.Context.User.Id = "context-user"; - - // GlobalProperties (medium priority) - override the same key - this.telemetryClient.Context.GlobalProperties["enduser.pseudo.id"] = "global-user"; - - this.telemetryClient.TrackEvent("TestEvent"); - this.telemetryClient.Flush(); - - var logRecord = this.logItems.FirstOrDefault(l => - l.Attributes != null && l.Attributes.Any(a => - a.Key == "microsoft.custom_event.name")); - Assert.NotNull(logRecord); - var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); - - // GlobalProperties should override context tag - Assert.Equal("global-user", attributes["enduser.pseudo.id"]); - } [Fact] public void TrackEvent_ItemPropertiesOverrideContextTags() @@ -444,11 +341,19 @@ public void TrackEvent_AllTiersMergeWithNoKeyOverlap() #region Activity-based Telemetry — Context Tags Applied [Fact] - public void TrackRequest_IncludesClientContextTags() + public void TrackRequest_IncludesAllClientContextTags() { this.telemetryClient.Context.User.Id = "req-user"; this.telemetryClient.Context.User.AuthenticatedUserId = "req-auth"; + this.telemetryClient.Context.User.AccountId = "req-acct"; + this.telemetryClient.Context.User.UserAgent = "req-agent"; this.telemetryClient.Context.Location.Ip = "10.0.0.1"; + this.telemetryClient.Context.Session.Id = "req-session"; + this.telemetryClient.Context.Device.Id = "req-device"; + this.telemetryClient.Context.Device.Model = "Laptop"; + this.telemetryClient.Context.Device.Type = "PC"; + this.telemetryClient.Context.Device.OperatingSystem = "macOS 14"; + this.telemetryClient.Context.Operation.SyntheticSource = "test-runner"; var request = new RequestTelemetry("GET /api", DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(50), "200", true); this.telemetryClient.TrackRequest(request); @@ -459,13 +364,31 @@ public void TrackRequest_IncludesClientContextTags() Assert.True(activity.Tags.Any(t => t.Key == "enduser.pseudo.id" && t.Value == "req-user")); Assert.True(activity.Tags.Any(t => t.Key == "enduser.id" && t.Value == "req-auth")); Assert.True(activity.Tags.Any(t => t.Key == "microsoft.client.ip" && t.Value == "10.0.0.1")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.session.id" && t.Value == "req-session")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.id" && t.Value == "req-device")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.model" && t.Value == "Laptop")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.type" && t.Value == "PC")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.osVersion" && t.Value == "macOS 14")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.synthetic_source" && t.Value == "test-runner")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.user.account_id" && t.Value == "req-acct")); + Assert.True(activity.Tags.Any(t => t.Key == "user_agent.original" && t.Value == "req-agent")); } [Fact] - public void TrackDependency_IncludesClientContextTags() + public void TrackDependency_IncludesAllClientContextTags() { this.telemetryClient.Context.User.Id = "dep-user"; + this.telemetryClient.Context.User.AuthenticatedUserId = "dep-auth"; + this.telemetryClient.Context.User.AccountId = "dep-acct"; + this.telemetryClient.Context.User.UserAgent = "dep-agent"; this.telemetryClient.Context.Operation.Name = "ParentOp"; + this.telemetryClient.Context.Operation.SyntheticSource = "dep-bot"; + this.telemetryClient.Context.Location.Ip = "10.0.0.4"; + this.telemetryClient.Context.Session.Id = "dep-session"; + this.telemetryClient.Context.Device.Id = "dep-device"; + this.telemetryClient.Context.Device.Model = "Watch"; + this.telemetryClient.Context.Device.Type = "Wearable"; + this.telemetryClient.Context.Device.OperatingSystem = "watchOS 10"; var dep = new DependencyTelemetry { @@ -480,7 +403,17 @@ public void TrackDependency_IncludesClientContextTags() Assert.Equal(1, this.activityItems.Count); var activity = this.activityItems[0]; Assert.True(activity.Tags.Any(t => t.Key == "enduser.pseudo.id" && t.Value == "dep-user")); + Assert.True(activity.Tags.Any(t => t.Key == "enduser.id" && t.Value == "dep-auth")); Assert.True(activity.Tags.Any(t => t.Key == "microsoft.operation_name" && t.Value == "ParentOp")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.client.ip" && t.Value == "10.0.0.4")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.session.id" && t.Value == "dep-session")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.id" && t.Value == "dep-device")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.model" && t.Value == "Watch")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.type" && t.Value == "Wearable")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.osVersion" && t.Value == "watchOS 10")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.synthetic_source" && t.Value == "dep-bot")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.user.account_id" && t.Value == "dep-acct")); + Assert.True(activity.Tags.Any(t => t.Key == "user_agent.original" && t.Value == "dep-agent")); } #endregion @@ -535,33 +468,6 @@ public void TrackDependency_ItemContextOverridesClientContext() Assert.True(activity.Tags.Any(t => t.Key == "enduser.pseudo.id" && t.Value == "client-user")); } - [Fact] - public void TrackRequest_ItemPropertiesOverrideGlobalPropertiesOverrideContextTags() - { - // Tier 1: Context tags (lowest) - this.telemetryClient.Context.User.Id = "ctx-user"; - - // Tier 2: GlobalProperties (medium) - this.telemetryClient.Context.GlobalProperties["enduser.pseudo.id"] = "global-user"; - this.telemetryClient.Context.GlobalProperties["env"] = "staging"; - - // Tier 3: Item properties (highest) - var request = new RequestTelemetry("GET /", DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(10), "200", true); - request.Properties["enduser.pseudo.id"] = "item-user"; - request.Properties["req-id"] = "r-1"; - - this.telemetryClient.TrackRequest(request); - this.telemetryClient.Flush(); - - Assert.Equal(1, this.activityItems.Count); - var activity = this.activityItems[0]; - - // Item > Global > Context - Assert.True(activity.Tags.Any(t => t.Key == "enduser.pseudo.id" && t.Value == "item-user")); - Assert.True(activity.Tags.Any(t => t.Key == "env" && t.Value == "staging")); - Assert.True(activity.Tags.Any(t => t.Key == "req-id" && t.Value == "r-1")); - } - [Fact] public void TrackDependency_ItemOperationNameOverridesClientOperationName() { @@ -591,162 +497,166 @@ public void TrackDependency_ItemOperationNameOverridesClientOperationName() #endregion - #region Metric Telemetry — Context Tags Applied + #region StartOperation — Context Tags Applied [Fact] - public void TrackMetric_String_IncludesClientContextTags() + public void StartOperation_Request_AppliesClientContextTags() { - this.telemetryClient.Context.User.Id = "metric-user"; + this.telemetryClient.Context.User.Id = "op-user"; this.telemetryClient.Context.Location.Ip = "10.0.0.5"; + this.telemetryClient.Context.Session.Id = "op-session"; - this.telemetryClient.TrackMetric("TestMetric", 42.0); - this.telemetryClient.Flush(); - - Assert.True(this.metricItems.Count > 0); - var metric = this.metricItems.FirstOrDefault(m => m.Name == "TestMetric"); - Assert.NotNull(metric); - - foreach (var point in metric.GetMetricPoints()) + using (var operation = this.telemetryClient.StartOperation("GET /api")) { - bool hasUserId = false; - bool hasIp = false; - foreach (var tag in point.Tags) - { - if (tag.Key == "enduser.pseudo.id" && tag.Value?.ToString() == "metric-user") - hasUserId = true; - if (tag.Key == "microsoft.client.ip" && tag.Value?.ToString() == "10.0.0.5") - hasIp = true; - } - Assert.True(hasUserId, "Context tag enduser.pseudo.id should be on metric"); - Assert.True(hasIp, "Context tag microsoft.client.ip should be on metric"); - break; + // Activity is live — context processor runs on end } + + this.telemetryClient.Flush(); + + Assert.True(this.activityItems.Count >= 1); + var activity = this.activityItems.First(a => a.DisplayName == "GET /api"); + Assert.True(activity.Tags.Any(t => t.Key == "enduser.pseudo.id" && t.Value == "op-user")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.client.ip" && t.Value == "10.0.0.5")); + Assert.True(activity.Tags.Any(t => t.Key == "microsoft.session.id" && t.Value == "op-session")); } [Fact] - public void TrackMetric_PropertiesOverrideContextTags() + public void StartOperation_Dependency_AppliesClientContextTags() { - this.telemetryClient.Context.User.Id = "ctx-user"; + this.telemetryClient.Context.User.Id = "dep-op-user"; + this.telemetryClient.Context.Device.Id = "dep-op-device"; - this.telemetryClient.TrackMetric("TestMetric", 10.0, new Dictionary + using (var operation = this.telemetryClient.StartOperation("SQL Query")) { - { "enduser.pseudo.id", "prop-user" }, - { "custom", "value" }, - }); + } + this.telemetryClient.Flush(); - var metric = this.metricItems.FirstOrDefault(m => m.Name == "TestMetric"); - Assert.NotNull(metric); + Assert.True(this.activityItems.Count >= 1); + var activity = this.activityItems.First(a => a.DisplayName == "SQL Query"); + Assert.True(activity.Tags.Any(t => t.Key == "enduser.pseudo.id" && t.Value == "dep-op-user")); + Assert.True(activity.Tags.Any(t => t.Key == "ai.device.id" && t.Value == "dep-op-device")); + } + + [Fact] + public void StartOperation_GlobalPropertiesApplied() + { + this.telemetryClient.Context.User.Id = "op-user"; + this.telemetryClient.Context.GlobalProperties["env"] = "prod"; + this.telemetryClient.Context.GlobalProperties["region"] = "westus"; - foreach (var point in metric.GetMetricPoints()) + using (var operation = this.telemetryClient.StartOperation("GET /health")) { - string userId = null; - string custom = null; - foreach (var tag in point.Tags) - { - if (tag.Key == "enduser.pseudo.id") - userId = tag.Value?.ToString(); - if (tag.Key == "custom") - custom = tag.Value?.ToString(); - } - // TagList appends in order; OTel should use the last value for duplicate keys - // Context tags are added first, then properties, so properties win - Assert.NotNull(userId); - Assert.Equal("prop-user", userId); - Assert.NotNull(custom); - Assert.Equal("value", custom); - break; } + + this.telemetryClient.Flush(); + + Assert.True(this.activityItems.Count >= 1); + var activity = this.activityItems.First(a => a.DisplayName == "GET /health"); + Assert.True(activity.Tags.Any(t => t.Key == "env" && t.Value == "prod")); + Assert.True(activity.Tags.Any(t => t.Key == "region" && t.Value == "westus")); + Assert.True(activity.Tags.Any(t => t.Key == "enduser.pseudo.id" && t.Value == "op-user")); } + #endregion + + #region GlobalProperties — Activity-based Telemetry + [Fact] - public void GetMetric_TrackValue_ZeroDimensions_IncludesContextTags() + public void TrackRequest_GlobalPropertiesAppliedAsTags() { - this.telemetryClient.Context.User.Id = "getmetric-user"; - - var metric = this.telemetryClient.GetMetric("ZeroDimMetric"); - metric.TrackValue(5.0); + this.telemetryClient.Context.GlobalProperties["env"] = "staging"; + this.telemetryClient.Context.GlobalProperties["deployment"] = "blue"; + var request = new RequestTelemetry("GET /api", DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(10), "200", true); + this.telemetryClient.TrackRequest(request); this.telemetryClient.Flush(); - var collected = this.metricItems.FirstOrDefault(m => m.Name == "ZeroDimMetric"); - Assert.NotNull(collected); - - foreach (var point in collected.GetMetricPoints()) - { - bool hasUserId = false; - foreach (var tag in point.Tags) - { - if (tag.Key == "enduser.pseudo.id" && tag.Value?.ToString() == "getmetric-user") - hasUserId = true; - } - Assert.True(hasUserId, "Context tag should appear on GetMetric().TrackValue()"); - break; - } + Assert.Equal(1, this.activityItems.Count); + var activity = this.activityItems[0]; + Assert.True(activity.Tags.Any(t => t.Key == "env" && t.Value == "staging")); + Assert.True(activity.Tags.Any(t => t.Key == "deployment" && t.Value == "blue")); } [Fact] - public void GetMetric_TrackValue_OneDimension_IncludesContextTagsAndDimension() + public void TrackDependency_GlobalPropertiesAppliedAsTags() { - this.telemetryClient.Context.User.Id = "dim-user"; - - var metric = this.telemetryClient.GetMetric("OneDimMetric", "region"); - metric.TrackValue(10.0, "us-west"); + this.telemetryClient.Context.GlobalProperties["tenant"] = "contoso"; + var dep = new DependencyTelemetry { Type = "HTTP", Name = "GET /ext", Duration = TimeSpan.FromMilliseconds(50), Success = true }; + this.telemetryClient.TrackDependency(dep); this.telemetryClient.Flush(); - var collected = this.metricItems.FirstOrDefault(m => m.Name == "OneDimMetric"); - Assert.NotNull(collected); + Assert.Equal(1, this.activityItems.Count); + Assert.True(this.activityItems[0].Tags.Any(t => t.Key == "tenant" && t.Value == "contoso")); + } - foreach (var point in collected.GetMetricPoints()) - { - bool hasUserId = false; - bool hasRegion = false; - foreach (var tag in point.Tags) - { - if (tag.Key == "enduser.pseudo.id" && tag.Value?.ToString() == "dim-user") - hasUserId = true; - if (tag.Key == "region" && tag.Value?.ToString() == "us-west") - hasRegion = true; - } - Assert.True(hasUserId, "Context tag should appear on metric"); - Assert.True(hasRegion, "Dimension should appear on metric"); - break; - } + [Fact] + public void TrackRequest_GlobalPropertiesDoNotOverrideItemProperties() + { + this.telemetryClient.Context.GlobalProperties["key1"] = "global-val"; + + var request = new RequestTelemetry("GET /", DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(10), "200", true); + request.Properties["key1"] = "item-val"; + this.telemetryClient.TrackRequest(request); + this.telemetryClient.Flush(); + + Assert.Equal(1, this.activityItems.Count); + // Item-level wins — SetTag from Track* runs before processor's SetTagIfAbsent + Assert.True(this.activityItems[0].Tags.Any(t => t.Key == "key1" && t.Value == "item-val")); } + #endregion + + #region GlobalProperties — Log-based Telemetry + [Fact] - public void GetMetric_TrackValue_TwoDimensions_IncludesContextTagsAndDimensions() + public void TrackEvent_GlobalPropertiesAppliedAsAttributes() { - this.telemetryClient.Context.Location.Ip = "192.168.1.1"; + this.telemetryClient.Context.GlobalProperties["env"] = "production"; + this.telemetryClient.Context.GlobalProperties["version"] = "1.2.3"; + + this.telemetryClient.TrackEvent("GlobalPropEvent"); + this.telemetryClient.Flush(); + + var logRecord = this.logItems.FirstOrDefault(l => + l.Attributes != null && l.Attributes.Any(a => + a.Key == "microsoft.custom_event.name" && a.Value?.ToString() == "GlobalPropEvent")); + Assert.NotNull(logRecord); + var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); + Assert.Equal("production", attributes["env"]); + Assert.Equal("1.2.3", attributes["version"]); + } - var metric = this.telemetryClient.GetMetric("TwoDimMetric", "region", "status"); - metric.TrackValue(7.0, "eu-west", "success"); + [Fact] + public void TrackTrace_GlobalPropertiesAppliedAsAttributes() + { + this.telemetryClient.Context.GlobalProperties["component"] = "worker"; + this.telemetryClient.TrackTrace("GlobalPropTrace"); this.telemetryClient.Flush(); - var collected = this.metricItems.FirstOrDefault(m => m.Name == "TwoDimMetric"); - Assert.NotNull(collected); + var logRecord = this.logItems.Last(); + var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); + Assert.Equal("worker", attributes["component"]); + } - foreach (var point in collected.GetMetricPoints()) - { - bool hasIp = false; - bool hasRegion = false; - bool hasStatus = false; - foreach (var tag in point.Tags) - { - if (tag.Key == "microsoft.client.ip" && tag.Value?.ToString() == "192.168.1.1") - hasIp = true; - if (tag.Key == "region" && tag.Value?.ToString() == "eu-west") - hasRegion = true; - if (tag.Key == "status" && tag.Value?.ToString() == "success") - hasStatus = true; - } - Assert.True(hasIp, "Context tag should appear on metric"); - Assert.True(hasRegion, "Dimension 1 should appear on metric"); - Assert.True(hasStatus, "Dimension 2 should appear on metric"); - break; - } + [Fact] + public void TrackEvent_GlobalPropertiesDoNotOverrideItemProperties() + { + this.telemetryClient.Context.GlobalProperties["key1"] = "global-val"; + + var props = new Dictionary { { "key1", "item-val" } }; + this.telemetryClient.TrackEvent("OverrideEvent", props); + this.telemetryClient.Flush(); + + var logRecord = this.logItems.FirstOrDefault(l => + l.Attributes != null && l.Attributes.Any(a => + a.Key == "microsoft.custom_event.name" && a.Value?.ToString() == "OverrideEvent")); + Assert.NotNull(logRecord); + var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); + // Item-level wins + Assert.Equal("item-val", attributes["key1"]); } #endregion @@ -769,9 +679,15 @@ public void TrackEvent_NoContextSet_EmitsWithoutError() var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); Assert.False(attributes.ContainsKey("enduser.pseudo.id")); Assert.False(attributes.ContainsKey("enduser.id")); - Assert.False(attributes.ContainsKey("user_agent.original")); Assert.False(attributes.ContainsKey("microsoft.operation_name")); Assert.False(attributes.ContainsKey("microsoft.client.ip")); + Assert.False(attributes.ContainsKey("microsoft.session.id")); + Assert.False(attributes.ContainsKey("ai.device.id")); + Assert.False(attributes.ContainsKey("ai.device.model")); + Assert.False(attributes.ContainsKey("ai.device.type")); + Assert.False(attributes.ContainsKey("microsoft.synthetic_source")); + Assert.False(attributes.ContainsKey("microsoft.user.account_id")); + Assert.False(attributes.ContainsKey("user_agent.original")); } [Fact] @@ -787,30 +703,6 @@ public void TrackTrace_NullProperties_ContextTagsStillApplied() Assert.Equal("null-props-user", attributes["enduser.pseudo.id"]); } - [Fact] - public void TrackMetric_NullProperties_ContextTagsStillApplied() - { - this.telemetryClient.Context.User.Id = "null-metric-user"; - - this.telemetryClient.TrackMetric("NullPropMetric", 1.0, null); - this.telemetryClient.Flush(); - - var metric = this.metricItems.FirstOrDefault(m => m.Name == "NullPropMetric"); - Assert.NotNull(metric); - - foreach (var point in metric.GetMetricPoints()) - { - bool hasUserId = false; - foreach (var tag in point.Tags) - { - if (tag.Key == "enduser.pseudo.id" && tag.Value?.ToString() == "null-metric-user") - hasUserId = true; - } - Assert.True(hasUserId, "Context tag should appear even with null properties"); - break; - } - } - [Fact] public void TrackRequest_NoContextSet_EmitsWithoutContextTags() { @@ -823,26 +715,14 @@ public void TrackRequest_NoContextSet_EmitsWithoutContextTags() Assert.False(activity.Tags.Any(t => t.Key == "enduser.pseudo.id")); Assert.False(activity.Tags.Any(t => t.Key == "enduser.id")); - Assert.False(activity.Tags.Any(t => t.Key == "user_agent.original")); Assert.False(activity.Tags.Any(t => t.Key == "microsoft.client.ip")); - } - - [Fact] - public void GetMetric_NoContextSet_TrackValueSucceeds() - { - // No context set - var metric = this.telemetryClient.GetMetric("EmptyCtxMetric"); - metric.TrackValue(3.0); - this.telemetryClient.Flush(); - - var collected = this.metricItems.FirstOrDefault(m => m.Name == "EmptyCtxMetric"); - Assert.NotNull(collected); - - foreach (var point in collected.GetMetricPoints()) - { - Assert.True(point.GetHistogramCount() > 0); - break; - } + Assert.False(activity.Tags.Any(t => t.Key == "microsoft.session.id")); + Assert.False(activity.Tags.Any(t => t.Key == "ai.device.id")); + Assert.False(activity.Tags.Any(t => t.Key == "ai.device.model")); + Assert.False(activity.Tags.Any(t => t.Key == "ai.device.type")); + Assert.False(activity.Tags.Any(t => t.Key == "microsoft.synthetic_source")); + Assert.False(activity.Tags.Any(t => t.Key == "microsoft.user.account_id")); + Assert.False(activity.Tags.Any(t => t.Key == "user_agent.original")); } #endregion diff --git a/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientTest.cs b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientTest.cs index 64ca8cb3c..f3b204779 100644 --- a/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientTest.cs +++ b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryClientTest.cs @@ -2131,7 +2131,7 @@ public void TrackRequest_UserAgent_MapsToUserAgentOriginal() } [Fact] - public void TrackDependency_UserAgent_DoesNotMapToUserAgentOriginal() + public void TrackDependency_UserAgent_MapsToUserAgentOriginal() { // Arrange var dependency = new DependencyTelemetry @@ -2147,14 +2147,14 @@ public void TrackDependency_UserAgent_DoesNotMapToUserAgentOriginal() this.telemetryClient.TrackDependency(dependency); this.telemetryClient.Flush(); - // Assert - UserAgent should NOT be mapped for non-request telemetry + // Assert - UserAgent is mapped for all activity-based telemetry Assert.Equal(1, this.activityItems.Count); var activity = this.activityItems[0]; - Assert.False(activity.Tags.Any(t => t.Key == "user_agent.original")); + Assert.True(activity.Tags.Any(t => t.Key == "user_agent.original" && t.Value == "TestAgent/1.0")); } [Fact] - public void TrackEvent_UserAgent_DoesNotMapToUserAgentOriginal() + public void TrackEvent_UserAgent_MapsToUserAgentOriginal() { // Arrange var eventTelemetry = new EventTelemetry("TestEvent"); @@ -2164,11 +2164,11 @@ public void TrackEvent_UserAgent_DoesNotMapToUserAgentOriginal() this.telemetryClient.TrackEvent(eventTelemetry); this.telemetryClient.Flush(); - // Assert - UserAgent should NOT be mapped for events + // Assert - UserAgent is mapped for all telemetry types var logRecord = this.logItems.FirstOrDefault(l => l.Attributes != null && l.Attributes.Any(a => a.Key == "microsoft.custom_event.name")); Assert.NotNull(logRecord); var attributes = logRecord.Attributes.ToDictionary(a => a.Key, a => a.Value?.ToString()); - Assert.False(attributes.ContainsKey("user_agent.original")); + Assert.Equal("TestAgent/2.0", attributes["user_agent.original"]); } [Fact] @@ -2216,6 +2216,20 @@ public void CloudContextRoleNameSetsEnvironmentVariable() } } + [Fact] + public void ComponentContextVersionSetsEnvironmentVariable() + { + try + { + this.telemetryClient.Context.Component.Version = "1.2.3"; + Assert.Equal("1.2.3", Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_COMPONENT_VERSION")); + } + finally + { + Environment.SetEnvironmentVariable("APPLICATIONINSIGHTS_COMPONENT_VERSION", null); + } + } + private double ComputeSomethingHeavy() { var random = new Random(); diff --git a/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryContextActivityProcessorTests.cs b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryContextActivityProcessorTests.cs new file mode 100644 index 000000000..4a6315763 --- /dev/null +++ b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryContextActivityProcessorTests.cs @@ -0,0 +1,502 @@ +namespace Microsoft.ApplicationInsights.Processors +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Reflection; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.ApplicationInsights.DataContracts; + using Microsoft.ApplicationInsights.Internal; + using Xunit; + + public class TelemetryContextActivityProcessorTests : IDisposable + { + private readonly ActivitySource activitySource; + private readonly ActivityListener activityListener; + + public TelemetryContextActivityProcessorTests() + { + this.activitySource = new ActivitySource("Test.TelemetryContextActivityProcessor"); + this.activityListener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(this.activityListener); + } + + public void Dispose() + { + this.activityListener.Dispose(); + this.activitySource.Dispose(); + } + + #region Basic tag application + + [Fact] + public void OnEnd_AppliesUserIdTag() + { + var context = new TelemetryContext(); + context.User.Id = "user-123"; + var processor = new TelemetryContextActivityProcessor(context); + + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + Assert.Equal("user-123", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + } + + [Fact] + public void OnEnd_AppliesAllContextTags() + { + var context = new TelemetryContext(); + context.User.Id = "user-123"; + context.User.AuthenticatedUserId = "auth-456"; + context.User.AccountId = "acct-789"; + context.Operation.Name = "GET /api"; + context.Operation.SyntheticSource = "bot"; + context.Location.Ip = "10.0.0.1"; + context.Session.Id = "session-1"; + context.Device.Id = "device-1"; + context.Device.Model = "Surface"; + context.Device.Type = "PC"; + context.Device.OperatingSystem = "Windows 11"; + + var processor = new TelemetryContextActivityProcessor(context); + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + Assert.Equal("user-123", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + Assert.Equal("auth-456", activity.GetTagItem(SemanticConventions.AttributeEnduserId)); + Assert.Equal("acct-789", activity.GetTagItem(SemanticConventions.AttributeMicrosoftUserAccountId)); + Assert.Equal("GET /api", activity.GetTagItem(SemanticConventions.AttributeMicrosoftOperationName)); + Assert.Equal("bot", activity.GetTagItem(SemanticConventions.AttributeMicrosoftSyntheticSource)); + Assert.Equal("10.0.0.1", activity.GetTagItem(SemanticConventions.AttributeMicrosoftClientIp)); + Assert.Equal("session-1", activity.GetTagItem(SemanticConventions.AttributeMicrosoftSessionId)); + Assert.Equal("device-1", activity.GetTagItem(SemanticConventions.AttributeAiDeviceId)); + Assert.Equal("Surface", activity.GetTagItem(SemanticConventions.AttributeAiDeviceModel)); + Assert.Equal("PC", activity.GetTagItem(SemanticConventions.AttributeAiDeviceType)); + Assert.Equal("Windows 11", activity.GetTagItem(SemanticConventions.AttributeAiDeviceOsVersion)); + } + + [Fact] + public void OnEnd_AppliesGlobalProperties() + { + var context = new TelemetryContext(); + context.GlobalProperties["env"] = "production"; + context.GlobalProperties["region"] = "westus2"; + + var processor = new TelemetryContextActivityProcessor(context); + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + Assert.Equal("production", activity.GetTagItem("env")); + Assert.Equal("westus2", activity.GetTagItem("region")); + } + + #endregion + + #region Skip-if-present semantics + + [Fact] + public void OnEnd_DoesNotOverwriteExistingTag() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + + var processor = new TelemetryContextActivityProcessor(context); + using var activity = this.CreateStoppedActivity(); + activity.SetTag(SemanticConventions.AttributeEnduserPseudoId, "item-user"); + processor.OnEnd(activity); + + Assert.Equal("item-user", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + } + + [Fact] + public void OnEnd_DoesNotOverwriteExistingGlobalPropertyTag() + { + var context = new TelemetryContext(); + context.GlobalProperties["env"] = "production"; + + var processor = new TelemetryContextActivityProcessor(context); + using var activity = this.CreateStoppedActivity(); + activity.SetTag("env", "staging"); + processor.OnEnd(activity); + + Assert.Equal("staging", activity.GetTagItem("env")); + } + + #endregion + + #region Null/empty handling + + [Fact] + public void OnEnd_SkipsNullValues() + { + var context = new TelemetryContext(); + // Don't set any properties — all are null by default. + var processor = new TelemetryContextActivityProcessor(context); + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + Assert.Null(activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + Assert.Null(activity.GetTagItem(SemanticConventions.AttributeEnduserId)); + Assert.Null(activity.GetTagItem(SemanticConventions.AttributeMicrosoftOperationName)); + } + + [Fact] + public void OnEnd_HandlesNullActivity() + { + var context = new TelemetryContext(); + var processor = new TelemetryContextActivityProcessor(context); + + // Should not throw. + processor.OnEnd(null); + } + + [Fact] + public void OnEnd_HandlesEmptyContext() + { + var context = new TelemetryContext(); + var processor = new TelemetryContextActivityProcessor(context); + using var activity = this.CreateStoppedActivity(); + + // Should not throw, and no tags should be added. + processor.OnEnd(activity); + + var tags = new List>(); + foreach (var tag in activity.Tags) + { + tags.Add(new KeyValuePair(tag.Key, tag.Value)); + } + + // Only the tags that the Activity already had (none from context). + Assert.Empty(tags); + } + + #endregion + + #region Warmup and snapshot freezing + + [Fact] + public void OnEnd_UsesSlowPathDuringWarmup() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextActivityProcessor(context); + + // During warmup, each call should still correctly apply tags. + for (int i = 0; i < TelemetryContextActivityProcessor.WarmupCountThreshold - 1; i++) + { + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + Assert.Equal("user-1", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + } + } + + [Fact] + public void OnEnd_FreezesSnapshotAfterWarmup() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + context.Device.Id = "device-1"; + + var processor = new TelemetryContextActivityProcessor(context); + ForceWarmupComplete(processor); + + // After warmup, snapshot should be frozen. + // Verify by mutating context — the frozen snapshot should still have original values. + context.User.Id = "user-CHANGED"; + + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + // The frozen snapshot captured "user-1", so even though context changed, + // the activity should get the original value. + Assert.Equal("user-1", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + Assert.Equal("device-1", activity.GetTagItem(SemanticConventions.AttributeAiDeviceId)); + } + + [Fact] + public void OnEnd_SnapshotOnlyContainsNonNullValues() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + // All other properties are null. + + var processor = new TelemetryContextActivityProcessor(context); + ForceWarmupComplete(processor); + + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + Assert.Equal("user-1", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + // Null properties should not appear as tags. + Assert.Null(activity.GetTagItem(SemanticConventions.AttributeAiDeviceId)); + Assert.Null(activity.GetTagItem(SemanticConventions.AttributeMicrosoftOperationName)); + } + + [Fact] + public void OnEnd_SnapshotIncludesGlobalProperties() + { + var context = new TelemetryContext(); + context.GlobalProperties["env"] = "prod"; + context.User.Id = "user-1"; + + var processor = new TelemetryContextActivityProcessor(context); + ForceWarmupComplete(processor); + + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + Assert.Equal("prod", activity.GetTagItem("env")); + Assert.Equal("user-1", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + } + + [Fact] + public void OnEnd_SnapshotRespectsSkipIfPresent() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + + var processor = new TelemetryContextActivityProcessor(context); + ForceWarmupComplete(processor); + + using var activity = this.CreateStoppedActivity(); + activity.SetTag(SemanticConventions.AttributeEnduserPseudoId, "item-user"); + processor.OnEnd(activity); + + Assert.Equal("item-user", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + } + + [Fact] + public void WarmupDoesNotFreezeBeforeTimeThreshold() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextActivityProcessor(context); + + // Run enough calls to pass the count threshold, but the time threshold + // should prevent freezing (we just created the processor). + for (int i = 0; i < TelemetryContextActivityProcessor.WarmupCountThreshold + 5; i++) + { + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + } + + // The frozenTags field should still be null because <5s has elapsed. + var frozenTags = GetFrozenTags(processor); + Assert.Null(frozenTags); + } + + [Fact] + public void WarmupFreezesAfterBothThresholdsMet() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextActivityProcessor(context); + ForceWarmupComplete(processor); + + var frozenTags = GetFrozenTags(processor); + Assert.NotNull(frozenTags); + Assert.Single(frozenTags); // Only user.Id was set. + } + + [Fact] + public void BuildSnapshot_NoDuplicateKeysWhenGlobalPropertiesOverlapContextFields() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + context.GlobalProperties[SemanticConventions.AttributeEnduserPseudoId] = "global-user"; + context.GlobalProperties["extra"] = "value"; + + var processor = new TelemetryContextActivityProcessor(context); + ForceWarmupComplete(processor); + + var frozenTags = GetFrozenTags(processor); + Assert.NotNull(frozenTags); + + var keys = new HashSet(); + foreach (var kvp in frozenTags) + { + Assert.True(keys.Add(kvp.Key), $"Duplicate key in snapshot: '{kvp.Key}'"); + } + + // 2 entries: "enduser.pseudo.id" from GlobalProperties, "extra" from GlobalProperties. + // User.Id should NOT add a duplicate "enduser.pseudo.id". + Assert.Equal(2, frozenTags.Length); + } + + #endregion + + #region Thread safety + + [Fact] + public void OnEnd_IsThreadSafe() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + context.Device.Id = "device-1"; + + var processor = new TelemetryContextActivityProcessor(context); + ForceWarmupComplete(processor); + + // Run many concurrent OnEnd calls on the frozen snapshot path. + const int threadCount = 8; + const int iterationsPerThread = 1000; + var exceptions = new List(); + + var barrier = new Barrier(threadCount); + var threads = new Thread[threadCount]; + + for (int t = 0; t < threadCount; t++) + { + threads[t] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + for (int i = 0; i < iterationsPerThread; i++) + { + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + + Assert.Equal("user-1", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + Assert.Equal("device-1", activity.GetTagItem(SemanticConventions.AttributeAiDeviceId)); + } + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + threads[t].Start(); + } + + foreach (var thread in threads) + { + thread.Join(); + } + + Assert.Empty(exceptions); + } + + [Fact] + public void OnEnd_ConcurrentWarmupProducesValidSnapshot() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + context.Session.Id = "session-1"; + + var processor = new TelemetryContextActivityProcessor(context); + + // Backdate the timestamp so the time threshold is already met. + ForceTimestampToFarPast(processor); + + // Fire many threads concurrently to race through warmup and snapshot creation. + const int threadCount = 16; + var barrier = new Barrier(threadCount); + var threads = new Thread[threadCount]; + var exceptions = new List(); + + for (int t = 0; t < threadCount; t++) + { + threads[t] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + for (int i = 0; i < 20; i++) + { + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + Assert.Equal("user-1", activity.GetTagItem(SemanticConventions.AttributeEnduserPseudoId)); + } + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + threads[t].Start(); + } + + foreach (var thread in threads) + { + thread.Join(); + } + + Assert.Empty(exceptions); + + // After all threads finished, snapshot must exist and be valid. + var frozenTags = GetFrozenTags(processor); + Assert.NotNull(frozenTags); + Assert.Equal(2, frozenTags.Length); + } + + #endregion + + #region Helpers + + private Activity CreateStoppedActivity() + { + var activity = this.activitySource.StartActivity("TestOp"); + Assert.NotNull(activity); + activity.Stop(); + return activity; + } + + /// + /// Forces the processor past both warmup thresholds (count + time) by: + /// 1. Backdating the constructedTimestamp so the time threshold is met. + /// 2. Calling OnEnd enough times to trigger the count threshold. + /// + private void ForceWarmupComplete(TelemetryContextActivityProcessor processor) + { + ForceTimestampToFarPast(processor); + + for (int i = 0; i < TelemetryContextActivityProcessor.WarmupCountThreshold + 1; i++) + { + using var activity = this.CreateStoppedActivity(); + processor.OnEnd(activity); + } + } + + /// + /// Sets the constructedTimestamp to a value far in the past so the time threshold is immediately met. + /// + private static void ForceTimestampToFarPast(TelemetryContextActivityProcessor processor) + { + var field = typeof(TelemetryContextActivityProcessor) + .GetField("constructedTimestamp", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + + // Set to a timestamp 10 seconds in the past. + long pastTimestamp = Stopwatch.GetTimestamp() - (Stopwatch.Frequency * 10); + field.SetValue(processor, pastTimestamp); + } + + /// + /// Reads the frozenTags field via reflection for test assertions. + /// + private static KeyValuePair[] GetFrozenTags(TelemetryContextActivityProcessor processor) + { + var field = typeof(TelemetryContextActivityProcessor) + .GetField("frozenTags", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + return (KeyValuePair[]?)field.GetValue(processor); + } + + #endregion + } +} diff --git a/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryContextLogProcessorTests.cs b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryContextLogProcessorTests.cs new file mode 100644 index 000000000..caeb7f2dc --- /dev/null +++ b/BASE/Test/Microsoft.ApplicationInsights.Test/Microsoft.ApplicationInsights.Tests/TelemetryContextLogProcessorTests.cs @@ -0,0 +1,585 @@ +namespace Microsoft.ApplicationInsights.Processors +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Reflection; + using System.Threading; + using Microsoft.ApplicationInsights.DataContracts; + using Microsoft.ApplicationInsights.Internal; + using OpenTelemetry.Logs; + using Xunit; + + public class TelemetryContextLogProcessorTests + { + #region Basic attribute application + + [Fact] + public void OnEnd_AppliesUserIdAttribute() + { + var context = new TelemetryContext(); + context.User.Id = "user-123"; + var processor = new TelemetryContextLogProcessor(context); + + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + AssertAttributeValue("user-123", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + + [Fact] + public void OnEnd_AppliesAllContextAttributes() + { + var context = new TelemetryContext(); + context.User.Id = "user-123"; + context.User.AuthenticatedUserId = "auth-456"; + context.User.AccountId = "acct-789"; + context.Operation.Name = "GET /api"; + context.Operation.SyntheticSource = "bot"; + context.Location.Ip = "10.0.0.1"; + context.Session.Id = "session-1"; + context.Device.Id = "device-1"; + context.Device.Model = "Surface"; + context.Device.Type = "PC"; + context.Device.OperatingSystem = "Windows 11"; + + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + AssertAttributeValue("user-123", logRecord, SemanticConventions.AttributeEnduserPseudoId); + AssertAttributeValue("auth-456", logRecord, SemanticConventions.AttributeEnduserId); + AssertAttributeValue("acct-789", logRecord, SemanticConventions.AttributeMicrosoftUserAccountId); + AssertAttributeValue("GET /api", logRecord, SemanticConventions.AttributeMicrosoftOperationName); + AssertAttributeValue("bot", logRecord, SemanticConventions.AttributeMicrosoftSyntheticSource); + AssertAttributeValue("10.0.0.1", logRecord, SemanticConventions.AttributeMicrosoftClientIp); + AssertAttributeValue("session-1", logRecord, SemanticConventions.AttributeMicrosoftSessionId); + AssertAttributeValue("device-1", logRecord, SemanticConventions.AttributeAiDeviceId); + AssertAttributeValue("Surface", logRecord, SemanticConventions.AttributeAiDeviceModel); + AssertAttributeValue("PC", logRecord, SemanticConventions.AttributeAiDeviceType); + AssertAttributeValue("Windows 11", logRecord, SemanticConventions.AttributeAiDeviceOsVersion); + } + + [Fact] + public void OnEnd_AppliesGlobalProperties() + { + var context = new TelemetryContext(); + context.GlobalProperties["env"] = "production"; + context.GlobalProperties["region"] = "westus2"; + + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + AssertAttributeValue("production", logRecord, "env"); + AssertAttributeValue("westus2", logRecord, "region"); + } + + #endregion + + #region Skip-if-present semantics + + [Fact] + public void OnEnd_DoesNotOverwriteExistingAttribute() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecordWithAttributes( + new KeyValuePair(SemanticConventions.AttributeEnduserPseudoId, "item-user")); + processor.OnEnd(logRecord); + + AssertAttributeValue("item-user", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + + [Fact] + public void OnEnd_DoesNotOverwriteExistingGlobalPropertyAttribute() + { + var context = new TelemetryContext(); + context.GlobalProperties["env"] = "production"; + + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecordWithAttributes( + new KeyValuePair("env", "staging")); + processor.OnEnd(logRecord); + + AssertAttributeValue("staging", logRecord, "env"); + } + + [Fact] + public void OnEnd_PreservesExistingAttributesWhenMerging() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecordWithAttributes( + new KeyValuePair("custom-key", "custom-value")); + processor.OnEnd(logRecord); + + AssertAttributeValue("custom-value", logRecord, "custom-key"); + AssertAttributeValue("user-1", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + + #endregion + + #region Null/empty handling + + [Fact] + public void OnEnd_SkipsNullValues() + { + var context = new TelemetryContext(); + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + // No attributes should be added since nothing is set. + Assert.True(logRecord.Attributes == null || logRecord.Attributes.Count == 0); + } + + [Fact] + public void OnEnd_HandlesNullLogRecord() + { + var context = new TelemetryContext(); + var processor = new TelemetryContextLogProcessor(context); + + // Should not throw. + processor.OnEnd(null); + } + + [Fact] + public void OnEnd_HandlesLogRecordWithNullAttributes() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecord(); + Assert.Null(logRecord.Attributes); + + processor.OnEnd(logRecord); + + AssertAttributeValue("user-1", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + + #endregion + + #region Warmup and snapshot freezing + + [Fact] + public void OnEnd_UsesSlowPathDuringWarmup() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextLogProcessor(context); + + for (int i = 0; i < TelemetryContextLogProcessor.WarmupCountThreshold - 1; i++) + { + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + AssertAttributeValue("user-1", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + } + + [Fact] + public void OnEnd_FreezesSnapshotAfterWarmup() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + context.Device.Id = "device-1"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + // Mutate context — frozen snapshot should retain original values. + context.User.Id = "user-CHANGED"; + + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + AssertAttributeValue("user-1", logRecord, SemanticConventions.AttributeEnduserPseudoId); + AssertAttributeValue("device-1", logRecord, SemanticConventions.AttributeAiDeviceId); + } + + [Fact] + public void OnEnd_SnapshotOnlyContainsNonNullValues() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var frozenAttrs = GetFrozenAttributes(processor); + Assert.NotNull(frozenAttrs); + Assert.Single(frozenAttrs); + Assert.Equal(SemanticConventions.AttributeEnduserPseudoId, frozenAttrs[0].Key); + } + + [Fact] + public void OnEnd_SnapshotIncludesGlobalProperties() + { + var context = new TelemetryContext(); + context.GlobalProperties["env"] = "prod"; + context.User.Id = "user-1"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + AssertAttributeValue("prod", logRecord, "env"); + AssertAttributeValue("user-1", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + + [Fact] + public void OnEnd_SnapshotRespectsSkipIfPresent() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var logRecord = CreateLogRecordWithAttributes( + new KeyValuePair(SemanticConventions.AttributeEnduserPseudoId, "item-user")); + processor.OnEnd(logRecord); + + AssertAttributeValue("item-user", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + + [Fact] + public void WarmupDoesNotFreezeBeforeTimeThreshold() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextLogProcessor(context); + + for (int i = 0; i < TelemetryContextLogProcessor.WarmupCountThreshold + 5; i++) + { + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + } + + var frozenAttrs = GetFrozenAttributes(processor); + Assert.Null(frozenAttrs); + } + + [Fact] + public void WarmupFreezesAfterBothThresholdsMet() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var frozenAttrs = GetFrozenAttributes(processor); + Assert.NotNull(frozenAttrs); + Assert.Single(frozenAttrs); + } + + #endregion + + #region Allocation reduction verification + + [Fact] + public void OnEnd_FastPathDoesNotAllocateWhenNoAttributesToAdd() + { + var context = new TelemetryContext(); + // No properties set — snapshot will be empty array. + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var frozenAttrs = GetFrozenAttributes(processor); + Assert.NotNull(frozenAttrs); + Assert.Empty(frozenAttrs); + + // Fast path with empty snapshot should not modify the log record. + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + Assert.Null(logRecord.Attributes); + } + + [Fact] + public void OnEnd_FastPathDoesNotModifyAttributesWhenAllKeysConflict() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var originalAttrs = new List> + { + new KeyValuePair(SemanticConventions.AttributeEnduserPseudoId, "existing-user"), + }; + var logRecord = CreateLogRecordWithAttributes(originalAttrs.ToArray()); + var attrsBefore = logRecord.Attributes; + processor.OnEnd(logRecord); + + // Attributes should not have been replaced since no new keys were added. + Assert.Same(attrsBefore, logRecord.Attributes); + } + + #endregion + + #region Thread safety + + [Fact] + public void OnEnd_IsThreadSafe() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + context.Device.Id = "device-1"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + const int threadCount = 8; + const int iterationsPerThread = 1000; + var exceptions = new List(); + var barrier = new Barrier(threadCount); + var threads = new Thread[threadCount]; + + for (int t = 0; t < threadCount; t++) + { + threads[t] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + for (int i = 0; i < iterationsPerThread; i++) + { + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + AssertAttributeValue("user-1", logRecord, SemanticConventions.AttributeEnduserPseudoId); + AssertAttributeValue("device-1", logRecord, SemanticConventions.AttributeAiDeviceId); + } + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + threads[t].Start(); + } + + foreach (var thread in threads) + { + thread.Join(); + } + + Assert.Empty(exceptions); + } + + [Fact] + public void OnEnd_ConcurrentWarmupProducesValidSnapshot() + { + var context = new TelemetryContext(); + context.User.Id = "user-1"; + context.Session.Id = "session-1"; + + var processor = new TelemetryContextLogProcessor(context); + ForceTimestampToFarPast(processor); + + const int threadCount = 16; + var barrier = new Barrier(threadCount); + var threads = new Thread[threadCount]; + var exceptions = new List(); + + for (int t = 0; t < threadCount; t++) + { + threads[t] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + for (int i = 0; i < 20; i++) + { + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + AssertAttributeValue("user-1", logRecord, SemanticConventions.AttributeEnduserPseudoId); + } + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + threads[t].Start(); + } + + foreach (var thread in threads) + { + thread.Join(); + } + + Assert.Empty(exceptions); + + var frozenAttrs = GetFrozenAttributes(processor); + Assert.NotNull(frozenAttrs); + Assert.Equal(2, frozenAttrs.Length); + } + + #endregion + + #region GlobalProperties overlap with context fields + + [Fact] + public void OnEnd_SlowPath_NoDuplicateKeysWhenGlobalPropertiesOverlapContextFields() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + context.Location.Ip = "1.1.1.1"; + context.GlobalProperties[SemanticConventions.AttributeEnduserPseudoId] = "global-user"; + context.GlobalProperties[SemanticConventions.AttributeMicrosoftClientIp] = "2.2.2.2"; + + var processor = new TelemetryContextLogProcessor(context); + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + // GlobalProperties should win over explicit context fields (applied first). + // There must be no duplicate keys. + Assert.NotNull(logRecord.Attributes); + var keys = new HashSet(); + foreach (var attr in logRecord.Attributes) + { + Assert.True(keys.Add(attr.Key), $"Duplicate key found: '{attr.Key}'"); + } + + AssertAttributeValue("global-user", logRecord, SemanticConventions.AttributeEnduserPseudoId); + AssertAttributeValue("2.2.2.2", logRecord, SemanticConventions.AttributeMicrosoftClientIp); + } + + [Fact] + public void OnEnd_FastPath_NoDuplicateKeysWhenGlobalPropertiesOverlapContextFields() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + context.Location.Ip = "1.1.1.1"; + context.GlobalProperties[SemanticConventions.AttributeEnduserPseudoId] = "global-user"; + context.GlobalProperties[SemanticConventions.AttributeMicrosoftClientIp] = "2.2.2.2"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + + Assert.NotNull(logRecord.Attributes); + var keys = new HashSet(); + foreach (var attr in logRecord.Attributes) + { + Assert.True(keys.Add(attr.Key), $"Duplicate key found in snapshot: '{attr.Key}'"); + } + + // GlobalProperties win (they are added to the snapshot first). + AssertAttributeValue("global-user", logRecord, SemanticConventions.AttributeEnduserPseudoId); + AssertAttributeValue("2.2.2.2", logRecord, SemanticConventions.AttributeMicrosoftClientIp); + } + + [Fact] + public void BuildSnapshot_NoDuplicateKeysWhenGlobalPropertiesOverlapContextFields() + { + var context = new TelemetryContext(); + context.User.Id = "context-user"; + context.GlobalProperties[SemanticConventions.AttributeEnduserPseudoId] = "global-user"; + context.GlobalProperties["extra"] = "value"; + + var processor = new TelemetryContextLogProcessor(context); + ForceWarmupComplete(processor); + + var frozenAttrs = GetFrozenAttributes(processor); + Assert.NotNull(frozenAttrs); + + var keys = new HashSet(); + foreach (var kvp in frozenAttrs) + { + Assert.True(keys.Add(kvp.Key), $"Duplicate key in snapshot: '{kvp.Key}'"); + } + + // 2 entries: "enduser.pseudo.id" from GlobalProperties, "extra" from GlobalProperties. + // User.Id should NOT add a duplicate "enduser.pseudo.id". + Assert.Equal(2, frozenAttrs.Length); + } + + #endregion + + #region Helpers + + private static LogRecord CreateLogRecord() + { + // LogRecord has no public parameterless constructor; use reflection to invoke internal/private ctor. + return (LogRecord)Activator.CreateInstance( + typeof(LogRecord), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + args: null, + culture: null); + } + + private static LogRecord CreateLogRecordWithAttributes(params KeyValuePair[] attributes) + { + var logRecord = CreateLogRecord(); + logRecord.Attributes = new List>(attributes); + return logRecord; + } + + private static void AssertAttributeValue(object expected, LogRecord logRecord, string key) + { + Assert.NotNull(logRecord.Attributes); + foreach (var attr in logRecord.Attributes) + { + if (attr.Key == key) + { + Assert.Equal(expected, attr.Value); + return; + } + } + + Assert.True(false, $"Attribute '{key}' not found in log record."); + } + + private static void ForceWarmupComplete(TelemetryContextLogProcessor processor) + { + ForceTimestampToFarPast(processor); + + for (int i = 0; i < TelemetryContextLogProcessor.WarmupCountThreshold + 1; i++) + { + var logRecord = CreateLogRecord(); + processor.OnEnd(logRecord); + } + } + + private static void ForceTimestampToFarPast(TelemetryContextLogProcessor processor) + { + var field = typeof(TelemetryContextLogProcessor) + .GetField("constructedTimestamp", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + long pastTimestamp = Stopwatch.GetTimestamp() - (Stopwatch.Frequency * 10); + field.SetValue(processor, pastTimestamp); + } + + private static KeyValuePair[] GetFrozenAttributes(TelemetryContextLogProcessor processor) + { + var field = typeof(TelemetryContextLogProcessor) + .GetField("frozenAttributes", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); + return (KeyValuePair[])field.GetValue(processor); + } + + #endregion + } +} diff --git a/BASE/src/Microsoft.ApplicationInsights/DataContracts/TelemetryContext.cs b/BASE/src/Microsoft.ApplicationInsights/DataContracts/TelemetryContext.cs index 606c37878..a538a30bc 100644 --- a/BASE/src/Microsoft.ApplicationInsights/DataContracts/TelemetryContext.cs +++ b/BASE/src/Microsoft.ApplicationInsights/DataContracts/TelemetryContext.cs @@ -79,7 +79,7 @@ public LocationContext Location /// /// Gets the object describing the cloud tracked by this . /// - internal CloudContext Cloud + public CloudContext Cloud { get { return LazyInitializer.EnsureInitialized(ref this.cloud, () => new CloudContext()); } } @@ -87,7 +87,7 @@ internal CloudContext Cloud /// /// Gets the object describing the component tracked by this . /// - internal ComponentContext Component + public ComponentContext Component { get { return LazyInitializer.EnsureInitialized(ref this.component, () => new ComponentContext()); } } @@ -95,17 +95,15 @@ internal ComponentContext Component /// /// Gets the object describing the device tracked by this . /// - internal DeviceContext Device + public DeviceContext Device { -#pragma warning disable CS0618 // Type or member is obsolete get { return LazyInitializer.EnsureInitialized(ref this.device, () => new DeviceContext(default)); } -#pragma warning restore CS0618 // Type or member is obsolete } /// /// Gets the object describing a user session tracked by this . /// - internal SessionContext Session + public SessionContext Session { get { return LazyInitializer.EnsureInitialized(ref this.session, () => new SessionContext()); } } diff --git a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/CloudContext.cs b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/CloudContext.cs index b827919bd..49765997d 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/CloudContext.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/CloudContext.cs @@ -5,7 +5,7 @@ /// /// Encapsulates information about a cloud where an application is running. /// - internal sealed class CloudContext + public sealed class CloudContext { /// /// Environment variable key used to communicate cloud role name override to the exporter. diff --git a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/ComponentContext.cs b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/ComponentContext.cs index c662738a0..ca3ef9111 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/ComponentContext.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/ComponentContext.cs @@ -1,5 +1,7 @@ namespace Microsoft.ApplicationInsights.Extensibility.Implementation { + using System; + /// /// Encapsulates information describing an Application Insights component. /// @@ -8,8 +10,13 @@ /// with terminology used by our portal and services and to encourage standardization of terminology within our /// organization. Once a consensus is reached, we will change type and property names to match. /// - internal sealed class ComponentContext + public sealed class ComponentContext { + /// + /// Environment variable key used to communicate component version to the exporter. + /// + internal const string ComponentVersionEnvironmentVariable = "APPLICATIONINSIGHTS_COMPONENT_VERSION"; + private string version; internal ComponentContext() @@ -21,8 +28,16 @@ internal ComponentContext() /// public string Version { - get { return string.IsNullOrEmpty(this.version) ? null : this.version; } - set { this.version = value; } + get + { + return string.IsNullOrEmpty(this.version) ? null : this.version; + } + + set + { + this.version = value; + Environment.SetEnvironmentVariable(ComponentVersionEnvironmentVariable, value); + } } } } diff --git a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/DeviceContext.cs b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/DeviceContext.cs index 8ed78cc86..9af740577 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/DeviceContext.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/DeviceContext.cs @@ -5,7 +5,7 @@ /// /// Encapsulates information about a device where an application is running. /// - internal sealed class DeviceContext + public sealed class DeviceContext { private readonly IDictionary properties; @@ -48,21 +48,21 @@ public string OperatingSystem } /// - /// Gets or sets the device OEM for the current device. + /// Gets or sets the device model for the current device. /// - public string OemName + public string Model { - get { return string.IsNullOrEmpty(this.oemName) ? null : this.oemName; } - set { this.oemName = value; } + get { return string.IsNullOrEmpty(this.model) ? null : this.model; } + set { this.model = value; } } /// - /// Gets or sets the device model for the current device. + /// Gets or sets the device OEM for the current device. /// - public string Model + internal string OemName { - get { return string.IsNullOrEmpty(this.model) ? null : this.model; } - set { this.model = value; } + get { return string.IsNullOrEmpty(this.oemName) ? null : this.oemName; } + set { this.oemName = value; } } } } diff --git a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/OperationContext.cs b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/OperationContext.cs index 6ede0b733..f3643305a 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/OperationContext.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/OperationContext.cs @@ -29,7 +29,7 @@ public string Name /// /// Gets or sets the application-defined operation SyntheticSource. /// - internal string SyntheticSource + public string SyntheticSource { get { return string.IsNullOrEmpty(this.syntheticSource) ? null : this.syntheticSource; } set { this.syntheticSource = value; } diff --git a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/SessionContext.cs b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/SessionContext.cs index 9f9402f71..c1c36b6f6 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/SessionContext.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/SessionContext.cs @@ -3,7 +3,7 @@ /// /// Encapsulates information about a user session. /// - internal sealed class SessionContext + public sealed class SessionContext { private string id; private bool? isFirst; @@ -24,7 +24,7 @@ public string Id /// /// Gets or sets the IsFirst Session for the user. /// - public bool? IsFirst + internal bool? IsFirst { get { return this.isFirst; } set { this.isFirst = value; } diff --git a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/UserContext.cs b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/UserContext.cs index 27d03d0ab..89945db7e 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/UserContext.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Extensibility/Implementation/UserContext.cs @@ -26,15 +26,6 @@ public string Id set { this.id = value; } } - /// - /// Gets or sets the UserAgent of an application-defined account associated with the user. - /// - public string UserAgent - { - get { return string.IsNullOrEmpty(this.userAgent) ? null : this.userAgent; } - set { this.userAgent = value; } - } - /// /// Gets or sets the authenticated user id. /// Authenticated user id should be a persistent string that uniquely represents each authenticated user in the application or service. @@ -48,10 +39,19 @@ public string AuthenticatedUserId /// /// Gets or sets the ID of an application-defined account associated with the user. /// - internal string AccountId + public string AccountId { get { return string.IsNullOrEmpty(this.accountId) ? null : this.accountId; } set { this.accountId = value; } } + + /// + /// Gets or sets the UserAgent of an application-defined account associated with the user. + /// + public string UserAgent + { + get { return string.IsNullOrEmpty(this.userAgent) ? null : this.userAgent; } + set { this.userAgent = value; } + } } } diff --git a/BASE/src/Microsoft.ApplicationInsights/Extensibility/TelemetryConfiguration.cs b/BASE/src/Microsoft.ApplicationInsights/Extensibility/TelemetryConfiguration.cs index 895e3196b..1f4eaa371 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Extensibility/TelemetryConfiguration.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Extensibility/TelemetryConfiguration.cs @@ -313,6 +313,23 @@ public void SetAzureTokenCredential(TokenCredential tokenCredential) }); } + /// + /// Prepends a configuration action so it runs before any user-registered configuration. + /// This ensures that enrichment processors (e.g., TelemetryContextLogProcessor) execute + /// before export processors, regardless of the order users call ConfigureOpenTelemetryBuilder. + /// + internal void PrependOpenTelemetryBuilderConfiguration(Action configure) + { + this.ThrowIfBuilt(); + + var previousConfiguration = this.builderConfiguration; + this.builderConfiguration = builder => + { + configure(builder); + previousConfiguration(builder); + }; + } + /// /// Sets the cloud role name and role instance for telemetry. /// This configures the OpenTelemetry Resource with service.name, service.namespace, service.instance.id, and service.version attributes diff --git a/BASE/src/Microsoft.ApplicationInsights/Internal/SemanticConventions.cs b/BASE/src/Microsoft.ApplicationInsights/Internal/SemanticConventions.cs index ce12e1adf..0c57e6b08 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Internal/SemanticConventions.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Internal/SemanticConventions.cs @@ -231,5 +231,26 @@ internal static class SemanticConventions /// When present, maps to ai.location.ip context tag. /// public const string AttributeMicrosoftClientIp = "microsoft.client.ip"; + + /// Attribute for session ID. + public const string AttributeMicrosoftSessionId = "microsoft.session.id"; + + /// Attribute for device ID. + public const string AttributeAiDeviceId = "ai.device.id"; + + /// Attribute for device model. + public const string AttributeAiDeviceModel = "ai.device.model"; + + /// Attribute for device type. + public const string AttributeAiDeviceType = "ai.device.type"; + + /// Attribute for device OS version. + public const string AttributeAiDeviceOsVersion = "ai.device.osVersion"; + + /// Attribute for synthetic source. + public const string AttributeMicrosoftSyntheticSource = "microsoft.synthetic_source"; + + /// Attribute for user account ID. + public const string AttributeMicrosoftUserAccountId = "microsoft.user.account_id"; } } \ No newline at end of file diff --git a/BASE/src/Microsoft.ApplicationInsights/Metric.cs b/BASE/src/Microsoft.ApplicationInsights/Metric.cs index 5250bc529..49aa9fe97 100644 --- a/BASE/src/Microsoft.ApplicationInsights/Metric.cs +++ b/BASE/src/Microsoft.ApplicationInsights/Metric.cs @@ -47,22 +47,7 @@ public void TrackValue(double metricValue) this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - if (allTags.Count > 0) - { - var tags = new TagList(); - foreach (var kvp in allTags) - { - tags.Add(kvp.Key, kvp.Value); - } - - histogram.Record(metricValue, tags); - } - else - { - histogram.Record(metricValue); - } + histogram.Record(metricValue); } /// @@ -107,19 +92,7 @@ public bool TrackValue(double metricValue, string dimension1Value) this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) - { - tags.Add(kvp.Key, kvp.Value); - } - - histogram.Record(metricValue, tags); + histogram.Record(metricValue, new TagList { { this.dimensionNames[0], dimension1Value } }); return true; } @@ -172,18 +145,11 @@ public bool TrackValue(double metricValue, string dimension1Value, string dimens this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + }; histogram.Record(metricValue, tags); return true; @@ -240,19 +206,12 @@ public bool TrackValue(double metricValue, string dimension1Value, string dimens this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + }; histogram.Record(metricValue, tags); return true; @@ -311,20 +270,13 @@ public bool TrackValue(double metricValue, string dimension1Value, string dimens this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - allTags[this.dimensionNames[3]] = dimension4Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + { this.dimensionNames[3], dimension4Value }, + }; histogram.Record(metricValue, tags); return true; @@ -391,21 +343,14 @@ public bool TrackValue( this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - allTags[this.dimensionNames[3]] = dimension4Value; - allTags[this.dimensionNames[4]] = dimension5Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + { this.dimensionNames[3], dimension4Value }, + { this.dimensionNames[4], dimension5Value }, + }; histogram.Record(metricValue, tags); return true; @@ -481,22 +426,15 @@ public bool TrackValue( this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - allTags[this.dimensionNames[3]] = dimension4Value; - allTags[this.dimensionNames[4]] = dimension5Value; - allTags[this.dimensionNames[5]] = dimension6Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + { this.dimensionNames[3], dimension4Value }, + { this.dimensionNames[4], dimension5Value }, + { this.dimensionNames[5], dimension6Value }, + }; histogram.Record(metricValue, tags); return true; @@ -576,23 +514,16 @@ public bool TrackValue( this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - allTags[this.dimensionNames[3]] = dimension4Value; - allTags[this.dimensionNames[4]] = dimension5Value; - allTags[this.dimensionNames[5]] = dimension6Value; - allTags[this.dimensionNames[6]] = dimension7Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + { this.dimensionNames[3], dimension4Value }, + { this.dimensionNames[4], dimension5Value }, + { this.dimensionNames[5], dimension6Value }, + { this.dimensionNames[6], dimension7Value }, + }; histogram.Record(metricValue, tags); return true; @@ -676,24 +607,17 @@ public bool TrackValue( this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - allTags[this.dimensionNames[3]] = dimension4Value; - allTags[this.dimensionNames[4]] = dimension5Value; - allTags[this.dimensionNames[5]] = dimension6Value; - allTags[this.dimensionNames[6]] = dimension7Value; - allTags[this.dimensionNames[7]] = dimension8Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + { this.dimensionNames[3], dimension4Value }, + { this.dimensionNames[4], dimension5Value }, + { this.dimensionNames[5], dimension6Value }, + { this.dimensionNames[6], dimension7Value }, + { this.dimensionNames[7], dimension8Value }, + }; histogram.Record(metricValue, tags); return true; @@ -781,25 +705,18 @@ public bool TrackValue( this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - allTags[this.dimensionNames[3]] = dimension4Value; - allTags[this.dimensionNames[4]] = dimension5Value; - allTags[this.dimensionNames[5]] = dimension6Value; - allTags[this.dimensionNames[6]] = dimension7Value; - allTags[this.dimensionNames[7]] = dimension8Value; - allTags[this.dimensionNames[8]] = dimension9Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) + var tags = new TagList { - tags.Add(kvp.Key, kvp.Value); - } + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + { this.dimensionNames[3], dimension4Value }, + { this.dimensionNames[4], dimension5Value }, + { this.dimensionNames[5], dimension6Value }, + { this.dimensionNames[6], dimension7Value }, + { this.dimensionNames[7], dimension8Value }, + { this.dimensionNames[8], dimension9Value }, + }; histogram.Record(metricValue, tags); return true; @@ -891,26 +808,19 @@ public bool TrackValue( this.metricName, this.metricNamespace); - var allTags = this.client.BuildBaseMetricTags(); - - // Dimension values (highest priority) - allTags[this.dimensionNames[0]] = dimension1Value; - allTags[this.dimensionNames[1]] = dimension2Value; - allTags[this.dimensionNames[2]] = dimension3Value; - allTags[this.dimensionNames[3]] = dimension4Value; - allTags[this.dimensionNames[4]] = dimension5Value; - allTags[this.dimensionNames[5]] = dimension6Value; - allTags[this.dimensionNames[6]] = dimension7Value; - allTags[this.dimensionNames[7]] = dimension8Value; - allTags[this.dimensionNames[8]] = dimension9Value; - allTags[this.dimensionNames[9]] = dimension10Value; - - // Convert to TagList for OTel histogram recording - var tags = new TagList(); - foreach (var kvp in allTags) - { - tags.Add(kvp.Key, kvp.Value); - } + var tags = new TagList + { + { this.dimensionNames[0], dimension1Value }, + { this.dimensionNames[1], dimension2Value }, + { this.dimensionNames[2], dimension3Value }, + { this.dimensionNames[3], dimension4Value }, + { this.dimensionNames[4], dimension5Value }, + { this.dimensionNames[5], dimension6Value }, + { this.dimensionNames[6], dimension7Value }, + { this.dimensionNames[7], dimension8Value }, + { this.dimensionNames[8], dimension9Value }, + { this.dimensionNames[9], dimension10Value }, + }; histogram.Record(metricValue, tags); return true; diff --git a/BASE/src/Microsoft.ApplicationInsights/OpenTelemetryBuilderExtensions.cs b/BASE/src/Microsoft.ApplicationInsights/OpenTelemetryBuilderExtensions.cs index 2ff7e8187..285d0b2ad 100644 --- a/BASE/src/Microsoft.ApplicationInsights/OpenTelemetryBuilderExtensions.cs +++ b/BASE/src/Microsoft.ApplicationInsights/OpenTelemetryBuilderExtensions.cs @@ -32,9 +32,9 @@ public static IOpenTelemetryBuilder WithApplicationInsights(this IOpenTelemetryB new KeyValuePair("telemetry.distro.name", "Microsoft.ApplicationInsights"), new KeyValuePair("telemetry.distro.version", VersionUtils.GetVersion(typeof(OpenTelemetryBuilderExtensions))), })) - .WithLogging() .WithMetrics(metrics => metrics.AddMeter(TelemetryConfiguration.ApplicationInsightsMeterName)) - .WithTracing(tracing => tracing.AddSource(TelemetryConfiguration.ApplicationInsightsActivitySourceName)); + .WithTracing(tracing => tracing.AddSource(TelemetryConfiguration.ApplicationInsightsActivitySourceName)) + .WithLogging(); // Ensure that all log severity levels (including Verbose/Debug) pass through // the internal LoggerFactory for the TelemetryClient category. Without this, diff --git a/BASE/src/Microsoft.ApplicationInsights/Processors/TelemetryContextActivityProcessor.cs b/BASE/src/Microsoft.ApplicationInsights/Processors/TelemetryContextActivityProcessor.cs new file mode 100644 index 000000000..190054098 --- /dev/null +++ b/BASE/src/Microsoft.ApplicationInsights/Processors/TelemetryContextActivityProcessor.cs @@ -0,0 +1,234 @@ +namespace Microsoft.ApplicationInsights.Processors +{ + using System.Collections.Generic; + using System.Diagnostics; + using System.Runtime.CompilerServices; + using System.Threading; + using Microsoft.ApplicationInsights.DataContracts; + using Microsoft.ApplicationInsights.Internal; + using OpenTelemetry; + + /// + /// An activity processor that applies client-level properties + /// to all activities as tags, using skip-if-present semantics. + /// This ensures context tags are applied universally — to Track* calls, Start/Stop operations, + /// and any OpenTelemetry API activities emitted by customer code. + /// + /// + /// Performance optimization: after a warmup period ( calls), + /// context properties are frozen into a compact snapshot array. This avoids repeatedly + /// navigating the TelemetryContext object graph and skipping null/empty values on every call. + /// This relies on the documented pattern that customers set TelemetryClient.Context + /// properties once during initialization. + /// + internal sealed class TelemetryContextActivityProcessor : BaseProcessor + { + /// + /// Minimum number of OnEnd calls before the context snapshot can be frozen. + /// + internal const int WarmupCountThreshold = 10; + + /// + /// Minimum time (in milliseconds) after construction before the context snapshot + /// can be frozen. This guards against high-throughput apps where 10 activities + /// complete before async initialization (e.g., IHostedService, middleware) has + /// had a chance to set TelemetryContext properties. + /// + internal const long WarmupTimeThresholdMs = 5_000; + + private readonly TelemetryContext context; + private readonly long constructedTimestamp; + + /// + /// Frozen snapshot of non-null context tags, built after warmup. + /// Once set, this array is immutable and read lock-free. + /// + private volatile KeyValuePair[] frozenTags; + + /// + /// Counter tracking the number of OnEnd calls during warmup. + /// + private int warmupCounter; + + /// + /// Initializes a new instance of the class. + /// + /// The client-level to apply. + public TelemetryContextActivityProcessor(TelemetryContext context) + { + this.context = context; + this.constructedTimestamp = Stopwatch.GetTimestamp(); + } + + /// + /// Called when an activity ends. Applies client-level context tags using + /// skip-if-present semantics: if a tag is already set (by item-level context, + /// Track* methods, or instrumentation), it is not overwritten. + /// This matches the 2.x SDK's Tags.CopyTagValue precedence behavior. + /// + /// The activity that ended. + public override void OnEnd(Activity activity) + { + if (activity == null || this.context == null) + { + return; + } + + var snapshot = this.frozenTags; + if (snapshot != null) + { + // Fast path: apply pre-computed snapshot (only non-null values, no object graph navigation) + ApplySnapshot(activity, snapshot); + } + else + { + // Slow path: full context evaluation during warmup + this.SlowPathOnEnd(activity); + } + + base.OnEnd(activity); + } + + /// + /// Applies the frozen snapshot of context tags to the activity. + /// Only contains entries where the value was non-null/non-empty at snapshot time. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ApplySnapshot(Activity activity, KeyValuePair[] snapshot) + { + for (int i = 0; i < snapshot.Length; i++) + { + ref readonly var kvp = ref snapshot[i]; + if (activity.GetTagItem(kvp.Key) == null) + { + activity.SetTag(kvp.Key, kvp.Value); + } + } + } + + private static void AddIfNotEmpty(List> list, string key, string value) + { + if (!string.IsNullOrEmpty(value) && !ContainsKey(list, key)) + { + list.Add(new KeyValuePair(key, value)); + } + } + + private static bool ContainsKey(List> list, string key) + { + for (int i = 0; i < list.Count; i++) + { + if (list[i].Key == key) + { + return true; + } + } + + return false; + } + + private static void SetTagIfAbsent(Activity activity, string key, string value) + { + if (!string.IsNullOrEmpty(value) && activity.GetTagItem(key) == null) + { + activity.SetTag(key, value); + } + } + + /// + /// Full context evaluation path used during warmup. After + /// calls, builds and freezes the snapshot for all subsequent calls. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void SlowPathOnEnd(Activity activity) + { + // Apply client-level GlobalProperties (lowest priority — will not overwrite existing tags) + var globalProperties = this.context.GlobalPropertiesValue; + if (globalProperties != null) + { + foreach (var kvp in globalProperties) + { + SetTagIfAbsent(activity, kvp.Key, kvp.Value); + } + } + + SetTagIfAbsent(activity, SemanticConventions.AttributeEnduserPseudoId, this.context.User?.Id); + SetTagIfAbsent(activity, SemanticConventions.AttributeEnduserId, this.context.User?.AuthenticatedUserId); + SetTagIfAbsent(activity, SemanticConventions.AttributeMicrosoftOperationName, this.context.Operation?.Name); + SetTagIfAbsent(activity, SemanticConventions.AttributeMicrosoftClientIp, this.context.Location?.Ip); + SetTagIfAbsent(activity, SemanticConventions.AttributeMicrosoftSessionId, this.context.Session?.Id); + SetTagIfAbsent(activity, SemanticConventions.AttributeAiDeviceId, this.context.Device?.Id); + SetTagIfAbsent(activity, SemanticConventions.AttributeAiDeviceModel, this.context.Device?.Model); + SetTagIfAbsent(activity, SemanticConventions.AttributeAiDeviceType, this.context.Device?.Type); + SetTagIfAbsent(activity, SemanticConventions.AttributeAiDeviceOsVersion, this.context.Device?.OperatingSystem); + SetTagIfAbsent(activity, SemanticConventions.AttributeMicrosoftSyntheticSource, this.context.Operation?.SyntheticSource); + SetTagIfAbsent(activity, SemanticConventions.AttributeMicrosoftUserAccountId, this.context.User?.AccountId); + SetTagIfAbsent(activity, SemanticConventions.AttributeUserAgentOriginal, this.context.User?.UserAgent); + + // Freeze the snapshot once BOTH conditions are met: + // 1. At least WarmupCountThreshold calls have occurred. + // 2. At least WarmupTimeThresholdMs has elapsed since construction. + // The count gate ensures we don't snapshot on the very first call. + // The time gate ensures we don't snapshot before async init (e.g., + // IHostedService.StartAsync, middleware) has had time to set context. + int count = Interlocked.Increment(ref this.warmupCounter); + if (count >= WarmupCountThreshold && this.HasTimeThresholdElapsed()) + { + // CAS-style: only the thread that transitions from null builds the snapshot. + if (this.frozenTags == null) + { + Interlocked.CompareExchange(ref this.frozenTags, this.BuildSnapshot(), null); + } + } + } + + /// + /// Builds an immutable snapshot array of all non-null/non-empty context tags. + /// Called once after warmup completes. + /// + private KeyValuePair[] BuildSnapshot() + { + var list = new List>(); + + var globalProperties = this.context.GlobalPropertiesValue; + if (globalProperties != null) + { + foreach (var kvp in globalProperties) + { + if (!string.IsNullOrEmpty(kvp.Value)) + { + list.Add(new KeyValuePair(kvp.Key, kvp.Value)); + } + } + } + + AddIfNotEmpty(list, SemanticConventions.AttributeEnduserPseudoId, this.context.User?.Id); + AddIfNotEmpty(list, SemanticConventions.AttributeEnduserId, this.context.User?.AuthenticatedUserId); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftOperationName, this.context.Operation?.Name); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftClientIp, this.context.Location?.Ip); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftSessionId, this.context.Session?.Id); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceId, this.context.Device?.Id); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceModel, this.context.Device?.Model); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceType, this.context.Device?.Type); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceOsVersion, this.context.Device?.OperatingSystem); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftSyntheticSource, this.context.Operation?.SyntheticSource); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftUserAccountId, this.context.User?.AccountId); + AddIfNotEmpty(list, SemanticConventions.AttributeUserAgentOriginal, this.context.User?.UserAgent); + + return list.ToArray(); + } + + /// + /// Returns true if at least milliseconds + /// have elapsed since this processor was constructed. + /// Uses for monotonic, allocation-free timing. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool HasTimeThresholdElapsed() + { + long elapsedTicks = Stopwatch.GetTimestamp() - this.constructedTimestamp; + long elapsedMs = (elapsedTicks * 1000) / Stopwatch.Frequency; + return elapsedMs >= WarmupTimeThresholdMs; + } + } +} diff --git a/BASE/src/Microsoft.ApplicationInsights/Processors/TelemetryContextLogProcessor.cs b/BASE/src/Microsoft.ApplicationInsights/Processors/TelemetryContextLogProcessor.cs new file mode 100644 index 000000000..7fb34853a --- /dev/null +++ b/BASE/src/Microsoft.ApplicationInsights/Processors/TelemetryContextLogProcessor.cs @@ -0,0 +1,312 @@ +namespace Microsoft.ApplicationInsights.Processors +{ + using System.Collections.Generic; + using System.Diagnostics; + using System.Runtime.CompilerServices; + using System.Threading; + using Microsoft.ApplicationInsights.DataContracts; + using Microsoft.ApplicationInsights.Internal; + using OpenTelemetry; + using OpenTelemetry.Logs; + + /// + /// A log processor that applies client-level properties + /// to all log records as attributes, using skip-if-present semantics. + /// This ensures context attributes are applied universally — to Track* calls + /// and any calls from customer code. + /// + /// + /// Performance optimization: after a warmup period ( calls), + /// context properties are frozen into a compact snapshot array. This eliminates per-call + /// allocations of and intermediate buffers, + /// and avoids repeatedly navigating the object graph. + /// This relies on the documented pattern that customers set TelemetryClient.Context + /// properties once during initialization. + /// + internal sealed class TelemetryContextLogProcessor : BaseProcessor + { + /// + /// Minimum number of OnEnd calls before the context snapshot can be frozen. + /// + internal const int WarmupCountThreshold = 10; + + /// + /// Minimum time (in milliseconds) after construction before the context snapshot + /// can be frozen. This guards against high-throughput apps where activities + /// complete before async initialization has had a chance to set TelemetryContext properties. + /// + internal const long WarmupTimeThresholdMs = 5_000; + + private readonly TelemetryContext context; + private readonly long constructedTimestamp; + + /// + /// Frozen snapshot of non-null context attributes, built after warmup. + /// Once set, this array is immutable and read lock-free. + /// + private volatile KeyValuePair[] frozenAttributes; + + /// + /// Counter tracking the number of OnEnd calls during warmup. + /// + private int warmupCounter; + + /// + /// Initializes a new instance of the class. + /// + /// The client-level to apply. + public TelemetryContextLogProcessor(TelemetryContext context) + { + this.context = context; + this.constructedTimestamp = Stopwatch.GetTimestamp(); + } + + /// + /// Called when a log record ends. Applies client-level context attributes using + /// skip-if-present semantics: if an attribute key is already present in the log record, + /// it is not overwritten. This matches the 2.x SDK's Tags.CopyTagValue precedence behavior. + /// The Azure Monitor Exporter's LogsHelper.ProcessLogRecordProperties will pick up + /// these attributes and route them into LogContextInfo for context tag mapping. + /// + /// The log record being processed. + public override void OnEnd(LogRecord logRecord) + { + if (logRecord == null || this.context == null) + { + return; + } + + var snapshot = this.frozenAttributes; + if (snapshot != null) + { + // Fast path: apply pre-computed snapshot (no HashSet, no intermediate List, no object graph) + ApplySnapshot(logRecord, snapshot); + } + else + { + // Slow path: full context evaluation during warmup + this.SlowPathOnEnd(logRecord); + } + + base.OnEnd(logRecord); + } + + /// + /// Fast path: merges the frozen snapshot attributes into the log record, + /// skipping any keys already present. + /// Zero-allocation when the log record has no pre-existing attributes. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ApplySnapshot(LogRecord logRecord, KeyValuePair[] snapshot) + { + if (snapshot.Length == 0) + { + return; + } + + var existing = logRecord.Attributes; + int existingCount = existing?.Count ?? 0; + + if (existingCount == 0) + { + // Most common case: no pre-existing attributes. + // Assign the snapshot array directly — T[] implements IReadOnlyList. + // Zero allocation. + logRecord.Attributes = snapshot; + return; + } + + // Count how many snapshot keys are NOT already present, to avoid + // allocating a merged list when all keys conflict. + int newCount = 0; + for (int i = 0; i < snapshot.Length; i++) + { + if (!ContainsKey(existing, snapshot[i].Key)) + { + newCount++; + } + } + + if (newCount == 0) + { + return; + } + + // Only allocate when we actually have new attributes to merge. + var merged = new List>(existingCount + newCount); + foreach (var attr in existing) + { + merged.Add(attr); + } + + for (int i = 0; i < snapshot.Length; i++) + { + ref readonly var kvp = ref snapshot[i]; + if (!ContainsKey(existing, kvp.Key)) + { + merged.Add(kvp); + } + } + + logRecord.Attributes = merged; + } + + /// + /// Checks whether the attribute collection contains the specified key. + /// Uses linear scan — attribute lists are typically small (<20 items). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ContainsKey(IReadOnlyList> attributes, string key) + { + if (attributes == null || attributes.Count == 0) + { + return false; + } + + for (int i = 0; i < attributes.Count; i++) + { + if (attributes[i].Key == key) + { + return true; + } + } + + return false; + } + + private static void AddIfAbsent( + List> contextAttributes, + IReadOnlyList> existingAttributes, + string key, + string value) + { + if (!string.IsNullOrEmpty(value) + && !ContainsKey(existingAttributes, key) + && !ContainsKey(contextAttributes, key)) + { + contextAttributes.Add(new KeyValuePair(key, value)); + } + } + + private static void AddIfNotEmpty(List> list, string key, string value) + { + if (!string.IsNullOrEmpty(value) && !ContainsKey(list, key)) + { + list.Add(new KeyValuePair(key, value)); + } + } + + /// + /// Full context evaluation path used during warmup. After both thresholds are met, + /// builds and freezes the snapshot for all subsequent calls. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void SlowPathOnEnd(LogRecord logRecord) + { + var existing = logRecord.Attributes; + + // Build list of context attributes to add (only those not already present). + // No HashSet needed — linear scan on the small attributes list is faster and allocation-free. + var contextAttributes = new List>(); + var globalProperties = this.context.GlobalPropertiesValue; + if (globalProperties != null) + { + foreach (var kvp in globalProperties) + { + AddIfAbsent(contextAttributes, existing, kvp.Key, kvp.Value); + } + } + + // Build list of structured context attributes to add (only those not already present) + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeEnduserPseudoId, this.context.User?.Id); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeEnduserId, this.context.User?.AuthenticatedUserId); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeMicrosoftOperationName, this.context.Operation?.Name); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeMicrosoftClientIp, this.context.Location?.Ip); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeMicrosoftSessionId, this.context.Session?.Id); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeAiDeviceId, this.context.Device?.Id); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeAiDeviceModel, this.context.Device?.Model); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeAiDeviceType, this.context.Device?.Type); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeAiDeviceOsVersion, this.context.Device?.OperatingSystem); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeMicrosoftSyntheticSource, this.context.Operation?.SyntheticSource); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeMicrosoftUserAccountId, this.context.User?.AccountId); + AddIfAbsent(contextAttributes, existing, SemanticConventions.AttributeUserAgentOriginal, this.context.User?.UserAgent); + + if (contextAttributes.Count > 0) + { + int existingCount = existing?.Count ?? 0; + var merged = new List>(existingCount + contextAttributes.Count); + + if (existing != null) + { + foreach (var attr in existing) + { + merged.Add(attr); + } + } + + merged.AddRange(contextAttributes); + logRecord.Attributes = merged; + } + + // Freeze the snapshot once BOTH conditions are met: + // 1. At least WarmupCountThreshold calls have occurred. + // 2. At least WarmupTimeThresholdMs has elapsed since construction. + int count = Interlocked.Increment(ref this.warmupCounter); + if (count >= WarmupCountThreshold && this.HasTimeThresholdElapsed()) + { + if (this.frozenAttributes == null) + { + Interlocked.CompareExchange(ref this.frozenAttributes, this.BuildSnapshot(), null); + } + } + } + + /// + /// Builds an immutable snapshot array of all non-null/non-empty context attributes. + /// Called once after warmup completes. + /// + private KeyValuePair[] BuildSnapshot() + { + var list = new List>(); + + var globalProperties = this.context.GlobalPropertiesValue; + if (globalProperties != null) + { + foreach (var kvp in globalProperties) + { + if (!string.IsNullOrEmpty(kvp.Value)) + { + list.Add(new KeyValuePair(kvp.Key, kvp.Value)); + } + } + } + + AddIfNotEmpty(list, SemanticConventions.AttributeEnduserPseudoId, this.context.User?.Id); + AddIfNotEmpty(list, SemanticConventions.AttributeEnduserId, this.context.User?.AuthenticatedUserId); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftOperationName, this.context.Operation?.Name); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftClientIp, this.context.Location?.Ip); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftSessionId, this.context.Session?.Id); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceId, this.context.Device?.Id); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceModel, this.context.Device?.Model); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceType, this.context.Device?.Type); + AddIfNotEmpty(list, SemanticConventions.AttributeAiDeviceOsVersion, this.context.Device?.OperatingSystem); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftSyntheticSource, this.context.Operation?.SyntheticSource); + AddIfNotEmpty(list, SemanticConventions.AttributeMicrosoftUserAccountId, this.context.User?.AccountId); + AddIfNotEmpty(list, SemanticConventions.AttributeUserAgentOriginal, this.context.User?.UserAgent); + + return list.ToArray(); + } + + /// + /// Returns true if at least milliseconds + /// have elapsed since this processor was constructed. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool HasTimeThresholdElapsed() + { + long elapsedTicks = Stopwatch.GetTimestamp() - this.constructedTimestamp; + long elapsedMs = (elapsedTicks * 1000) / Stopwatch.Frequency; + return elapsedMs >= WarmupTimeThresholdMs; + } + } +} diff --git a/BASE/src/Microsoft.ApplicationInsights/TelemetryClient.cs b/BASE/src/Microsoft.ApplicationInsights/TelemetryClient.cs index 25f46ba95..6935d0ba7 100644 --- a/BASE/src/Microsoft.ApplicationInsights/TelemetryClient.cs +++ b/BASE/src/Microsoft.ApplicationInsights/TelemetryClient.cs @@ -14,6 +14,7 @@ using Microsoft.ApplicationInsights.Extensibility.Implementation.Tracing; using Microsoft.ApplicationInsights.Internal; using Microsoft.ApplicationInsights.Metrics; + using Microsoft.ApplicationInsights.Processors; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenTelemetry; @@ -54,13 +55,19 @@ internal TelemetryClient(TelemetryConfiguration configuration, bool isFromDepend // Use the shared ActivitySource from configuration this.activitySource = configuration.ApplicationInsightsActivitySource; - // Apply CloudContext to Resource if set - this.ApplyCloudContextToResource(); - - // For non-DI scenarios: Build SDK eagerly to ensure TracerProvider is ready - // For DI scenarios: SDK will be built by configuration when accessed + // For non-DI scenarios: Register context processors and build SDK eagerly + // For DI scenarios: SDK will be built by configuration when accessed; + // processors are registered via UseApplicationInsightsTelemetry() in NETCORE if (!isFromDependencyInjection) { + // Prepend context processors so they run BEFORE any user-registered exporters. + // This ensures LogRecords/Activities are enriched with context tags before export. + configuration.PrependOpenTelemetryBuilderConfiguration(builder => + { + builder.WithTracing(tracing => tracing.AddProcessor(new TelemetryContextActivityProcessor(this.Context))); + builder.WithLogging(logging => logging.AddProcessor(new TelemetryContextLogProcessor(this.Context))); + }); + this.sdk = configuration.Build(); } @@ -128,12 +135,6 @@ internal ILogger Logger } } - /// - /// Gets the context tags built from TelemetryClient.Context public properties. - /// Computed fresh on each access to reflect the latest Context values. - /// - internal IReadOnlyDictionary ContextTags => this.BuildContextTags(); - /// /// Check to determine if the tracking is enabled. /// @@ -161,7 +162,7 @@ public void TrackEvent(string eventName, IDictionary properties var mergedProperties = EnsureMutable(properties); mergedProperties["microsoft.custom_event.name"] = eventName; - var state = new DictionaryLogState(this.Context, this.ContextTags, mergedProperties, String.Empty); + var state = new DictionaryLogState(mergedProperties, String.Empty); this.Logger.Log(LogLevel.Information, 0, state, null, (s, ex) => s.Message); } @@ -188,7 +189,7 @@ public void TrackEvent(EventTelemetry telemetry) // Map item-level context properties to semantic conventions ApplyContextToProperties(telemetry.Context, mergedProperties); - var state = new DictionaryLogState(telemetry.Context, this.ContextTags, mergedProperties, String.Empty); + var state = new DictionaryLogState(telemetry.Context, mergedProperties, String.Empty); this.Logger.Log(LogLevel.Information, 0, state, null, (s, ex) => s.Message); } @@ -267,7 +268,7 @@ public void TrackAvailability(AvailabilityTelemetry telemetry) // Map item-level context properties to semantic conventions ApplyContextToProperties(telemetry.Context, properties); - var state = new DictionaryLogState(telemetry.Context, this.ContextTags, properties, telemetry.Message ?? String.Empty); + var state = new DictionaryLogState(telemetry.Context, properties, telemetry.Message ?? String.Empty); this.Logger.Log(LogLevel.Information, 0, state, null, (s, ex) => s.Message); } @@ -281,7 +282,7 @@ public void TrackAvailability(AvailabilityTelemetry telemetry) public void TrackTrace(string message) { this.Configuration.FeatureReporter.MarkFeatureInUse(StatsbeatFeatures.TrackTrace); - var state = new DictionaryLogState(this.Context, this.ContextTags, null, message); + var state = new DictionaryLogState(null, message); this.Logger.Log(LogLevel.Information, 0, state, null, (s, ex) => s.Message); } @@ -297,7 +298,7 @@ public void TrackTrace(string message, SeverityLevel severityLevel) { this.Configuration.FeatureReporter.MarkFeatureInUse(StatsbeatFeatures.TrackTrace); LogLevel logLevel = GetLogLevel(severityLevel); - var state = new DictionaryLogState(this.Context, this.ContextTags, null, message); + var state = new DictionaryLogState(null, message); this.Logger.Log(logLevel, 0, state, null, (s, ex) => s.Message); } @@ -312,7 +313,7 @@ public void TrackTrace(string message, SeverityLevel severityLevel) public void TrackTrace(string message, IDictionary properties) { this.Configuration.FeatureReporter.MarkFeatureInUse(StatsbeatFeatures.TrackTrace); - var state = new DictionaryLogState(this.Context, this.ContextTags, properties, message); + var state = new DictionaryLogState(properties, message); this.Logger.Log(LogLevel.Information, 0, state, null, (s, ex) => s.Message); } @@ -329,7 +330,7 @@ public void TrackTrace(string message, SeverityLevel severityLevel, IDictionary< { this.Configuration.FeatureReporter.MarkFeatureInUse(StatsbeatFeatures.TrackTrace); LogLevel logLevel = GetLogLevel(severityLevel); - var state = new DictionaryLogState(this.Context, this.ContextTags, properties, message); + var state = new DictionaryLogState(properties, message); this.Logger.Log(logLevel, 0, state, null, (s, ex) => s.Message); } @@ -364,7 +365,7 @@ public void TrackTrace(TraceTelemetry telemetry) ApplyContextToProperties(telemetry.Context, mergedProperties); LogLevel logLevel = GetLogLevel(telemetry.SeverityLevel.Value); - var state = new DictionaryLogState(telemetry.Context, this.ContextTags, mergedProperties, telemetry.Message); + var state = new DictionaryLogState(telemetry.Context, mergedProperties, telemetry.Message); this.Logger.Log(logLevel, 0, state, null, (s, ex) => s.Message); } @@ -385,22 +386,10 @@ public void TrackMetric(string name, double value, IDictionary p // Get or create histogram for this metric var histogram = this.Configuration.MetricsManager.GetOrCreateHistogram(name, null); - // Merge: client context tags + GlobalProperties (lowest) → caller properties (highest) - var allTags = this.BuildBaseMetricTags(); - - // Caller-provided properties (highest priority) - if (properties != null) - { - foreach (var kvp in properties) - { - allTags[kvp.Key] = kvp.Value; - } - } - - if (allTags.Count > 0) + if (properties != null && properties.Count > 0) { var tags = new TagList(); - foreach (var kvp in allTags) + foreach (var kvp in properties) { tags.Add(kvp.Key, kvp.Value); } @@ -436,34 +425,10 @@ public void TrackMetric(MetricTelemetry telemetry) telemetry.Name, telemetry.MetricNamespace); - // Merge: client context tags + GlobalProperties (lowest) → item layers (highest) - var allTags = this.BuildBaseMetricTags(); - - // 3. GlobalProperties from item context - if (telemetry.Context?.GlobalPropertiesValue != null) - { - foreach (var property in telemetry.Context.GlobalPropertiesValue) - { - allTags[property.Key] = property.Value; - } - } - - // 4. Item custom properties - if (telemetry.Properties != null) - { - foreach (var kvp in telemetry.Properties) - { - allTags[kvp.Key] = kvp.Value; - } - } - - // 5. Item-level context (highest priority) - ApplyContextToProperties(telemetry.Context, allTags); - - if (allTags.Count > 0) + if (telemetry.Properties != null && telemetry.Properties.Count > 0) { var tags = new TagList(); - foreach (var kvp in allTags) + foreach (var kvp in telemetry.Properties) { tags.Add(kvp.Key, kvp.Value); } @@ -492,7 +457,7 @@ public void TrackException(Exception exception, IDictionary prop exception = new InvalidOperationException(Utils.PopulateRequiredStringValue(null, "message", typeof(ExceptionTelemetry).FullName)); } - var state = new DictionaryLogState(this.Context, this.ContextTags, properties, exception.Message); + var state = new DictionaryLogState(properties, exception.Message); this.Logger.Log(LogLevel.Error, 0, state, exception, (s, ex) => s.Message); } @@ -519,7 +484,7 @@ public void TrackException(ExceptionTelemetry telemetry) var mergedProperties = EnsureMutable(telemetry.Properties); ApplyContextToProperties(telemetry.Context, mergedProperties); - var state = new DictionaryLogState(telemetry.Context, this.ContextTags, mergedProperties, reconstructedException.Message); + var state = new DictionaryLogState(telemetry.Context, mergedProperties, reconstructedException.Message); var logLevel = GetLogLevel(telemetry.SeverityLevel ?? SeverityLevel.Error); this.Logger.Log(logLevel, 0, state, reconstructedException, (s, ex) => s.Message); } @@ -647,26 +612,11 @@ public void TrackDependency(DependencyTelemetry telemetry) dependencyTelemetryActivity.SetTag(SemanticConventions.AttributeMicrosoftDependencyResultCode, telemetry.ResultCode); } - // Apply client-level context tags (lowest priority — applied first so higher layers override) - foreach (var tag in this.ContextTags) - { - dependencyTelemetryActivity.SetTag(tag.Key, tag.Value); - } - if (!string.IsNullOrEmpty(telemetry.Context?.Operation?.Name)) { dependencyTelemetryActivity.SetTag(SemanticConventions.AttributeMicrosoftOperationName, telemetry.Context.Operation.Name); } - // Add GlobalProperties first (lower priority) - if (this.Context?.GlobalPropertiesValue != null) - { - foreach (var property in this.Context.GlobalPropertiesValue) - { - dependencyTelemetryActivity.SetTag(property.Key, property.Value); - } - } - // Add telemetry context GlobalProperties if (telemetry.Context?.GlobalPropertiesValue != null) { @@ -831,26 +781,11 @@ public void TrackRequest(RequestTelemetry request) activity.SetTag(SemanticConventions.AttributeMicrosoftRequestResultCode, request.ResponseCode); } - // Apply client-level context tags (lowest priority — applied first so higher layers override) - foreach (var tag in this.ContextTags) - { - activity.SetTag(tag.Key, tag.Value); - } - if (!string.IsNullOrEmpty(request.Context?.Operation?.Name)) { activity.SetTag(SemanticConventions.AttributeMicrosoftOperationName, request.Context.Operation.Name); } - // Add GlobalProperties first (lower priority) - if (this.Context?.GlobalPropertiesValue != null) - { - foreach (var property in this.Context.GlobalPropertiesValue) - { - activity.SetTag(property.Key, property.Value); - } - } - // Add request context GlobalProperties if (request.Context?.GlobalPropertiesValue != null) { @@ -1161,33 +1096,6 @@ public Metric GetMetric( return new Metric(this, metricIdentifier.MetricId, metricIdentifier.MetricNamespace, dimensionNames); } - /// - /// Builds a dictionary of metric tags by merging client-level context tags and GlobalProperties. - /// Context tags have lowest priority; GlobalProperties override them. - /// Used by both and to avoid duplicating merge logic. - /// - internal Dictionary BuildBaseMetricTags() - { - var allTags = new Dictionary(); - - // 1. Client-level context tags (lowest priority) - foreach (var tag in this.ContextTags) - { - allTags[tag.Key] = tag.Value; - } - - // 2. GlobalProperties from client context - if (this.Context.GlobalPropertiesValue != null) - { - foreach (var property in this.Context.GlobalPropertiesValue) - { - allTags[property.Key] = property.Value; - } - } - - return allTags; - } - private static LogLevel GetLogLevel(SeverityLevel severityLevel) { return severityLevel switch @@ -1451,11 +1359,6 @@ private static void ApplyContextToActivity(TelemetryContext context, Activity ac activity.SetTag(SemanticConventions.AttributeEnduserId, context.User.AuthenticatedUserId); } - if (!string.IsNullOrEmpty(context.User?.UserAgent)) - { - activity.SetTag(SemanticConventions.AttributeUserAgentOriginal, context.User.UserAgent); - } - if (!string.IsNullOrEmpty(context.Operation?.Name)) { activity.SetTag(SemanticConventions.AttributeMicrosoftOperationName, context.Operation.Name); @@ -1465,6 +1368,46 @@ private static void ApplyContextToActivity(TelemetryContext context, Activity ac { activity.SetTag(SemanticConventions.AttributeMicrosoftClientIp, context.Location.Ip); } + + if (!string.IsNullOrEmpty(context.Session?.Id)) + { + activity.SetTag(SemanticConventions.AttributeMicrosoftSessionId, context.Session.Id); + } + + if (!string.IsNullOrEmpty(context.Device?.Id)) + { + activity.SetTag(SemanticConventions.AttributeAiDeviceId, context.Device.Id); + } + + if (!string.IsNullOrEmpty(context.Device?.Model)) + { + activity.SetTag(SemanticConventions.AttributeAiDeviceModel, context.Device.Model); + } + + if (!string.IsNullOrEmpty(context.Device?.Type)) + { + activity.SetTag(SemanticConventions.AttributeAiDeviceType, context.Device.Type); + } + + if (!string.IsNullOrEmpty(context.Device?.OperatingSystem)) + { + activity.SetTag(SemanticConventions.AttributeAiDeviceOsVersion, context.Device.OperatingSystem); + } + + if (!string.IsNullOrEmpty(context.Operation?.SyntheticSource)) + { + activity.SetTag(SemanticConventions.AttributeMicrosoftSyntheticSource, context.Operation.SyntheticSource); + } + + if (!string.IsNullOrEmpty(context.User?.AccountId)) + { + activity.SetTag(SemanticConventions.AttributeMicrosoftUserAccountId, context.User.AccountId); + } + + if (!string.IsNullOrEmpty(context.User?.UserAgent)) + { + activity.SetTag(SemanticConventions.AttributeUserAgentOriginal, context.User.UserAgent); + } } /// @@ -1489,11 +1432,6 @@ private static void ApplyContextToProperties(TelemetryContext context, IDictiona properties[SemanticConventions.AttributeEnduserId] = context.User.AuthenticatedUserId; } - if (!string.IsNullOrEmpty(context.User?.UserAgent)) - { - properties[SemanticConventions.AttributeUserAgentOriginal] = context.User.UserAgent; - } - if (!string.IsNullOrEmpty(context.Operation?.Name)) { properties[SemanticConventions.AttributeMicrosoftOperationName] = context.Operation.Name; @@ -1503,61 +1441,46 @@ private static void ApplyContextToProperties(TelemetryContext context, IDictiona { properties[SemanticConventions.AttributeMicrosoftClientIp] = context.Location.Ip; } - } - /// - /// Applies CloudContext (RoleName/RoleInstance) and ComponentContext (Version) to the OpenTelemetry Resource if set. - /// - private void ApplyCloudContextToResource() - { - var roleName = this.Context.Cloud.RoleName; - var roleInstance = this.Context.Cloud.RoleInstance; - var componentVersion = this.Context.Component.Version; - - if (!string.IsNullOrEmpty(roleName) || !string.IsNullOrEmpty(roleInstance) || !string.IsNullOrEmpty(componentVersion)) + if (!string.IsNullOrEmpty(context.Session?.Id)) { - this.Configuration.SetCloudRole( - serviceName: roleName, - serviceInstanceId: roleInstance, - serviceVersion: componentVersion); + properties[SemanticConventions.AttributeMicrosoftSessionId] = context.Session.Id; } - } - /// - /// Builds a dictionary of context tags from public TelemetryClient.Context properties. - /// Computed fresh on each call to reflect the latest Context values. - /// Only includes properties that are not null or empty. - /// - private Dictionary BuildContextTags() - { - var tags = new Dictionary(); + if (!string.IsNullOrEmpty(context.Device?.Id)) + { + properties[SemanticConventions.AttributeAiDeviceId] = context.Device.Id; + } - if (!string.IsNullOrEmpty(this.Context.User?.Id)) + if (!string.IsNullOrEmpty(context.Device?.Model)) { - tags[SemanticConventions.AttributeEnduserPseudoId] = this.Context.User.Id; + properties[SemanticConventions.AttributeAiDeviceModel] = context.Device.Model; } - if (!string.IsNullOrEmpty(this.Context.User?.AuthenticatedUserId)) + if (!string.IsNullOrEmpty(context.Device?.Type)) { - tags[SemanticConventions.AttributeEnduserId] = this.Context.User.AuthenticatedUserId; + properties[SemanticConventions.AttributeAiDeviceType] = context.Device.Type; } - if (!string.IsNullOrEmpty(this.Context.User?.UserAgent)) + if (!string.IsNullOrEmpty(context.Device?.OperatingSystem)) { - tags[SemanticConventions.AttributeUserAgentOriginal] = this.Context.User.UserAgent; + properties[SemanticConventions.AttributeAiDeviceOsVersion] = context.Device.OperatingSystem; } - if (!string.IsNullOrEmpty(this.Context.Operation?.Name)) + if (!string.IsNullOrEmpty(context.Operation?.SyntheticSource)) { - tags[SemanticConventions.AttributeMicrosoftOperationName] = this.Context.Operation.Name; + properties[SemanticConventions.AttributeMicrosoftSyntheticSource] = context.Operation.SyntheticSource; } - if (!string.IsNullOrEmpty(this.Context.Location?.Ip)) + if (!string.IsNullOrEmpty(context.User?.AccountId)) { - tags[SemanticConventions.AttributeMicrosoftClientIp] = this.Context.Location.Ip; + properties[SemanticConventions.AttributeMicrosoftUserAccountId] = context.User.AccountId; } - return tags; + if (!string.IsNullOrEmpty(context.User?.UserAgent)) + { + properties[SemanticConventions.AttributeUserAgentOriginal] = context.User.UserAgent; + } } private readonly struct DictionaryLogState : IReadOnlyList> @@ -1587,29 +1510,20 @@ public DictionaryLogState(IDictionary properties, string message } /// - /// Constructor that merges TelemetryContext.GlobalProperties with client-level context tags and item-level properties. + /// Constructor that merges TelemetryContext.GlobalProperties with item-level properties. + /// Client-level context tags are handled by . /// /// The telemetry context containing GlobalProperties. - /// Client-level cached context tags (lowest priority). - /// Item-level properties (highest priority, override all). + /// Item-level properties (highest priority, override GlobalProperties). /// The log message. - public DictionaryLogState(TelemetryContext context, IReadOnlyDictionary clientContextTags, IDictionary properties, string message) + public DictionaryLogState(TelemetryContext context, IDictionary properties, string message) { this.Message = message ?? string.Empty; - // Merge in priority order: clientContextTags (lowest) -> GlobalProperties -> properties (highest) + // Merge in priority order: GlobalProperties (lower) -> properties (higher) var allProperties = new Dictionary(); - // 1. Client-level cached context tags (lowest priority) - if (clientContextTags != null) - { - foreach (var kvp in clientContextTags) - { - allProperties[kvp.Key] = kvp.Value; - } - } - - // 2. GlobalProperties from context (override client-level) + // 1. GlobalProperties from context if (context?.GlobalProperties != null) { foreach (var kvp in context.GlobalProperties) @@ -1618,12 +1532,12 @@ public DictionaryLogState(TelemetryContext context, IReadOnlyDictionary [!IMPORTANT] +> Context properties set on `TelemetryClient.Context` are applied to all **traces and logs** (TrackRequest, TrackDependency, TrackTrace, TrackEvent, TrackException, TrackAvailability). **Metrics are not enriched** with context properties. ### TelemetryContext Properties Removed - **`InstrumentationKey`** - Removed. Use `TelemetryConfiguration.ConnectionString` instead. @@ -156,31 +159,26 @@ Several TelemetryContext sub-context classes have been made internal, and some p - **`Properties`** (obsolete) - Was obsoleted in 2.x in favor of `GlobalProperties`. - **`TryGetRawObject()`** / **`StoreRawObject()`** - Removed. These methods were used to pass raw objects between collectors and initializers, which no longer exist. -### Sub-Context Classes Made Internal -The following sub-context classes were **public** in 2.x and are now **internal** in 3.x. Their properties are no longer accessible: -- **`Cloud`** (`CloudContext`) — Had `RoleName`, `RoleInstance`. -- **`Component`** (`ComponentContext`) — Had `Version`. -- **`Device`** (`DeviceContext`) — Had `Type`, `Id`, `OperatingSystem`, `OemName`, `Model`. -- **`Session`** (`SessionContext`) — Had `Id`, `IsFirst`. - -See detailed migration guidance [here](MigrationGuidance.md#telemetry-context) - ### Sub-Context Properties Made Internal -The following properties on **still-public** sub-context classes have been made internal: -- **`User.AccountId`** — Was public in 2.x, now internal. This can be set via adding properties to Track() calls or creating custom OpenTelemetry processors. -- **`Operation.Id`** — Was public in 2.x, now internal. Correlation IDs are managed automatically by OpenTelemetry. -- **`Operation.ParentId`** — Was public in 2.x, now internal. Correlation IDs are managed automatically by OpenTelemetry. -- **`Operation.CorrelationVector`** — No longer needed due to shift to OpenTelemetry correlation. -- **`Operation.SyntheticSource`** — There is future work planned to reset this to public. - -### Properties Retained -The following remain **public**: -- `User` (`Id`, `AuthenticatedUserId`, `UserAgent`) -- `Operation` (`Name`) +The following properties on sub-context classes have been made internal: +- **`Device.OemName`** — Now internal. The Application Insights ingestion service does not surface this field in workspace-based resources. +- **`Session.IsFirst`** — Now internal. The Application Insights ingestion service does not surface this field in workspace-based resources. +- **`Operation.Id`** — Now internal. Correlation IDs are managed automatically by OpenTelemetry. +- **`Operation.ParentId`** — Now internal. Correlation IDs are managed automatically by OpenTelemetry. +- **`Operation.CorrelationVector`** — Now internal. No longer needed due to shift to OpenTelemetry correlation. + +### Properties Retained (Public) +All sub-context classes remain **public**. The following properties remain **public** and can be set on `TelemetryClient.Context` (applies to all non-metric telemetry) or on individual telemetry items: +- `Cloud` (`RoleName`, `RoleInstance`) +- `Component` (`Version`) +- `User` (`Id`, `AuthenticatedUserId`, `AccountId`, `UserAgent`) +- `Operation` (`Name`, `SyntheticSource`) - `Location` (`Ip`) +- `Session` (`Id`) +- `Device` (`Type`, `Id`, `OperatingSystem`, `Model`) - `GlobalProperties` -Note that these properties are currently settable on individual telemetry items; there is future work planned to make these settable via TelemetryClient. +See detailed migration guidance [here](MigrationGuidance.md#telemetry-context) --- # 2. Microsoft.ApplicationInsights.AspNetCore diff --git a/MigrationGuidance.md b/MigrationGuidance.md index 22f558736..44f40e2ee 100644 --- a/MigrationGuidance.md +++ b/MigrationGuidance.md @@ -320,28 +320,30 @@ There is not a direct replacement for the InMemoryChannel. However, OpenTelemetr Customers who implemented custom `ITelemetryChannel` for sending telemetry to additional backends should use OpenTelemetry exporters instead. See the [TelemetrySinks](#telemetrysinks) section for a console exporter example. ## Telemetry Context -As mentioned in [breaking changes](BreakingChanges.md#telemetrycontext-breaking-changes), many context classes and properties have been marked as internal. For each of the removed classes, workarounds are listed: +As mentioned in [breaking changes](BreakingChanges.md#telemetrycontext-breaking-changes), many context classes and properties have been marked as internal. The sections below describe how to set context in 3.x. -### Cloud: RoleName, RoleInstance & Component Version -To set these values on all telemetry items, configure the OpenTelemetry service attributes `service.name` (maps to Cloud RoleName) and `service.instance.id` (maps to Cloud RoleInstance) and `service.version` (maps to application version). +### Cloud RoleName, RoleInstance & Component Version + +These values map to OpenTelemetry Resource attributes (`service.name`, `service.instance.id`, `service.version`). Because Resource attributes are immutable after the OpenTelemetry SDK is built, the mechanism differs between non-DI and DI scenarios. #### Microsoft.ApplicationInsights (Base SDK) & Microsoft.ApplicationInsights.Web + +In non-DI scenarios, set these directly on the `TelemetryClient.Context` before sending telemetry. + ```csharp -var configuration = TelemetryConfiguration.CreateDefault(); -configuration.ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://..."; +var config = TelemetryConfiguration.CreateDefault(); +config.ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://..."; -configuration.ConfigureOpenTelemetryBuilder(builder => -{ - builder.ConfigureResource(r => r - .AddService( - serviceName: "MyRoleName", // Maps to Cloud.RoleName - serviceInstanceId: Environment.MachineName, // Maps to Cloud.RoleInstance - serviceVersion: "1.0.0") // Maps to Component.Version - ); -}); +var client = new TelemetryClient(config); +client.Context.Cloud.RoleName = "MyRoleName"; // Maps to Cloud.RoleName +client.Context.Cloud.RoleInstance = Environment.MachineName; // Maps to Cloud.RoleInstance +client.Context.Component.Version = "1.0.0"; // Maps to Component.Version ``` #### Microsoft.ApplicationInsights.AspNetCore & Microsoft.ApplicationInsights.WorkerService + +In DI scenarios, the OpenTelemetry SDK is built by the host before user code runs, so the `Context.Cloud` setters cannot influence the Resource in time. Instead, configure the Resource via the OpenTelemetry builder: + ```csharp builder.Services.ConfigureOpenTelemetryTracerProvider((sp, tracerBuilder) => { @@ -363,40 +365,54 @@ builder.Services.ConfigureOpenTelemetryLoggerProvider((sp, loggerBuilder) => ); }); ``` -If one wishes to set service attributes only on specific telemetry items, that can be done via Track* overloads that take in custom properties, or via setting the property on the telemetry item before passing it to a Track* call. - -> [!NOTE] -> There is a future work item to enable the setting of CloudContext and ComponentVersion properties without having to explicitly configure OpenTelemetry providers. -### Device and Session Context & User.AccountId -Currently, the Azure Monitor Exporter does not contain the mapping for these context attributes to appear in the correct part of the payload if a customer explicitly specifies it. -The current workaround is to add custom dimensions to your telemetry items instead. -For individual items, this can be done via Track* overloads that take in custom properties, or it can be set on the individual telemetry item via `Properties` before being passed to Track* calls. -To add dimensions to all telemetry items, consider this [alternative](#familiar-api-alternative) or a [custom OpenTelemetry processor](#creating-a-custom-opentelemetry-processor). +### User, Operation, Location, Device & Session Context -> [!NOTE] -> There is a future work item to enable the setting of these context properties via their original Context attributes. +These context properties can be set in two ways: on individual telemetry items, or on the `TelemetryClient.Context` to apply to all telemetry sent by that client. -### Setting TelemetryContext via TelemetryClient -In 2.x it was possible to set TelemetryContext on the TelemetryClient, such that the context appeared on every telemetry item. -In 3.x, this functionality is not completely implemented - ie, only the `GlobalProperties` will propagate. We are working on a fix to ensure other properties will also propagate. +> [!IMPORTANT] +> Context set on `TelemetryClient.Context` applies to all **traces and logs**. **Metrics are not enriched** with these context properties. -Intended syntax: +#### Setting context via TelemetryClient ```csharp var client = new TelemetryClient(config); -// GlobalProperties - this works today and will appear in customDimensions on all telemetry -client.Context.GlobalProperties["MyCustomGlobalProperty"] = "Production"; +// GlobalProperties appear in customDimensions +client.Context.GlobalProperties["Environment"] = "Production"; -// The following are public and settable on individual telemetry items today. -// There is a known issue where these do not yet propagate when set on TelemetryClient.Context. -// Once fixed, setting them here will apply to all telemetry sent by this client: +// These context properties are applied to all traces and logs sent by this client: client.Context.User.Id = "anonymous-user-id"; client.Context.User.AuthenticatedUserId = "authenticated-user-id"; +client.Context.User.AccountId = "account-123"; client.Context.User.UserAgent = "MyApp/1.0"; client.Context.Operation.Name = "MyOperation"; +client.Context.Operation.SyntheticSource = "BotTraffic"; client.Context.Location.Ip = "127.0.0.1"; +client.Context.Session.Id = "session-abc"; +client.Context.Device.Id = "device-xyz"; +client.Context.Device.Model = "Surface Pro"; +client.Context.Device.Type = "PC"; +client.Context.Device.OperatingSystem = "Windows 11"; +``` + +#### Setting context on an individual telemetry item + +Item-level context overrides client-level context for that item. This works for both activity-based telemetry (Request, Dependency) and log-based telemetry (Trace, Event, Exception, Availability). + +```csharp +// Activity-based: set context on the telemetry item before passing to Track* +var request = new RequestTelemetry("GET /api/orders", DateTimeOffset.Now, TimeSpan.FromMilliseconds(150), "200", true); +request.Context.User.Id = "specific-user"; +request.Context.User.UserAgent = "CustomAgent/2.0"; +request.Context.Session.Id = "specific-session"; +client.TrackRequest(request); + +// Log-based: set context on the telemetry item before passing to Track* +var trace = new TraceTelemetry("Processing order"); +trace.Context.User.Id = "specific-user"; +trace.Context.Operation.Name = "OrderProcessing"; +client.TrackTrace(trace); ``` ## Changes to ApplicationInsightsServiceOptions diff --git a/NETCORE/src/Microsoft.ApplicationInsights.AspNetCore/Extensions/ApplicationInsightsExtensions.cs b/NETCORE/src/Microsoft.ApplicationInsights.AspNetCore/Extensions/ApplicationInsightsExtensions.cs index def7a8e63..1380c6942 100644 --- a/NETCORE/src/Microsoft.ApplicationInsights.AspNetCore/Extensions/ApplicationInsightsExtensions.cs +++ b/NETCORE/src/Microsoft.ApplicationInsights.AspNetCore/Extensions/ApplicationInsightsExtensions.cs @@ -12,10 +12,12 @@ using Microsoft.ApplicationInsights.Extensibility; using Microsoft.ApplicationInsights.Extensibility.Implementation.Tracing; using Microsoft.ApplicationInsights.Internal; + using Microsoft.ApplicationInsights.Processors; using Microsoft.ApplicationInsights.Shared.Vendoring.OpenTelemetry.Resources; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using OpenTelemetry; + using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -157,11 +159,19 @@ internal static IOpenTelemetryBuilder UseApplicationInsightsTelemetry(this IOpen return true; }) - .AddProcessor()); + .AddProcessor() + .AddProcessor(sp => + new TelemetryContextActivityProcessor(sp.GetRequiredService().Context))); // Register ActivityFilterProcessor in DI builder.Services.AddSingleton(); + builder.Services.ConfigureOpenTelemetryLoggerProvider((sp, loggerBuilder) => + { + loggerBuilder.AddProcessor( + new TelemetryContextLogProcessor(sp.GetRequiredService().Context)); + }); + builder.WithMetrics(b => b.AddHttpClientAndServerMetrics()); builder.Services.AddOptions() diff --git a/NETCORE/src/Microsoft.ApplicationInsights.WorkerService/ApplicationInsightsExtensions.cs b/NETCORE/src/Microsoft.ApplicationInsights.WorkerService/ApplicationInsightsExtensions.cs index 805c95b14..34a3c0506 100644 --- a/NETCORE/src/Microsoft.ApplicationInsights.WorkerService/ApplicationInsightsExtensions.cs +++ b/NETCORE/src/Microsoft.ApplicationInsights.WorkerService/ApplicationInsightsExtensions.cs @@ -9,12 +9,14 @@ using Microsoft.ApplicationInsights.Extensibility; using Microsoft.ApplicationInsights.Extensibility.Implementation.Tracing; using Microsoft.ApplicationInsights.Internal; + using Microsoft.ApplicationInsights.Processors; using Microsoft.ApplicationInsights.Shared.Vendoring.OpenTelemetry.Resources; using Microsoft.ApplicationInsights.WorkerService; using Microsoft.ApplicationInsights.WorkerService.Implementation.Tracing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using OpenTelemetry; + using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -152,11 +154,19 @@ internal static IOpenTelemetryBuilder UseApplicationInsightsTelemetry(this IOpen return true; }) - .AddProcessor()); + .AddProcessor() + .AddProcessor(sp => + new TelemetryContextActivityProcessor(sp.GetRequiredService().Context))); // Register ActivityFilterProcessor in DI builder.Services.AddSingleton(); + builder.Services.ConfigureOpenTelemetryLoggerProvider((sp, loggerBuilder) => + { + loggerBuilder.AddProcessor( + new TelemetryContextLogProcessor(sp.GetRequiredService().Context)); + }); + builder.WithMetrics(b => b.AddHttpClientMetrics()); builder.Services.AddOptions() diff --git a/examples/AspNetCoreWebApp/Controllers/HomeController.cs b/examples/AspNetCoreWebApp/Controllers/HomeController.cs index 8588d30c2..e933fc45f 100644 --- a/examples/AspNetCoreWebApp/Controllers/HomeController.cs +++ b/examples/AspNetCoreWebApp/Controllers/HomeController.cs @@ -20,6 +20,8 @@ public HomeController(ILogger logger, TelemetryClient telemetryC { this._logger = logger; this._telemetryClient = telemetryClient; + telemetryClient.Context.User.Id = "TestUserId"; + telemetryClient.Context.User.UserAgent = "curl/8.0"; // In a real app, you wouldn't need the TelemetryConfiguration here. // This is included in this sample because it allows you to debug and verify that the configuration at runtime matches the expected configuration. @@ -28,9 +30,21 @@ public HomeController(ILogger logger, TelemetryClient telemetryC public IActionResult Index() { - // this._telemetryClient.TrackEvent(eventName: "Hello World!"); + this._telemetryClient.TrackEvent(eventName: "Hello World!"); + this._telemetryClient.TrackTrace(message: "This is a trace message."); + this._telemetryClient.TrackException(exception: new Exception("This is a test exception.")); + this._telemetryClient.TrackDependency(dependencyTypeName: "HTTP", target: "www.example.com", dependencyName: "GET /api/test", data: null, startTime: DateTimeOffset.Now, duration: TimeSpan.FromMilliseconds(100), resultCode: "200", success: true); this._telemetryClient.TrackRequest("Test Request", DateTimeOffset.Now, TimeSpan.FromMilliseconds(123), "200", true); + _logger.LogInformation("Hello from HomeController.Index!"); + _logger.LogError(new Exception("This is a test exception logged with ILogger."), "This is a test exception logged with ILogger."); + + using (var operation = this._telemetryClient.StartOperation("TestOperation")) + { + operation.Telemetry.Properties["CustomProperty"] = "CustomValue"; + // Simulate some work + System.Threading.Thread.Sleep(100); + } return View(); } diff --git a/examples/AspNetCoreWebApp/appsettings.json b/examples/AspNetCoreWebApp/appsettings.json index 90798873f..400698671 100644 --- a/examples/AspNetCoreWebApp/appsettings.json +++ b/examples/AspNetCoreWebApp/appsettings.json @@ -8,6 +8,6 @@ }, "AllowedHosts": "*", "ApplicationInsights": { - "ConnectionString": "InstrumentationKey=11111111-2222-3333-4444-555555555555;IngestionEndpoint=http://testendpoint" + "ConnectionString": "" } } diff --git a/examples/BasicConsoleApp/Program.cs b/examples/BasicConsoleApp/Program.cs index ab3e9a0e5..840484a42 100644 --- a/examples/BasicConsoleApp/Program.cs +++ b/examples/BasicConsoleApp/Program.cs @@ -19,7 +19,8 @@ static void Main(string[] args) { var telemetryConfig = TelemetryConfiguration.CreateDefault(); - telemetryConfig.ConnectionString = ""; + telemetryConfig.ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000"; + telemetryConfig.SamplingRatio = 1.0f; // Set to 100% for testing; adjust as needed for production telemetryConfig.ConfigureOpenTelemetryBuilder(builder => builder.WithTracing(tracing => tracing.AddSource("MyCompany.MyProduct.MyLibrary").AddConsoleExporter()) .WithLogging(logging => logging.AddConsoleExporter()) @@ -28,6 +29,24 @@ static void Main(string[] args) // Initialize the TelemetryClient var telemetryClient = new TelemetryClient(telemetryConfig); + // Set all public TelemetryContext properties to verify context tag propagation + telemetryClient.Context.User.Id = "test-user-123"; + telemetryClient.Context.User.AuthenticatedUserId = "auth-user-456"; + telemetryClient.Context.User.AccountId = "account-789"; + telemetryClient.Context.User.UserAgent = "curl/8.0"; + telemetryClient.Context.Session.Id = "session-abc"; + telemetryClient.Context.Device.Id = "device-xyz"; + telemetryClient.Context.Device.Model = "Surface Pro"; + telemetryClient.Context.Device.Type = "PC"; + telemetryClient.Context.Device.OperatingSystem = "Windows 11"; + telemetryClient.Context.Operation.Name = "TestOperation"; + telemetryClient.Context.Operation.SyntheticSource = "test-bot"; + telemetryClient.Context.Location.Ip = "10.0.0.1"; + telemetryClient.Context.Cloud.RoleName = "TestRole"; + telemetryClient.Context.Cloud.RoleInstance = "TestInstance"; + telemetryClient.Context.Component.Version = "2.0.0"; + telemetryClient.Context.GlobalProperties["GlobalKey"] = "GlobalValue"; + // **The following lines are examples of tracking different telemetry types.** telemetryClient.TrackEvent("SampleEvent");