Skip to content

Add metric exemplar support - #67

Merged
ladd merged 5 commits into
mainfrom
lvt-exemplars
Jul 17, 2026
Merged

ladd merged 5 commits into
mainfrom
lvt-exemplars

Conversation

@ladd

@ladd ladd commented Jul 16, 2026 •

Copy link
Copy Markdown

Summary

Adds OpenTelemetry metric exemplar support — a sampled measurement plus a link back to the trace that produced it.

  • Instruments record exemplars via addExemplar(span:value:attributes:), stored thread-safely (Mutex<[Exemplar<T>]>) alongside their values and drained on snapshotAndReset().
  • Tracer+Metrics records the exemplar inside the span retire callback, where the real measurement value (and collected attributes) is known.
  • The exporter attaches exemplars to the matching data point by aggregation key, emitting asDouble/asInt, span/trace IDs, and filteredAttributes.
  • Exporter.exemplarSamplingDecision lets a reporter gate exemplar attachment on the trace sampling decision. ExampleReporter demonstrates reusing its per-span decision.
  • Made parentId public for ease of integration testing.

addExemplar stays on the concrete generic instruments (like add/record/observe); exemplarSpans remains on the Instrument protocol for type-erased read access.

Testing

swift test — all suites pass, including new coverage for exemplar wiring, attribute matching, snapshot/reset, and the sampling decision.

@jparise
@bachand

ladd and others added 3 commits July 16, 2026 14:15
Instruments now record exemplars (a span, its measurement value, and the
aggregation attributes) via addExemplar(span:value:attributes:), stored
thread-safely alongside their values and drained on snapshotAndReset.

Tracer+Metrics records an exemplar in the retire callback where the real
measurement value is known. The exporter attaches exemplars to the matching
data point (by aggregation key) and, via Exporter.exemplarSamplingDecision,
lets a reporter gate attachment on the trace sampling decision. ExampleReporter
demonstrates reusing its per-span decision for exemplars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a DurationUnit enum (seconds, milliseconds, microseconds, nanoseconds)
to reportAsDurationHistogramMetric, defaulting to milliseconds. The unit
drives both the metric's UCUM symbol (s/ms/µs/ns) and the conversion applied
to each recorded span duration.

Collapse the four Duration conversion properties into a single wholeUnits
helper that derives the half-unit rounding threshold from the per-unit
amount, and add asMicroseconds/asSeconds.

Also rename the exemplar helper's parameter key -> metricAttributes, and make
the duration histogram tests deterministic by asserting against the span's
own frozen elapsed (the value is recorded during end(), so wall-clock time
can't be cancelled after the fact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ladd
ladd marked this pull request as ready for review July 17, 2026 19:38
Comment thread Sources/NautilusTelemetry/Exporters/Exporter+Metrics.swift Outdated
Co-authored-by: Jon Parise <jon.parise@airbnb.com>
@ladd
ladd merged commit 82ea971 into main Jul 17, 2026
4 checks passed
@ladd
ladd deleted the lvt-exemplars branch July 17, 2026 19:54

@bachand bachand left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thank you @ladd! I'm generally deferring to @jparise's review (thanks!) and I looked over the changes at a high level to follow along. 👍

timeUnixNano: timeUnixNano,
asDouble: doubleValue,
asInt: intValueString,
exemplars: nil, // no exemplar support yet

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice!

Comment on lines +160 to +171
// Decision drops everything: no exemplars are attached.
let noneExporter = Exporter(timeReference: timeReference, exemplarSamplingDecision: { _ in false })
#expect(noneExporter.convertToOTLP(exemplars: counter.exemplars, metricAttributes: [:]) == nil)

// Decision keeps only the sampled span.
let selectiveExporter = Exporter(timeReference: timeReference, exemplarSamplingDecision: { $0.id == sampledSpan.id })
let attached = try #require(selectiveExporter.convertToOTLP(exemplars: counter.exemplars, metricAttributes: [:]))
#expect(attached.map(\.spanId) == [sampledSpan.id])

// Default decision attaches every exemplar.
let defaultExporter = Exporter(timeReference: timeReference)
#expect(defaultExporter.convertToOTLP(exemplars: counter.exemplars, metricAttributes: [:])?.count == 2)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍

Comment on lines +113 to +115
// Exemplars move to the snapshot; the live instrument is reset.
#expect(snapshot.exemplarSpans.map(\.id) == [span.id])
#expect(counter.exemplarSpans.isEmpty)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍

Comment on lines +13 to +14
/// Reporters typically drive this from the current trace sampling decision, so that exemplars — which
/// link a metric data point back to a trace — are only attached when that trace is sampled.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants