What happens
The Elasticsearch/OpenSearch v2 write path converts OTLP timestamps to microseconds before anything else sees them, so the @timestamp field that data streams partition on carries microsecond resolution even though it is mapped as date_nanos:
internal/storage/v2/elasticsearch/tracestore/to_dbmodel.go:159 sets StartTime: model.TimeAsEpochMicroseconds(startTime), and that helper is uint64(t.UnixNano() / 1000) — the last three digits of the OTLP timestamp are discarded here.
dbmodel.Span.StartTime is declared // microseconds since Unix epoch (internal/storage/v2/elasticsearch/tracestore/core/dbmodel/model.go:52).
SpanWriter.WriteSpans formats that field for @timestamp (internal/storage/v2/elasticsearch/tracestore/core/writer.go:117), so the string it writes has at most six fractional digits.
This is not new and it is not a regression from #9363, which only changed the encoding from a number to an RFC 3339 string. The truncation predates data streams: it is how the legacy Jaeger span schema has always stored startTime.
Why it is worth recording
Two reasons, one cosmetic and one real.
The RFC is inaccurate. RFC 0004 §3.3 says @timestamp is "derived from the OTLP StartTimestamp at nanosecond precision" and gives "OTLP defines timestamps in nanoseconds; truncating to milliseconds loses precision unnecessarily" as the rationale for date_nanos. The type choice is right — microseconds still need sub-millisecond precision, and a plain date would truncate them — but the stated precision is not what the pipeline delivers.
The behavior may or may not be what we want. Spans emitted by high-resolution instrumentation lose sub-microsecond start times on the way into Elasticsearch, and two spans within the same microsecond sort arbitrarily. Whether that matters is a product question nobody has asked out loud yet, which is why this is an issue rather than a patch.
Options
| Criterion |
A. Document the real resolution |
B. Carry nanoseconds for @timestamp only |
C. Make StartTime nanoseconds |
| OTLP fidelity |
🔴 stays microseconds |
🟢 full nanoseconds in @timestamp |
🟢 full nanoseconds everywhere |
| Storage schema stability |
🟢 unchanged |
🟡 one added field |
🔴 changes the meaning of an existing field |
| Query-path impact |
🟢 none |
🟢 none — @timestamp has no readers |
🔴 every reader, the UI, and stored data |
| Backward compatibility |
🟢 none needed |
🟢 additive |
🔴 needs migration for existing indices |
| Effort |
🟢 one line of prose |
🟡 thread the original timestamp through to_dbmodel |
🔴 large |
| User-visible benefit |
🔴 none |
🟡 finer rollover and sort granularity |
🟡 same, plus precise startTime |
🟢 good 🟡 partial 🔴 poor
Recommendation
A, and revisit B only if someone reports needing it.
@timestamp exists for rollover and time-based partitioning, where the difference between microsecond and nanosecond granularity has no operational effect: rollover happens on the scale of hours. Every Jaeger read path, the query API, and the UI already work in microseconds, so B would create a field whose precision nothing consumes, and C changes a schema shared with the legacy write path for the same non-benefit. The honest fix is to say microseconds in the RFC and keep date_nanos, which remains the correct type because date cannot hold microseconds at all.
If B ever becomes worthwhile, the narrow version is cheap: to_dbmodel.go already holds the untruncated span.StartTimestamp().AsTime(), so it could set the @timestamp string there instead of letting the writer re-derive it from the microsecond field.
What happens
The Elasticsearch/OpenSearch v2 write path converts OTLP timestamps to microseconds before anything else sees them, so the
@timestampfield that data streams partition on carries microsecond resolution even though it is mapped asdate_nanos:internal/storage/v2/elasticsearch/tracestore/to_dbmodel.go:159setsStartTime: model.TimeAsEpochMicroseconds(startTime), and that helper isuint64(t.UnixNano() / 1000)— the last three digits of the OTLP timestamp are discarded here.dbmodel.Span.StartTimeis declared// microseconds since Unix epoch(internal/storage/v2/elasticsearch/tracestore/core/dbmodel/model.go:52).SpanWriter.WriteSpansformats that field for@timestamp(internal/storage/v2/elasticsearch/tracestore/core/writer.go:117), so the string it writes has at most six fractional digits.This is not new and it is not a regression from #9363, which only changed the encoding from a number to an RFC 3339 string. The truncation predates data streams: it is how the legacy Jaeger span schema has always stored
startTime.Why it is worth recording
Two reasons, one cosmetic and one real.
The RFC is inaccurate. RFC 0004 §3.3 says
@timestampis "derived from the OTLPStartTimestampat nanosecond precision" and gives "OTLP defines timestamps in nanoseconds; truncating to milliseconds loses precision unnecessarily" as the rationale fordate_nanos. The type choice is right — microseconds still need sub-millisecond precision, and a plaindatewould truncate them — but the stated precision is not what the pipeline delivers.The behavior may or may not be what we want. Spans emitted by high-resolution instrumentation lose sub-microsecond start times on the way into Elasticsearch, and two spans within the same microsecond sort arbitrarily. Whether that matters is a product question nobody has asked out loud yet, which is why this is an issue rather than a patch.
Options
@timestamponlyStartTimenanoseconds@timestamp@timestamphas no readersto_dbmodelstartTime🟢 good 🟡 partial 🔴 poor
Recommendation
A, and revisit B only if someone reports needing it.
@timestampexists for rollover and time-based partitioning, where the difference between microsecond and nanosecond granularity has no operational effect: rollover happens on the scale of hours. Every Jaeger read path, the query API, and the UI already work in microseconds, so B would create a field whose precision nothing consumes, and C changes a schema shared with the legacy write path for the same non-benefit. The honest fix is to say microseconds in the RFC and keepdate_nanos, which remains the correct type becausedatecannot hold microseconds at all.If B ever becomes worthwhile, the narrow version is cheap:
to_dbmodel.goalready holds the untruncatedspan.StartTimestamp().AsTime(), so it could set the@timestampstring there instead of letting the writer re-derive it from the microsecond field.