Otel.Common.Types (otel v0.4.1)

Copy Markdown View Source

Macro module injecting shared OTel type aliases into consumer modules.

Not a spec-aligned module — this is an internal compile-time helper with no runtime API surface, so the Tier (Application/SDK/Internal) system in .claude/rules/documentation.md does not apply. The single public entry point is __using__/1, invoked via use Otel.Common.Types.

Consumer modules use Otel.Common.Types to gain two type aliases that describe every value our public and SDK APIs pass around:

  • t:primitive/0 — a single primitive value (OTel common/README.md §AnyValue L41-L50).
  • t:primitive_any/0 — the recursive extension of primitive/0 into lists and maps, matching OTLP AnyValue (common.proto L25-L53).

Where each is used

Attribute values across every signal (Span, Event, Link, LogRecord, Metric data points, Resource, Instrumentation Scope) are primitive_any/0 — full OTLP AnyValue including nested maps and heterogeneous arrays. Spec common/README.md L187 (v1.55.0):

"The attribute value MUST be one of types defined in AnyValue."

And spec L198-L209 lists exactly which collections this applies to:

"Resources, Instrumentation Scopes, Metric points, Spans, Events, Links and Log Records, contain a collection of attributes."

So attribute-carrying struct fields spell the type as:

attributes: %{String.t() => primitive_any()}

The map type is written inline at each call site rather than behind an alias — attribute keys are String.t/0 and the value type is the existing primitive_any/0 alias, so a new dedicated alias would not earn its keep.

LogRecord.body is also primitive_any/0, by direct spec fiat (logs/data-model.md Field: Body), and naturally so because Body and attribute-values share the AnyValue oneof.

Spec evolution context

Pre-v1.50 the spec narrowed attribute values to "primitive or homogeneous primitive array". v1.50.0 (#4614) opened the door, v1.52.0 (#4651) added complex types in Development, and v1.53.0 (#4794) stabilised complex AnyValue attribute value types and related attribute limits. Code written against pre-v1.53 spec versions correctly used the narrow shape; current code uses the wide shape. See .claude/skills/spec-module-review/SKILL.md § Pattern D for the detection pattern this saga produced.

string vs bytes

Elixir collapses UTF-8 strings and raw byte arrays into a single binary/0. OTLP's AnyValue proto exposes them as separate string_value and bytes_value oneof variants (common.proto L32, L36). This module disambiguates with an explicit tag:

  • plain String.t() / binary()string_value
  • {:bytes, binary()}bytes_value

The exporter pattern-matches the :bytes tag. Invalid UTF-8 binaries passed without the tag surface as Protobuf.EncodeError at export time (the protobuf library refuses to encode non-UTF-8 bytes into a string_value field).

Empty values (nil)

primitive/0 includes nil per common/README.md §AnyValue L50-L51 language-dependent clause:

"an empty value if supported by the language, (e.g. null, undefined in JavaScript/TypeScript, None in Python, nil in Go/Ruby, not supported in Erlang, etc.)"

Elixir supports nil natively, and spec L63 confirms that "null is a valid attribute value" for the attribute-map contexts where primitive/0 appears. nil is also preserved through primitive_any/0 for LogRecord.body and through [primitive()] for array attribute values per spec L67-L68 MUST "null values within arrays MUST be preserved as-is (i.e., passed on to processors / exporters as null)".

This diverges from opentelemetry-erlang, which — per spec — does not support empty values (Erlang is explicitly listed as "not supported"). Our inclusion is spec-aligned via the language-dependent clause; Elixir's idiomatic nullable pattern makes nil the natural empty representation and matches what Elixir users already expect from any struct field typed as String.t() | nil.

Attribute key constraints

Spec common/README.md §Attribute L185 MUST:

"The attribute key MUST be a non-null and non-empty string."

The attribute-carrying maps across this project use String.t() as the key type:

attributes: %{String.t() => primitive_any()}

Two aspects of the MUST:

  • Non-null is enforced at compile time. String.t() is an alias for binary/0, which does not include nil (nil is an atom, not a binary). Dialyzer rejects a literal %{nil => value} at the call site.

  • Non-empty is not expressible in Elixir's type system. Dialyzer has no "non-empty binary" primitive — <<_::_*8>> matches any byte count including zero. An empty-string key %{"" => value} passes the typespec unflagged.

Runtime enforcement of the non-empty MUST is therefore an SDK concern — the API layer is a happy-path dispatcher (per .claude/rules/code-conventions.md §"Not error handling") and does not guard against empty keys. SDK implementations are expected to drop or otherwise handle empty-key attributes at storage / export time per spec L185.

API users are responsible for not passing empty-string keys. Downstream behaviour on empty keys depends on the installed SDK and exporter.

Integer range

Per spec L44 integer values must fit in a signed 64-bit range (-2^63 through 2^63 - 1). Elixir's integer/0 is arbitrary precision; this typespec does not encode the limit. Exporters are responsible for out-of-range handling.

Array shapes

AnyValue arrays come in two flavours per spec L45-L48:

  • Homogeneous primitive arrays — array of primitive values, all the same type, no mixing (spec L45-L46).
  • AnyValue arrays — array of AnyValue (i.e. primitive_any) values, may be heterogeneous and may nest further arrays / maps.

primitive_any covers both via [primitive_any()]. The homogeneity SHOULD on plain primitive arrays is a caller obligation documented here but not Dialyzer-checked.

Performance

Per common/README.md L56-L57 SHOULD:

"APIs SHOULD be documented in a way to communicate to users that using array and map values may carry higher performance overhead compared to primitive values."

Single-primitive attribute values (String.t(), integer(), etc.) are the cheapest. List values, map values, and any nested composite under primitive_any() carry additional allocation and traversal cost at recording time, during SDK aggregation, and at exporter serialisation. Prefer primitives where the signal permits.

References

  • OTel Common §AnyValue: opentelemetry-specification/specification/common/README.md L39-L74
  • OTel Common §Attributes: opentelemetry-specification/specification/common/README.md L179-L187
  • OTel Common §Attribute Collections: opentelemetry-specification/specification/common/README.md L198-L209
  • OTLP AnyValue proto: opentelemetry-proto/opentelemetry/proto/common/v1/common.proto L25-L53

Summary

Functions

Injects the OTel type aliases into the consumer module.

Functions

__using__(opts)

(macro)

Injects the OTel type aliases into the consumer module.

Adds two @type definitions to the caller:

  • t:primitive/0 — single primitive value (string / bytes / boolean / integer / float / nil)
  • t:primitive_any/0 — recursive primitive + list + map, matching OTLP AnyValue

Example

defmodule MyModule do
  use Otel.Common.Types

  @type attributes :: %{String.t() => primitive_any()}
end