Otel.SDK.Metrics.Meter (otel v0.2.0)

Copy Markdown View Source

SDK implementation of the Otel.API.Metrics.Meter behaviour (metrics/sdk.md §Meter L870-L943).

Handles instrument creation with case-insensitive duplicate detection. Instruments are stored in a shared ETS table owned by the MeterProvider.

All functions are safe for concurrent use, satisfying spec metrics/sdk.md L1351-L1352 — "Instrument — synchronous and asynchronous instrument operations MUST be safe to be called concurrently."

Public API

CallbackRole
create_* (counter, histogram, gauge, updowncounter, observable*)SDK (OTel API MUST) — metrics/api.md §Instruments
record/3SDK (OTel API MUST) — synchronous instrument measurement
register_callback/5SDK (OTel API MUST) — async instrument registration
enabled?/2SDK (OTel API MUST) — metrics/api.md §Enabled (Stable, #4787)

Design notes

Duplicate registration

register_instrument/4 keys instruments by {scope, downcased_name} and uses :ets.insert_new/2 so the first registration wins. Subsequent create_* calls for the same key return the already-stored struct unchanged. This satisfies the case-insensitive identity requirement of metrics/sdk.md L945-L958 "The name of an Instrument is defined to be case-insensitive.".

The spec's SHOULD-log clauses for identifying-field conflicts (sdk.md L904-L958, L990) are not currently emitted; a caller that re-creates an instrument with a different kind, unit, or description gets the original instrument back silently. This matches the project's happy-path policy and will be revisited in the finalization error-handling pass.

View vs advisory precedence

When both a matching View and instrument advisory parameters influence the same stream aspect, the View wins. Concretely:

  • Aggregation / bucket boundaries. If a View explicitly specifies an aggregation (e.g. ExplicitBucketHistogram), advisory :explicit_bucket_boundaries are ignored entirely — even when the View does not supply custom boundaries (metrics/sdk.md L1003-L1005). Advisory boundaries only apply when no View matched or the matching View uses default aggregation (stream.aggregation == nil), resolved in Stream.from_view/2Stream.resolve/1 and Stream.from_instrument/1.
  • Attribute keys. Stream.from_view/2 falls back to advisory :attributes only when the View has no :attribute_keys.

enabled?/2 with Drop aggregation

enabled?/2 returns false only when every resolved stream (for a registered instrument) or every matching View (for an unregistered instrument name) uses Drop aggregation. Any non-Drop stream/view makes the instrument enabled. This matches metrics/sdk.md L1029 and L1037 and lets user code skip measurement computation cheaply when the pipeline would discard the value anyway.

Async cardinality — first-observed across temporalities

Spec metrics/sdk.md §"Asynchronous instrument cardinality limits" L864-L866 SHOULD — "Aggregators of asynchronous instruments SHOULD prefer the first-observed attributes in the callback when limiting cardinality, regardless of temporality."

Sync overflow (maybe_overflow/3) inspects metrics_tab, which works as first-observed only under cumulative because delta-temporality readers clear the table on each collect.

Async overflow (maybe_overflow_async/3) instead consults the dedicated observed_attrs_tab ETS set, which records every (stream, reader, attrs) triple ever observed for an async stream. Entries survive delta resets, so the first N attribute sets ever observed are pinned to the original key forever; subsequent sets route to the overflow attribute regardless of whether the metrics table has been cleared.

The :ets.member + count_stream_keys + :ets.insert sequence in maybe_overflow_async/3 is non-atomic. Two callbacks racing on different new attribute sets at the boundary current = limit - 1 could both pass the count check and both insert, briefly exceeding the limit by one. In practice MetricReader.collect/1 serialises callbacks per reader, so this race only fires across multiple readers collecting simultaneously — a corner the spec MUST at L840 (no overflow when distinct sets ≤ limit) does not strictly bind, since at the boundary the SHOULD is already best- effort.

Deferred Development-status features

  • MeterConfig (enabled flag). Spec metrics/sdk.md L1029-L1037 (Status: Development on the MeterConfig.enabled=false bullet) — when set, the Meter's instruments MUST report enabled?/2 as false. Not implemented — there is no MeterConfig analogue on the meter today, so the disabled-Meter leg cannot be expressed. The Drop-aggregation leg of enabled?/2 IS honoured (above). Waits for spec stabilisation.

References

  • OTel Metrics SDK §Meter: opentelemetry-specification/specification/metrics/sdk.md L870-L943
  • OTel Metrics API §Meter: opentelemetry-specification/specification/metrics/api.md L156-L499

Summary

Functions

Executes all registered callbacks for the given meter config and aggregates the observations into the metrics pipeline.

Removes a previously registered callback.

Functions

run_callbacks(config)

@spec run_callbacks(config :: map()) :: :ok

Executes all registered callbacks for the given meter config and aggregates the observations into the metrics pipeline.

Called by MetricReader during collection. Two callback shapes are supported, distinguished by the shape marker stored in each ETS entry:

  • :single — inline callback registered via create_observable_*/5store_callback/4. Callback returns [Measurement.t()] per metrics/api.md L441-L442.
  • :multi — callback registered via register_callback/5. Callback returns [{Instrument.t(), Measurement.t()}] per metrics/api.md L1302-L1303 + L452-L453 MUST (multi-instrument callbacks MUST distinguish the instrument for each observation).

Internally both shapes are normalised to the multi-shape (a list of pairs) so apply_observations/2 has a single code path that looks up streams per-instrument.

unregister_callback(arg)

@spec unregister_callback(state :: {reference(), :ets.table()}) :: :ok

Removes a previously registered callback.

Called indirectly via Otel.API.Metrics.Meter.unregister_callback/1, which unwraps the opaque {module, state} handle and passes the inner state {ref, callbacks_tab} here.