diff --git a/.changelog/5740.added b/.changelog/5740.added
new file mode 100644
index 00000000000..a6a1d714a23
--- /dev/null
+++ b/.changelog/5740.added
@@ -0,0 +1 @@
+`opentelemetry-docker-tests`: add declarative config end-to-end tests
diff --git a/.github/workflows/generate_workflows.py b/.github/workflows/generate_workflows.py
index 8e4ccd3c87c..bee7e00198e 100644
--- a/.github/workflows/generate_workflows.py
+++ b/.github/workflows/generate_workflows.py
@@ -1,4 +1,5 @@
from collections import defaultdict
+from configparser import ConfigParser
from pathlib import Path
from re import compile as re_compile
@@ -122,16 +123,30 @@ def get_misc_job_datas(tox_envs: list) -> list:
return [tox_env for tox_env in tox_envs if not any(pattern.match(tox_env) for pattern in regex_patterns)]
+def get_contrib_job_datas(tox_ini_path: Path, job_datas: list) -> list:
+ """Return the jobs whose tox env installs packages from a contrib repo checkout."""
+ tox_ini = ConfigParser(interpolation=None, strict=False)
+ tox_ini.read(tox_ini_path)
+
+ return [
+ job_data
+ for job_data in job_datas
+ if "{env:CONTRIB_REPO_" in tox_ini.get(f"testenv:{job_data}", "deps", fallback="")
+ ]
+
+
def _generate_workflow(
job_datas: list,
template_name: str,
output_dir: Path,
+ **template_kwargs,
) -> None:
env = Environment(loader=FileSystemLoader(Path(__file__).parent.joinpath("templates")))
with open(output_dir.joinpath(f"{template_name}.yml"), "w") as yml_file:
yml_file.write(
env.get_template(f"{template_name}.yml.j2").render(
job_datas=job_datas,
+ **template_kwargs,
)
)
yml_file.write("\n")
@@ -160,10 +175,12 @@ def generate_misc_workflow(
tox_ini_path: Path,
workflow_directory_path: Path,
) -> None:
+ misc_job_datas = get_misc_job_datas(get_tox_envs(tox_ini_path))
_generate_workflow(
- get_misc_job_datas(get_tox_envs(tox_ini_path)),
+ misc_job_datas,
"misc",
workflow_directory_path,
+ contrib_job_datas=get_contrib_job_datas(tox_ini_path, misc_job_datas),
)
diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml
index 78234e8cfcb..dec23dc149a 100644
--- a/.github/workflows/misc.yml
+++ b/.github/workflows/misc.yml
@@ -23,6 +23,7 @@ env:
PIP_EXISTS_ACTION: w
CONTRIB_REPO_UTIL_HTTP: ${{ github.workspace }}/opentelemetry-python-contrib/util/opentelemetry-util-http
CONTRIB_REPO_INSTRUMENTATION: ${{ github.workspace }}/opentelemetry-python-contrib/opentelemetry-instrumentation
+ CONTRIB_REPO_DISTRO: ${{ github.workspace }}/opentelemetry-python-contrib/opentelemetry-distro
CONTRIB_REPO_INSTRUMENTATION_REQUESTS: ${{ github.workspace }}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-requests
CONTRIB_REPO_INSTRUMENTATION_WSGI: ${{ github.workspace }}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-wsgi
CONTRIB_REPO_INSTRUMENTATION_FLASK: ${{ github.workspace }}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-flask
@@ -216,6 +217,35 @@ jobs:
- name: Run tests
run: uvx --with tox-uv tox -e docker-tests-opencensus
+ docker-tests-declconfig:
+ name: docker-tests-declconfig
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout repo @ SHA - ${{ github.sha }}
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Checkout contrib repo @ ${{ env.CONTRIB_REPO_SHA }}
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ repository: open-telemetry/opentelemetry-python-contrib
+ ref: ${{ env.CONTRIB_REPO_SHA }}
+ path: opentelemetry-python-contrib
+
+ - name: Set up Python 3.11
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+
+ - name: Run tests
+ run: uvx --with tox-uv tox -e docker-tests-declconfig
+
public-symbols-check:
name: public-symbols-check
runs-on: ubuntu-latest
diff --git a/.github/workflows/templates/misc.yml.j2 b/.github/workflows/templates/misc.yml.j2
index 193e5bc864a..451eec1a263 100644
--- a/.github/workflows/templates/misc.yml.j2
+++ b/.github/workflows/templates/misc.yml.j2
@@ -23,6 +23,7 @@ env:
PIP_EXISTS_ACTION: w
CONTRIB_REPO_UTIL_HTTP: ${% raw %}{{ github.workspace }}{% endraw %}/opentelemetry-python-contrib/util/opentelemetry-util-http
CONTRIB_REPO_INSTRUMENTATION: ${% raw %}{{ github.workspace }}{% endraw %}/opentelemetry-python-contrib/opentelemetry-instrumentation
+ CONTRIB_REPO_DISTRO: ${% raw %}{{ github.workspace }}{% endraw %}/opentelemetry-python-contrib/opentelemetry-distro
CONTRIB_REPO_INSTRUMENTATION_REQUESTS: ${% raw %}{{ github.workspace }}{% endraw %}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-requests
CONTRIB_REPO_INSTRUMENTATION_WSGI: ${% raw %}{{ github.workspace }}{% endraw %}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-wsgi
CONTRIB_REPO_INSTRUMENTATION_FLASK: ${% raw %}{{ github.workspace }}{% endraw %}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-flask
@@ -56,7 +57,7 @@ jobs:
with:
persist-credentials: false{% if job_data == "public-symbols-check" %}
fetch-depth: 0{% endif %}
- {%- if job_data == "tracecontext" %}
+ {%- if job_data in contrib_job_datas %}
- name: Checkout contrib repo @ ${% raw %}{{ env.CONTRIB_REPO_SHA }}{% endraw %}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/dev-requirements.txt b/dev-requirements.txt
index ec24ee8db4c..2ccc908e376 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -20,3 +20,4 @@ psutil==7.2.2
GitPython==3.1.59
pre-commit==3.7.0
ruff==0.16.1
+testcontainers==4.13.3
diff --git a/tests/opentelemetry-docker-tests/tests/conftest.py b/tests/opentelemetry-docker-tests/tests/conftest.py
deleted file mode 100644
index c066112ab24..00000000000
--- a/tests/opentelemetry-docker-tests/tests/conftest.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Copyright The OpenTelemetry Authors
-# SPDX-License-Identifier: Apache-2.0
-
-from __future__ import annotations
-
-from collections.abc import Iterator
-
-import pytest
-from prometheus_client import CollectorRegistry, start_http_server
-
-_PROMETHEUS_PORT = 9464
-
-
-@pytest.fixture(scope="session")
-def prometheus_registry() -> CollectorRegistry:
- return CollectorRegistry()
-
-
-@pytest.fixture(scope="session", autouse=True)
-def _prometheus_scrape_target(prometheus_registry: CollectorRegistry) -> Iterator[None]:
- """Keep a Prometheus HTTP endpoint up on :9464 for the whole docker-test session."""
- httpd, _thread = start_http_server(port=_PROMETHEUS_PORT, addr="0.0.0.0", registry=prometheus_registry)
- try:
- yield
- finally:
- httpd.shutdown()
- httpd.server_close()
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/README.md b/tests/opentelemetry-docker-tests/tests/declconfig/README.md
new file mode 100644
index 00000000000..8cb2bf69a8d
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/README.md
@@ -0,0 +1,86 @@
+# Declarative configuration end-to-end tests
+
+Each scenario runs an app under `opentelemetry-instrument`, configured only by
+a declarative config file. The app exports to a real OpenTelemetry Collector,
+which forwards everything to an OTLP receiver in the test process (the sink).
+
+```mermaid
+flowchart LR
+ config[otel-config.yaml] -. configures .-> app["run.py
(opentelemetry-instrument)"]
+ app -- OTLP --> collector["Collector
(Docker)"]
+ collector -- otlphttp --> sink["Sink
(in the test process)"]
+ sink --> checks["pytest
until / expect"]
+```
+
+Run the suite with:
+
+```sh
+uv run tox -e docker-tests-declconfig
+```
+
+## Scenarios
+
+Every directory under `scenarios/` is one test case:
+
+| File | Purpose |
+| ----------------------- | --------------------------------------------------------------------------- |
+| `scenario.yaml` | Description, when to stop (`until`) and what to check (`expect`). |
+| `otel-config.yaml` | The SDK config, passed as `OTEL_CONFIG_FILE`. Can be `otel-config.json`. |
+| `run.py` | The app. Uses only the OpenTelemetry API. |
+| `collector-config.yaml` | Optional. Defaults to the shared [`collector-config.yaml`](collector-config.yaml). |
+
+The shared collector receives OTLP on gRPC `4317` and HTTP `4318`. Configs
+reach it at `://${OTEL_COLLECTOR_ENDPOINT}:`. A custom collector
+config must enable `health_check` on `0.0.0.0:13133` and export with
+`otlphttp` to `${env:OTLP_SINK_ENDPOINT}`.
+
+The app doesn't inherit `OTEL_*` variables from the test environment.
+
+## `scenario.yaml`
+
+The full format is in [`scenario.schema.json`](scenario.schema.json).
+
+```yaml
+description: Exports a span over OTLP/HTTP.
+timeout: 30 # seconds, default 30
+env: {MY_VAR: value} # extra environment variables for the app
+until: # all must hold
+ - spans: {name: my-span, min_count: 1}
+ - metric: {name: requests, field: value, min: 5} # field: value | count | sum
+ - logs: {body_pattern: "user .* in"}
+ - exited: {} # the app exited cleanly
+expect:
+ - resource: # every received item must match
+ schema_url: https://opentelemetry.io/schemas/1.26.0
+ attributes: {values: {service.name: my-service}}
+ - spans:
+ name: my-span
+ kind: INTERNAL
+ parent: {name: outer} # nested span matcher
+ attributes: {values: {http.method: GET}, strict: true}
+ count: 1 # or min_count (default 1) / max_count
+ - metric:
+ name: requests
+ type: sum
+ temporality: CUMULATIVE
+ data_point: {value: {gte: 3}, exemplar_count: {gte: 1}}
+ - logs:
+ body: {pattern: "user .* in"}
+ severity_number: WARN
+ span: {name: handler}
+ - output:
+ stream: stderr # stdout | stderr
+ pattern: Unsupported file_format
+ exists: true # false asserts no match
+```
+
+`count: 0` asserts that nothing matches. Pair it with `until: [exited: {}]`
+when nothing should be exported at all.
+
+Values are type strict: `5` matches only an int, `5.0` only a double and
+`"5"` only a string. Use `{double: 5}` or `{kvlist: {...}}` to set a type
+explicitly. Instead of a literal, a matcher can use the operators `pattern`,
+`exists`, `gt`, `gte`, `lt` and `lte`.
+
+A failure lists each unmet entry with its closest candidates, plus the app's
+stdout and stderr.
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/__init__.py b/tests/opentelemetry-docker-tests/tests/declconfig/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/_expect.py b/tests/opentelemetry-docker-tests/tests/declconfig/_expect.py
new file mode 100644
index 00000000000..a8c6b2a9e92
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/_expect.py
@@ -0,0 +1,704 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Matchers for a scenario's ``expect`` entries, checked against the received telemetry."""
+
+from __future__ import annotations
+
+import operator
+import re
+from collections.abc import Callable, Iterable, Iterator, Sequence
+from dataclasses import dataclass, field, replace
+from typing import TYPE_CHECKING, Any, Generic, TypeVar
+
+import yaml
+
+from opentelemetry.proto.common.v1.common_pb2 import AnyValue, InstrumentationScope, KeyValue
+from opentelemetry.proto.logs.v1.logs_pb2 import SeverityNumber
+from opentelemetry.proto.metrics.v1.metrics_pb2 import AggregationTemporality, Metric
+from opentelemetry.proto.trace.v1.trace_pb2 import Span, Status
+from opentelemetry.test._otlp_test_server import RecordedLogRecord, RecordedMetric, RecordedSpan
+
+if TYPE_CHECKING:
+ from ._scenario import Received
+
+# Metric data types that each metric or data point field applies to.
+FIELD_DATA_TYPES = {
+ "temporality": ("sum", "histogram", "exponential_histogram"),
+ "monotonic": ("sum",),
+ "value": ("gauge", "sum"),
+ "count": ("histogram", "exponential_histogram", "summary"),
+ "sum": ("histogram", "exponential_histogram", "summary"),
+ "min": ("histogram", "exponential_histogram"),
+ "max": ("histogram", "exponential_histogram"),
+ "explicit_bounds": ("histogram",),
+ "bucket_counts": ("histogram",),
+}
+
+# Closest (or unexpected) candidates listed in a failure message.
+_MAX_CANDIDATES = 3
+
+_TYPED_KEYS = frozenset({"string", "bool", "int", "double", "kvlist"})
+_COMPARISONS: dict[str, Callable[[Any, Any], bool]] = {
+ "gt": operator.gt,
+ "gte": operator.ge,
+ "lt": operator.lt,
+ "lte": operator.le,
+}
+
+# Received spans by ``(trace_id, span_id)``, for resolving parents, links and log correlation.
+SpanIndex = dict[tuple[bytes, bytes], RecordedSpan]
+
+_T = TypeVar("_T")
+
+
+def build_span_index(spans: Iterable[RecordedSpan]) -> SpanIndex:
+ return {(recorded.span.trace_id, recorded.span.span_id): recorded for recorded in spans}
+
+
+def require_metric_field(metric: Metric, field_name: str) -> None:
+ data_type = metric.WhichOneof("data")
+ if data_type not in FIELD_DATA_TYPES[field_name]:
+ raise ValueError(f"Metric {metric.name!r} is a {data_type}, field {field_name!r} does not apply to it")
+
+
+# Values are compared and described as plain Python values: str, bool, int, float, bytes,
+# list for arrays and dict for kvlists. ``None`` stands for an absent value.
+def _any_value_to_python(value: AnyValue) -> Any:
+ kind = value.WhichOneof("value")
+ if kind is None:
+ return None
+ if kind == "array_value":
+ return [_any_value_to_python(item) for item in value.array_value.values]
+ if kind == "kvlist_value":
+ return _key_values_to_python(value.kvlist_value.values)
+ return getattr(value, kind)
+
+
+def _key_values_to_python(key_values: Iterable[KeyValue]) -> dict[str, Any]:
+ return {kv.key: _any_value_to_python(kv.value) for kv in key_values}
+
+
+def _literal_to_python(literal: Any) -> Any:
+ """Resolve the typed literals of a scenario value, such as ``{double: 5}``, to plain values."""
+ if isinstance(literal, dict):
+ ((kind, payload),) = literal.items()
+ if kind == "kvlist":
+ return {key: _literal_to_python(value) for key, value in payload.items()}
+ return float(payload) if kind == "double" else payload
+ if isinstance(literal, list):
+ return [_literal_to_python(item) for item in literal]
+ return literal
+
+
+def _is_numeric_value(value: Any) -> bool:
+ # bool is a subclass of int, but not a number here.
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
+
+
+def _values_equal(expected: Any, actual: Any, strict: bool) -> bool:
+ """Compare values including their types, so ``5``, ``5.0``, ``"5"`` and ``True`` all differ.
+
+ With ``strict=False``, numbers compare by value regardless of int or double.
+ """
+ if not strict and _is_numeric_value(expected) and _is_numeric_value(actual):
+ return expected == actual
+ if type(expected) is not type(actual):
+ return False
+ if isinstance(expected, list):
+ return len(expected) == len(actual) and all(
+ _values_equal(item, other, strict) for item, other in zip(expected, actual)
+ )
+ if isinstance(expected, dict):
+ return expected.keys() == actual.keys() and all(
+ _values_equal(value, actual[key], strict) for key, value in expected.items()
+ )
+ return expected == actual
+
+
+def _to_flow_yaml(value: Any) -> str:
+ dumped = yaml.safe_dump(value, default_flow_style=True, sort_keys=False, width=1000, allow_unicode=True)
+ # A plain scalar document ends with an explicit "..." marker.
+ return dumped.removesuffix("\n...\n").strip()
+
+
+def _describe_value(value: Any) -> str:
+ return "" if value is None else _to_flow_yaml(value)
+
+
+@dataclass(frozen=True)
+class Mismatch:
+ """How one field of a received item fails its matcher."""
+
+ path: tuple[str, ...]
+ expected: str
+ actual: str
+
+ def nested(self, prefix: str) -> Mismatch:
+ return replace(self, path=(prefix, *self.path))
+
+ def __str__(self) -> str:
+ # Index segments such as "['service.name']" attach without a dot.
+ path = "".join(segment if segment.startswith("[") else f".{segment}" for segment in self.path)
+ return f"{path.removeprefix('.')}: expected {self.expected}, got {self.actual}"
+
+
+def _nest_mismatches(prefix: str, mismatches: Iterable[Mismatch]) -> Iterator[Mismatch]:
+ return (mismatch.nested(prefix) for mismatch in mismatches)
+
+
+@dataclass(frozen=True)
+class ValueMatcher:
+ """A literal (``equals``) or operators that must all hold, applied to one value.
+
+ Without any of them, it matches anything, including an absent value.
+ """
+
+ equals: Any = None
+ pattern: re.Pattern[str] | None = None
+ exists: bool | None = None
+ bounds: tuple[tuple[str, float], ...] = ()
+
+ def describe(self) -> str:
+ if self.equals is not None:
+ return _describe_value(self.equals)
+ parts = []
+ if self.exists is not None:
+ parts.append("present" if self.exists else "")
+ if self.pattern is not None:
+ parts.append(f"match for {self.pattern.pattern!r}")
+ parts.extend(f"{name} {bound}" for name, bound in self.bounds)
+ return " and ".join(parts)
+
+ def check(self, label: str, actual: Any, strict: bool = True) -> Iterator[Mismatch]:
+ """Yield how ``actual`` fails this matcher, prefixed with ``label``.
+
+ With ``strict=False``, numeric literals match int and double values alike.
+ """
+ if self == _ANY_VALUE:
+ return
+ if (actual is None) != (self.exists is False) or not self._holds(actual, strict):
+ yield Mismatch((label,), self.describe(), _describe_value(actual))
+
+ def _holds(self, actual: Any, strict: bool) -> bool:
+ if actual is None:
+ return True
+ if self.equals is not None and not _values_equal(self.equals, actual, strict):
+ return False
+ if self.pattern is not None and (not isinstance(actual, str) or self.pattern.search(actual) is None):
+ return False
+ return all(_is_numeric_value(actual) and _COMPARISONS[name](actual, bound) for name, bound in self.bounds)
+
+
+_ANY_VALUE = ValueMatcher()
+
+
+def parse_value_matcher(spec: Any) -> ValueMatcher:
+ """Build a matcher from a ``value_matcher``, ``string_matcher`` or ``number_matcher``."""
+ if not isinstance(spec, dict) or spec.keys() <= _TYPED_KEYS:
+ return ValueMatcher(equals=_literal_to_python(spec))
+ pattern = spec.get("pattern")
+ return ValueMatcher(
+ pattern=re.compile(pattern) if pattern is not None else None,
+ exists=spec.get("exists"),
+ bounds=tuple((name, spec[name]) for name in _COMPARISONS if name in spec),
+ )
+
+
+@dataclass(frozen=True)
+class LiteralMatcher(Generic[_T]):
+ """An exact, untagged value such as an enum name or a bool. Unset, it matches anything."""
+
+ expected: _T | None = None
+
+ def check(self, label: str, actual: Any) -> Iterator[Mismatch]:
+ if self.expected is not None and self.expected != actual:
+ yield Mismatch((label,), str(self.expected), str(actual))
+
+
+@dataclass(frozen=True)
+class AttributesMatcher:
+ matchers: dict[str, ValueMatcher] = field(default_factory=dict)
+ strict: bool = False
+
+ def check(self, label: str, attributes: Sequence[KeyValue]) -> Iterator[Mismatch]:
+ actual = _key_values_to_python(attributes)
+ for key, matcher in self.matchers.items():
+ yield from _nest_mismatches(label, matcher.check(f"[{key!r}]", actual.get(key)))
+ if self.strict and (unexpected := sorted(actual.keys() - self.matchers.keys())):
+ yield Mismatch((label,), "no unlisted keys", ", ".join(unexpected))
+
+
+@dataclass(frozen=True)
+class ScopeMatcher:
+ name: ValueMatcher = ValueMatcher()
+ version: ValueMatcher = ValueMatcher()
+ attributes: AttributesMatcher = AttributesMatcher()
+
+ def check(self, scope: InstrumentationScope) -> Iterator[Mismatch]:
+ yield from self.name.check("name", scope.name or None)
+ yield from self.version.check("version", scope.version or None)
+ yield from self.attributes.check("attributes", scope.attributes)
+
+
+@dataclass(frozen=True)
+class LinkMatcher:
+ trace_state: ValueMatcher = ValueMatcher()
+ span: SpanMatcher | None = None
+ attributes: AttributesMatcher = AttributesMatcher()
+
+ def check(self, link: Span.Link, spans: SpanIndex) -> Iterator[Mismatch]:
+ yield from self.trace_state.check("trace_state", link.trace_state or None)
+ if self.span is not None:
+ yield from self.span.check_reference("span", spans, link.trace_id, link.span_id)
+ yield from self.attributes.check("attributes", link.attributes)
+
+
+@dataclass(frozen=True)
+class SpanMatcher:
+ name: ValueMatcher = ValueMatcher()
+ kind: LiteralMatcher[str] = LiteralMatcher()
+ status_code: LiteralMatcher[str] = LiteralMatcher()
+ status_message: ValueMatcher = ValueMatcher()
+ trace_state: ValueMatcher = ValueMatcher()
+ root: LiteralMatcher[bool] = LiteralMatcher()
+ parent: SpanMatcher | None = None
+ links: tuple[LinkMatcher, ...] = ()
+ link_count: ValueMatcher = ValueMatcher()
+ scope: ScopeMatcher | None = None
+ attributes: AttributesMatcher = AttributesMatcher()
+
+ def check(self, recorded: RecordedSpan, spans: SpanIndex) -> Iterator[Mismatch]:
+ span = recorded.span
+ yield from self.name.check("name", span.name or None)
+ yield from self.kind.check("kind", Span.SpanKind.Name(span.kind).removeprefix("SPAN_KIND_"))
+ status_code = Status.StatusCode.Name(span.status.code).removeprefix("STATUS_CODE_")
+ yield from _nest_mismatches("status", self.status_code.check("code", status_code))
+ yield from _nest_mismatches("status", self.status_message.check("message", span.status.message or None))
+ yield from self.trace_state.check("trace_state", span.trace_state or None)
+ yield from self.root.check("root", not span.parent_span_id)
+ if self.parent is not None:
+ yield from self.parent.check_reference("parent", spans, span.trace_id, span.parent_span_id)
+ for position, link_matcher in enumerate(self.links):
+ label = f"links[{position}]"
+ candidates = [list(link_matcher.check(link, spans)) for link in span.links]
+ if not candidates:
+ yield Mismatch((label,), "a link", "")
+ elif all(candidates):
+ yield from _nest_mismatches(label, min(candidates, key=len))
+ yield from self.link_count.check("link_count", len(span.links), strict=False)
+ if self.scope is not None:
+ yield from _nest_mismatches("scope", self.scope.check(recorded.scope))
+ yield from self.attributes.check("attributes", span.attributes)
+
+ def check_reference(self, label: str, spans: SpanIndex, trace_id: bytes, span_id: bytes) -> Iterator[Mismatch]:
+ """Match the received span with the given IDs, yielding its mismatches nested under ``label``."""
+ if not span_id:
+ yield Mismatch((label,), "a span", "")
+ return
+ target = spans.get((trace_id, span_id))
+ if target is None:
+ yield Mismatch((label,), "a received span", span_id.hex())
+ return
+ yield from _nest_mismatches(label, self.check(target, spans))
+
+
+def _get_optional_double(point: Any, field_name: str) -> float | None:
+ try:
+ present = point.HasField(field_name)
+ except ValueError:
+ # Not an optional field, so it is always present.
+ present = True
+ return getattr(point, field_name) if present else None
+
+
+@dataclass(frozen=True)
+class DataPointMatcher:
+ value: ValueMatcher | None = None
+ count: ValueMatcher | None = None
+ sum: ValueMatcher | None = None
+ min: ValueMatcher | None = None
+ max: ValueMatcher | None = None
+ explicit_bounds: LiteralMatcher[list[float]] | None = None
+ bucket_counts: LiteralMatcher[list[int]] | None = None
+ exemplar_count: ValueMatcher = ValueMatcher()
+ attributes: AttributesMatcher = AttributesMatcher()
+
+ def check(self, point: Any, metric: Metric) -> Iterator[Mismatch]:
+ if self.value is not None:
+ require_metric_field(metric, "value")
+ kind = point.WhichOneof("value")
+ actual = getattr(point, kind) if kind else None
+ yield from self.value.check("value", actual)
+ if self.count is not None:
+ require_metric_field(metric, "count")
+ yield from self.count.check("count", point.count, strict=False)
+ for field_name in ("sum", "min", "max"):
+ if (matcher := getattr(self, field_name)) is not None:
+ require_metric_field(metric, field_name)
+ yield from matcher.check(field_name, _get_optional_double(point, field_name), strict=False)
+ for field_name in ("explicit_bounds", "bucket_counts"):
+ if (literal := getattr(self, field_name)) is not None:
+ require_metric_field(metric, field_name)
+ yield from literal.check(field_name, list(getattr(point, field_name)))
+ yield from self.exemplar_count.check("exemplar_count", len(point.exemplars), strict=False)
+ yield from self.attributes.check("attributes", point.attributes)
+
+
+@dataclass(frozen=True)
+class MetricMatcher:
+ name: ValueMatcher = ValueMatcher()
+ type: LiteralMatcher[str] = LiteralMatcher()
+ unit: ValueMatcher = ValueMatcher()
+ description: ValueMatcher = ValueMatcher()
+ temporality: LiteralMatcher[str] | None = None
+ monotonic: LiteralMatcher[bool] | None = None
+ scope: ScopeMatcher | None = None
+ data_point: DataPointMatcher | None = None
+
+ def check(self, recorded: RecordedMetric, point: Any) -> Iterator[Mismatch]:
+ metric = recorded.metric
+ data_type = metric.WhichOneof("data")
+ identity = [*self.name.check("name", metric.name or None), *self.type.check("type", data_type)]
+ yield from identity
+ if identity:
+ # A different metric: skip the fields that may not apply to its type.
+ return
+ yield from self.unit.check("unit", metric.unit or None)
+ yield from self.description.check("description", metric.description or None)
+ if self.temporality is not None:
+ require_metric_field(metric, "temporality")
+ temporality = AggregationTemporality.Name(getattr(metric, data_type).aggregation_temporality)
+ yield from self.temporality.check("temporality", temporality.removeprefix("AGGREGATION_TEMPORALITY_"))
+ if self.monotonic is not None:
+ require_metric_field(metric, "monotonic")
+ yield from self.monotonic.check("monotonic", metric.sum.is_monotonic)
+ if self.scope is not None:
+ yield from _nest_mismatches("scope", self.scope.check(recorded.scope))
+ if self.data_point is not None:
+ yield from _nest_mismatches("data_point", self.data_point.check(point, metric))
+
+
+@dataclass(frozen=True)
+class LogMatcher:
+ body: ValueMatcher = ValueMatcher()
+ severity_number: LiteralMatcher[str] = LiteralMatcher()
+ severity_text: ValueMatcher = ValueMatcher()
+ event_name: ValueMatcher = ValueMatcher()
+ trace_context: LiteralMatcher[bool] = LiteralMatcher()
+ span: SpanMatcher | None = None
+ scope: ScopeMatcher | None = None
+ attributes: AttributesMatcher = AttributesMatcher()
+
+ def check(self, recorded: RecordedLogRecord, spans: SpanIndex) -> Iterator[Mismatch]:
+ log_record = recorded.log_record
+ yield from self.body.check("body", _any_value_to_python(log_record.body))
+ severity_number = SeverityNumber.Name(log_record.severity_number).removeprefix("SEVERITY_NUMBER_")
+ yield from self.severity_number.check("severity_number", severity_number)
+ yield from self.severity_text.check("severity_text", log_record.severity_text or None)
+ yield from self.event_name.check("event_name", log_record.event_name or None)
+ ids_set = (bool(log_record.trace_id), bool(log_record.span_id))
+ yield from self.trace_context.check("trace_context", ids_set[0] if ids_set[0] == ids_set[1] else "partial")
+ if self.span is not None:
+ yield from self.span.check_reference("span", spans, log_record.trace_id, log_record.span_id)
+ if self.scope is not None:
+ yield from _nest_mismatches("scope", self.scope.check(recorded.scope))
+ yield from self.attributes.check("attributes", log_record.attributes)
+
+
+@dataclass(frozen=True)
+class Counts:
+ count: int | None = None
+ min_count: int | None = None
+ max_count: int | None = None
+
+ @property
+ def lower(self) -> int:
+ if self.count is not None:
+ return self.count
+ return self.min_count if self.min_count is not None else 1
+
+ @property
+ def upper(self) -> int | None:
+ return self.count if self.count is not None else self.max_count
+
+ def satisfied(self, matched: int) -> bool:
+ return matched >= self.lower and (self.upper is None or matched <= self.upper)
+
+ def __str__(self) -> str:
+ if self.count is not None:
+ return f"== {self.count}"
+ if self.upper is None:
+ return f">= {self.lower}"
+ return f">= {self.lower} and <= {self.upper}"
+
+
+@dataclass(frozen=True)
+class ExpectationFailure:
+ """An unmet ``expect`` entry: its source, a summary line, and indented detail lines."""
+
+ key: str
+ source: dict[str, Any]
+ summary: str
+ details: tuple[str, ...] = ()
+
+ def __str__(self) -> str:
+ header = _to_flow_yaml(self.source)
+ return "\n".join([f"{self.key}: {header}", f" {self.summary}", *(f" {detail}" for detail in self.details)])
+
+
+def _count_failure(
+ key: str, source: dict[str, Any], counts: Counts, candidates: list[tuple[str, list[Mismatch]]], noun: str
+) -> ExpectationFailure | None:
+ """Report a counted expectation, given ``(label, mismatches)`` for every candidate item."""
+ matched = [label for label, mismatches in candidates if not mismatches]
+ if counts.satisfied(len(matched)):
+ return None
+ details: list[str] = []
+ if not candidates:
+ details.append(f"no {noun} received")
+ elif len(matched) < counts.lower:
+ unmatched = dict.fromkeys((label, tuple(mismatches)) for label, mismatches in candidates if mismatches)
+ # Prefer candidates with the expected name, then those with the fewest mismatches.
+ closest = sorted(
+ unmatched,
+ key=lambda item: (any(mismatch.path == ("name",) for mismatch in item[1]), len(item[1])),
+ )
+ details.extend(
+ f"closest {label}: {'; '.join(map(str, mismatches))}" for label, mismatches in closest[:_MAX_CANDIDATES]
+ )
+ else:
+ details.extend(f"matched {label}" for label in matched[:_MAX_CANDIDATES])
+ return ExpectationFailure(key, source, f"matched {len(matched)}, expected {counts}", tuple(details))
+
+
+@dataclass(frozen=True)
+class SpansExpectation:
+ matcher: SpanMatcher
+ counts: Counts = Counts()
+ source: dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
+
+ def failure(self, received: Received, spans: SpanIndex) -> ExpectationFailure | None:
+ candidates = [
+ (repr(recorded.span.name), list(self.matcher.check(recorded, spans))) for recorded in received.spans
+ ]
+ return _count_failure("spans", self.source, self.counts, candidates, "spans")
+
+
+def _get_data_points(metric: Metric) -> Sequence[Any]:
+ data_type = metric.WhichOneof("data")
+ return getattr(metric, data_type).data_points if data_type is not None else ()
+
+
+@dataclass(frozen=True)
+class MetricExpectation:
+ matcher: MetricMatcher
+ counts: Counts = Counts()
+ source: dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
+
+ def failure(self, received: Received, spans: SpanIndex) -> ExpectationFailure | None:
+ candidates = [
+ (
+ f"{recorded.metric.name!r} {_describe_value(_key_values_to_python(point.attributes))}",
+ list(self.matcher.check(recorded, point)),
+ )
+ for recorded in received.metrics
+ for point in _get_data_points(recorded.metric)
+ ]
+ return _count_failure("metric", self.source, self.counts, candidates, "metric data points")
+
+
+@dataclass(frozen=True)
+class LogsExpectation:
+ matcher: LogMatcher
+ counts: Counts = Counts()
+ source: dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
+
+ def failure(self, received: Received, spans: SpanIndex) -> ExpectationFailure | None:
+ candidates = [
+ (
+ f"body={_describe_value(_any_value_to_python(recorded.log_record.body))}",
+ list(self.matcher.check(recorded, spans)),
+ )
+ for recorded in received.logs
+ ]
+ return _count_failure("logs", self.source, self.counts, candidates, "logs")
+
+
+@dataclass(frozen=True)
+class ResourceExpectation:
+ attributes: AttributesMatcher = AttributesMatcher()
+ schema_url: ValueMatcher = ValueMatcher()
+ source: dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
+
+ def failure(self, received: Received, spans: SpanIndex) -> ExpectationFailure | None:
+ resources = [
+ *(("span", recorded.resource, recorded.resource_schema_url) for recorded in received.spans),
+ *(("metric", recorded.resource, recorded.resource_schema_url) for recorded in received.metrics),
+ *(("log", recorded.resource, recorded.resource_schema_url) for recorded in received.logs),
+ ]
+ if not resources:
+ return ExpectationFailure("resource", self.source, "nothing received")
+ failing = 0
+ # One example per distinct non-matching resource.
+ examples: dict[tuple[bytes, str], tuple[str, list[Mismatch]]] = {}
+ for signal, resource, schema_url in resources:
+ mismatches = [
+ *self.schema_url.check("schema_url", schema_url or None),
+ *self.attributes.check("attributes", resource.attributes),
+ ]
+ if mismatches:
+ failing += 1
+ examples.setdefault((resource.SerializeToString(deterministic=True), schema_url), (signal, mismatches))
+ if not failing:
+ return None
+ return ExpectationFailure(
+ "resource",
+ self.source,
+ f"{failing} of {len(resources)} items have a non-matching resource",
+ tuple(
+ f"{signal}: {'; '.join(map(str, mismatches))}"
+ for signal, mismatches in list(examples.values())[:_MAX_CANDIDATES]
+ ),
+ )
+
+
+@dataclass(frozen=True)
+class OutputExpectation:
+ """A regex that must (or, with ``exists=False``, must not) be found in the app's stdout or stderr."""
+
+ stream: str
+ pattern: re.Pattern[str]
+ exists: bool = True
+ source: dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
+
+ def failure(self, received: Received, spans: SpanIndex) -> ExpectationFailure | None:
+ found = self.pattern.search(getattr(received, self.stream)) is not None
+ if found == self.exists:
+ return None
+ summary = f"{self.stream} {'has no' if self.exists else 'has a'} match for {self.pattern.pattern!r}"
+ return ExpectationFailure("output", self.source, summary)
+
+
+Expectation = SpansExpectation | MetricExpectation | LogsExpectation | ResourceExpectation | OutputExpectation
+
+
+def _parse_value(spec: dict[str, Any], key: str) -> ValueMatcher:
+ return parse_value_matcher(spec[key]) if key in spec else ValueMatcher()
+
+
+def _parse_optional(spec: dict[str, Any], key: str) -> ValueMatcher | None:
+ return parse_value_matcher(spec[key]) if key in spec else None
+
+
+def _parse_optional_literal(spec: dict[str, Any], key: str) -> LiteralMatcher[Any] | None:
+ return LiteralMatcher(spec[key]) if key in spec else None
+
+
+def _parse_attributes(spec: dict[str, Any]) -> AttributesMatcher:
+ if (attributes := spec.get("attributes")) is None:
+ return AttributesMatcher()
+ return AttributesMatcher(
+ matchers={key: parse_value_matcher(value) for key, value in attributes.get("values", {}).items()},
+ strict=attributes.get("strict", False),
+ )
+
+
+def _parse_scope(spec: dict[str, Any]) -> ScopeMatcher | None:
+ if "scope" not in spec:
+ return None
+ scope = spec["scope"]
+ return ScopeMatcher(
+ name=_parse_value(scope, "name"),
+ version=_parse_value(scope, "version"),
+ attributes=_parse_attributes(scope),
+ )
+
+
+def _parse_span_matcher(spec: dict[str, Any]) -> SpanMatcher:
+ status = spec.get("status", {})
+ return SpanMatcher(
+ name=_parse_value(spec, "name"),
+ kind=LiteralMatcher(spec.get("kind")),
+ status_code=LiteralMatcher(status.get("code")),
+ status_message=_parse_value(status, "message"),
+ trace_state=_parse_value(spec, "trace_state"),
+ root=LiteralMatcher(spec.get("root")),
+ parent=_parse_span_matcher(spec["parent"]) if "parent" in spec else None,
+ links=tuple(
+ LinkMatcher(
+ trace_state=_parse_value(link, "trace_state"),
+ span=_parse_span_matcher(link["span"]) if "span" in link else None,
+ attributes=_parse_attributes(link),
+ )
+ for link in spec.get("links", [])
+ ),
+ link_count=_parse_value(spec, "link_count"),
+ scope=_parse_scope(spec),
+ attributes=_parse_attributes(spec),
+ )
+
+
+def _parse_data_point_matcher(spec: dict[str, Any]) -> DataPointMatcher:
+ return DataPointMatcher(
+ value=_parse_optional(spec, "value"),
+ count=_parse_optional(spec, "count"),
+ sum=_parse_optional(spec, "sum"),
+ min=_parse_optional(spec, "min"),
+ max=_parse_optional(spec, "max"),
+ explicit_bounds=_parse_optional_literal(spec, "explicit_bounds"),
+ bucket_counts=_parse_optional_literal(spec, "bucket_counts"),
+ exemplar_count=_parse_value(spec, "exemplar_count"),
+ attributes=_parse_attributes(spec),
+ )
+
+
+def _parse_metric_matcher(spec: dict[str, Any]) -> MetricMatcher:
+ return MetricMatcher(
+ name=_parse_value(spec, "name"),
+ type=LiteralMatcher(spec.get("type")),
+ unit=_parse_value(spec, "unit"),
+ description=_parse_value(spec, "description"),
+ temporality=_parse_optional_literal(spec, "temporality"),
+ monotonic=_parse_optional_literal(spec, "monotonic"),
+ scope=_parse_scope(spec),
+ data_point=_parse_data_point_matcher(spec["data_point"]) if "data_point" in spec else None,
+ )
+
+
+def _parse_log_matcher(spec: dict[str, Any]) -> LogMatcher:
+ return LogMatcher(
+ body=_parse_value(spec, "body"),
+ severity_number=LiteralMatcher(spec.get("severity_number")),
+ severity_text=_parse_value(spec, "severity_text"),
+ event_name=_parse_value(spec, "event_name"),
+ trace_context=LiteralMatcher(spec.get("trace_context")),
+ span=_parse_span_matcher(spec["span"]) if "span" in spec else None,
+ scope=_parse_scope(spec),
+ attributes=_parse_attributes(spec),
+ )
+
+
+def _parse_counts(spec: dict[str, Any]) -> Counts:
+ return Counts(count=spec.get("count"), min_count=spec.get("min_count"), max_count=spec.get("max_count"))
+
+
+def parse_expectation(item: dict[str, Any]) -> Expectation:
+ """Build an expectation from one schema-validated ``expect`` item."""
+ if (spans := item.get("spans")) is not None:
+ return SpansExpectation(matcher=_parse_span_matcher(spans), counts=_parse_counts(spans), source=spans)
+ if (metric := item.get("metric")) is not None:
+ return MetricExpectation(matcher=_parse_metric_matcher(metric), counts=_parse_counts(metric), source=metric)
+ if (logs := item.get("logs")) is not None:
+ return LogsExpectation(matcher=_parse_log_matcher(logs), counts=_parse_counts(logs), source=logs)
+ if (output := item.get("output")) is not None:
+ return OutputExpectation(
+ stream=output["stream"],
+ pattern=re.compile(output["pattern"]),
+ exists=output.get("exists", True),
+ source=output,
+ )
+ resource = item["resource"]
+ return ResourceExpectation(
+ attributes=_parse_attributes(resource), schema_url=_parse_value(resource, "schema_url"), source=resource
+ )
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/_scenario.py b/tests/opentelemetry-docker-tests/tests/declconfig/_scenario.py
new file mode 100644
index 00000000000..383fb2f9711
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/_scenario.py
@@ -0,0 +1,187 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Loading and evaluation of a scenario's ``scenario.yaml``."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass, field
+from functools import cache
+from pathlib import Path
+from typing import Any
+
+import jsonschema
+import yaml
+
+from opentelemetry.proto.metrics.v1.metrics_pb2 import Metric
+from opentelemetry.test._otlp_test_server import (
+ OtlpProtoTestServer,
+ RecordedLogRecord,
+ RecordedMetric,
+ RecordedSpan,
+)
+
+from ._expect import Expectation, ExpectationFailure, build_span_index, parse_expectation, require_metric_field
+
+_SCHEMA_FILE = Path(__file__).parent / "scenario.schema.json"
+_DEFAULT_TIMEOUT = 30.0
+
+
+@dataclass
+class Received:
+ """Telemetry accumulated from the sink over a scenario run, along with the app's state."""
+
+ spans: list[RecordedSpan] = field(default_factory=list)
+ metrics: list[RecordedMetric] = field(default_factory=list)
+ logs: list[RecordedLogRecord] = field(default_factory=list)
+ # Whether the app has exited cleanly on its own.
+ exited: bool = False
+ # The app's output, captured once it has stopped.
+ stdout: str = ""
+ stderr: str = ""
+
+ def drain(self, sink: OtlpProtoTestServer) -> int:
+ """Collect what the sink received since the last drain and return how many items that was."""
+ spans = sink.drain_spans()
+ metrics = sink.drain_metrics()
+ logs = sink.drain_log_records()
+ self.spans.extend(spans)
+ self.metrics.extend(metrics)
+ self.logs.extend(logs)
+ return len(spans) + len(metrics) + len(logs)
+
+
+@dataclass(frozen=True)
+class SpansCondition:
+ min_count: int = 1
+ name: str | None = None
+
+ def _get_count(self, received: Received) -> int:
+ return sum(1 for recorded in received.spans if self.name is None or recorded.span.name == self.name)
+
+ def satisfied(self, received: Received) -> bool:
+ return self._get_count(received) >= self.min_count
+
+ def progress(self, received: Received) -> str:
+ return f"spans(name={self.name!r}): {self._get_count(received)}/{self.min_count}"
+
+
+@dataclass(frozen=True)
+class MetricCondition:
+ name: str
+ min: float
+ field: str = "value"
+
+ def _get_field_values(self, metric: Metric) -> list[float]:
+ require_metric_field(metric, self.field)
+ data_points = getattr(metric, metric.WhichOneof("data")).data_points
+ if self.field == "value":
+ return [getattr(point, point.WhichOneof("value")) for point in data_points if point.WhichOneof("value")]
+ return [getattr(point, self.field) for point in data_points]
+
+ def _get_max(self, received: Received) -> float | None:
+ values = (
+ value
+ for recorded in received.metrics
+ if recorded.metric.name == self.name
+ for value in self._get_field_values(recorded.metric)
+ )
+ return max(values, default=None)
+
+ def satisfied(self, received: Received) -> bool:
+ highest = self._get_max(received)
+ return highest is not None and highest >= self.min
+
+ def progress(self, received: Received) -> str:
+ return f"metric(name={self.name!r}, field={self.field!r}): max {self._get_max(received)} (need >= {self.min})"
+
+
+@dataclass(frozen=True)
+class LogsCondition:
+ min_count: int = 1
+ body_pattern: re.Pattern[str] | None = None
+
+ def _matches(self, recorded: RecordedLogRecord) -> bool:
+ if self.body_pattern is None:
+ return True
+ body = recorded.log_record.body
+ return body.WhichOneof("value") == "string_value" and self.body_pattern.search(body.string_value) is not None
+
+ def _get_count(self, received: Received) -> int:
+ return sum(1 for recorded in received.logs if self._matches(recorded))
+
+ def satisfied(self, received: Received) -> bool:
+ return self._get_count(received) >= self.min_count
+
+ def progress(self, received: Received) -> str:
+ pattern = self.body_pattern.pattern if self.body_pattern is not None else None
+ return f"logs(body_pattern={pattern!r}): {self._get_count(received)}/{self.min_count}"
+
+
+@dataclass(frozen=True)
+class ExitedCondition:
+ def satisfied(self, received: Received) -> bool:
+ return received.exited
+
+ def progress(self, received: Received) -> str:
+ return f"exited: {received.exited}"
+
+
+Condition = SpansCondition | MetricCondition | LogsCondition | ExitedCondition
+
+
+@dataclass(frozen=True)
+class Scenario:
+ description: str
+ timeout: float
+ until: list[Condition]
+ expect: list[Expectation] = field(default_factory=list)
+ env: dict[str, str] = field(default_factory=dict)
+
+ def unmet(self, received: Received) -> list[Condition]:
+ return [condition for condition in self.until if not condition.satisfied(received)]
+
+ def failed_expectations(self, received: Received) -> list[ExpectationFailure]:
+ """Return a failure for each ``expect`` entry the received telemetry doesn't satisfy."""
+ spans = build_span_index(received.spans)
+ return [failure for expectation in self.expect if (failure := expectation.failure(received, spans)) is not None]
+
+
+@cache
+def _get_schema() -> dict[str, Any]:
+ with open(_SCHEMA_FILE, encoding="utf-8") as schema_file:
+ return json.load(schema_file)
+
+
+def _parse_condition(item: dict[str, Any]) -> Condition:
+ if (spans := item.get("spans")) is not None:
+ return SpansCondition(**spans)
+ if (metric := item.get("metric")) is not None:
+ return MetricCondition(**metric)
+ if "exited" in item:
+ return ExitedCondition()
+ logs = item["logs"]
+ pattern = logs.get("body_pattern")
+ return LogsCondition(
+ min_count=logs.get("min_count", 1),
+ body_pattern=re.compile(pattern) if pattern is not None else None,
+ )
+
+
+def parse_scenario(document: Any) -> Scenario:
+ """Validate a loaded ``scenario.yaml`` document and build a :class:`Scenario`."""
+ jsonschema.validate(document, _get_schema())
+ return Scenario(
+ description=document["description"],
+ timeout=float(document.get("timeout", _DEFAULT_TIMEOUT)),
+ until=[_parse_condition(item) for item in document["until"]],
+ expect=[parse_expectation(item) for item in document.get("expect", [])],
+ env=document.get("env", {}),
+ )
+
+
+def load_scenario(path: Path) -> Scenario:
+ with open(path, encoding="utf-8") as scenario_file:
+ return parse_scenario(yaml.safe_load(scenario_file))
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/collector-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/collector-config.yaml
new file mode 100644
index 00000000000..2c3a74ddf16
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/collector-config.yaml
@@ -0,0 +1,29 @@
+# Default collector config, used by scenarios without their own collector-config.yaml.
+extensions:
+ health_check:
+ endpoint: 0.0.0.0:13133
+
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+ http:
+ endpoint: 0.0.0.0:4318
+
+exporters:
+ otlphttp:
+ endpoint: ${env:OTLP_SINK_ENDPOINT}
+
+service:
+ extensions: [health_check]
+ pipelines:
+ traces:
+ receivers: [otlp]
+ exporters: [otlphttp]
+ metrics:
+ receivers: [otlp]
+ exporters: [otlphttp]
+ logs:
+ receivers: [otlp]
+ exporters: [otlphttp]
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/conftest.py b/tests/opentelemetry-docker-tests/tests/declconfig/conftest.py
new file mode 100644
index 00000000000..0e72943bf03
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/conftest.py
@@ -0,0 +1,84 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import re
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+import yaml
+from testcontainers.core.container import DockerContainer
+from testcontainers.core.wait_strategies import HttpWaitStrategy
+
+from opentelemetry.test._otlp_test_server import OtlpProtoTestServer
+
+collect_ignore_glob = ["scenarios/*"]
+
+_DOCKER_COMPOSE_FILE = Path(__file__).parent.parent / "docker-compose.yml"
+_DEFAULT_COLLECTOR_CONFIG = Path(__file__).parent / "collector-config.yaml"
+_COLLECTOR_CONFIG_PATH = "/etc/otelcol/collector-config.yaml"
+_HEALTH_CHECK_PORT = 13133
+_ENDPOINT_PORT_RE = re.compile(r":(\d+)$")
+
+
+def _get_collector_image() -> str:
+ """Reuse the collector image pinned in docker-compose.yml."""
+ with open(_DOCKER_COMPOSE_FILE, encoding="utf-8") as compose_file:
+ return yaml.safe_load(compose_file)["services"]["otcollector"]["image"]
+
+
+def _get_receiver_ports(collector_config: Path) -> list[int]:
+ """Collect the ``host:port`` receiver endpoints declared in a collector config."""
+ with open(collector_config, encoding="utf-8") as config_file:
+ receivers = yaml.safe_load(config_file).get("receivers") or {}
+
+ ports: set[int] = set()
+
+ def _walk(node: Any) -> None:
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if key == "endpoint" and isinstance(value, str) and (match := _ENDPOINT_PORT_RE.search(value)):
+ ports.add(int(match.group(1)))
+ else:
+ _walk(value)
+ elif isinstance(node, list):
+ for item in node:
+ _walk(item)
+
+ _walk(receivers)
+ return sorted(ports)
+
+
+@pytest.fixture
+def otlp_sink() -> Iterator[OtlpProtoTestServer]:
+ """Host side OTLP receiver the scenario's collector forwards to."""
+ with OtlpProtoTestServer(host="0.0.0.0", port=0) as sink:
+ yield sink
+
+
+@pytest.fixture
+def collector(scenario: Path, otlp_sink: OtlpProtoTestServer) -> Iterator[str]:
+ """Start a collector with the scenario's config, or the default one, and yield its host.
+
+ Receiver ports are bound 1:1 on the host, so scenario configs reach them
+ as ``://${OTEL_COLLECTOR_ENDPOINT}:``.
+ """
+ collector_config = scenario / "collector-config.yaml"
+ if not collector_config.exists():
+ collector_config = _DEFAULT_COLLECTOR_CONFIG
+ container = (
+ DockerContainer(_get_collector_image())
+ .with_volume_mapping(collector_config, _COLLECTOR_CONFIG_PATH)
+ .with_command(f"--config={_COLLECTOR_CONFIG_PATH}")
+ .with_env("OTLP_SINK_ENDPOINT", f"http://host.docker.internal:{otlp_sink.port}")
+ .with_kwargs(extra_hosts={"host.docker.internal": "host-gateway"})
+ .with_exposed_ports(_HEALTH_CHECK_PORT)
+ .waiting_for(HttpWaitStrategy(_HEALTH_CHECK_PORT).for_status_code(200))
+ )
+ for port in _get_receiver_ports(collector_config):
+ container.with_bind_ports(port, port)
+ with container:
+ yield container.get_container_host_ip()
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenario.schema.json b/tests/opentelemetry-docker-tests/tests/declconfig/scenario.schema.json
new file mode 100644
index 00000000000..57a5f0336de
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenario.schema.json
@@ -0,0 +1,457 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Declarative config docker-test scenario",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["description", "until"],
+ "properties": {
+ "description": {
+ "type": "string",
+ "minLength": 1
+ },
+ "timeout": {
+ "description": "Seconds to wait for every `until` condition to hold.",
+ "type": "number",
+ "exclusiveMinimum": 0,
+ "default": 30
+ },
+ "env": {
+ "description": "Extra environment variables for the app.",
+ "type": "object",
+ "additionalProperties": {"type": "string"}
+ },
+ "until": {
+ "description": "Termination conditions; all of them must hold.",
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["spans"],
+ "properties": {"spans": {"$ref": "#/$defs/spans"}}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["metric"],
+ "properties": {"metric": {"$ref": "#/$defs/metric"}}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["logs"],
+ "properties": {"logs": {"$ref": "#/$defs/logs"}}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["exited"],
+ "properties": {
+ "exited": {
+ "description": "Met once the app has exited cleanly.",
+ "type": "object",
+ "additionalProperties": false
+ }
+ }
+ }
+ ]
+ }
+ },
+ "expect": {
+ "description": "Content assertions, checked after the app has stopped and the sink is drained.",
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["spans"],
+ "properties": {"spans": {"$ref": "#/$defs/spans_expectation"}}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["metric"],
+ "properties": {"metric": {"$ref": "#/$defs/metric_expectation"}}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["logs"],
+ "properties": {"logs": {"$ref": "#/$defs/logs_expectation"}}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["resource"],
+ "properties": {"resource": {"$ref": "#/$defs/resource_matcher"}}
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["output"],
+ "properties": {"output": {"$ref": "#/$defs/output_expectation"}}
+ }
+ ]
+ }
+ }
+ },
+ "$defs": {
+ "min_count": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 1
+ },
+ "spans": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "min_count": {"$ref": "#/$defs/min_count"},
+ "name": {
+ "description": "Exact match on Span.name.",
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "metric": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "min"],
+ "properties": {
+ "name": {
+ "description": "Exact match on Metric.name.",
+ "type": "string",
+ "minLength": 1
+ },
+ "field": {
+ "description": "`value` for Gauge/Sum; `count` or `sum` for Histogram/ExponentialHistogram/Summary.",
+ "enum": ["value", "count", "sum"],
+ "default": "value"
+ },
+ "min": {
+ "description": "Met once any received data point has field >= min.",
+ "type": "number"
+ }
+ }
+ },
+ "logs": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "min_count": {"$ref": "#/$defs/min_count"},
+ "body_pattern": {
+ "description": "Regex searched (re.search) in string log bodies.",
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "typed_literal": {
+ "description": "A literal of an explicit AnyValue type.",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "maxProperties": 1,
+ "properties": {
+ "string": {"type": "string"},
+ "bool": {"type": "boolean"},
+ "int": {"type": "integer"},
+ "double": {"type": "number"},
+ "kvlist": {"type": "object", "additionalProperties": {"$ref": "#/$defs/value_literal"}}
+ }
+ },
+ "value_literal": {
+ "description": "Type-strict literal: string, bool, int (integer), double (float), array (list) or a typed_literal.",
+ "oneOf": [
+ {"type": ["string", "number", "boolean"]},
+ {"type": "array", "items": {"$ref": "#/$defs/value_literal"}},
+ {"$ref": "#/$defs/typed_literal"}
+ ]
+ },
+ "value_operators": {
+ "description": "Operators, all of which must hold. `exists: false` stands alone.",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "pattern": {
+ "description": "Regex searched (re.search) in a string value.",
+ "type": "string",
+ "minLength": 1
+ },
+ "exists": {"type": "boolean"},
+ "gt": {"type": "number"},
+ "gte": {"type": "number"},
+ "lt": {"type": "number"},
+ "lte": {"type": "number"}
+ },
+ "if": {"required": ["exists"], "properties": {"exists": {"const": false}}},
+ "then": {"maxProperties": 1}
+ },
+ "value_matcher": {
+ "oneOf": [{"$ref": "#/$defs/value_literal"}, {"$ref": "#/$defs/value_operators"}]
+ },
+ "string_matcher": {
+ "description": "Exact string, or `pattern`/`exists` operators. An empty string field counts as absent.",
+ "oneOf": [
+ {"type": "string"},
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "pattern": {"type": "string", "minLength": 1},
+ "exists": {"type": "boolean"}
+ },
+ "if": {"required": ["exists"], "properties": {"exists": {"const": false}}},
+ "then": {"maxProperties": 1}
+ }
+ ]
+ },
+ "number_matcher": {
+ "description": "Number, or `exists`/`gt`/`gte`/`lt`/`lte` operators.",
+ "oneOf": [
+ {"type": "number"},
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "exists": {"type": "boolean"},
+ "gt": {"type": "number"},
+ "gte": {"type": "number"},
+ "lt": {"type": "number"},
+ "lte": {"type": "number"}
+ },
+ "if": {"required": ["exists"], "properties": {"exists": {"const": false}}},
+ "then": {"maxProperties": 1}
+ }
+ ]
+ },
+ "attributes": {
+ "description": "Attribute assertions. Unlisted attributes are ignored unless `strict` is set.",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "values": {
+ "description": "Attributes that must match.",
+ "type": "object",
+ "additionalProperties": {"$ref": "#/$defs/value_matcher"}
+ },
+ "strict": {
+ "description": "Fail on attributes not listed in `values`.",
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
+ "counts": {
+ "description": "How many items must match. `min_count` defaults to 1 unless `count` is set.",
+ "properties": {
+ "count": {"type": "integer", "minimum": 0},
+ "min_count": {"type": "integer", "minimum": 1},
+ "max_count": {"type": "integer", "minimum": 1}
+ },
+ "not": {"anyOf": [{"required": ["count", "min_count"]}, {"required": ["count", "max_count"]}]}
+ },
+ "resource_matcher": {
+ "description": "Every received span, metric and log must carry a matching resource.",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "schema_url": {"$ref": "#/$defs/string_matcher"},
+ "attributes": {"$ref": "#/$defs/attributes"}
+ }
+ },
+ "output_expectation": {
+ "description": "A regex searched in the app's captured stdout or stderr.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["stream", "pattern"],
+ "properties": {
+ "stream": {"enum": ["stdout", "stderr"]},
+ "pattern": {"type": "string", "minLength": 1},
+ "exists": {
+ "description": "Whether the pattern must be found (true) or must not be found (false).",
+ "type": "boolean",
+ "default": true
+ }
+ }
+ },
+ "scope_matcher": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {"$ref": "#/$defs/string_matcher"},
+ "version": {"$ref": "#/$defs/string_matcher"},
+ "attributes": {"$ref": "#/$defs/attributes"}
+ }
+ },
+ "span_fields": {
+ "properties": {
+ "name": {"$ref": "#/$defs/string_matcher"},
+ "kind": {"enum": ["UNSPECIFIED", "INTERNAL", "SERVER", "CLIENT", "PRODUCER", "CONSUMER"]},
+ "status": {
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "code": {"enum": ["UNSET", "OK", "ERROR"]},
+ "message": {"$ref": "#/$defs/string_matcher"}
+ }
+ },
+ "trace_state": {"$ref": "#/$defs/string_matcher"},
+ "root": {"description": "Whether parent_span_id is empty.", "type": "boolean"},
+ "parent": {
+ "description": "Matcher for the received span that is this span's parent.",
+ "$ref": "#/$defs/span_matcher"
+ },
+ "links": {
+ "description": "Each entry must be matched by some link of the span.",
+ "type": "array",
+ "items": {"$ref": "#/$defs/link_matcher"}
+ },
+ "link_count": {"description": "Number of links on the span.", "$ref": "#/$defs/number_matcher"},
+ "scope": {"$ref": "#/$defs/scope_matcher"},
+ "attributes": {"$ref": "#/$defs/attributes"}
+ },
+ "not": {"required": ["root", "parent"]}
+ },
+ "span_matcher": {
+ "type": "object",
+ "$ref": "#/$defs/span_fields",
+ "unevaluatedProperties": false
+ },
+ "spans_expectation": {
+ "type": "object",
+ "allOf": [{"$ref": "#/$defs/span_fields"}, {"$ref": "#/$defs/counts"}],
+ "unevaluatedProperties": false
+ },
+ "link_matcher": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "trace_state": {"$ref": "#/$defs/string_matcher"},
+ "span": {
+ "description": "Matcher for the received span the link points to.",
+ "$ref": "#/$defs/span_matcher"
+ },
+ "attributes": {"$ref": "#/$defs/attributes"}
+ }
+ },
+ "data_point_matcher": {
+ "description": "Fields that don't apply to the received metric's type fail the test.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "description": "Gauge/Sum. Type-strict: an integer matches as_int, a float as_double.",
+ "$ref": "#/$defs/number_matcher"
+ },
+ "count": {
+ "description": "Histogram/ExponentialHistogram/Summary.",
+ "$ref": "#/$defs/number_matcher"
+ },
+ "sum": {
+ "description": "Histogram/ExponentialHistogram/Summary.",
+ "$ref": "#/$defs/number_matcher"
+ },
+ "min": {"description": "Histogram/ExponentialHistogram.", "$ref": "#/$defs/number_matcher"},
+ "max": {"description": "Histogram/ExponentialHistogram.", "$ref": "#/$defs/number_matcher"},
+ "explicit_bounds": {
+ "description": "Histogram, exact list.",
+ "type": "array",
+ "items": {"type": "number"}
+ },
+ "bucket_counts": {
+ "description": "Histogram, exact list.",
+ "type": "array",
+ "items": {"type": "integer", "minimum": 0}
+ },
+ "exemplar_count": {"description": "Number of exemplars on the data point.", "$ref": "#/$defs/number_matcher"},
+ "attributes": {"$ref": "#/$defs/attributes"}
+ }
+ },
+ "metric_expectation": {
+ "description": "Matched against every (metric, data point) pair across all exports.",
+ "type": "object",
+ "allOf": [
+ {
+ "properties": {
+ "name": {"$ref": "#/$defs/string_matcher"},
+ "type": {"enum": ["gauge", "sum", "histogram", "exponential_histogram", "summary"]},
+ "unit": {"$ref": "#/$defs/string_matcher"},
+ "description": {"$ref": "#/$defs/string_matcher"},
+ "temporality": {
+ "description": "Sum/Histogram/ExponentialHistogram.",
+ "enum": ["DELTA", "CUMULATIVE"]
+ },
+ "monotonic": {"description": "Sum.", "type": "boolean"},
+ "scope": {"$ref": "#/$defs/scope_matcher"},
+ "data_point": {"$ref": "#/$defs/data_point_matcher"}
+ }
+ },
+ {"$ref": "#/$defs/counts"}
+ ],
+ "unevaluatedProperties": false
+ },
+ "logs_expectation": {
+ "type": "object",
+ "allOf": [
+ {
+ "properties": {
+ "body": {"$ref": "#/$defs/value_matcher"},
+ "severity_number": {
+ "enum": [
+ "UNSPECIFIED",
+ "TRACE",
+ "TRACE2",
+ "TRACE3",
+ "TRACE4",
+ "DEBUG",
+ "DEBUG2",
+ "DEBUG3",
+ "DEBUG4",
+ "INFO",
+ "INFO2",
+ "INFO3",
+ "INFO4",
+ "WARN",
+ "WARN2",
+ "WARN3",
+ "WARN4",
+ "ERROR",
+ "ERROR2",
+ "ERROR3",
+ "ERROR4",
+ "FATAL",
+ "FATAL2",
+ "FATAL3",
+ "FATAL4"
+ ]
+ },
+ "severity_text": {"$ref": "#/$defs/string_matcher"},
+ "event_name": {"$ref": "#/$defs/string_matcher"},
+ "trace_context": {
+ "description": "Whether trace_id and span_id are both set (true) or both empty (false).",
+ "type": "boolean"
+ },
+ "span": {
+ "description": "Matcher for the received span the record was emitted in.",
+ "$ref": "#/$defs/span_matcher"
+ },
+ "scope": {"$ref": "#/$defs/scope_matcher"},
+ "attributes": {"$ref": "#/$defs/attributes"}
+ }
+ },
+ {"$ref": "#/$defs/counts"}
+ ],
+ "unevaluatedProperties": false
+ }
+ }
+}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/otel-config.yaml
new file mode 100644
index 00000000000..9cfd8a0ae7b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/otel-config.yaml
@@ -0,0 +1,12 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-error-grpc-deflate-compression
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_grpc:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4317
+ compression: deflate
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/run.py
new file mode 100644
index 00000000000..26611934cd9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("error-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/scenario.yaml
new file mode 100644
index 00000000000..b78a0bcc946
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_grpc_deflate_compression/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ `deflate` compression is only supported over HTTP, so it fails for `otlp_grpc`.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stderr
+ pattern: Failed to auto initialize OpenTelemetry
+ - output:
+ stream: stderr
+ pattern: "deflate"
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/otel-config.yaml
new file mode 100644
index 00000000000..9d42f21bff7
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/otel-config.yaml
@@ -0,0 +1,12 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-error-schema-violation
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ timeout: not-a-number
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/run.py
new file mode 100644
index 00000000000..26611934cd9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("error-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/scenario.yaml
new file mode 100644
index 00000000000..d5cf7c0a713
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_schema_violation/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ A config that doesn't match the schema fails configuration.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stderr
+ pattern: Failed to auto initialize OpenTelemetry
+ - output:
+ stream: stderr
+ pattern: "Configuration does not match schema"
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/otel-config.yaml
new file mode 100644
index 00000000000..d802fd9e896
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-error-unknown-propagator
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+propagator:
+ composite_list: tracecontext,bogus
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/run.py
new file mode 100644
index 00000000000..26611934cd9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("error-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/scenario.yaml
new file mode 100644
index 00000000000..bfdbd44fd94
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unknown_propagator/scenario.yaml
@@ -0,0 +1,11 @@
+description: >
+ An unknown propagator name in `composite_list` fails configuration.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stderr
+ pattern: Failed to auto initialize OpenTelemetry
+ - output:
+ stream: stderr
+ pattern: "bogus"
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/otel-config.yaml
new file mode 100644
index 00000000000..6255a74503a
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "2.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-error-unsupported-file-format
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/run.py
new file mode 100644
index 00000000000..26611934cd9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("error-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/scenario.yaml
new file mode 100644
index 00000000000..1c25b60f0bf
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_file_format/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ An unsupported major `file_format` version fails configuration.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stderr
+ pattern: Failed to auto initialize OpenTelemetry
+ - output:
+ stream: stderr
+ pattern: "Unsupported file_format '2.0'"
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/otel-config.yaml
new file mode 100644
index 00000000000..ba02dc86692
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/otel-config.yaml
@@ -0,0 +1,16 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-error-unsupported-sampler
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ sampler:
+ jaeger_remote/development:
+ endpoint: http://localhost:14250
+ initial_sampler:
+ always_on:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/run.py
new file mode 100644
index 00000000000..26611934cd9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("error-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/scenario.yaml
new file mode 100644
index 00000000000..4a68a8cf78d
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/error_unsupported_sampler/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ The `jaeger_remote/development` sampler is unsupported and fails configuration.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stderr
+ pattern: Failed to auto initialize OpenTelemetry
+ - output:
+ stream: stderr
+ pattern: "Unsupported sampler type"
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/otel-config.yaml
new file mode 100644
index 00000000000..995359c3457
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/otel-config.yaml
@@ -0,0 +1,20 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-general-attribute-limits
+attribute_limits:
+ attribute_count_limit: 2
+ attribute_value_length_limit: 4
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+logger_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/logs
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/run.py
new file mode 100644
index 00000000000..3bdcd5e231e
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/run.py
@@ -0,0 +1,13 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+from opentelemetry._logs import get_logger
+
+# Once the count limit is reached, the oldest attribute is dropped.
+attributes = {"dropped": 0, "a": "abcdefgh", "b": 1}
+
+with trace.get_tracer(__name__).start_as_current_span("limited-span", attributes=attributes):
+ pass
+
+get_logger(__name__).emit(body="limited log", attributes=attributes)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/scenario.yaml
new file mode 100644
index 00000000000..ea311115c39
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_attribute_limits/scenario.yaml
@@ -0,0 +1,24 @@
+description: >
+ Global `attribute_limits` apply to span and log record attributes.
+until:
+ - spans:
+ name: limited-span
+ - logs:
+ body_pattern: limited log
+expect:
+ - spans:
+ name: limited-span
+ attributes:
+ values:
+ dropped: {exists: false}
+ a: abcd
+ b: 1
+ count: 1
+ - logs:
+ body: limited log
+ attributes:
+ values:
+ dropped: {exists: false}
+ a: abcd
+ b: 1
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/otel-config.yaml
new file mode 100644
index 00000000000..a60740ae65e
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/otel-config.yaml
@@ -0,0 +1,12 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-general-disabled
+disabled: true
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/run.py
new file mode 100644
index 00000000000..a051696fe65
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("disabled-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/scenario.yaml
new file mode 100644
index 00000000000..446480d62c6
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_disabled/scenario.yaml
@@ -0,0 +1,8 @@
+description: >
+ `disabled: true` leaves the SDK unconfigured, so nothing is exported.
+until:
+ - exited: {}
+expect:
+ - spans:
+ name: disabled-span
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/otel-config.yaml
new file mode 100644
index 00000000000..d361c9ec4cc
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/otel-config.yaml
@@ -0,0 +1,23 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: ${DECLCONFIG_SERVICE_NAME}
+ - name: env.standalone
+ value: ${DECLCONFIG_INT}
+ - name: env.quoted
+ value: "${DECLCONFIG_INT}"
+ - name: env.embedded
+ value: v-${DECLCONFIG_INT}
+ - name: env.default
+ value: ${DECLCONFIG_UNSET:-fallback}
+ - name: env.empty
+ value: "${DECLCONFIG_EMPTY:-fallback}"
+ - name: env.escaped
+ value: $${DECLCONFIG_INT}
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/run.py
new file mode 100644
index 00000000000..e0b5a69c45b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("env-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/scenario.yaml
new file mode 100644
index 00000000000..5c7d58961f6
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_env_substitution/scenario.yaml
@@ -0,0 +1,21 @@
+description: >
+ Environment variable substitution: plain, typed standalone, embedded, quoted, defaults,
+ set-but-empty and `$$` escapes.
+env:
+ DECLCONFIG_SERVICE_NAME: declconfig-general-env-substitution
+ DECLCONFIG_INT: "42"
+ DECLCONFIG_EMPTY: ""
+until:
+ - spans:
+ name: env-span
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: declconfig-general-env-substitution
+ env.standalone: 42
+ env.quoted: "42"
+ env.embedded: v-42
+ env.default: fallback
+ env.empty: ""
+ env.escaped: ${DECLCONFIG_INT}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/otel-config.yaml
new file mode 100644
index 00000000000..2b751271320
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "1.99"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-general-file-format-minor
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/run.py
new file mode 100644
index 00000000000..78691b96471
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("minor-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/scenario.yaml
new file mode 100644
index 00000000000..0cf724c98d3
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_file_format_minor/scenario.yaml
@@ -0,0 +1,12 @@
+description: >
+ A newer minor `file_format` version is accepted with a warning.
+until:
+ - spans:
+ name: minor-span
+expect:
+ - spans:
+ name: minor-span
+ count: 1
+ - output:
+ stream: stderr
+ pattern: "1\\.99"
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/otel-config.json b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/otel-config.json
new file mode 100644
index 00000000000..96c7f5cbb99
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/otel-config.json
@@ -0,0 +1,11 @@
+{
+ "file_format": "1.0",
+ "resource": {
+ "attributes": [{"name": "service.name", "value": "declconfig-general-json-config"}]
+ },
+ "tracer_provider": {
+ "processors": [
+ {"simple": {"exporter": {"otlp_http": {"endpoint": "http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces"}}}}
+ ]
+ }
+}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/run.py
new file mode 100644
index 00000000000..b69342a6b11
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("json-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/scenario.yaml
new file mode 100644
index 00000000000..88b1aac61c2
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/general_json_config/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ A JSON config file (`otel-config.json`) configures the SDK like a YAML one.
+until:
+ - spans:
+ name: json-span
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: declconfig-general-json-config
+ - spans:
+ name: json-span
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/otel-config.yaml
new file mode 100644
index 00000000000..4ee996e9374
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/otel-config.yaml
@@ -0,0 +1,21 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-log-batch-otlp-http
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+logger_provider:
+ processors:
+ - batch:
+ schedule_delay: 100
+ export_timeout: 5000
+ max_queue_size: 64
+ max_export_batch_size: 8
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/logs
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/run.py
new file mode 100644
index 00000000000..c3023851768
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/run.py
@@ -0,0 +1,17 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+from opentelemetry._logs import SeverityNumber, get_logger
+
+logger = get_logger(__name__)
+
+with trace.get_tracer(__name__).start_as_current_span("handler"):
+ logger.emit(
+ body="user logged in",
+ severity_number=SeverityNumber.WARN,
+ severity_text="WARN",
+ event_name="user.login",
+ attributes={"user.id": "u-1"},
+ )
+logger.emit(body={"kind": "structured", "count": 2}, severity_number=SeverityNumber.INFO)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/scenario.yaml
new file mode 100644
index 00000000000..9a14d32e676
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_batch_otlp_http/scenario.yaml
@@ -0,0 +1,22 @@
+description: >
+ Exports log records over OTLP/HTTP through a batch processor with every batch setting,
+ correlated with the active span.
+until:
+ - logs:
+ min_count: 2
+expect:
+ - logs:
+ body: user logged in
+ severity_number: WARN
+ severity_text: WARN
+ event_name: user.login
+ trace_context: true
+ span: {name: handler}
+ scope: {name: __main__}
+ attributes: {values: {user.id: u-1}, strict: true}
+ count: 1
+ - logs:
+ body: {kvlist: {kind: structured, count: 2}}
+ severity_number: INFO
+ trace_context: false
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/otel-config.yaml
new file mode 100644
index 00000000000..2fb8ab9d211
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/otel-config.yaml
@@ -0,0 +1,10 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-log-console
+logger_provider:
+ processors:
+ - simple:
+ exporter:
+ console:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/run.py
new file mode 100644
index 00000000000..a1528725936
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/run.py
@@ -0,0 +1,6 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry._logs import get_logger
+
+get_logger(__name__).emit(body="console log")
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/scenario.yaml
new file mode 100644
index 00000000000..c474c5e5221
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_console/scenario.yaml
@@ -0,0 +1,8 @@
+description: >
+ The console log exporter writes log records to stdout.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stdout
+ pattern: '"body": "console log"'
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/otel-config.yaml
new file mode 100644
index 00000000000..2c34d48db5b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/otel-config.yaml
@@ -0,0 +1,17 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-log-limits
+attribute_limits:
+ attribute_count_limit: 10
+ attribute_value_length_limit: 100
+logger_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/logs
+ limits:
+ attribute_count_limit: 2
+ attribute_value_length_limit: 4
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/run.py
new file mode 100644
index 00000000000..96995969528
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/run.py
@@ -0,0 +1,7 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry._logs import get_logger
+
+# Once the count limit is reached, the oldest attribute is dropped.
+get_logger(__name__).emit(body="limited log", attributes={"dropped": 0, "a": "abcdefgh", "b": 1})
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/scenario.yaml
new file mode 100644
index 00000000000..0396eb389da
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_limits/scenario.yaml
@@ -0,0 +1,14 @@
+description: >
+ Log record `limits` cap attribute count and length, overriding the global `attribute_limits`.
+until:
+ - logs:
+ body_pattern: limited log
+expect:
+ - logs:
+ body: limited log
+ attributes:
+ values:
+ a: abcd
+ b: 1
+ strict: true
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/otel-config.yaml
new file mode 100644
index 00000000000..4b7f5e9fc6f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/otel-config.yaml
@@ -0,0 +1,18 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-log-logger-configurator
+logger_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/logs
+ logger_configurator/development:
+ default_config:
+ enabled: true
+ loggers:
+ - name: disabled.*
+ config:
+ enabled: false
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/run.py
new file mode 100644
index 00000000000..26bcb5ae9d7
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/run.py
@@ -0,0 +1,7 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry._logs import get_logger
+
+get_logger("enabled.lib").emit(body="enabled log")
+get_logger("disabled.lib").emit(body="disabled log")
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/scenario.yaml
new file mode 100644
index 00000000000..82495a5c6bf
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_logger_configurator/scenario.yaml
@@ -0,0 +1,12 @@
+description: >
+ `logger_configurator/development` disables loggers whose name matches a glob.
+until:
+ - exited: {}
+expect:
+ - logs:
+ body: enabled log
+ scope: {name: enabled.lib}
+ count: 1
+ - logs:
+ body: disabled log
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/otel-config.yaml
new file mode 100644
index 00000000000..ad448353267
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-log-simple-otlp-grpc
+logger_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_grpc:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4317
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/run.py
new file mode 100644
index 00000000000..a44a52fdc5b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/run.py
@@ -0,0 +1,6 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry._logs import get_logger
+
+get_logger(__name__).emit(body="grpc log")
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/scenario.yaml
new file mode 100644
index 00000000000..0c2da18eb99
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/log_simple_otlp_grpc/scenario.yaml
@@ -0,0 +1,9 @@
+description: >
+ Exports a log record over OTLP/gRPC through a simple processor.
+until:
+ - logs:
+ body_pattern: grpc log
+expect:
+ - logs:
+ body: grpc log
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/otel-config.yaml
new file mode 100644
index 00000000000..80067428173
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-console
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ console:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/run.py
new file mode 100644
index 00000000000..f70c72cba97
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/run.py
@@ -0,0 +1,6 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+
+metrics.get_meter(__name__).create_counter("console.requests").add(1)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/scenario.yaml
new file mode 100644
index 00000000000..cfbc8c979db
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_console/scenario.yaml
@@ -0,0 +1,8 @@
+description: >
+ The console metric exporter writes metrics to stdout.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stdout
+ pattern: '"name": "console.requests"'
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/otel-config.yaml
new file mode 100644
index 00000000000..0225f8e624b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-default-histogram-exponential
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ default_histogram_aggregation: base2_exponential_bucket_histogram
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/run.py
new file mode 100644
index 00000000000..129d83570f7
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/run.py
@@ -0,0 +1,8 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+
+latency = metrics.get_meter(__name__).create_histogram("latency", unit="ms")
+latency.record(10)
+latency.record(20)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/scenario.yaml
new file mode 100644
index 00000000000..db16a9f8fa3
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_default_histogram_exponential/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ `default_histogram_aggregation: base2_exponential_bucket_histogram` exports histograms as
+ exponential histograms.
+until:
+ - metric:
+ name: latency
+ field: count
+ min: 2
+expect:
+ - metric:
+ name: latency
+ type: exponential_histogram
+ data_point: {count: 2, sum: 30, min: 10, max: 20}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/otel-config.yaml
new file mode 100644
index 00000000000..e884dcc591d
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/otel-config.yaml
@@ -0,0 +1,19 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-exemplar-filter-always-off
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ exemplar_filter: always_off
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/run.py
new file mode 100644
index 00000000000..4b35cc23461
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/run.py
@@ -0,0 +1,12 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics, trace
+
+meter = metrics.get_meter(__name__)
+inside = meter.create_counter("inside.requests")
+outside = meter.create_counter("outside.requests")
+
+with trace.get_tracer(__name__).start_as_current_span("exemplar-span"):
+ inside.add(1)
+outside.add(1)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/scenario.yaml
new file mode 100644
index 00000000000..290e933db88
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_off/scenario.yaml
@@ -0,0 +1,14 @@
+description: >
+ `exemplar_filter: always_off` records no exemplars, even in a sampled span.
+until:
+ - metric:
+ name: inside.requests
+ min: 1
+ - metric:
+ name: outside.requests
+ min: 1
+expect:
+ - metric:
+ name: inside.requests
+ data_point: {exemplar_count: {gte: 1}}
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/otel-config.yaml
new file mode 100644
index 00000000000..c88b1e29b4f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/otel-config.yaml
@@ -0,0 +1,19 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-exemplar-filter-always-on
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ exemplar_filter: always_on
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/run.py
new file mode 100644
index 00000000000..4b35cc23461
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/run.py
@@ -0,0 +1,12 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics, trace
+
+meter = metrics.get_meter(__name__)
+inside = meter.create_counter("inside.requests")
+outside = meter.create_counter("outside.requests")
+
+with trace.get_tracer(__name__).start_as_current_span("exemplar-span"):
+ inside.add(1)
+outside.add(1)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/scenario.yaml
new file mode 100644
index 00000000000..8ca63a11177
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_always_on/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ `exemplar_filter: always_on` records exemplars for every measurement.
+until:
+ - metric:
+ name: inside.requests
+ min: 1
+ - metric:
+ name: outside.requests
+ min: 1
+expect:
+ - metric:
+ name: outside.requests
+ data_point: {exemplar_count: {gte: 1}}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/otel-config.yaml
new file mode 100644
index 00000000000..427b0a63ffc
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/otel-config.yaml
@@ -0,0 +1,19 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-exemplar-filter-trace-based
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ exemplar_filter: trace_based
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/run.py
new file mode 100644
index 00000000000..4b35cc23461
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/run.py
@@ -0,0 +1,12 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics, trace
+
+meter = metrics.get_meter(__name__)
+inside = meter.create_counter("inside.requests")
+outside = meter.create_counter("outside.requests")
+
+with trace.get_tracer(__name__).start_as_current_span("exemplar-span"):
+ inside.add(1)
+outside.add(1)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/scenario.yaml
new file mode 100644
index 00000000000..ce1705291c5
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_exemplar_filter_trace_based/scenario.yaml
@@ -0,0 +1,17 @@
+description: >
+ `exemplar_filter: trace_based` records exemplars only for measurements in a sampled span.
+until:
+ - metric:
+ name: inside.requests
+ min: 1
+ - metric:
+ name: outside.requests
+ min: 1
+expect:
+ - metric:
+ name: inside.requests
+ data_point: {exemplar_count: {gte: 1}}
+ - metric:
+ name: outside.requests
+ data_point: {exemplar_count: {gte: 1}}
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/otel-config.yaml
new file mode 100644
index 00000000000..b50efd53f3b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/otel-config.yaml
@@ -0,0 +1,12 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-instruments-cumulative
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/run.py
new file mode 100644
index 00000000000..242260474df
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/run.py
@@ -0,0 +1,19 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+from opentelemetry.metrics import Observation
+
+meter = metrics.get_meter("declconfig.metrics")
+
+meter.create_counter("requests").add(3, {"route": "/a"})
+queue_size = meter.create_up_down_counter("queue.size")
+queue_size.add(5)
+queue_size.add(-2)
+latency = meter.create_histogram("latency", unit="ms")
+latency.record(10)
+latency.record(20)
+meter.create_gauge("temperature").set(21.5)
+meter.create_observable_counter("cpu.time", [lambda options: [Observation(7)]])
+meter.create_observable_gauge("memory.usage", [lambda options: [Observation(1024)]])
+meter.create_observable_up_down_counter("connections", [lambda options: [Observation(4)]])
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/scenario.yaml
new file mode 100644
index 00000000000..b66fffda60b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_instruments_cumulative/scenario.yaml
@@ -0,0 +1,48 @@
+description: >
+ Every instrument kind exported with the default cumulative temporality.
+until:
+ - metric:
+ name: requests
+ min: 3
+expect:
+ - metric:
+ name: requests
+ type: sum
+ monotonic: true
+ temporality: CUMULATIVE
+ scope: {name: declconfig.metrics}
+ data_point:
+ value: 3
+ attributes: {values: {route: /a}, strict: true}
+ - metric:
+ name: queue.size
+ type: sum
+ monotonic: false
+ temporality: CUMULATIVE
+ data_point: {value: 3}
+ - metric:
+ name: latency
+ type: histogram
+ unit: ms
+ temporality: CUMULATIVE
+ data_point: {count: 2, sum: 30, min: 10, max: 20}
+ - metric:
+ name: temperature
+ type: gauge
+ data_point: {value: 21.5}
+ - metric:
+ name: cpu.time
+ type: sum
+ monotonic: true
+ temporality: CUMULATIVE
+ data_point: {value: 7}
+ - metric:
+ name: memory.usage
+ type: gauge
+ data_point: {value: 1024}
+ - metric:
+ name: connections
+ type: sum
+ monotonic: false
+ temporality: CUMULATIVE
+ data_point: {value: 4}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/otel-config.yaml
new file mode 100644
index 00000000000..3fa0746d388
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/otel-config.yaml
@@ -0,0 +1,19 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-meter-configurator
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ meter_configurator/development:
+ default_config:
+ enabled: true
+ meters:
+ - name: disabled.*
+ config:
+ enabled: false
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/run.py
new file mode 100644
index 00000000000..755ac3b2820
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/run.py
@@ -0,0 +1,7 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+
+metrics.get_meter("enabled.meter").create_counter("enabled.requests").add(1)
+metrics.get_meter("disabled.meter").create_counter("disabled.requests").add(1)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/scenario.yaml
new file mode 100644
index 00000000000..e0aaa1b4d7b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_meter_configurator/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ `meter_configurator/development` disables meters whose name matches a glob.
+until:
+ - metric:
+ name: enabled.requests
+ min: 1
+expect:
+ - metric:
+ name: enabled.requests
+ scope: {name: enabled.meter}
+ - metric:
+ name: disabled.requests
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/otel-config.yaml
new file mode 100644
index 00000000000..4a3dd40e7ef
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-otlp-grpc
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_grpc:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4317
+ compression: gzip
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/run.py
new file mode 100644
index 00000000000..417d6fa1b32
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/run.py
@@ -0,0 +1,6 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+
+metrics.get_meter(__name__).create_counter("grpc.requests").add(1)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/scenario.yaml
new file mode 100644
index 00000000000..84abba26d51
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_otlp_grpc/scenario.yaml
@@ -0,0 +1,11 @@
+description: >
+ Exports metrics over OTLP/gRPC with gzip compression.
+until:
+ - metric:
+ name: grpc.requests
+ min: 1
+expect:
+ - metric:
+ name: grpc.requests
+ type: sum
+ data_point: {value: 1}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/collector-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/collector-config.yaml
new file mode 100644
index 00000000000..fe4b3e392d5
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/collector-config.yaml
@@ -0,0 +1,32 @@
+extensions:
+ health_check:
+ endpoint: 0.0.0.0:13133
+
+receivers:
+ prometheus:
+ config:
+ scrape_configs:
+ - job_name: declconfig
+ scrape_interval: 1s
+ static_configs:
+ - targets: [host.docker.internal:9464]
+
+processors:
+ # The receiver reports these on every scrape, even once the app has stopped.
+ filter/scrape:
+ error_mode: ignore
+ metrics:
+ metric:
+ - 'name == "up" or IsMatch(name, "^scrape_")'
+
+exporters:
+ otlphttp:
+ endpoint: ${env:OTLP_SINK_ENDPOINT}
+
+service:
+ extensions: [health_check]
+ pipelines:
+ metrics:
+ receivers: [prometheus]
+ processors: [filter/scrape]
+ exporters: [otlphttp]
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/otel-config.yaml
new file mode 100644
index 00000000000..10712b71530
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-prometheus-pull
+meter_provider:
+ readers:
+ - pull:
+ exporter:
+ prometheus/development:
+ host: 0.0.0.0
+ port: 9464
+ target_info_enabled/development: false
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/run.py
new file mode 100644
index 00000000000..0f44289f7ff
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/run.py
@@ -0,0 +1,12 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+import time
+
+from opentelemetry import metrics
+
+requests = metrics.get_meter(__name__).create_counter("prom.requests")
+# Keep serving until the harness stops the app.
+while True:
+ requests.add(1)
+ time.sleep(0.5)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/scenario.yaml
new file mode 100644
index 00000000000..d1dc47553c1
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_prometheus_pull/scenario.yaml
@@ -0,0 +1,12 @@
+description: >
+ A `pull` reader with the `prometheus/development` exporter serves metrics for the collector's
+ prometheus receiver to scrape.
+until:
+ - metric:
+ name: prom_requests_total
+ min: 1
+expect:
+ - metric:
+ name: prom_requests_total
+ type: sum
+ monotonic: true
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/otel-config.yaml
new file mode 100644
index 00000000000..67f585b7f8f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-temporality-delta
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ temporality_preference: delta
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/run.py
new file mode 100644
index 00000000000..242260474df
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/run.py
@@ -0,0 +1,19 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+from opentelemetry.metrics import Observation
+
+meter = metrics.get_meter("declconfig.metrics")
+
+meter.create_counter("requests").add(3, {"route": "/a"})
+queue_size = meter.create_up_down_counter("queue.size")
+queue_size.add(5)
+queue_size.add(-2)
+latency = meter.create_histogram("latency", unit="ms")
+latency.record(10)
+latency.record(20)
+meter.create_gauge("temperature").set(21.5)
+meter.create_observable_counter("cpu.time", [lambda options: [Observation(7)]])
+meter.create_observable_gauge("memory.usage", [lambda options: [Observation(1024)]])
+meter.create_observable_up_down_counter("connections", [lambda options: [Observation(4)]])
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/scenario.yaml
new file mode 100644
index 00000000000..b0923a1c0e7
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_delta/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ `temporality_preference: delta` exports counters, histograms and observable counters as delta,
+ and up-down counters as cumulative.
+until:
+ - metric:
+ name: requests
+ min: 3
+expect:
+ - metric: {name: requests, temporality: DELTA}
+ - metric: {name: latency, temporality: DELTA}
+ - metric: {name: cpu.time, temporality: DELTA}
+ - metric: {name: queue.size, temporality: CUMULATIVE}
+ - metric: {name: connections, temporality: CUMULATIVE}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/otel-config.yaml
new file mode 100644
index 00000000000..13e6bf16913
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-temporality-low-memory
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ temporality_preference: low_memory
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/run.py
new file mode 100644
index 00000000000..242260474df
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/run.py
@@ -0,0 +1,19 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+from opentelemetry.metrics import Observation
+
+meter = metrics.get_meter("declconfig.metrics")
+
+meter.create_counter("requests").add(3, {"route": "/a"})
+queue_size = meter.create_up_down_counter("queue.size")
+queue_size.add(5)
+queue_size.add(-2)
+latency = meter.create_histogram("latency", unit="ms")
+latency.record(10)
+latency.record(20)
+meter.create_gauge("temperature").set(21.5)
+meter.create_observable_counter("cpu.time", [lambda options: [Observation(7)]])
+meter.create_observable_gauge("memory.usage", [lambda options: [Observation(1024)]])
+meter.create_observable_up_down_counter("connections", [lambda options: [Observation(4)]])
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/scenario.yaml
new file mode 100644
index 00000000000..865155ee3e5
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_temporality_low_memory/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ `temporality_preference: low_memory` exports synchronous counters and histograms as delta,
+ and everything else as cumulative.
+until:
+ - metric:
+ name: requests
+ min: 3
+expect:
+ - metric: {name: requests, temporality: DELTA}
+ - metric: {name: latency, temporality: DELTA}
+ - metric: {name: cpu.time, temporality: CUMULATIVE}
+ - metric: {name: queue.size, temporality: CUMULATIVE}
+ - metric: {name: connections, temporality: CUMULATIVE}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/otel-config.yaml
new file mode 100644
index 00000000000..8941dc2cf39
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/otel-config.yaml
@@ -0,0 +1,38 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-views-aggregations
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ views:
+ - selector: {instrument_name: dropped}
+ stream:
+ aggregation:
+ drop:
+ - selector: {instrument_name: summed}
+ stream:
+ aggregation:
+ sum:
+ - selector: {instrument_name: last}
+ stream:
+ aggregation:
+ last_value:
+ - selector: {instrument_name: bucketed}
+ stream:
+ aggregation:
+ explicit_bucket_histogram:
+ boundaries: [10, 100]
+ record_min_max: false
+ - selector: {instrument_name: exponential}
+ stream:
+ aggregation:
+ base2_exponential_bucket_histogram:
+ max_size: 4
+ max_scale: 2
+ record_min_max: true
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/run.py
new file mode 100644
index 00000000000..9d0db17c454
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/run.py
@@ -0,0 +1,17 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+
+meter = metrics.get_meter(__name__)
+meter.create_counter("dropped").add(1)
+summed = meter.create_histogram("summed")
+summed.record(10)
+summed.record(20)
+last = meter.create_counter("last")
+last.add(1)
+last.add(2)
+for name in ("bucketed", "exponential"):
+ histogram = meter.create_histogram(name)
+ histogram.record(5)
+ histogram.record(50)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/scenario.yaml
new file mode 100644
index 00000000000..084371d5407
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_aggregations/scenario.yaml
@@ -0,0 +1,35 @@
+description: >
+ Views apply drop, sum, last_value, explicit bucket histogram and base2 exponential bucket
+ histogram aggregations.
+until:
+ - metric:
+ name: summed
+ min: 30
+ - metric:
+ name: last
+ min: 2
+expect:
+ - metric:
+ name: dropped
+ count: 0
+ - metric:
+ name: summed
+ type: sum
+ data_point: {value: 30}
+ - metric:
+ name: last
+ type: gauge
+ data_point: {value: 2}
+ - metric:
+ name: bucketed
+ type: histogram
+ data_point:
+ count: 2
+ explicit_bounds: [10, 100]
+ bucket_counts: [1, 1, 0]
+ min: {exists: false}
+ max: {exists: false}
+ - metric:
+ name: exponential
+ type: exponential_histogram
+ data_point: {count: 2}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/otel-config.yaml
new file mode 100644
index 00000000000..a1db7638f60
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/otel-config.yaml
@@ -0,0 +1,29 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-metric-views-stream
+meter_provider:
+ readers:
+ - periodic:
+ interval: 500
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/metrics
+ views:
+ - selector:
+ instrument_name: requests
+ instrument_type: counter
+ unit: "1"
+ meter_name: declconfig.views
+ meter_version: "1.0"
+ stream:
+ name: renamed.requests
+ description: Renamed by a view
+ attribute_keys:
+ included: [route]
+ - selector:
+ instrument_name: unmatched
+ meter_name: other.meter
+ stream:
+ name: should.not.apply
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/run.py
new file mode 100644
index 00000000000..650af70fe8b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/run.py
@@ -0,0 +1,8 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import metrics
+
+meter = metrics.get_meter("declconfig.views", version="1.0")
+meter.create_counter("requests", unit="1").add(1, {"route": "/a", "method": "GET"})
+meter.create_counter("unmatched").add(1)
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/scenario.yaml
new file mode 100644
index 00000000000..52e924b5746
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/metric_views_stream/scenario.yaml
@@ -0,0 +1,24 @@
+description: >
+ A view selects an instrument by name, type, unit and meter name/version, and renames it,
+ sets its description and keeps only the included attribute keys. A view whose selector
+ doesn't match has no effect.
+until:
+ - metric:
+ name: renamed.requests
+ min: 1
+ - metric:
+ name: unmatched
+ min: 1
+expect:
+ - metric:
+ name: renamed.requests
+ type: sum
+ description: Renamed by a view
+ data_point:
+ attributes: {values: {route: /a}, strict: true}
+ - metric:
+ name: requests
+ count: 0
+ - metric:
+ name: should.not.apply
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/otel-config.yaml
new file mode 100644
index 00000000000..6e3f95ec242
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-propagator-absent
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/run.py
new file mode 100644
index 00000000000..303f1fa59bb
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/run.py
@@ -0,0 +1,18 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import baggage, propagate, trace
+
+tracer = trace.get_tracer(__name__)
+
+carrier: dict[str, str] = {}
+with tracer.start_as_current_span("outgoing"):
+ propagate.inject(carrier, context=baggage.set_baggage("user.id", "u-123"))
+
+extracted = propagate.extract(carrier)
+attributes = {
+ "carrier.keys": ",".join(sorted(carrier)) or "",
+ "baggage.user.id": str(baggage.get_baggage("user.id", extracted) or ""),
+}
+with tracer.start_as_current_span("incoming", context=extracted, attributes=attributes):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/scenario.yaml
new file mode 100644
index 00000000000..14d3155239c
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_absent/scenario.yaml
@@ -0,0 +1,14 @@
+description: >
+ Without a `propagator` section nothing is propagated.
+until:
+ - spans:
+ name: incoming
+expect:
+ - spans:
+ name: incoming
+ root: true
+ attributes:
+ values:
+ carrier.keys:
+ baggage.user.id:
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/otel-config.yaml
new file mode 100644
index 00000000000..604e664cf2f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/otel-config.yaml
@@ -0,0 +1,14 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-propagator-b3-single
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+propagator:
+ composite:
+ - b3:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/run.py
new file mode 100644
index 00000000000..303f1fa59bb
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/run.py
@@ -0,0 +1,18 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import baggage, propagate, trace
+
+tracer = trace.get_tracer(__name__)
+
+carrier: dict[str, str] = {}
+with tracer.start_as_current_span("outgoing"):
+ propagate.inject(carrier, context=baggage.set_baggage("user.id", "u-123"))
+
+extracted = propagate.extract(carrier)
+attributes = {
+ "carrier.keys": ",".join(sorted(carrier)) or "",
+ "baggage.user.id": str(baggage.get_baggage("user.id", extracted) or ""),
+}
+with tracer.start_as_current_span("incoming", context=extracted, attributes=attributes):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/scenario.yaml
new file mode 100644
index 00000000000..dcc1883acb8
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_b3_single/scenario.yaml
@@ -0,0 +1,14 @@
+description: >
+ `composite: [b3]` propagates the span context in the single `b3` header, without baggage.
+until:
+ - spans:
+ name: incoming
+expect:
+ - spans:
+ name: incoming
+ parent: {name: outgoing}
+ attributes:
+ values:
+ carrier.keys: b3
+ baggage.user.id:
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/otel-config.yaml
new file mode 100644
index 00000000000..71500618e5d
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-propagator-composite-list
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+propagator:
+ composite_list: b3multi,jaeger
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/run.py
new file mode 100644
index 00000000000..303f1fa59bb
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/run.py
@@ -0,0 +1,18 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import baggage, propagate, trace
+
+tracer = trace.get_tracer(__name__)
+
+carrier: dict[str, str] = {}
+with tracer.start_as_current_span("outgoing"):
+ propagate.inject(carrier, context=baggage.set_baggage("user.id", "u-123"))
+
+extracted = propagate.extract(carrier)
+attributes = {
+ "carrier.keys": ",".join(sorted(carrier)) or "",
+ "baggage.user.id": str(baggage.get_baggage("user.id", extracted) or ""),
+}
+with tracer.start_as_current_span("incoming", context=extracted, attributes=attributes):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/scenario.yaml
new file mode 100644
index 00000000000..fe3312bdd39
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_composite_list/scenario.yaml
@@ -0,0 +1,14 @@
+description: >
+ `composite_list` loads b3multi and jaeger propagators through entry points.
+until:
+ - spans:
+ name: incoming
+expect:
+ - spans:
+ name: incoming
+ parent: {name: outgoing}
+ attributes:
+ values:
+ carrier.keys: uber-trace-id,uberctx-user.id,x-b3-sampled,x-b3-spanid,x-b3-traceid
+ baggage.user.id: u-123
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/otel-config.yaml
new file mode 100644
index 00000000000..0cf8d368ca6
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/otel-config.yaml
@@ -0,0 +1,15 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-propagator-tracecontext-baggage
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+propagator:
+ composite:
+ - tracecontext:
+ - baggage:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/run.py
new file mode 100644
index 00000000000..303f1fa59bb
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/run.py
@@ -0,0 +1,18 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import baggage, propagate, trace
+
+tracer = trace.get_tracer(__name__)
+
+carrier: dict[str, str] = {}
+with tracer.start_as_current_span("outgoing"):
+ propagate.inject(carrier, context=baggage.set_baggage("user.id", "u-123"))
+
+extracted = propagate.extract(carrier)
+attributes = {
+ "carrier.keys": ",".join(sorted(carrier)) or "",
+ "baggage.user.id": str(baggage.get_baggage("user.id", extracted) or ""),
+}
+with tracer.start_as_current_span("incoming", context=extracted, attributes=attributes):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/scenario.yaml
new file mode 100644
index 00000000000..ca89c0faafa
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/propagator_tracecontext_baggage/scenario.yaml
@@ -0,0 +1,14 @@
+description: >
+ `composite: [tracecontext, baggage]` propagates the span context and baggage.
+until:
+ - spans:
+ name: incoming
+expect:
+ - spans:
+ name: incoming
+ parent: {name: outgoing}
+ attributes:
+ values:
+ carrier.keys: baggage,traceparent
+ baggage.user.id: u-123
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/otel-config.yaml
new file mode 100644
index 00000000000..343ca79c8d9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/otel-config.yaml
@@ -0,0 +1,12 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: list.conflict
+ value: from-attributes
+ attributes_list: service.name=declconfig-resource-attributes-list,list.encoded=hello%20world,list.number=5,list.conflict=from-list
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/run.py
new file mode 100644
index 00000000000..3d3644d0f63
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("list-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/scenario.yaml
new file mode 100644
index 00000000000..87a2c1ba054
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_list/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ `attributes_list` adds URL-decoded string attributes; `attributes` wins on conflicts.
+until:
+ - spans:
+ name: list-span
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: declconfig-resource-attributes-list
+ list.encoded: hello world
+ list.number: "5"
+ list.conflict: from-attributes
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/otel-config.yaml
new file mode 100644
index 00000000000..adf1be40344
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/otel-config.yaml
@@ -0,0 +1,43 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-resource-attributes-typed
+ - name: typed.string
+ value: 12
+ type: string
+ - name: typed.bool
+ value: "false"
+ type: bool
+ - name: typed.int
+ value: "12"
+ type: int
+ - name: typed.double
+ value: 12
+ type: double
+ - name: typed.string_array
+ value: [1, 2]
+ type: string_array
+ - name: typed.bool_array
+ value: [true, false]
+ type: bool_array
+ - name: typed.int_array
+ value: [1, 2]
+ type: int_array
+ - name: typed.double_array
+ value: [1, 2.5]
+ type: double_array
+ - name: untyped.string
+ value: abc
+ - name: untyped.int
+ value: 3
+ - name: untyped.double
+ value: 2.5
+ - name: untyped.bool
+ value: true
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/run.py
new file mode 100644
index 00000000000..33ab4ff9f30
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("typed-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/scenario.yaml
new file mode 100644
index 00000000000..03f7b43a3d7
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_attributes_typed/scenario.yaml
@@ -0,0 +1,22 @@
+description: >
+ Resource attributes are coerced to their declared `type`; untyped values keep their YAML type.
+until:
+ - spans:
+ name: typed-span
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: declconfig-resource-attributes-typed
+ typed.string: "12"
+ typed.bool: false
+ typed.int: 12
+ typed.double: 12.0
+ typed.string_array: ["1", "2"]
+ typed.bool_array: [true, false]
+ typed.int_array: [1, 2]
+ typed.double_array: [1.0, 2.5]
+ untyped.string: abc
+ untyped.int: 3
+ untyped.double: 2.5
+ untyped.bool: true
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/otel-config.yaml
new file mode 100644
index 00000000000..4bfbf278e29
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/otel-config.yaml
@@ -0,0 +1,7 @@
+file_format: "1.0"
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/run.py
new file mode 100644
index 00000000000..2a46c7f4067
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("default-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/scenario.yaml
new file mode 100644
index 00000000000..fb40edda6c3
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_default/scenario.yaml
@@ -0,0 +1,13 @@
+description: >
+ Without a `resource` section, the resource has the SDK attributes and a default service name.
+until:
+ - spans:
+ name: default-span
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: {pattern: "^unknown_service"}
+ telemetry.sdk.language: python
+ telemetry.sdk.name: opentelemetry
+ telemetry.sdk.version: {exists: true}
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/otel-config.yaml
new file mode 100644
index 00000000000..d23695d714f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/otel-config.yaml
@@ -0,0 +1,22 @@
+file_format: "1.0"
+resource:
+ detection/development:
+ detectors:
+ - service:
+ - host:
+ - process:
+ - os:
+ attributes:
+ included:
+ - service.*
+ - host.*
+ - process.*
+ - os.*
+ excluded:
+ - process.command*
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/run.py
new file mode 100644
index 00000000000..c5a78623f06
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("detected-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/scenario.yaml
new file mode 100644
index 00000000000..2c3b5aac4e0
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_detectors/scenario.yaml
@@ -0,0 +1,21 @@
+description: >
+ `detection/development` runs the service, host, process and os detectors, filtered by
+ `included`/`excluded` globs.
+env:
+ OTEL_SERVICE_NAME: declconfig-resource-detectors
+until:
+ - spans:
+ name: detected-span
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: declconfig-resource-detectors
+ service.instance.id: {exists: true}
+ host.name: {exists: true}
+ process.pid: {exists: true}
+ os.type: {exists: true}
+ process.command: {exists: false}
+ process.command_args: {exists: false}
+ process.command_line: {exists: false}
+ telemetry.sdk.language: python
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/otel-config.yaml
new file mode 100644
index 00000000000..316c0f75955
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/otel-config.yaml
@@ -0,0 +1,12 @@
+file_format: "1.0"
+resource:
+ schema_url: https://opentelemetry.io/schemas/1.26.0
+ attributes:
+ - name: service.name
+ value: declconfig-resource-schema-url
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/run.py
new file mode 100644
index 00000000000..4a85347d619
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("schema-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/scenario.yaml
new file mode 100644
index 00000000000..38830c8fe9a
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/resource_schema_url/scenario.yaml
@@ -0,0 +1,11 @@
+description: >
+ `resource.schema_url` is exported with the resource.
+until:
+ - spans:
+ name: schema-span
+expect:
+ - resource:
+ schema_url: https://opentelemetry.io/schemas/1.26.0
+ attributes:
+ values:
+ service.name: declconfig-resource-schema-url
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/otel-config.yaml
new file mode 100644
index 00000000000..be9c73f3644
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/otel-config.yaml
@@ -0,0 +1,17 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-batch-otlp-http
+tracer_provider:
+ processors:
+ - batch:
+ schedule_delay: 100
+ export_timeout: 5000
+ max_queue_size: 64
+ max_export_batch_size: 8
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ id_generator:
+ random:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/run.py
new file mode 100644
index 00000000000..4cc12539aa2
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/run.py
@@ -0,0 +1,10 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+for _ in range(20):
+ with tracer.start_as_current_span("batch-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/scenario.yaml
new file mode 100644
index 00000000000..b877dd7cc2e
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_batch_otlp_http/scenario.yaml
@@ -0,0 +1,19 @@
+description: >
+ Exports spans over OTLP/HTTP through a batch span processor with every batch setting.
+until:
+ - spans:
+ min_count: 20
+ name: batch-span
+expect:
+ - resource:
+ attributes:
+ values:
+ service.name: declconfig-trace-batch-otlp-http
+ telemetry.sdk.language: python
+ - spans:
+ name: batch-span
+ kind: INTERNAL
+ root: true
+ scope:
+ name: __main__
+ count: 20
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/otel-config.yaml
new file mode 100644
index 00000000000..a6ff5244541
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/otel-config.yaml
@@ -0,0 +1,10 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-console
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ console:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/run.py
new file mode 100644
index 00000000000..9ac3249010e
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("console-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/scenario.yaml
new file mode 100644
index 00000000000..ad5d571dcf3
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_console/scenario.yaml
@@ -0,0 +1,8 @@
+description: >
+ The console span exporter writes spans to stdout.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stdout
+ pattern: '"name": "console-span"'
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/otel-config.yaml
new file mode 100644
index 00000000000..b96c6f93bea
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/otel-config.yaml
@@ -0,0 +1,15 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-multiple-processors
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ - batch:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/run.py
new file mode 100644
index 00000000000..8ee336a5ad9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("multi-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/scenario.yaml
new file mode 100644
index 00000000000..e54f3db1c30
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_multiple_processors/scenario.yaml
@@ -0,0 +1,10 @@
+description: >
+ Every configured span processor receives each span.
+until:
+ - spans:
+ min_count: 2
+ name: multi-span
+expect:
+ - spans:
+ name: multi-span
+ count: 2
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/otel-config.yaml
new file mode 100644
index 00000000000..b15a4259cb9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-otlp-file-stdout
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_file/development:
+ output_stream: stdout
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/run.py
new file mode 100644
index 00000000000..81b24bb8c90
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("file-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/scenario.yaml
new file mode 100644
index 00000000000..9f50ec1fdc9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_file_stdout/scenario.yaml
@@ -0,0 +1,8 @@
+description: >
+ The `otlp_file/development` span exporter writes OTLP JSON to stdout.
+until:
+ - exited: {}
+expect:
+ - output:
+ stream: stdout
+ pattern: '"name": ?"file-span"'
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/otel-config.yaml
new file mode 100644
index 00000000000..68f490b3d6c
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/otel-config.yaml
@@ -0,0 +1,16 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-otlp-grpc
+tracer_provider:
+ processors:
+ - batch:
+ exporter:
+ otlp_grpc:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4317
+ compression: gzip
+ timeout: 5000
+ headers:
+ - name: x-declconfig
+ value: header
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/run.py
new file mode 100644
index 00000000000..c7b03e9466a
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("grpc-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/scenario.yaml
new file mode 100644
index 00000000000..cb5d6e584a4
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_grpc/scenario.yaml
@@ -0,0 +1,9 @@
+description: >
+ Exports a span over OTLP/gRPC with gzip compression, headers and a timeout.
+until:
+ - spans:
+ name: grpc-span
+expect:
+ - spans:
+ name: grpc-span
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/otel-config.yaml
new file mode 100644
index 00000000000..f9b06844719
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/otel-config.yaml
@@ -0,0 +1,17 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-otlp-http-options
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ compression: gzip
+ timeout: 5000
+ headers:
+ - name: x-declconfig
+ value: header
+ headers_list: x-declconfig-list=list
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/run.py
new file mode 100644
index 00000000000..58a303a3417
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("options-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/scenario.yaml
new file mode 100644
index 00000000000..714b7348e3d
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_otlp_http_options/scenario.yaml
@@ -0,0 +1,9 @@
+description: >
+ The OTLP/HTTP exporter accepts gzip compression, headers, headers_list and a timeout.
+until:
+ - spans:
+ name: options-span
+expect:
+ - spans:
+ name: options-span
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/otel-config.yaml
new file mode 100644
index 00000000000..4ed89cd5d9b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/otel-config.yaml
@@ -0,0 +1,13 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-sampler-always-off
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ sampler:
+ always_off:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/run.py
new file mode 100644
index 00000000000..7b0c19ded8b
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("dropped-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/scenario.yaml
new file mode 100644
index 00000000000..7fb72a169d0
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_always_off/scenario.yaml
@@ -0,0 +1,7 @@
+description: >
+ The `always_off` sampler drops every span.
+until:
+ - exited: {}
+expect:
+ - spans:
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/otel-config.yaml
new file mode 100644
index 00000000000..d4979262eca
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/otel-config.yaml
@@ -0,0 +1,31 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-sampler-composite-probability
+tracer_provider:
+ processors:
+ - batch:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ sampler:
+ composite/development:
+ rule_based:
+ rules:
+ - attribute_values:
+ key: sample
+ values: [never]
+ sampler:
+ probability:
+ ratio: 0
+ - attribute_values:
+ key: sample
+ values: [always]
+ sampler:
+ probability:
+ ratio: 1
+ - sampler:
+ parent_threshold:
+ root:
+ always_off:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/run.py
new file mode 100644
index 00000000000..c057528bc45
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/run.py
@@ -0,0 +1,17 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+for _ in range(10):
+ with tracer.start_as_current_span("never", attributes={"sample": "never"}):
+ pass
+for index in range(10):
+ with tracer.start_as_current_span("always", attributes={"sample": "always"}):
+ if index == 0:
+ with tracer.start_as_current_span("threshold-child"):
+ pass
+with tracer.start_as_current_span("threshold-root"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/scenario.yaml
new file mode 100644
index 00000000000..e9a398ba6a9
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_probability/scenario.yaml
@@ -0,0 +1,19 @@
+description: >
+ `composite/development` probability samplers with ratios 0 and 1, and `parent_threshold`
+ following the parent's decision and using its `root` sampler for root spans.
+until:
+ - exited: {}
+expect:
+ - spans:
+ name: never
+ count: 0
+ - spans:
+ name: always
+ count: 10
+ - spans:
+ name: threshold-root
+ count: 0
+ - spans:
+ name: threshold-child
+ parent: {name: always}
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/otel-config.yaml
new file mode 100644
index 00000000000..46e3d10d0e8
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/otel-config.yaml
@@ -0,0 +1,34 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-sampler-composite-rule-based
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ sampler:
+ composite/development:
+ rule_based:
+ rules:
+ - attribute_values:
+ key: route
+ values: [/health]
+ sampler:
+ always_off:
+ - attribute_patterns:
+ key: route
+ included: [/api/*]
+ excluded: [/api/internal*]
+ sampler:
+ always_on:
+ - span_kinds: [server]
+ sampler:
+ always_on:
+ - parent: [local]
+ sampler:
+ always_on:
+ - sampler:
+ always_off:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/run.py
new file mode 100644
index 00000000000..759078fc07e
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/run.py
@@ -0,0 +1,19 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+from opentelemetry.trace import SpanKind
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("health", kind=SpanKind.SERVER, attributes={"route": "/health"}):
+ pass
+with tracer.start_as_current_span("api", attributes={"route": "/api/users"}):
+ with tracer.start_as_current_span("child"):
+ pass
+with tracer.start_as_current_span("api-internal", attributes={"route": "/api/internal/x"}):
+ pass
+with tracer.start_as_current_span("server", kind=SpanKind.SERVER):
+ pass
+with tracer.start_as_current_span("other"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/scenario.yaml
new file mode 100644
index 00000000000..37124597ee0
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_composite_rule_based/scenario.yaml
@@ -0,0 +1,25 @@
+description: >
+ `composite/development` with `rule_based` rules on attribute values, attribute patterns,
+ span kinds and parent, falling back to `always_off`.
+until:
+ - exited: {}
+expect:
+ - spans:
+ name: health
+ count: 0
+ - spans:
+ name: api
+ count: 1
+ - spans:
+ name: api-internal
+ count: 0
+ - spans:
+ name: server
+ count: 1
+ - spans:
+ name: other
+ count: 0
+ - spans:
+ name: child
+ parent: {name: api}
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/otel-config.yaml
new file mode 100644
index 00000000000..713e7f02f34
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/otel-config.yaml
@@ -0,0 +1,23 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-sampler-parent-based
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ sampler:
+ parent_based:
+ root:
+ always_on:
+ remote_parent_sampled:
+ always_off:
+ remote_parent_not_sampled:
+ always_on:
+ local_parent_sampled:
+ always_off:
+ local_parent_not_sampled:
+ always_on:
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/run.py
new file mode 100644
index 00000000000..a33896dd261
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/run.py
@@ -0,0 +1,30 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
+
+tracer = trace.get_tracer(__name__)
+
+
+def parent_context(sampled: bool, remote: bool):
+ span_context = SpanContext(
+ trace_id=0x0AF7651916CD43DD8448EB211C80319C,
+ span_id=0xB7AD6B7169203331,
+ is_remote=remote,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED if sampled else TraceFlags.DEFAULT),
+ )
+ return trace.set_span_in_context(NonRecordingSpan(span_context))
+
+
+with tracer.start_as_current_span("root"):
+ with tracer.start_as_current_span("local-sampled-child"):
+ pass
+
+for name, sampled, remote in [
+ ("local-unsampled-child", False, False),
+ ("remote-sampled-child", True, True),
+ ("remote-unsampled-child", False, True),
+]:
+ with tracer.start_as_current_span(name, context=parent_context(sampled, remote)):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/scenario.yaml
new file mode 100644
index 00000000000..10514f7e462
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_parent_based/scenario.yaml
@@ -0,0 +1,21 @@
+description: >
+ `parent_based` applies each configured sub-sampler; every one is the opposite of its default.
+until:
+ - exited: {}
+expect:
+ - spans:
+ name: root
+ root: true
+ count: 1
+ - spans:
+ name: local-sampled-child
+ count: 0
+ - spans:
+ name: local-unsampled-child
+ count: 1
+ - spans:
+ name: remote-sampled-child
+ count: 0
+ - spans:
+ name: remote-unsampled-child
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/otel-config.yaml
new file mode 100644
index 00000000000..ffef8501164
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/otel-config.yaml
@@ -0,0 +1,14 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-sampler-trace-id-ratio
+tracer_provider:
+ processors:
+ - batch:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ sampler:
+ trace_id_ratio_based:
+ ratio: 0.5
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/run.py
new file mode 100644
index 00000000000..27605cb9f71
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/run.py
@@ -0,0 +1,10 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+for _ in range(200):
+ with tracer.start_as_current_span("ratio-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/scenario.yaml
new file mode 100644
index 00000000000..62bb1bd5634
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_sampler_trace_id_ratio/scenario.yaml
@@ -0,0 +1,9 @@
+description: >
+ `trace_id_ratio_based` with ratio 0.5 samples about half of 200 root spans.
+until:
+ - exited: {}
+expect:
+ - spans:
+ name: ratio-span
+ min_count: 60
+ max_count: 140
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/otel-config.yaml
new file mode 100644
index 00000000000..02d5ba40d23
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/otel-config.yaml
@@ -0,0 +1,11 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-simple-otlp-http
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/run.py
new file mode 100644
index 00000000000..cac0e497404
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("simple-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/scenario.yaml
new file mode 100644
index 00000000000..4b17a73d348
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_simple_otlp_http/scenario.yaml
@@ -0,0 +1,9 @@
+description: >
+ Exports a span over OTLP/HTTP through a simple span processor.
+until:
+ - spans:
+ name: simple-span
+expect:
+ - spans:
+ name: simple-span
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/otel-config.yaml
new file mode 100644
index 00000000000..f3bd2145d91
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/otel-config.yaml
@@ -0,0 +1,19 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-span-limits
+attribute_limits:
+ attribute_count_limit: 10
+ attribute_value_length_limit: 100
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ limits:
+ attribute_count_limit: 2
+ attribute_value_length_limit: 4
+ link_count_limit: 1
+ link_attribute_count_limit: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/run.py
new file mode 100644
index 00000000000..ba8a7dfa6b6
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/run.py
@@ -0,0 +1,18 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+from opentelemetry.trace import Link
+
+tracer = trace.get_tracer(__name__)
+
+with tracer.start_as_current_span("target") as target:
+ pass
+
+# Once a count limit is reached, the oldest attribute or link is dropped.
+links = [
+ Link(target.get_span_context(), {"z": 3}),
+ Link(target.get_span_context(), {"x": 1, "y": 2}),
+]
+with tracer.start_as_current_span("limited-span", attributes={"dropped": 0, "a": "abcdefgh", "b": 1}, links=links):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/scenario.yaml
new file mode 100644
index 00000000000..3c97114613a
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_span_limits/scenario.yaml
@@ -0,0 +1,22 @@
+description: >
+ Span `limits` cap attribute count and length, link count and link attribute count, overriding
+ the global `attribute_limits`.
+until:
+ - spans:
+ name: limited-span
+expect:
+ - spans:
+ name: limited-span
+ attributes:
+ values:
+ a: abcd
+ b: 1
+ strict: true
+ link_count: 1
+ links:
+ - span: {name: target}
+ attributes:
+ values:
+ y: 2
+ strict: true
+ count: 1
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/otel-config.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/otel-config.yaml
new file mode 100644
index 00000000000..e8dd16ad15a
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/otel-config.yaml
@@ -0,0 +1,18 @@
+file_format: "1.0"
+resource:
+ attributes:
+ - name: service.name
+ value: declconfig-trace-tracer-configurator
+tracer_provider:
+ processors:
+ - simple:
+ exporter:
+ otlp_http:
+ endpoint: http://${OTEL_COLLECTOR_ENDPOINT}:4318/v1/traces
+ tracer_configurator/development:
+ default_config:
+ enabled: true
+ tracers:
+ - name: disabled.*
+ config:
+ enabled: false
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/run.py b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/run.py
new file mode 100644
index 00000000000..26864c3d55c
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/run.py
@@ -0,0 +1,9 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from opentelemetry import trace
+
+with trace.get_tracer("enabled.lib").start_as_current_span("enabled-span"):
+ pass
+with trace.get_tracer("disabled.lib").start_as_current_span("disabled-span"):
+ pass
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/scenario.yaml b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/scenario.yaml
new file mode 100644
index 00000000000..1cf9add1fa1
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/scenarios/trace_tracer_configurator/scenario.yaml
@@ -0,0 +1,12 @@
+description: >
+ `tracer_configurator/development` disables tracers whose name matches a glob.
+until:
+ - exited: {}
+expect:
+ - spans:
+ name: enabled-span
+ scope: {name: enabled.lib}
+ count: 1
+ - spans:
+ name: disabled-span
+ count: 0
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/test_scenario_schema.py b/tests/opentelemetry-docker-tests/tests/declconfig/test_scenario_schema.py
new file mode 100644
index 00000000000..89ab0909ecf
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/test_scenario_schema.py
@@ -0,0 +1,798 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+from unittest import TestCase
+
+import jsonschema
+
+from opentelemetry.proto.common.v1.common_pb2 import (
+ AnyValue,
+ ArrayValue,
+ InstrumentationScope,
+ KeyValue,
+ KeyValueList,
+)
+from opentelemetry.proto.logs.v1.logs_pb2 import LogRecord, SeverityNumber
+from opentelemetry.proto.metrics.v1.metrics_pb2 import (
+ AggregationTemporality,
+ Exemplar,
+ Gauge,
+ Histogram,
+ HistogramDataPoint,
+ Metric,
+ NumberDataPoint,
+ Sum,
+)
+from opentelemetry.proto.resource.v1.resource_pb2 import Resource
+from opentelemetry.proto.trace.v1.trace_pb2 import Span, Status
+from opentelemetry.test._otlp_test_server import (
+ RecordedLogRecord,
+ RecordedMetric,
+ RecordedSpan,
+)
+
+from ._expect import (
+ AttributesMatcher,
+ Counts,
+ DataPointMatcher,
+ LinkMatcher,
+ LiteralMatcher,
+ LogMatcher,
+ LogsExpectation,
+ MetricExpectation,
+ MetricMatcher,
+ Mismatch,
+ ResourceExpectation,
+ ScopeMatcher,
+ SpanMatcher,
+ SpansExpectation,
+ ValueMatcher,
+ _describe_value,
+ parse_value_matcher,
+)
+from ._scenario import (
+ _SCHEMA_FILE,
+ ExitedCondition,
+ LogsCondition,
+ MetricCondition,
+ Received,
+ SpansCondition,
+ load_scenario,
+ parse_scenario,
+)
+
+_SCENARIOS_DIR = Path(__file__).parent / "scenarios"
+_TRACE_ID = b"\x0a" * 16
+_OUTER_SPAN_ID = b"\x01" * 8
+_INNER_SPAN_ID = b"\x02" * 8
+
+
+def _build_attributes(**attributes: AnyValue) -> list[KeyValue]:
+ return [KeyValue(key=key, value=value) for key, value in attributes.items()]
+
+
+def _build_span(
+ name: str,
+ resource: Resource | None = None,
+ scope: InstrumentationScope | None = None,
+ **span_fields: Any,
+) -> RecordedSpan:
+ return RecordedSpan(
+ span=Span(name=name, **span_fields),
+ resource=resource or Resource(),
+ scope=scope or InstrumentationScope(),
+ )
+
+
+def _build_metric(
+ metric: Metric, resource: Resource | None = None, scope: InstrumentationScope | None = None
+) -> RecordedMetric:
+ return RecordedMetric(metric=metric, resource=resource or Resource(), scope=scope or InstrumentationScope())
+
+
+def _build_log(
+ body: str,
+ resource: Resource | None = None,
+ scope: InstrumentationScope | None = None,
+ **log_fields: Any,
+) -> RecordedLogRecord:
+ return RecordedLogRecord(
+ log_record=LogRecord(body=AnyValue(string_value=body), **log_fields),
+ resource=resource or Resource(),
+ scope=scope or InstrumentationScope(),
+ )
+
+
+def _build_scenario_doc(**overrides: Any) -> dict[str, Any]:
+ return {"description": "test", "until": [{"spans": {}}], **overrides}
+
+
+def _get_failures(expect: list[dict[str, Any]], received: Received) -> list[str]:
+ failures = parse_scenario(_build_scenario_doc(expect=expect)).failed_expectations(received)
+ return [str(failure) for failure in failures]
+
+
+class TestScenarioSchema(TestCase):
+ def test_bundled_scenarios_load(self):
+ for path in sorted(_SCENARIOS_DIR.glob("*/scenario.yaml")):
+ with self.subTest(scenario=path.parent.name):
+ load_scenario(path)
+
+ def test_defaults(self):
+ scenario = parse_scenario(
+ _build_scenario_doc(until=[{"spans": {}}, {"metric": {"name": "m", "min": 1}}, {"logs": {}}])
+ )
+ self.assertEqual(scenario.timeout, 30.0)
+ self.assertEqual(scenario.env, {})
+ self.assertEqual(
+ scenario.until,
+ [SpansCondition(), MetricCondition(name="m", min=1), LogsCondition()],
+ )
+
+ def test_parses_conditions(self):
+ scenario = parse_scenario(
+ _build_scenario_doc(
+ timeout=5,
+ env={"NAME": "value"},
+ until=[
+ {"spans": {"min_count": 2, "name": "s"}},
+ {"metric": {"name": "m", "field": "count", "min": 3}},
+ {"logs": {"min_count": 4, "body_pattern": "a.*b"}},
+ {"exited": {}},
+ ],
+ )
+ )
+ self.assertEqual(scenario.timeout, 5.0)
+ self.assertEqual(scenario.env, {"NAME": "value"})
+ self.assertEqual(
+ scenario.until,
+ [
+ SpansCondition(min_count=2, name="s"),
+ MetricCondition(name="m", min=3, field="count"),
+ LogsCondition(min_count=4, body_pattern=re.compile("a.*b")),
+ ExitedCondition(),
+ ],
+ )
+
+ def test_schema_is_valid(self):
+ with open(_SCHEMA_FILE, encoding="utf-8") as schema_file:
+ jsonschema.Draft202012Validator.check_schema(json.load(schema_file))
+
+ def test_parses_expectations(self):
+ scenario = parse_scenario(
+ _build_scenario_doc(
+ expect=[
+ {"resource": {"attributes": {"values": {"service.name": "svc"}, "strict": True}}},
+ {
+ "spans": {
+ "name": {"pattern": "^GET "},
+ "kind": "SERVER",
+ "status": {"code": "ERROR", "message": "boom"},
+ "parent": {"name": "outer", "root": True},
+ "links": [
+ {"trace_state": "k=v", "span": {"name": "producer"}, "attributes": {"values": {"n": 1}}}
+ ],
+ "scope": {"name": "lib", "version": {"exists": True}},
+ "count": 2,
+ }
+ },
+ {
+ "metric": {
+ "name": "requests",
+ "type": "sum",
+ "temporality": "CUMULATIVE",
+ "monotonic": True,
+ "data_point": {
+ "attributes": {"values": {"route": "/a"}},
+ "value": {"gte": 3, "lt": 10},
+ "explicit_bounds": [0, 5],
+ },
+ "max_count": 4,
+ }
+ },
+ {
+ "logs": {
+ "body": {"pattern": "user .* in"},
+ "severity_number": "WARN",
+ "event_name": "login",
+ "trace_context": True,
+ "span": {"name": "outer"},
+ "attributes": {"values": {"absent": {"exists": False}, "ratio": {"double": 1}}},
+ "count": 0,
+ }
+ },
+ ]
+ )
+ )
+ self.assertEqual(
+ scenario.expect,
+ [
+ ResourceExpectation(
+ attributes=AttributesMatcher(matchers={"service.name": ValueMatcher(equals="svc")}, strict=True)
+ ),
+ SpansExpectation(
+ matcher=SpanMatcher(
+ name=ValueMatcher(pattern=re.compile("^GET ")),
+ kind=LiteralMatcher("SERVER"),
+ status_code=LiteralMatcher("ERROR"),
+ status_message=ValueMatcher(equals="boom"),
+ parent=SpanMatcher(name=ValueMatcher(equals="outer"), root=LiteralMatcher(True)),
+ links=(
+ LinkMatcher(
+ trace_state=ValueMatcher(equals="k=v"),
+ span=SpanMatcher(name=ValueMatcher(equals="producer")),
+ attributes=AttributesMatcher(matchers={"n": ValueMatcher(equals=1)}),
+ ),
+ ),
+ scope=ScopeMatcher(name=ValueMatcher(equals="lib"), version=ValueMatcher(exists=True)),
+ ),
+ counts=Counts(count=2),
+ ),
+ MetricExpectation(
+ matcher=MetricMatcher(
+ name=ValueMatcher(equals="requests"),
+ type=LiteralMatcher("sum"),
+ temporality=LiteralMatcher("CUMULATIVE"),
+ monotonic=LiteralMatcher(True),
+ data_point=DataPointMatcher(
+ value=ValueMatcher(bounds=(("gte", 3), ("lt", 10))),
+ explicit_bounds=LiteralMatcher([0, 5]),
+ attributes=AttributesMatcher(matchers={"route": ValueMatcher(equals="/a")}),
+ ),
+ ),
+ counts=Counts(max_count=4),
+ ),
+ LogsExpectation(
+ matcher=LogMatcher(
+ body=ValueMatcher(pattern=re.compile("user .* in")),
+ severity_number=LiteralMatcher("WARN"),
+ event_name=ValueMatcher(equals="login"),
+ trace_context=LiteralMatcher(True),
+ span=SpanMatcher(name=ValueMatcher(equals="outer")),
+ attributes=AttributesMatcher(
+ matchers={
+ "absent": ValueMatcher(exists=False),
+ "ratio": ValueMatcher(equals=1.0),
+ }
+ ),
+ ),
+ counts=Counts(count=0),
+ ),
+ ],
+ )
+
+ def test_invalid_documents(self):
+ cases = {
+ "missing description": {"until": [{"spans": {}}]},
+ "missing until": {"description": "test"},
+ "empty until": _build_scenario_doc(until=[]),
+ "unknown top-level key": _build_scenario_doc(extra=1),
+ "non-positive timeout": _build_scenario_doc(timeout=0),
+ "unknown signal": _build_scenario_doc(until=[{"traces": {}}]),
+ "two signals in one item": _build_scenario_doc(until=[{"spans": {}, "logs": {}}]),
+ "unknown condition key": _build_scenario_doc(until=[{"spans": {"count": 1}}]),
+ "zero min_count": _build_scenario_doc(until=[{"spans": {"min_count": 0}}]),
+ "metric missing min": _build_scenario_doc(until=[{"metric": {"name": "m"}}]),
+ "bad metric field": _build_scenario_doc(until=[{"metric": {"name": "m", "field": "max", "min": 1}}]),
+ "unknown expect signal": _build_scenario_doc(expect=[{"traces": {}}]),
+ "two signals in one expect item": _build_scenario_doc(expect=[{"spans": {}, "logs": {}}]),
+ "unknown span field": _build_scenario_doc(expect=[{"spans": {"events": []}}]),
+ "count with min_count": _build_scenario_doc(expect=[{"spans": {"count": 1, "min_count": 1}}]),
+ "count with max_count": _build_scenario_doc(expect=[{"logs": {"count": 1, "max_count": 1}}]),
+ "zero max_count": _build_scenario_doc(expect=[{"logs": {"max_count": 0}}]),
+ "count on nested span matcher": _build_scenario_doc(expect=[{"spans": {"parent": {"count": 1}}}]),
+ "root with parent": _build_scenario_doc(expect=[{"spans": {"root": True, "parent": {}}}]),
+ "bad span kind": _build_scenario_doc(expect=[{"spans": {"kind": "server"}}]),
+ "bad status code": _build_scenario_doc(expect=[{"spans": {"status": {"code": "FAILED"}}}]),
+ "bad severity": _build_scenario_doc(expect=[{"logs": {"severity_number": "WARNING"}}]),
+ "bad temporality": _build_scenario_doc(expect=[{"metric": {"temporality": "cumulative"}}]),
+ "bad metric type": _build_scenario_doc(expect=[{"metric": {"type": "counter"}}]),
+ "unknown data point field": _build_scenario_doc(expect=[{"metric": {"data_point": {"buckets": []}}}]),
+ "string data point value": _build_scenario_doc(expect=[{"metric": {"data_point": {"value": "3"}}}]),
+ "pattern on data point value": _build_scenario_doc(
+ expect=[{"metric": {"data_point": {"value": {"pattern": "3"}}}}]
+ ),
+ "exists false with other operators": _build_scenario_doc(
+ expect=[{"spans": {"attributes": {"values": {"a": {"exists": False, "pattern": "x"}}}}}]
+ ),
+ "typed literal with two types": _build_scenario_doc(
+ expect=[{"spans": {"attributes": {"values": {"a": {"int": 1, "double": 1.0}}}}}]
+ ),
+ "typed literal with operator": _build_scenario_doc(
+ expect=[{"spans": {"attributes": {"values": {"a": {"int": 1, "gt": 0}}}}}]
+ ),
+ "wrongly typed literal": _build_scenario_doc(
+ expect=[{"spans": {"attributes": {"values": {"a": {"int": "1"}}}}}]
+ ),
+ "empty operators": _build_scenario_doc(expect=[{"spans": {"attributes": {"values": {"a": {}}}}}]),
+ "empty resource": _build_scenario_doc(expect=[{"resource": {}}]),
+ "resource with count": _build_scenario_doc(
+ expect=[{"resource": {"attributes": {"values": {}}, "count": 1}}]
+ ),
+ "empty attributes": _build_scenario_doc(expect=[{"spans": {"attributes": {}}}]),
+ "unwrapped attributes": _build_scenario_doc(expect=[{"spans": {"attributes": {"a": 1}}}]),
+ "unknown scope field": _build_scenario_doc(expect=[{"spans": {"scope": {"schema_url": "x"}}}]),
+ "non-string env value": _build_scenario_doc(env={"N": 1}),
+ "exited with fields": _build_scenario_doc(until=[{"exited": {"code": 0}}]),
+ "output without pattern": _build_scenario_doc(expect=[{"output": {"stream": "stdout"}}]),
+ "bad output stream": _build_scenario_doc(expect=[{"output": {"stream": "stdin", "pattern": "x"}}]),
+ "string link_count": _build_scenario_doc(expect=[{"spans": {"link_count": "1"}}]),
+ "pattern on exemplar_count": _build_scenario_doc(
+ expect=[{"metric": {"data_point": {"exemplar_count": {"pattern": "1"}}}}]
+ ),
+ }
+ for case, document in cases.items():
+ with self.subTest(case=case), self.assertRaises(jsonschema.ValidationError):
+ parse_scenario(document)
+
+ def test_invalid_body_pattern(self):
+ with self.assertRaises(re.error):
+ parse_scenario(_build_scenario_doc(until=[{"logs": {"body_pattern": "("}}]))
+
+ def test_invalid_expect_pattern(self):
+ with self.assertRaises(re.error):
+ parse_scenario(_build_scenario_doc(expect=[{"spans": {"name": {"pattern": "("}}}]))
+
+
+class TestConditions(TestCase):
+ def test_spans(self):
+ received = Received(spans=[_build_span("a"), _build_span("b"), _build_span("a")])
+ cases = [
+ (SpansCondition(min_count=3), True, "spans(name=None): 3/3"),
+ (SpansCondition(min_count=4), False, "spans(name=None): 3/4"),
+ (SpansCondition(min_count=2, name="a"), True, "spans(name='a'): 2/2"),
+ (SpansCondition(name="c"), False, "spans(name='c'): 0/1"),
+ ]
+ for condition, met, progress in cases:
+ with self.subTest(condition=condition):
+ self.assertEqual(condition.satisfied(received), met)
+ self.assertEqual(condition.progress(received), progress)
+
+ def test_metrics(self):
+ counter = Metric(
+ name="counter",
+ sum=Sum(data_points=[NumberDataPoint(as_int=2), NumberDataPoint(as_int=5)]),
+ )
+ gauge = Metric(name="gauge", gauge=Gauge(data_points=[NumberDataPoint(as_double=1.5)]))
+ histogram = Metric(
+ name="histogram",
+ histogram=Histogram(data_points=[HistogramDataPoint(count=4, sum=10.0)]),
+ )
+ received = Received(metrics=[_build_metric(counter), _build_metric(gauge), _build_metric(histogram)])
+ cases = [
+ (MetricCondition(name="counter", min=5), True),
+ (MetricCondition(name="counter", min=6), False),
+ (MetricCondition(name="gauge", min=1.5), True),
+ (MetricCondition(name="histogram", field="count", min=4), True),
+ (MetricCondition(name="histogram", field="sum", min=10.5), False),
+ (MetricCondition(name="missing", min=0), False),
+ ]
+ for condition, met in cases:
+ with self.subTest(condition=condition):
+ self.assertEqual(condition.satisfied(received), met)
+
+ self.assertEqual(
+ MetricCondition(name="missing", min=0).progress(received),
+ "metric(name='missing', field='value'): max None (need >= 0)",
+ )
+
+ def test_metric_field_mismatch(self):
+ received = Received(
+ metrics=[_build_metric(Metric(name="gauge", gauge=Gauge(data_points=[NumberDataPoint(as_int=1)])))]
+ )
+ cases = [
+ MetricCondition(name="gauge", field="count", min=1),
+ MetricCondition(name="gauge", field="sum", min=1),
+ ]
+ for condition in cases:
+ with self.subTest(condition=condition), self.assertRaises(ValueError):
+ condition.satisfied(received)
+
+ def test_logs(self):
+ received = Received(
+ logs=[_build_log("user alice logged in"), _build_log("startup"), _build_log("user bob logged in")]
+ )
+ cases = [
+ (LogsCondition(min_count=3), True, "logs(body_pattern=None): 3/3"),
+ (
+ LogsCondition(min_count=2, body_pattern=re.compile(r"user .* in")),
+ True,
+ "logs(body_pattern='user .* in'): 2/2",
+ ),
+ (LogsCondition(body_pattern=re.compile("shutdown")), False, "logs(body_pattern='shutdown'): 0/1"),
+ ]
+ for condition, met, progress in cases:
+ with self.subTest(condition=condition):
+ self.assertEqual(condition.satisfied(received), met)
+ self.assertEqual(condition.progress(received), progress)
+
+ def test_exited(self):
+ cases = [(Received(), False, "exited: False"), (Received(exited=True), True, "exited: True")]
+ for received, met, progress in cases:
+ with self.subTest(exited=received.exited):
+ self.assertEqual(ExitedCondition().satisfied(received), met)
+ self.assertEqual(ExitedCondition().progress(received), progress)
+
+
+class TestExpectations(TestCase):
+ def test_mismatch_str(self):
+ cases = [
+ (Mismatch(("name",), "a", "b"), "name: expected a, got b"),
+ (Mismatch(("attributes", "['k']"), "1", ""), "attributes['k']: expected 1, got "),
+ (Mismatch(("value",), "3", "4").nested("data_point"), "data_point.value: expected 3, got 4"),
+ (
+ Mismatch(("name",), "a span", "").nested("span").nested("links[0]"),
+ "links[0].span.name: expected a span, got ",
+ ),
+ ]
+ for mismatch, text in cases:
+ with self.subTest(mismatch=mismatch):
+ self.assertEqual(str(mismatch), text)
+
+ def test_value_matchers(self):
+ int_value = AnyValue(int_value=5)
+ double_value = AnyValue(double_value=5.0)
+ string_value = AnyValue(string_value="abc")
+ array_value = AnyValue(array_value=ArrayValue(values=[AnyValue(int_value=1), AnyValue(string_value="a")]))
+ kvlist_value = AnyValue(
+ kvlist_value=KeyValueList(values=_build_attributes(b=AnyValue(bool_value=True), a=AnyValue(int_value=1)))
+ )
+ cases = [
+ (5, int_value, True),
+ (5, double_value, False),
+ (5.0, double_value, True),
+ (5.0, int_value, False),
+ ("5", int_value, False),
+ ({"double": 5}, double_value, True),
+ ({"int": 5}, double_value, False),
+ ({"string": "abc"}, string_value, True),
+ (True, AnyValue(bool_value=True), True),
+ (True, AnyValue(int_value=1), False),
+ (1, AnyValue(bool_value=True), False),
+ ([1, "a"], array_value, True),
+ ([1.0, "a"], array_value, False),
+ (["a", 1], array_value, False),
+ ({"kvlist": {"a": 1, "b": True}}, kvlist_value, True),
+ ({"kvlist": {"a": 1}}, kvlist_value, False),
+ ({"kvlist": {"a": True, "b": True}}, kvlist_value, False),
+ (
+ [1, "a"],
+ AnyValue(array_value=ArrayValue(values=[AnyValue(bool_value=True), AnyValue(string_value="a")])),
+ False,
+ ),
+ ({"pattern": "^ab"}, string_value, True),
+ ({"pattern": "^b"}, string_value, False),
+ ({"pattern": "5"}, int_value, False),
+ ({"gte": 5, "lt": 6}, int_value, True),
+ ({"gte": 5, "lt": 6}, double_value, True),
+ ({"gt": 5}, int_value, False),
+ ({"gt": 0}, string_value, False),
+ ({"exists": True, "pattern": "c$"}, string_value, True),
+ ({"exists": True}, None, False),
+ ({"exists": False}, None, True),
+ ({"exists": False}, int_value, False),
+ (5, None, False),
+ ]
+ for spec, value, matches in cases:
+ with self.subTest(spec=spec, value=value):
+ attributes = _build_attributes(key=value) if value is not None else []
+ matcher = AttributesMatcher(matchers={"key": parse_value_matcher(spec)})
+ self.assertEqual(not list(matcher.check("attributes", attributes)), matches)
+
+ def test_describe_value(self):
+ cases = [
+ (5, "5"),
+ (5.0, "5.0"),
+ ("5", "'5'"),
+ ("true", "'true'"),
+ ("", "''"),
+ ("abc", "abc"),
+ (True, "true"),
+ ([1, "a"], "[1, a]"),
+ ({"a": 1, "b": [2.5]}, "{a: 1, b: [2.5]}"),
+ (None, ""),
+ ]
+ for value, text in cases:
+ with self.subTest(value=value):
+ self.assertEqual(_describe_value(value), text)
+
+ def test_attributes_strict(self):
+ attributes = _build_attributes(a=AnyValue(int_value=1), b=AnyValue(int_value=2))
+ cases = [
+ ({"attributes": {"values": {"a": 1}}}, []),
+ ({"attributes": {"values": {"a": 1}, "strict": True}}, ["attributes: expected no unlisted keys, got b"]),
+ ({"attributes": {"values": {"a": 1, "b": 2, "c": {"exists": False}}, "strict": True}}, []),
+ ({"attributes": {"strict": True}}, ["attributes: expected no unlisted keys, got a, b"]),
+ ]
+ received = Received(spans=[_build_span("s", attributes=attributes)])
+ for spec, problems in cases:
+ with self.subTest(spec=spec):
+ failures = _get_failures([{"spans": spec}], received)
+ self.assertEqual(failures != [], bool(problems))
+ for problem in problems:
+ self.assertIn(problem, failures[0])
+
+ def test_resource(self):
+ service = Resource(attributes=_build_attributes(**{"service.name": AnyValue(string_value="svc")}))
+ other = Resource(attributes=_build_attributes(**{"service.name": AnyValue(string_value="other")}))
+ expect = [{"resource": {"attributes": {"values": {"service.name": "svc"}}}}]
+ cases = [
+ ("all match", Received(spans=[_build_span("s", service)], logs=[_build_log("b", service)]), None),
+ (
+ "one mismatch",
+ Received(spans=[_build_span("s", service)], logs=[_build_log("b", other), _build_log("c", other)]),
+ "2 of 3 items have a non-matching resource\n log: attributes['service.name']: expected svc, got other",
+ ),
+ ("nothing received", Received(), "nothing received"),
+ ]
+ for case, received, problem in cases:
+ with self.subTest(case=case):
+ failures = _get_failures(expect, received)
+ if problem is None:
+ self.assertEqual(failures, [])
+ else:
+ self.assertEqual(len(failures), 1)
+ self.assertIn(problem, failures[0])
+
+ def test_resource_schema_url(self):
+ resource = Resource(attributes=_build_attributes(**{"service.name": AnyValue(string_value="svc")}))
+ span = _build_span("s", resource)
+ cases = [
+ ({"pattern": "1\\.26"}, "https://opentelemetry.io/schemas/1.26.0", None),
+ ("https://opentelemetry.io/schemas/1.26.0", "https://opentelemetry.io/schemas/1.27.0", "schema_url"),
+ ({"exists": False}, "", None),
+ ({"exists": True}, "", "schema_url: expected present, got "),
+ ]
+ for spec, schema_url, problem in cases:
+ with self.subTest(spec=spec, schema_url=schema_url):
+ span.resource_schema_url = schema_url
+ failures = _get_failures([{"resource": {"schema_url": spec}}], Received(spans=[span]))
+ if problem is None:
+ self.assertEqual(failures, [])
+ else:
+ self.assertEqual(len(failures), 1)
+ self.assertIn(problem, failures[0])
+
+ def test_output(self):
+ received = Received(stdout='{"name": "console-span"}', stderr="Failed to auto initialize OpenTelemetry")
+ cases = [
+ ({"stream": "stdout", "pattern": '"name": "console-span"'}, None),
+ ({"stream": "stderr", "pattern": "console-span"}, "stderr has no match for 'console-span'"),
+ (
+ {"stream": "stderr", "pattern": "Failed to auto", "exists": False},
+ "stderr has a match for 'Failed to auto'",
+ ),
+ ({"stream": "stdout", "pattern": "Traceback", "exists": False}, None),
+ ]
+ for spec, problem in cases:
+ with self.subTest(spec=spec):
+ failures = _get_failures([{"output": spec}], received)
+ if problem is None:
+ self.assertEqual(failures, [])
+ else:
+ self.assertEqual(len(failures), 1)
+ self.assertIn(problem, failures[0])
+
+ def test_link_count(self):
+ target = _build_span("target", trace_id=_TRACE_ID, span_id=_OUTER_SPAN_ID)
+ link = Span.Link(trace_id=_TRACE_ID, span_id=_OUTER_SPAN_ID)
+ received = Received(spans=[target, _build_span("linked", links=[link, link])])
+ cases = [
+ ({"link_count": 2}, True),
+ ({"link_count": 1}, False),
+ ({"link_count": {"gte": 1, "lt": 3}}, True),
+ ({"name": "target", "link_count": 0}, True),
+ ]
+ for spec, met in cases:
+ with self.subTest(spec=spec):
+ spec = {"name": "linked", **spec}
+ self.assertEqual(_get_failures([{"spans": spec}], received) == [], met)
+
+ def test_exemplar_count(self):
+ sum_metric = Metric(
+ name="requests",
+ sum=Sum(data_points=[NumberDataPoint(as_int=1, exemplars=[Exemplar(as_int=1), Exemplar(as_int=1)])]),
+ )
+ received = Received(metrics=[_build_metric(sum_metric)])
+ cases = [
+ ({"exemplar_count": 2}, True),
+ ({"exemplar_count": {"gte": 1}}, True),
+ ({"exemplar_count": 0}, False),
+ ]
+ for data_point, met in cases:
+ with self.subTest(data_point=data_point):
+ expect = [{"metric": {"name": "requests", "data_point": data_point}}]
+ self.assertEqual(_get_failures(expect, received) == [], met)
+
+ def test_spans(self):
+ outer = _build_span(
+ "outer",
+ scope=InstrumentationScope(name="lib", version="1.0"),
+ trace_id=_TRACE_ID,
+ span_id=_OUTER_SPAN_ID,
+ )
+ inner = _build_span(
+ "GET /a",
+ trace_id=_TRACE_ID,
+ span_id=_INNER_SPAN_ID,
+ parent_span_id=_OUTER_SPAN_ID,
+ kind=Span.SPAN_KIND_SERVER,
+ status=Status(code=Status.STATUS_CODE_ERROR, message="boom"),
+ trace_state="k=v",
+ links=[
+ Span.Link(
+ trace_id=_TRACE_ID,
+ span_id=_OUTER_SPAN_ID,
+ attributes=_build_attributes(n=AnyValue(int_value=1)),
+ )
+ ],
+ attributes=_build_attributes(**{"http.method": AnyValue(string_value="GET")}),
+ )
+ orphan = _build_span("orphan", trace_id=_TRACE_ID, span_id=b"\x03" * 8, parent_span_id=b"\x09" * 8)
+ received = Received(spans=[outer, inner, orphan])
+ cases = [
+ ({}, True),
+ ({"count": 3}, True),
+ ({"max_count": 2}, False),
+ ({"name": {"pattern": "^GET "}, "kind": "SERVER"}, True),
+ ({"name": "GET /a", "kind": "INTERNAL"}, False),
+ ({"status": {"code": "ERROR", "message": {"pattern": "boo"}}}, True),
+ ({"status": {"code": "OK"}}, False),
+ ({"trace_state": "k=v"}, True),
+ ({"root": True, "count": 1}, True),
+ ({"root": False, "count": 2}, True),
+ ({"name": "GET /a", "parent": {"name": "outer", "root": True}}, True),
+ ({"name": "GET /a", "parent": {"name": "other"}}, False),
+ ({"name": "orphan", "parent": {}}, False),
+ ({"links": [{"attributes": {"values": {"n": 1}}, "span": {"name": "outer"}}]}, True),
+ ({"links": [{"attributes": {"values": {"n": 2}}}]}, False),
+ ({"links": [{"trace_state": {"exists": True}}]}, False),
+ ({"name": "outer", "scope": {"name": "lib", "version": "1.0"}}, True),
+ ({"name": "outer", "scope": {"version": {"exists": False}}}, False),
+ ({"attributes": {"values": {"http.method": "GET"}}, "count": 1}, True),
+ ({"name": "missing", "count": 0}, True),
+ ({"name": "outer", "count": 0}, False),
+ ]
+ for spec, met in cases:
+ with self.subTest(spec=spec):
+ self.assertEqual(_get_failures([{"spans": spec}], received) == [], met)
+
+ def test_metrics(self):
+ def build_sum(value: int, route: str) -> Metric:
+ return Metric(
+ name="requests",
+ unit="1",
+ sum=Sum(
+ aggregation_temporality=AggregationTemporality.AGGREGATION_TEMPORALITY_CUMULATIVE,
+ is_monotonic=True,
+ data_points=[
+ NumberDataPoint(as_int=value, attributes=_build_attributes(route=AnyValue(string_value=route)))
+ ],
+ ),
+ )
+
+ histogram = Metric(
+ name="duration",
+ histogram=Histogram(
+ aggregation_temporality=AggregationTemporality.AGGREGATION_TEMPORALITY_DELTA,
+ data_points=[
+ HistogramDataPoint(count=4, sum=10.0, min=1.0, explicit_bounds=[0, 5], bucket_counts=[0, 3, 1])
+ ],
+ ),
+ )
+ gauge = Metric(name="temperature", gauge=Gauge(data_points=[NumberDataPoint(as_double=21.5)]))
+ received = Received(
+ metrics=[
+ _build_metric(build_sum(1, "/a"), scope=InstrumentationScope(name="app")),
+ _build_metric(build_sum(3, "/a")),
+ _build_metric(histogram),
+ _build_metric(gauge),
+ ]
+ )
+ cases = [
+ ({"name": "requests", "count": 2}, True),
+ ({"name": "requests", "data_point": {"value": {"gte": 3}}, "count": 1}, True),
+ ({"name": "requests", "data_point": {"value": 3}}, True),
+ ({"name": "requests", "data_point": {"value": 3.0}}, False),
+ ({"name": "requests", "data_point": {"attributes": {"values": {"route": "/b"}}}, "count": 0}, True),
+ ({"name": "requests", "type": "sum", "unit": "1", "temporality": "CUMULATIVE", "monotonic": True}, True),
+ ({"name": "requests", "temporality": "DELTA"}, False),
+ ({"name": "requests", "scope": {"name": "app"}, "count": 1}, True),
+ ({"name": "requests", "type": "gauge"}, False),
+ ({"name": "requests", "description": {"exists": False}}, True),
+ ({"name": "duration", "temporality": "DELTA", "data_point": {"count": 4, "sum": {"gt": 9}}}, True),
+ ({"name": "duration", "data_point": {"sum": 10, "min": 1, "max": {"exists": False}}}, True),
+ ({"name": "duration", "data_point": {"max": {"exists": True}}}, False),
+ ({"name": "duration", "data_point": {"explicit_bounds": [0, 5], "bucket_counts": [0, 3, 1]}}, True),
+ ({"name": "duration", "data_point": {"explicit_bounds": [0, 10]}}, False),
+ ({"name": "temperature", "data_point": {"value": 21.5}}, True),
+ ({"type": "gauge", "data_point": {"value": {"lt": 20}}}, False),
+ ]
+ for spec, met in cases:
+ with self.subTest(spec=spec):
+ self.assertEqual(_get_failures([{"metric": spec}], received) == [], met)
+
+ def test_metric_field_mismatch(self):
+ gauge = Metric(name="gauge", gauge=Gauge(data_points=[NumberDataPoint(as_int=1)]))
+ histogram = Metric(name="histogram", histogram=Histogram(data_points=[HistogramDataPoint(count=1)]))
+ received = Received(metrics=[_build_metric(gauge), _build_metric(histogram)])
+ cases = [
+ {"name": "gauge", "temporality": "CUMULATIVE"},
+ {"name": "gauge", "monotonic": True},
+ {"name": "gauge", "data_point": {"count": 1}},
+ {"name": "histogram", "data_point": {"value": 1}},
+ {"data_point": {"explicit_bounds": []}},
+ ]
+ for spec in cases:
+ with self.subTest(spec=spec), self.assertRaises(ValueError):
+ _get_failures([{"metric": spec}], received)
+
+ def test_logs(self):
+ span = _build_span("handler", trace_id=_TRACE_ID, span_id=_OUTER_SPAN_ID)
+ in_span = _build_log(
+ "user alice logged in",
+ scope=InstrumentationScope(name="auth"),
+ trace_id=_TRACE_ID,
+ span_id=_OUTER_SPAN_ID,
+ severity_number=SeverityNumber.SEVERITY_NUMBER_WARN,
+ severity_text="WARN",
+ event_name="login",
+ attributes=_build_attributes(user=AnyValue(string_value="alice")),
+ )
+ orphan = _build_log("lost", trace_id=_TRACE_ID, span_id=_INNER_SPAN_ID)
+ plain = _build_log("startup")
+ received = Received(spans=[span], logs=[in_span, orphan, plain])
+ cases = [
+ ({"body": {"pattern": "user .* in"}, "count": 1}, True),
+ ({"body": "startup"}, True),
+ ({"body": {"string": "start"}}, False),
+ ({"severity_number": "WARN", "severity_text": "WARN", "event_name": "login"}, True),
+ ({"severity_number": "WARN2"}, False),
+ ({"trace_context": True, "count": 2}, True),
+ ({"trace_context": False, "count": 1}, True),
+ ({"span": {"name": "handler"}, "count": 1}, True),
+ ({"body": "lost", "span": {}}, False),
+ ({"scope": {"name": "auth"}, "attributes": {"values": {"user": "alice"}}}, True),
+ ({"event_name": {"exists": True}, "count": 1}, True),
+ ({"severity_number": "ERROR", "count": 0}, True),
+ ]
+ for spec, met in cases:
+ with self.subTest(spec=spec):
+ self.assertEqual(_get_failures([{"logs": spec}], received) == [], met)
+
+ def test_failure_messages(self):
+ received = Received(spans=[_build_span("basic-span"), _build_span("other"), _build_span("other")])
+ cases = [
+ (
+ {"name": "basic-span", "kind": "SERVER"},
+ "spans: {name: basic-span, kind: SERVER}\n"
+ " matched 0, expected >= 1\n"
+ " closest 'basic-span': kind: expected SERVER, got UNSPECIFIED\n"
+ " closest 'other': name: expected basic-span, got other; "
+ "kind: expected SERVER, got UNSPECIFIED",
+ ),
+ (
+ {"name": "other", "count": 0},
+ "spans: {name: other, count: 0}\n matched 2, expected == 0\n matched 'other'\n matched 'other'",
+ ),
+ (
+ {"kind": "SERVER", "min_count": 2, "max_count": 3},
+ "spans: {kind: SERVER, min_count: 2, max_count: 3}\n"
+ " matched 0, expected >= 2 and <= 3\n"
+ " closest 'basic-span': kind: expected SERVER, got UNSPECIFIED\n"
+ " closest 'other': kind: expected SERVER, got UNSPECIFIED",
+ ),
+ ]
+ for spec, message in cases:
+ with self.subTest(spec=spec):
+ self.assertEqual(_get_failures([{"spans": spec}], received), [message])
+ self.assertEqual(
+ _get_failures([{"logs": {}}], received),
+ ["logs: {}\n matched 0, expected >= 1\n no logs received"],
+ )
diff --git a/tests/opentelemetry-docker-tests/tests/declconfig/test_scenarios.py b/tests/opentelemetry-docker-tests/tests/declconfig/test_scenarios.py
new file mode 100644
index 00000000000..53ec03b568f
--- /dev/null
+++ b/tests/opentelemetry-docker-tests/tests/declconfig/test_scenarios.py
@@ -0,0 +1,108 @@
+# Copyright The OpenTelemetry Authors
+# SPDX-License-Identifier: Apache-2.0
+
+from __future__ import annotations
+
+import os
+import shutil
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+from opentelemetry.test._otlp_test_server import OtlpProtoTestServer
+
+from ._scenario import Received, load_scenario
+
+_SCENARIOS_DIR = Path(__file__).parent / "scenarios"
+_SCENARIOS = sorted(path for path in _SCENARIOS_DIR.iterdir() if path.is_dir())
+_POLL_INTERVAL = 0.1
+_STOP_GRACE_PERIOD = 5.0
+# How long the sink must receive nothing after the app stops before `expect` is checked.
+_SETTLE_PERIOD = 1.0
+# Upper bound on settling, for sources that never go quiet.
+_MAX_SETTLE_DURATION = 10.0
+
+
+def _stop(process: subprocess.Popen[bytes]) -> None:
+ """Interrupt the app so atexit (and SDK shutdown) runs, then kill it if it lingers."""
+ if process.poll() is not None:
+ return
+ process.send_signal(signal.SIGINT)
+ try:
+ process.wait(timeout=_STOP_GRACE_PERIOD)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+
+
+def _get_otel_config(scenario: Path) -> Path:
+ yaml_config = scenario / "otel-config.yaml"
+ return yaml_config if yaml_config.exists() else scenario / "otel-config.json"
+
+
+def _drain_until_quiet(received: Received, sink: OtlpProtoTestServer) -> None:
+ """Collect telemetry still in flight, such as what the SDK flushed on shutdown."""
+ deadline = time.monotonic() + _MAX_SETTLE_DURATION
+ quiet_until = time.monotonic() + _SETTLE_PERIOD
+ while time.monotonic() < min(quiet_until, deadline):
+ time.sleep(_POLL_INTERVAL)
+ if received.drain(sink):
+ quiet_until = time.monotonic() + _SETTLE_PERIOD
+
+
+@pytest.mark.parametrize("scenario", _SCENARIOS, ids=[path.name for path in _SCENARIOS])
+def test_scenario(scenario: Path, collector: str, otlp_sink: OtlpProtoTestServer, tmp_path: Path) -> None:
+ spec = load_scenario(scenario / "scenario.yaml")
+ instrument = shutil.which("opentelemetry-instrument")
+ assert instrument is not None, "opentelemetry-instrument is not installed"
+
+ stdout_path = tmp_path / "stdout.txt"
+ stderr_path = tmp_path / "stderr.txt"
+
+ def app_output() -> str:
+ return f"stdout:\n{stdout_path.read_text()}\nstderr:\n{stderr_path.read_text()}"
+
+ received = Received()
+ with open(stdout_path, "wb") as stdout, open(stderr_path, "wb") as stderr:
+ process = subprocess.Popen(
+ [instrument, sys.executable, "run.py"],
+ cwd=scenario,
+ env={
+ # Inherited OTEL_* variables would leak into the SDK.
+ **{key: value for key, value in os.environ.items() if not key.startswith("OTEL_")},
+ **spec.env,
+ "OTEL_CONFIG_FILE": str(_get_otel_config(scenario)),
+ "OTEL_COLLECTOR_ENDPOINT": collector,
+ },
+ stdout=stdout,
+ stderr=stderr,
+ )
+ try:
+ deadline = time.monotonic() + spec.timeout
+ while time.monotonic() < deadline:
+ received.drain(otlp_sink)
+ returncode = process.poll()
+ assert returncode in (None, 0), f"run.py exited with {returncode}:\n{app_output()}"
+ received.exited = returncode == 0
+ if not spec.unmet(received):
+ break
+ time.sleep(_POLL_INTERVAL)
+ finally:
+ _stop(process)
+
+ received.stdout = stdout_path.read_text()
+ received.stderr = stderr_path.read_text()
+
+ _drain_until_quiet(received, otlp_sink)
+ unmet = spec.unmet(received)
+ assert not unmet, (
+ f"Timed out after {spec.timeout}s waiting for:\n"
+ + "\n".join(f" {condition.progress(received)}" for condition in unmet)
+ + f"\n{app_output()}"
+ )
+ failures = spec.failed_expectations(received)
+ assert not failures, "Unmet expectations:\n" + "\n".join(map(str, failures)) + f"\n{app_output()}"
diff --git a/tests/opentelemetry-docker-tests/tests/otlpexporter/conftest.py b/tests/opentelemetry-docker-tests/tests/otlpexporter/conftest.py
index d777a33fa6a..17d572ba0f7 100644
--- a/tests/opentelemetry-docker-tests/tests/otlpexporter/conftest.py
+++ b/tests/opentelemetry-docker-tests/tests/otlpexporter/conftest.py
@@ -6,9 +6,28 @@
from collections.abc import Iterator
import pytest
+from prometheus_client import CollectorRegistry, start_http_server
from opentelemetry.test._otlp_test_server import OtlpProtoTestServer
+_PROMETHEUS_PORT = 9464
+
+
+@pytest.fixture(scope="session")
+def prometheus_registry() -> CollectorRegistry:
+ return CollectorRegistry()
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _prometheus_scrape_target(prometheus_registry: CollectorRegistry) -> Iterator[None]:
+ """Keep a Prometheus HTTP endpoint up on :9464 for the whole docker-test session."""
+ httpd, _thread = start_http_server(port=_PROMETHEUS_PORT, addr="0.0.0.0", registry=prometheus_registry)
+ try:
+ yield
+ finally:
+ httpd.shutdown()
+ httpd.server_close()
+
@pytest.fixture(scope="class")
def server() -> Iterator[OtlpProtoTestServer]:
diff --git a/tests/opentelemetry-test-utils/src/opentelemetry/test/_otlp_test_server.py b/tests/opentelemetry-test-utils/src/opentelemetry/test/_otlp_test_server.py
index 553f8506efe..f7c3b053ed3 100644
--- a/tests/opentelemetry-test-utils/src/opentelemetry/test/_otlp_test_server.py
+++ b/tests/opentelemetry-test-utils/src/opentelemetry/test/_otlp_test_server.py
@@ -26,6 +26,7 @@ class RecordedSpan:
span: Span
resource: Resource
scope: InstrumentationScope
+ resource_schema_url: str = ""
@dataclass
@@ -33,6 +34,7 @@ class RecordedMetric:
metric: Metric
resource: Resource
scope: InstrumentationScope
+ resource_schema_url: str = ""
@dataclass
@@ -40,6 +42,7 @@ class RecordedLogRecord:
log_record: LogRecord
resource: Resource
scope: InstrumentationScope
+ resource_schema_url: str = ""
def _make_handler(
@@ -101,7 +104,14 @@ def _handle_traces(body: bytes) -> bytes:
for rs in request.resource_spans:
for ss in rs.scope_spans:
for span in ss.spans:
- spans_queue.put(RecordedSpan(span=span, resource=rs.resource, scope=ss.scope))
+ spans_queue.put(
+ RecordedSpan(
+ span=span,
+ resource=rs.resource,
+ scope=ss.scope,
+ resource_schema_url=rs.schema_url,
+ )
+ )
return ExportTraceServiceResponse().SerializeToString()
@staticmethod
@@ -116,6 +126,7 @@ def _handle_metrics(body: bytes) -> bytes:
metric=metric,
resource=rm.resource,
scope=sm.scope,
+ resource_schema_url=rm.schema_url,
)
)
return ExportMetricsServiceResponse().SerializeToString()
@@ -132,6 +143,7 @@ def _handle_logs(body: bytes) -> bytes:
log_record=log_record,
resource=rl.resource,
scope=sl.scope,
+ resource_schema_url=rl.schema_url,
)
)
return ExportLogsServiceResponse().SerializeToString()
diff --git a/tox.ini b/tox.ini
index 7f01c0df478..4729a50c0ee 100644
--- a/tox.ini
+++ b/tox.ini
@@ -130,6 +130,7 @@ envlist =
typecheck
docs
docker-tests-{otlpexporter,opencensus}
+ docker-tests-declconfig
public-symbols-check
shellcheck
generate-workflows
@@ -220,6 +221,7 @@ setenv =
CONTRIB_REPO=git+https://github.com/open-telemetry/opentelemetry-python-contrib.git@{env:CONTRIB_REPO_SHA}
CONTRIB_REPO_UTIL_HTTP={env:CONTRIB_REPO_UTIL_HTTP:{env:CONTRIB_REPO}\#egg=opentelemetry-util-http&subdirectory=util/opentelemetry-util-http}
CONTRIB_REPO_INSTRUMENTATION={env:CONTRIB_REPO_INSTRUMENTATION:{env:CONTRIB_REPO}\#egg=opentelemetry-instrumentation&subdirectory=opentelemetry-instrumentation}
+ CONTRIB_REPO_DISTRO={env:CONTRIB_REPO_DISTRO:{env:CONTRIB_REPO}\#egg=opentelemetry-distro&subdirectory=opentelemetry-distro}
CONTRIB_REPO_INSTRUMENTATION_REQUESTS={env:CONTRIB_REPO_INSTRUMENTATION_REQUESTS:{env:CONTRIB_REPO}\#egg=opentelemetry-instrumentation-requests&subdirectory=instrumentation/opentelemetry-instrumentation-requests}
CONTRIB_REPO_INSTRUMENTATION_WSGI={env:CONTRIB_REPO_INSTRUMENTATION_WSGI:{env:CONTRIB_REPO}\#egg=opentelemetry-instrumentation-wsgi&subdirectory=instrumentation/opentelemetry-instrumentation-wsgi}
CONTRIB_REPO_INSTRUMENTATION_FLASK={env:CONTRIB_REPO_INSTRUMENTATION_FLASK:{env:CONTRIB_REPO}\#egg=opentelemetry-instrumentation-flask&subdirectory=instrumentation/opentelemetry-instrumentation-flask}
@@ -369,7 +371,7 @@ commands =
deps =
-c dev-requirements.txt
pytest
- prometheus_client
+ otlpexporter: prometheus_client
; core packages
-e {toxinidir}/opentelemetry-api
-e {toxinidir}/opentelemetry-semantic-conventions
@@ -416,6 +418,43 @@ commands =
commands_post =
docker compose down -v
+[testenv:docker-tests-declconfig]
+deps =
+ -c dev-requirements.txt
+ pytest
+ jsonschema
+ pyyaml
+ testcontainers
+ ; core packages
+ -e {toxinidir}/opentelemetry-api
+ -e {toxinidir}/opentelemetry-semantic-conventions
+ -e {toxinidir}/opentelemetry-sdk
+ -e {toxinidir}/opentelemetry-configuration
+ -e {toxinidir}/tests/opentelemetry-test-utils
+ ; OTLP packages
+ -e {toxinidir}/opentelemetry-proto
+ -e {toxinidir}/opentelemetry-proto-json
+ -e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-common
+ -e {toxinidir}/exporter/opentelemetry-exporter-otlp-common
+ -e {toxinidir}/exporter/opentelemetry-exporter-http-transport[requests]
+ -e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-http
+ -e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-grpc
+ -e {toxinidir}/exporter/opentelemetry-exporter-otlp-json-common
+ -e {toxinidir}/exporter/opentelemetry-exporter-otlp-json-file
+ -e {toxinidir}/exporter/opentelemetry-exporter-prometheus
+ ; propagators
+ -e {toxinidir}/propagator/opentelemetry-propagator-b3
+ -e {toxinidir}/propagator/opentelemetry-propagator-jaeger
+ ; zero-code instrumentation
+ {env:CONTRIB_REPO_INSTRUMENTATION}
+ {env:CONTRIB_REPO_DISTRO}
+
+changedir =
+ tests/opentelemetry-docker-tests/tests
+
+commands =
+ pytest declconfig {posargs}
+
[testenv:lint-opentelemetry-test-utils]
; This environment must be defined explicitly because of how
; tox parses and matches environment names.
@@ -486,6 +525,7 @@ deps =
-e {toxinidir}/exporter/opentelemetry-exporter-otlp-proto-http
-e {toxinidir}/opentelemetry-proto
-e {toxinidir}/opentelemetry-proto-json
+ -e {toxinidir}/opentelemetry-proto-json
-e {toxinidir}/codegen/opentelemetry-codegen-json
commands =