Elasticsearch data stream `@timestamp` carries microsecond precision, not OTLP nanoseconds
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: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 @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.
Source: jaegertracing/jaeger