Tracking matrix for end-to-end tests against the local Grafana LGTM stack. The infrastructure (case template, HTTP poller, backend URL builders) lives under test/e2e/support/.

Running

docker compose up -d
mix test --only e2e test/e2e/

Trace

Done#ScenarioAPIBackend assertion
[x]1Single span (with_span)with_span/4Tempo: 1 span, name match
[x]2Manual lifecyclestart_span + end_spanTempo: 1 span
[x]3start_span with explicit parent contextstart_span/4 (with ctx)Tempo: parent_span_id matches passed ctx
[x]4Initial attributes via optswith_span(opts: [attributes: %{...}])Tempo: span carries attrs
[x]5Initial links via optswith_span(opts: [links: [...]])Tempo: links array
[x]6is_root: true ignores parentwith_span(opts: [is_root: true]) inside outer spanTempo: parent_span_id empty
[x]7set_attribute/3mid-span mutationTempo: span carries attr
[x]8set_attributes/2 (bulk)mid-span mutationTempo: all attrs
[x]9Single eventadd_event/2Tempo: events array
[x]10Multiple events preserve orderadd_event/2 × NTempo: events ordered
[x]11Single linkadd_link/2Tempo: links array
[x]12Multiple links preserve orderadd_link/2 × NTempo: links ordered
[x]13Status :okset_status/2Tempo: status.code = OK
[x]14Status :errorset_status/2Tempo: status.code = ERROR + message
[x]15Update nameupdate_name/2Tempo: updated name
[x]16Span kinds — 5 variants iteratedkind: :internal/:server/:client/:producer/:consumerTempo: each kind matches
[x]17Exception (with_span auto-records)raise inside with_spanTempo: exception event + Error status
[x]18record_exception/3 (manual)record_exception/3Tempo: exception event
[x]19record_exception/4 with override attrsextra attrs override exception.*Tempo: caller-supplied attrs win
[x]20Nested (parent-child)with_span inside with_spanTempo: parent_span_id link
[x]21Sibling spanswith_span under one parentTempo: same parent_span_id
[x]22Deep nesting (5 levels)recursive with_spanTempo: parent chain
[x]23Tracestate propagates across nested spansnested under parent w/ tracestateTempo: child carries parent tracestate
[x]24Span limits — attribute_count_limitexceed limitTempo: dropped_attributes_count > 0
[x]25Span limits — attribute_value_length_limit truncationlong string attributeTempo: value truncated
[x]26Span limits — event_count_limitexceed via add_eventTempo: dropped_events_count > 0
[x]27Span limits — link_count_limitexceed via add_linkTempo: dropped_links_count > 0
[x]28Span limits — attribute_per_event_limitevent w/ excess attrsTempo: event dropped_attributes_count
[x]29Span limits — attribute_per_link_limitlink w/ excess attrsTempo: link dropped_attributes_count
[x]30Sampler always_onconfigured then emitTempo: span present
[x]31Sampler always_offconfigured then emitTempo: span absent
[x]32Sampler parentbased_always_oninherit parent decisionTempo: span present iff parent sampled
[x]33Sampler traceidratio (e.g. 1.0)configured then emitTempo: span present

Log — SDK API (Otel.API.Logs.Logger.emit/2)

Done#ScenarioAPIBackend assertion
[x]1String bodybody: "msg"Loki: line match
[x]2Map bodybody: %{...}Loki: structured fields
[x]3Map body — nested map keys recursively stringifiedbody: %{user: %{id: 42}}Loki: keys all String.t()
[x]4Bytes bodybody: {:bytes, ...}Loki: structured-metadata query on e2e.id attribute (line filter would fail because the body is base64-encoded)
[x]5All 8 severity levelsseverity_number: 5/9/10/13/17/18/19/21Loki: severity_text matches each
[x]6severity_number: 0 sentineldefault unspecified severityLoki: severity_number_unspecified
[x]7event_name fieldevent_name: "..."Loki: event_name attribute
[x]8timestamp vs observed_timestampomit timestamp → SDK fills observedLoki: both fields present, distinct
[x]9Custom attributesattributes: %{...}Loki: labels / fields
[x]10Trace context auto-propagationinside with_spanLoki: trace_id / span_id match
[x]11LogRecord limits — attribute_count_limitexceed attr countLoki: dropped_attributes_count
[x]12LogRecord limits — attribute_value_length_limit truncationlong string attrLoki: value truncated
[x]13Multi-logger (different scopes)get_logger(A), get_logger(B)Loki: scope_name disambiguation
[x]14Exception sidecar via SDK APIset exception: field on LogRecordLoki: exception.type / exception.message

Log — :logger Handler bridge

