diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Customizations/Models/TelemetryItem.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Customizations/Models/TelemetryItem.cs index 17d367bf5cb1..1ef8c57a24d1 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Customizations/Models/TelemetryItem.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Customizations/Models/TelemetryItem.cs @@ -137,7 +137,12 @@ public TelemetryItem(string name, TelemetryItem telemetryItem, ActivitySpanId ac } public TelemetryItem (string name, LogRecord logRecord, AzureMonitorResource? resource, string instrumentationKey, LogContextInfo logContext) : - this(name, FormatUtcTimestamp(logRecord.Timestamp)) + this(name, FormatUtcTimestamp(logRecord.Timestamp), logRecord, resource, instrumentationKey, logContext) + { + } + + public TelemetryItem(string name, DateTimeOffset envelopeTime, LogRecord logRecord, AzureMonitorResource? resource, string instrumentationKey, LogContextInfo logContext) : + this(name, envelopeTime) { if (logRecord.TraceId != default) { diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/LogsHelper.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/LogsHelper.cs index 8247732ccba1..3dd530b6bce0 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/LogsHelper.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/src/Internals/LogsHelper.cs @@ -39,6 +39,7 @@ internal static class LogsHelper private const string AvailabilitySuccessAttributeName = "microsoft.availability.success"; private const string AvailabilityRunLocationAttributeName = "microsoft.availability.runLocation"; private const string AvailabilityMessageAttributeName = "microsoft.availability.message"; + private const string AvailabilityTestTimestampAttributeName = "microsoft.availability.testTimestamp"; private const int Version = 2; private static readonly Action> s_processScope = (scope, properties) => { @@ -110,7 +111,16 @@ internal static (List TelemetryItems, TelemetrySchemaTypeCounter } else if (availabilityInfo is not null) { - telemetryItem = new TelemetryItem("Availability", logRecord, resource, instrumentationKey, logContext) + DateTimeOffset envelopeTime = availabilityInfo.Value.TestTimestamp != null + && DateTimeOffset.TryParse( + availabilityInfo.Value.TestTimestamp, + CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var parsedTs) + ? parsedTs.ToUniversalTime() + : TelemetryItem.FormatUtcTimestamp(logRecord.Timestamp); + + telemetryItem = new TelemetryItem("Availability", envelopeTime, logRecord, resource, instrumentationKey, logContext) { Data = new MonitorBase { @@ -278,6 +288,7 @@ internal static void ProcessLogRecordProperties(LogRecord logRecord, IDictionary string? availabilitySuccess = null; string? availabilityRunLocation = null; string? availabilityMessage = null; + string? availabilityTestTimestamp = null; logContext = default; foreach (KeyValuePair item in logRecord.Attributes ?? Enumerable.Empty>()) @@ -302,6 +313,9 @@ internal static void ProcessLogRecordProperties(LogRecord logRecord, IDictionary case AvailabilityMessageAttributeName: availabilityMessage = item.Value?.ToString(); break; + case AvailabilityTestTimestampAttributeName: + availabilityTestTimestamp = item.Value?.ToString(); + break; case ClientIpAttributeName: logContext.MicrosoftClientIp = item.Value?.ToString().Truncate(SchemaConstants.AvailabilityData_Properties_MaxValueLength); break; @@ -359,7 +373,8 @@ internal static void ProcessLogRecordProperties(LogRecord logRecord, IDictionary Duration = availabilityDuration!, Success = bool.TryParse(availabilitySuccess, out var success) ? success : false, RunLocation = availabilityRunLocation, - Message = availabilityMessage ?? message + Message = availabilityMessage ?? message, + TestTimestamp = availabilityTestTimestamp }; } @@ -441,5 +456,6 @@ internal struct AvailabilityInfo public bool Success { get; set; } public string? RunLocation { get; set; } public string? Message { get; set; } + public string? TestTimestamp { get; set; } } } diff --git a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/E2ETelemetryItemValidation/AvailabilityTests.cs b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/E2ETelemetryItemValidation/AvailabilityTests.cs index 039b96fdd9bb..7e23694ccc20 100644 --- a/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/E2ETelemetryItemValidation/AvailabilityTests.cs +++ b/sdk/monitor/Azure.Monitor.OpenTelemetry.Exporter/tests/Azure.Monitor.OpenTelemetry.Exporter.Tests/E2ETelemetryItemValidation/AvailabilityTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Azure.Monitor.OpenTelemetry.Exporter.Models; using Azure.Monitor.OpenTelemetry.Exporter.Tests.CommonTestFramework; @@ -306,5 +307,96 @@ public void VerifyIncompleteAvailabilityDataFallsBackToMessage() var telemetryItem = telemetryItems?.Where(x => x.Name == "Message").Single(); Assert.NotNull(telemetryItem); } + + [Fact] + public void VerifyAvailabilityTelemetryWithTestTimestamp() + { + // SETUP + var uniqueTestId = Guid.NewGuid(); + var logCategoryName = $"logCategoryName{uniqueTestId}"; + var testTimestamp = "2025-04-19T12:10:59.9930000+00:00"; + var expectedTime = DateTimeOffset.Parse(testTimestamp, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind).ToUniversalTime(); + + List? telemetryItems = null; + + var loggerFactory = LoggerFactory.Create(builder => + { + builder + .AddFilter(logCategoryName, LogLevel.Information) + .AddOpenTelemetry(options => + { + options.SetResourceBuilder(ResourceBuilder.CreateDefault().AddAttributes(testResourceAttributes)); + options.AddAzureMonitorLogExporterForTest(out telemetryItems); + }); + }); + + // ACT + var logger = loggerFactory.CreateLogger(logCategoryName); + logger.LogInformation("{microsoft.availability.id} {microsoft.availability.name} {microsoft.availability.duration} {microsoft.availability.success} {microsoft.availability.testTimestamp}", + "test-id-ts", "TimestampTest", "00:00:03", true, testTimestamp); + + // CLEANUP + loggerFactory.Dispose(); + + // ASSERT + Assert.True(telemetryItems?.Any(), "Unit test failed to collect telemetry."); + this.telemetryOutput.Write(telemetryItems); + var telemetryItem = telemetryItems?.Where(x => x.Name == "Availability").Single(); + Assert.NotNull(telemetryItem); + + // Envelope time should equal the testTimestamp, not the logRecord.Timestamp + Assert.Equal(expectedTime, telemetryItem!.Time); + + // testTimestamp attribute must NOT appear in AvailabilityData.Properties + var availabilityData = (AvailabilityData)telemetryItem.Data.BaseData; + Assert.False(availabilityData.Properties.ContainsKey("microsoft.availability.testTimestamp"), + "microsoft.availability.testTimestamp should not be leaked into AvailabilityData.Properties."); + } + + [Fact] + public void VerifyAvailabilityTelemetryWithoutTestTimestamp() + { + // SETUP + var uniqueTestId = Guid.NewGuid(); + var logCategoryName = $"logCategoryName{uniqueTestId}"; + + List? telemetryItems = null; + + var loggerFactory = LoggerFactory.Create(builder => + { + builder + .AddFilter(logCategoryName, LogLevel.Information) + .AddOpenTelemetry(options => + { + options.SetResourceBuilder(ResourceBuilder.CreateDefault().AddAttributes(testResourceAttributes)); + options.AddAzureMonitorLogExporterForTest(out telemetryItems); + }); + }); + + // ACT + var beforeLog = DateTimeOffset.UtcNow; + var logger = loggerFactory.CreateLogger(logCategoryName); + logger.LogInformation("{microsoft.availability.id} {microsoft.availability.name} {microsoft.availability.duration} {microsoft.availability.success}", + "test-id-nots", "NoTimestampTest", "00:00:01", true); + var afterLog = DateTimeOffset.UtcNow; + + // CLEANUP + loggerFactory.Dispose(); + + // ASSERT + Assert.True(telemetryItems?.Any(), "Unit test failed to collect telemetry."); + this.telemetryOutput.Write(telemetryItems); + var telemetryItem = telemetryItems?.Where(x => x.Name == "Availability").Single(); + Assert.NotNull(telemetryItem); + + // Envelope time should be derived from logRecord.Timestamp (wall-clock time), not a fixed past time + Assert.True(telemetryItem!.Time >= beforeLog && telemetryItem.Time <= afterLog, + $"Expected envelope time between {beforeLog} and {afterLog}, but got {telemetryItem.Time}."); + + // testTimestamp attribute must NOT appear in AvailabilityData.Properties + var availabilityData = (AvailabilityData)telemetryItem.Data.BaseData; + Assert.False(availabilityData.Properties.ContainsKey("microsoft.availability.testTimestamp"), + "microsoft.availability.testTimestamp should not appear in AvailabilityData.Properties when not provided."); + } } }