Skip to content

Elasticsearch data stream @timestamp carries microsecond precision, not OTLP nanoseconds #9374

Description

@yurishkuro

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions