diff --git a/src/OpenTelemetry.Api/CHANGELOG.md b/src/OpenTelemetry.Api/CHANGELOG.md index 8f6f72a1c33..233eb8147e7 100644 --- a/src/OpenTelemetry.Api/CHANGELOG.md +++ b/src/OpenTelemetry.Api/CHANGELOG.md @@ -17,6 +17,14 @@ Notes](../../RELEASENOTES.md). instead of discarding the entire `tracestate` header. ([#7309](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7309)) +* **Breaking change** (pre-release only versions): The default value of + the `Timestamp` property on `LogRecordData` has changed from `DateTime.UtcNow` + to `DateTime.MinValue`. `DateTime.MinValue` represents an unset timestamp as + defined by the OpenTelemetry specification. Callers of the [Logs API](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/api.md) + who relied on the timestamp being populated automatically must now set + `Timestamp = DateTime.UtcNow` explicitly on their `LogRecordData` instance. + ([#7045](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7045)) + ## 1.15.3 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Api/Logs/LogRecordData.cs b/src/OpenTelemetry.Api/Logs/LogRecordData.cs index 0889ce0b6fb..8084f615f47 100644 --- a/src/OpenTelemetry.Api/Logs/LogRecordData.cs +++ b/src/OpenTelemetry.Api/Logs/LogRecordData.cs @@ -26,7 +26,7 @@ namespace OpenTelemetry.Logs; struct LogRecordData #pragma warning restore CA1815 // Override equals and operator equals on value types { - internal DateTime TimestampBacking = DateTime.UtcNow; + internal DateTime TimestampBacking = DateTime.MinValue; /// /// Initializes a new instance of the struct. @@ -35,7 +35,7 @@ struct LogRecordData /// Notes: /// /// The property is initialized to automatically. + /// cref="DateTime.MinValue"/> automatically. /// The , , and properties will be set using the instance. @@ -51,7 +51,9 @@ public LogRecordData() /// /// /// Note: The property is initialized to automatically. + /// cref="DateTime.MinValue"/> automatically, indicating the timestamp is + /// not set. Set explicitly to provide the time the + /// event occurred. /// /// Optional used to populate /// trace context properties (, , @@ -66,7 +68,9 @@ public LogRecordData(Activity? activity) /// /// /// Note: The property is initialized to automatically. + /// cref="DateTime.MinValue"/> automatically, indicating the timestamp is + /// not set. Set explicitly to provide the time the + /// event occurred. /// /// used to /// populate trace context properties (, /// - /// Note: If is set to a value with it will be automatically converted to - /// UTC using . + /// Notes: + /// + /// The default value is , which is + /// treated as "not set" per the OpenTelemetry specification. When + /// exported via OTLP this maps to time_unix_nano = 0 (unknown or + /// missing timestamp). + /// If is set to a value with it will be automatically converted to UTC + /// using . + /// /// public DateTime Timestamp { diff --git a/src/OpenTelemetry.Exporter.Console/ConsoleLogRecordExporter.cs b/src/OpenTelemetry.Exporter.Console/ConsoleLogRecordExporter.cs index 49f18cb646c..14c9d1b028d 100644 --- a/src/OpenTelemetry.Exporter.Console/ConsoleLogRecordExporter.cs +++ b/src/OpenTelemetry.Exporter.Console/ConsoleLogRecordExporter.cs @@ -1,6 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Globalization; using OpenTelemetry.Internal; using OpenTelemetry.Logs; using OpenTelemetry.Resources; @@ -47,7 +48,8 @@ public override ExportResult Export(in Batch batch) foreach (var logRecord in batch) { - this.WriteLine($"{"LogRecord.Timestamp:",-RightPaddingLength}{logRecord.Timestamp:yyyy-MM-ddTHH:mm:ss.fffffffZ}"); + var timestamp = logRecord.Timestamp; + this.WriteLine($"{"LogRecord.Timestamp:",-RightPaddingLength}{(timestamp == DateTime.MinValue ? "(not set)" : timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ", CultureInfo.InvariantCulture))}"); if (logRecord.TraceId != default) { diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpLogSerializer.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpLogSerializer.cs index 8b7ca3e9b79..a0800057e57 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpLogSerializer.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/Serializer/ProtobufOtlpLogSerializer.cs @@ -179,9 +179,25 @@ internal static int WriteLogRecord(byte[] buffer, int writePosition, SdkLimitOpt var logRecordLengthPosition = otlpTagWriterState.WritePosition; otlpTagWriterState.WritePosition += ReserveSizeForLength; - var timestamp = (ulong)logRecord.Timestamp.ToUnixTimeNanoseconds(); - otlpTagWriterState.WritePosition = ProtobufSerializer.WriteFixed64WithTag(otlpTagWriterState.Buffer, otlpTagWriterState.WritePosition, ProtobufOtlpLogFieldNumberConstants.LogRecord_Time_Unix_Nano, timestamp); - otlpTagWriterState.WritePosition = ProtobufSerializer.WriteFixed64WithTag(otlpTagWriterState.Buffer, otlpTagWriterState.WritePosition, ProtobufOtlpLogFieldNumberConstants.LogRecord_Observed_Time_Unix_Nano, timestamp); + var logTimestamp = logRecord.Timestamp; + ulong timeUnixNano; + ulong observedTimeUnixNano; + if (logTimestamp != DateTime.MinValue) + { + // Timestamp was explicitly set: use it for both fields. + timeUnixNano = (ulong)logTimestamp.ToUnixTimeNanoseconds(); + observedTimeUnixNano = timeUnixNano; + } + else + { + // Timestamp not set -> time_unix_nano = 0 ("unknown or missing" per OTLP spec). + // observed_time_unix_nano MUST still be populated (proto spec requirement). + timeUnixNano = 0; + observedTimeUnixNano = (ulong)DateTime.UtcNow.ToUnixTimeNanoseconds(); + } + + otlpTagWriterState.WritePosition = ProtobufSerializer.WriteFixed64WithTag(otlpTagWriterState.Buffer, otlpTagWriterState.WritePosition, ProtobufOtlpLogFieldNumberConstants.LogRecord_Time_Unix_Nano, timeUnixNano); + otlpTagWriterState.WritePosition = ProtobufSerializer.WriteFixed64WithTag(otlpTagWriterState.Buffer, otlpTagWriterState.WritePosition, ProtobufOtlpLogFieldNumberConstants.LogRecord_Observed_Time_Unix_Nano, observedTimeUnixNano); otlpTagWriterState.WritePosition = ProtobufSerializer.WriteEnumWithTag(otlpTagWriterState.Buffer, otlpTagWriterState.WritePosition, ProtobufOtlpLogFieldNumberConstants.LogRecord_Severity_Number, logRecord.Severity.HasValue ? (int)logRecord.Severity : 0); diff --git a/src/OpenTelemetry/Logs/LogRecord.cs b/src/OpenTelemetry/Logs/LogRecord.cs index f27261e6806..add7addf0a9 100644 --- a/src/OpenTelemetry/Logs/LogRecord.cs +++ b/src/OpenTelemetry/Logs/LogRecord.cs @@ -104,9 +104,14 @@ internal enum LogRecordSource /// Gets or sets the log timestamp. /// /// - /// Note: If is set to a value with it will be automatically converted to - /// UTC using . + /// Notes: + /// + /// The default value is , which is + /// treated as "not set" per the OpenTelemetry specification. + /// If is set to a value with it will be automatically converted to UTC + /// using . + /// /// public DateTime Timestamp { diff --git a/test/OpenTelemetry.Api.Tests/Logs/LogRecordDataTests.cs b/test/OpenTelemetry.Api.Tests/Logs/LogRecordDataTests.cs index 1b515636de7..87b06f711e5 100644 --- a/test/OpenTelemetry.Api.Tests/Logs/LogRecordDataTests.cs +++ b/test/OpenTelemetry.Api.Tests/Logs/LogRecordDataTests.cs @@ -85,10 +85,8 @@ public void ActivityContextConstructorTest() [Fact] public void TimestampTest() { - var nowUtc = DateTime.UtcNow; - var record = new LogRecordData(); - Assert.True(record.Timestamp >= nowUtc); + Assert.Equal(DateTime.MinValue, record.Timestamp); record = default; Assert.Equal(DateTime.MinValue, record.Timestamp); diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpLogExporterTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpLogExporterTests.cs index 3d13e47a527..d36197af173 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpLogExporterTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpLogExporterTests.cs @@ -460,6 +460,43 @@ public void CheckToOtlpLogRecordTimestamps() Assert.True(otlpLogRecord.ObservedTimeUnixNano > 0); } + [Fact] + public void CheckToOtlpLogRecordTimestamps_BridgeApi_UnsetTimestamp() + { + // When the Bridge API caller leaves Timestamp as DateTime.MinValue + // (not set), the serializer must write: + // time_unix_nano = 0 (unknown/missing per OTLP spec) + // observed_time_unix_nano >= the time of observation (MUST be set per OTLP spec) + var logRecords = new List(); + using var loggerProvider = Sdk.CreateLoggerProviderBuilder() + .AddInMemoryExporter(logRecords) + .Build(); + + var bridgeLogger = loggerProvider.GetLogger("OtlpLogExporterTests"); + + // Capture a lower-bound for the observation timestamp before emitting. + var beforeEmitUtc = DateTime.UtcNow; + + // Emit with default LogRecordData -- Timestamp stays DateTime.MinValue. + bridgeLogger.EmitLog(new LogRecordData()); + + Assert.Single(logRecords); + Assert.Equal(DateTime.MinValue, logRecords[0].Timestamp); + + var otlpLogRecord = ToOtlpLogs(DefaultSdkLimitOptions, new ExperimentalOptions(), logRecords[0]); + + Assert.NotNull(otlpLogRecord); + + // time_unix_nano must be 0 -- "unknown or missing" per OTLP spec. + Assert.Equal(0UL, otlpLogRecord.TimeUnixNano); + + // observed_time_unix_nano must be >= the moment we captured before emitting. + var beforeEmitNano = (ulong)new DateTimeOffset(beforeEmitUtc).ToUnixTimeNanoseconds(); + Assert.True( + otlpLogRecord.ObservedTimeUnixNano >= beforeEmitNano, + $"ObservedTimeUnixNano ({otlpLogRecord.ObservedTimeUnixNano}) should be >= beforeEmitNano ({beforeEmitNano})"); + } + [Fact] public void CheckToOtlpLogRecordTraceIdSpanIdFlagWithNoActivity() {