Done#ScenarioAPIBackend assertion
[x]1Logger.info("msg") baselinestring msgLoki: line + severity=info
[x]2All 8 levels iterated:emergency through :debugLoki: severity_number 21/19/18/17/13/10/9/5
[x]3Logger metadata — primitiveLogger.info("...", k: v)Loki: attr k=v
[x]4Report (map)Logger.info(%{k: v})Loki: structured
[x]5Report (keyword)Logger.info(k: v, ...)Loki: structured
[x]6{format, args} msg shape:logger.log(:info, ~c"~p", [v])Loki: formatted body
[x]7report_cb/1 callbackmeta: %{report_cb: cb1}Loki: callback output
[x]8report_cb/2 callbackmeta: %{report_cb: cb2}Loki: callback output
[x]9Atom value coercionLogger.info(role: :admin)Loki: "admin" (no colon)
[x]10Struct via String.Chars (Date)Logger.info(at: ~D[...])Loki: ISO string
[x]11Tuple → inspectLogger.info(point: {1, 2})Loki: "{1, 2}"
[x]12crash_reason → exception.*Logger.error(..., crash_reason: {e, st})Loki: exception.type, exception.message, exception.stacktrace
[x]13Non-exception crash_reason ignoredcrash_reason: {:shutdown, _}Loki: no exception.* attrs
[x]14mfacode.function.nameLogger.info(...) (auto from compile)Loki: code.function.name
[x]15filecode.file.pathauto from compileLoki: code.file.path
[x]16linecode.line.numberauto from compileLoki: code.line.number
[x]17Malformed mfa silently skippedmeta: %{mfa: :not_a_tuple}Loki: no code.function.name, no crash
[x]18domainlog.domainmeta: %{domain: [:a, :b]}Loki: array
[x]19Reserved keys all filteredmfa, file, line, domain, crash_reason, time, report_cb, gl, pidLoki: none of these atoms appear
[x]20Trace context auto-propagationinside with_spanLoki: trace_id / span_id
[x]21Scope config — 4 keysscope_name, scope_version, scope_schema_url, scope_attributesLoki: each value visible

Metrics

