Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/OpenTelemetry.Api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ Notes](../../RELEASENOTES.md).
Add support for using environment variables as context propagation carriers.
([#7174](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7174))

* **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
Expand Down
25 changes: 18 additions & 7 deletions src/OpenTelemetry.Api/Logs/LogRecordData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My read of the API spec is that timestamp is an optional parameter, but the spec does not lend guidance for what the default value should be. I think one could argue that our current behavior of defaulting to UtcNow is acceptable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However if we were to change the default value, why DateTime.MinValue? Wouldn't a default value of 1/1/1970 00:00 be better? Someone correct me if I'm wrong, but I believe that's what Java and Go SDKs default to.

Copy link
Copy Markdown
Member

@pellared pellared Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My read of the API spec is that timestamp is an optional parameter, but the spec does not lend guidance for what the default value should be.

We could clarify it in the spec. The intention is that the default is "0" when the value is exported via OTLP if no value was explicitly set.
From https://github.com/open-telemetry/opentelemetry-proto/blob/62498ba4f8e268c40e9420ed912e3cc32e11f487/opentelemetry/proto/logs/v1/logs.proto#L139-L142:

  // time_unix_nano is the time when the event occurred.
  // Value is UNIX Epoch time in nanoseconds since 00:00:00 UTC on 1 January 1970.
  // Value of 0 indicates unknown or missing timestamp.
  fixed64 time_unix_nano = 1;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alan lets consider this:
https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-timestamp
and https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-observedtimestamp

The first one is marked as fully optional (what is more proto treats its as a nullable, sets to 0).Does not say anything about default.
Observed timestamp is the moment when the OTel sees it first time, and it should be set to the UtcNow.

We are still missing ObservedTimestamp, it will be added in scope of #6979.


Sdk already exposes LogRecord as DateTime non nullable this value (see changes in this file). To do not break this, I considered choosing one value as null/0/equivalent. IMO DateTime.MinValue is the most obvious choice. It was discussed earlier with @pellared, and the Go-lang is doing mostly the same.


The following part is prepared by the Codex, but as I understand the code, it confirms what I stated about go-lang.

Short version: this branch behavior matches Java and Go.

OpenTelemetry defines Timestamp as optional when the source/event time is unknown. ObservedTimestamp should be set once OpenTelemetry observes the event. In OTLP protobuf, time_unix_nano = 0 means unknown or missing timestamp.

Java

Java v1.61.0 keeps the source timestamp unset when the caller does not provide one. The SDK stores timestampEpochNanos as 0 by default, while observedTimestampEpochNanos is filled from the SDK clock if absent.

Go

Go v1.43.0 / logs v0.19.0 behaves similarly. A zero-value log.Record has no source timestamp. The SDK preserves that zero timestamp, fills ObservedTimestamp with time.Now() if it is unset, and the OTLP transform maps a zero timestamp to 0.

Conclusion

This change makes .NET follow the same model:

  • DateTime.MinValue represents an unset source timestamp.
  • OTLP exports that as time_unix_nano = 0.
  • observed_time_unix_nano is still populated when OpenTelemetry observes/serializes the record.

Sources

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could clarify it in the spec. The intention is that the default is "0" when the value is exported via OTLP if no value was explicitly set.

I think a clarification in the logs API spec would be beneficial. Both time_unix_nano and observed_time_unix_nano are marked as optional parameters which could lead someone to believe the intent is that they both remain unset if they're not provided. Only if you go read the data model spec can you begin to try and infer what the intended default behavior should be.

OTLP exports that as time_unix_nano = 0

Seemingly silly question, but I think it's important for us to think through: What if the timestamp is set to DateTime.MinValue.AddDays(1);?

  1. What should the OTLP exporter export? The timestamp from the OTLP data model is an unsigned 64 bit integer.
  2. What do we recommend other exporters do like the console export or a custom exporter? Should they export 1/2/0001, or would we recommend a behavior that matches OTLP?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should note, I'm supportive of changing the default value and I favor matching our behavior to what Java and Go are doing, but the fact we're using a DateTime adds a bit of a wrinkle we just need to think through...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/// <summary>
/// Initializes a new instance of the <see cref="LogRecordData"/> struct.
Expand All @@ -35,7 +35,7 @@ struct LogRecordData
/// Notes:
/// <list type="bullet">
/// <item>The <see cref="Timestamp"/> property is initialized to <see
/// cref="DateTime.UtcNow"/> automatically.</item>
/// cref="DateTime.MinValue"/> automatically.</item>
/// <item>The <see cref="TraceId"/>, <see cref="SpanId"/>, and <see
/// cref="TraceFlags"/> properties will be set using the <see
/// cref="Activity.Current"/> instance.</item>
Expand All @@ -51,7 +51,9 @@ public LogRecordData()
/// </summary>
/// <remarks>
/// Note: The <see cref="Timestamp"/> property is initialized to <see
/// cref="DateTime.UtcNow"/> automatically.
/// cref="DateTime.MinValue"/> automatically, indicating the timestamp is
/// not set. Set <see cref="Timestamp"/> explicitly to provide the time the
/// event occurred.
/// </remarks>
/// <param name="activity">Optional <see cref="Activity"/> used to populate
/// trace context properties (<see cref="TraceId"/>, <see cref="SpanId"/>,
Expand All @@ -66,7 +68,9 @@ public LogRecordData(Activity? activity)
/// </summary>
/// <remarks>
/// Note: The <see cref="Timestamp"/> property is initialized to <see
/// cref="DateTime.UtcNow"/> automatically.
/// cref="DateTime.MinValue"/> automatically, indicating the timestamp is
/// not set. Set <see cref="Timestamp"/> explicitly to provide the time the
/// event occurred.
/// </remarks>
/// <param name="activityContext"><see cref="ActivityContext"/> used to
/// populate trace context properties (<see cref="TraceId"/>, <see
Expand All @@ -82,9 +86,16 @@ public LogRecordData(in ActivityContext activityContext)
/// Gets or sets the log timestamp.
/// </summary>
/// <remarks>
/// Note: If <see cref="Timestamp"/> is set to a value with <see
/// cref="DateTimeKind.Local"/> it will be automatically converted to
/// UTC using <see cref="DateTime.ToUniversalTime"/>.
/// Notes:
/// <list type="bullet">
/// <item>The default value is <see cref="DateTime.MinValue"/>, which is
/// treated as "not set" per the OpenTelemetry specification. When
/// exported via OTLP this maps to <c>time_unix_nano = 0</c> (unknown or
/// missing timestamp).</item>
/// <item>If <see cref="Timestamp"/> is set to a value with <see
/// cref="DateTimeKind.Local"/> it will be automatically converted to UTC
/// using <see cref="DateTime.ToUniversalTime"/>.</item>
/// </list>
/// </remarks>
public DateTime Timestamp
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,7 +48,8 @@ public override ExportResult Export(in Batch<LogRecord> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not always setting observedTimeUnixNano = (ulong)DateTime.UtcNow.ToUnixTimeNanoseconds();?

From https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#field-observedtimestamp:

This field SHOULD be set once the event is observed by OpenTelemetry.

I am not sure what would be the value of setting the same time for ObservedTimestamp and Timestamp.

Here is what we do in OTel Go: https://github.com/open-telemetry/opentelemetry-go/blob/3157d0a7d9d22c3343838da6b1a691557676072f/sdk/log/logger.go#L116-L119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting ObservedTimestamp is addressed in #6979; this PR only addresses nullability of Timestamp if I understand it correctly. Changes from the other PR would allow users to set ObservedTimestamp in the API/SDK.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Julius is right, it is intermittent state and should be changed in the short cadence in #6973.
I hope that both will be merged together. For now, I am keeping existing behavior as much as possible.

}
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();
}
Comment on lines +185 to +197
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered changing ToUnixTimeNanoseconds (and other extension methods) to return 0 when datetime is min?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but we should handle timestamp and observed timespan differently, so I would keep as is.


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);

Expand Down
11 changes: 8 additions & 3 deletions src/OpenTelemetry/Logs/LogRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,14 @@ internal enum LogRecordSource
/// Gets or sets the log timestamp.
/// </summary>
/// <remarks>
/// Note: If <see cref="Timestamp"/> is set to a value with <see
/// cref="DateTimeKind.Local"/> it will be automatically converted to
/// UTC using <see cref="DateTime.ToUniversalTime"/>.
/// Notes:
/// <list type="bullet">
/// <item>The default value is <see cref="DateTime.MinValue"/>, which is
/// treated as "not set" per the OpenTelemetry specification.</item>
/// <item>If <see cref="Timestamp"/> is set to a value with <see
/// cref="DateTimeKind.Local"/> it will be automatically converted to UTC
/// using <see cref="DateTime.ToUniversalTime"/>.</item>
/// </list>
/// </remarks>
public DateTime Timestamp
{
Expand Down
4 changes: 1 addition & 3 deletions test/OpenTelemetry.Api.Tests/Logs/LogRecordDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,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<LogRecord>();
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()
{
Expand Down
Loading