Done#ScenarioAPIBackend assertion
[x]1Counter (single)Counter.add/3Mimir: counter_total == 1
[x]2Counter cumulativeN addsMimir: counter == N
[x]3UpDownCounteradd 5, add -2Mimir: gauge 3
[x]4Histogramrecord × NMimir: bucket counts, sum, count, min/max
[x]5Histogram custom bucketsadvisory: [explicit_bucket_boundaries: ...]Mimir: explicit_bounds
[~]6Histogram record_min_max: falseView optMimir: lands (Prometheus exposition doesn't surface min/max separately)
[~]7Base2ExponentialBucketHistogramView aggregation: ...Base2ExponentialBucketHistogramMimir: lands (LGTM 0.26.0 doesn't expose exponential buckets as PromQL series)
[x]8Gauge (sync)record/3Mimir: gauge value
[x]9ObservableCountercallback returns [%Measurement{}]Mimir: counter from callback
[x]10ObservableUpDownCountercallback (multi-attr)Mimir: multi-series
[x]11ObservableGaugecallbackMimir: gauge from callback
[x]12register_callback/5 (multi-instrument)shared callback for several instrumentsMimir: each instrument fed
[x]13unregister_callback/1unregister; collect againMimir: no further values
[x]14Drop aggregationView w/ aggregation: ...DropMimir: no series for that instrument
[x]15Meter.enabled?/2 gatingwhen matching streams all :dropReturns false; add is a no-op
[x]16Cumulative temporality (default)record over timeMimir: monotonic accumulation
[~]17Delta temporalityreader configured :deltaUnit-tested only — Mimir's OTLP receiver in LGTM 0.26.0 drops delta-temporality counters (delta-to-cumulative is opt-in, off by default), so an e2e test would have no signal beyond what test/otel/sdk/metrics/temporality_test.exs and test/otel/otlp/encoder_test.exs already cover. The setup_all-driven SDK restart that the e2e test would need also leaks delta config into other modules' tests
[x]18Multi-dimensional attrssame instrument, varying attrsMimir: multiple series
[x]19Cardinality overflow (sync)exceed View aggregation_cardinality_limitMimir: otel.metric.overflow=true
[x]20Cardinality first-observed (async)observable callback emits N+1 attrsMimir: first-N pinned across delta resets
[x]21Float vs int values mixedrecord 1 then 1.5 on same seriesMimir: numerically correct
[x]22View — rename instrumentcriteria: %{name: ...}, config: %{name: "renamed"}Mimir: series under new name
[x]23View — attribute include filterconfig: %{attribute_keys: {:include, [...]}}Mimir: only listed labels
[x]24View — override aggregationconfig: %{aggregation: ...ExplicitBucketHistogram} for a CounterMimir: histogram series
[~]25Exemplar filter :always_onsampling-mode reservoirMimir: lands (exemplar exposure config-dependent in LGTM 0.26.0)
[~]26Exemplar filter :always_offreservoir is DropMimir: lands (Drop is internal-only contract)
[~]27Exemplar filter :trace_based (default)sampled span onlyMimir: lands inside with_span (exemplar correlation in unit tests)
[~]28Exemplar reservoir — AlignedHistogramBuckethistogram instrumentMimir: histogram lands
[~]29Exemplar reservoir — SimpleFixedSizenon-histogram instrumentMimir: counter lands
[x]30PeriodicExporting force_flushcall force_flush after recordMimir: data visible immediately
[x]31Case-insensitive duplicate registrationcreate_counter("HTTP") then ("http")Warns + returns first instrument

Propagator (cross-process trace continuation)

Done#ScenarioAPIBackend assertion
[x]1TraceContext round-tripTextMap.inject/3 → carrier → TextMap.extract/3 → child span with extracted ctxTempo: same trace_id, child parent_span_id = parent span_id
[x]2Trace flags propagation (sampled bit)sampled parent → inject → extractTempo: child also sampled / present
[x]3Tracestate (vendor data) propagationparent w/ tracestate → inject → extractTempo: child carries identical tracestate
[x]4Baggage round-trip (manual span copy)Baggage.set_value/3 → inject → extract → copy to span attrTempo: span carries baggage value
[x]5Composite (TraceContext + Baggage)both propagators → inject → extractTempo: both trace ctx + baggage preserved

Resource / service identification

Done#ScenarioAPIBackend assertion
[x]1OTEL_SERVICE_NAME env varset then SDK restartAll 3 backends: service.name matches env value
[x]2OTEL_RESOURCE_ATTRIBUTES env varset deployment.environment=test,…Tempo / Loki / Mimir: resource carries those attrs
[x]3OTEL_SERVICE_NAME precedenceboth env vars set with conflicting service.nameservice.name matches OTEL_SERVICE_NAME (spec MUST)
[x]4Mix Config :resourceconfig :otel, trace: [resource: …]Tempo: resource overridden by Mix value

Global SDK control

Done#ScenarioAPIBackend assertion
[x]1OTEL_SDK_DISABLED=truerestart with env set; emit on all 3 pillarsAll 3 backends: zero records for the e2e_id
[x]2Provider shutdown then emitcall TracerProvider.shutdown/1 etc., emit afterwardNo new records appear in backends

Cross-signal / Resource

Done#ScenarioBackend assertion
[x]1Span-internal log carries trace_idTempo.trace_id == Loki.trace_id
[x]2Metric exemplar carries trace_idMimir.exemplar.trace_id == Tempo.trace_id
[x]3Resource consistency (3 pillars)All backends share service.name
[~]4InstrumentationScope (Trace + Log)scope.name carried through Tempo + Loki; Mimir doesn't promote OTLP scope to PromQL labels in LGTM 0.26.0 (lands-only)

Concurrency

The single-process happy-path scenarios in the per-signal sections cover what the SDK exports. This section covers how it behaves under load and async fan-out — concerns that don't show up in spec-MUST checks but matter in production. Scoped to scenarios that need no SDK reconfig (every scenario runs in the standard mix test --only e2e pass without touching Application.put_env).

Done#ScenarioAPIBackend assertion
[x]1N=50 concurrent tasks each emit one spanTask.async_stream over 50 namesTempo: every span name lands
[x]21000 child spans under one parent (single trace)for _ <- 1..1000 of nested with_spanTempo: trace contains all 1000 children within force_flush
[x]3Three signals concurrent (trace + log + metric same scope)Task.async × 3 emitting different signalsTempo + Loki + Mimir each receive their record for the e2e_id
[x]4Span context propagated across Task.async_streamparent with_span wrapping async_stream that creates child spansTempo: every child carries the parent's parent_span_id

PR plan

PhaseFileScenarios
C-1trace_test.exs33
C-2alog_sdk_test.exs14
C-2blog_handler_test.exs21
C-3ametrics_sync_test.exs~15 (rows 1–11, 14–17, 21)
C-3bmetrics_async_test.exs~6 (rows 9–13, 20)
C-3cmetrics_view_test.exs~9 (rows 22–24, 25–29, 30–31)
C-4propagator_test.exs5
C-5resource_test.exs4
C-6disabled_test.exs2
C-7cross_signal_test.exs4
C-8concurrency_test.exs4

Total: ~113 scenarios. Tick [x] in the Done column as each scenario lands. Phase C-3 splits into three focused PRs because the Metrics surface is broad (sync vs observable vs View / exemplar / reader knobs); the others stay one file each.

Out of e2e scope (covered by unit tests in test/otel/...):

  • OTLP exporter knobs (compression, headers, retry, timeout) — exercised by test/otel/otlp/{trace,metrics,logs}/*/http_test.exs against a fake socket server.
  • OTEL_CONFIG_FILE declarative YAML loading / substitution / schema validation — exercised by test/otel/configuration/*_test.exs.
  • Concurrency / queue overflow / backpressure — exercised by test/otel/sdk/trace/span_processor/batch_test.exs and friends.
  • Severity number → text mapping, attribute coercion rules, malformed metadata silent-skip — exercised by test/otel/logger_handler_test.exs.