diff --git a/.changelog/2432.changed b/.changelog/2432.changed new file mode 100644 index 00000000000..0c29e0a0a6a --- /dev/null +++ b/.changelog/2432.changed @@ -0,0 +1 @@ +`opentelemetry-api`: document baggage current context behavior. diff --git a/.changelog/5402.fixed b/.changelog/5402.fixed new file mode 100644 index 00000000000..7ba5f9b424b --- /dev/null +++ b/.changelog/5402.fixed @@ -0,0 +1 @@ +`opentelemetry-codegen-json`: support decoding enums from names diff --git a/.changelog/5448.fixed b/.changelog/5448.fixed new file mode 100644 index 00000000000..3550497a64e --- /dev/null +++ b/.changelog/5448.fixed @@ -0,0 +1 @@ +`opentelemetry-exporter-otlp-proto-grpc`: warn and fall back to the default instead of raising `ValueError` when a timeout environment variable holds an invalid value. diff --git a/.changelog/5706.fixed b/.changelog/5706.fixed new file mode 100644 index 00000000000..56bc9fd10b3 --- /dev/null +++ b/.changelog/5706.fixed @@ -0,0 +1 @@ +`opentelemetry-sdk`: fix thread leak bug in resource initialization logic for long running resource detectors diff --git a/.changelog/5738.fixed b/.changelog/5738.fixed new file mode 100644 index 00000000000..cbb026f1f60 --- /dev/null +++ b/.changelog/5738.fixed @@ -0,0 +1,5 @@ +`opentelemetry-test-utils`: require Weaver 0.27.0 or later for `WeaverLiveCheck` +and fix its shutdown sequence. +Make readiness polling quiet and add a configurable startup timeout. +Reuse completed reports on repeated end calls; raise when no completed report +request is available instead of returning an empty report. diff --git a/.changelog/5741.fixed b/.changelog/5741.fixed new file mode 100644 index 00000000000..9ea630a8ec6 --- /dev/null +++ b/.changelog/5741.fixed @@ -0,0 +1 @@ +`opentelemetry-sdk`: omit histogram min/max when record_min_max is disabled diff --git a/.changelog/5742.fixed b/.changelog/5742.fixed new file mode 100644 index 00000000000..a11d1072ab4 --- /dev/null +++ b/.changelog/5742.fixed @@ -0,0 +1 @@ +`opentelemetry-sdk`: add return type annotations to `SynchronousMultiLogRecordProcessor` and `ConcurrentMultiLogRecordProcessor` diff --git a/.changelog/5746.fixed b/.changelog/5746.fixed new file mode 100644 index 00000000000..8493915990e --- /dev/null +++ b/.changelog/5746.fixed @@ -0,0 +1 @@ +`opentelemetry-api`: fix quadratic time parsing of the `tracestate` header and discard `tracestate` headers longer than 8192 characters diff --git a/.changelog/5757.fixed b/.changelog/5757.fixed new file mode 100644 index 00000000000..4515137542e --- /dev/null +++ b/.changelog/5757.fixed @@ -0,0 +1 @@ +`opentelemetry-api`: replace excessively nested attribute values with `None` diff --git a/.github/dependabot.yml b/.github/dependabot.yml index be006de9a1a..ace03dbb25d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,8 @@ version: 2 updates: - package-ecosystem: github-actions directory: / + cooldown: + default-days: 7 groups: github-actions: patterns: diff --git a/.github/workflows/add-to-project.yml b/.github/workflows/add-to-project.yml index d0d4b5f98d3..353ca676ae2 100644 --- a/.github/workflows/add-to-project.yml +++ b/.github/workflows/add-to-project.yml @@ -21,6 +21,8 @@ jobs: with: client-id: ${{ vars.OTELBOT_PYTHON_CLIENT_ID }} private-key: ${{ secrets.OTELBOT_PYTHON_PRIVATE_KEY }} + permission-organization-projects: write + permission-pull-requests: read - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 51b072e9e1b..a885fe7f2e2 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -36,8 +36,10 @@ jobs: with: client-id: ${{ vars.OTELBOT_CLIENT_ID }} private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + permission-pull-requests: write - name: Create pull request + id: create_pr env: NUMBER: ${{ github.event.inputs.number }} # not using secrets.GITHUB_TOKEN since pull requests from that token do not run workflows @@ -50,7 +52,16 @@ jobs: git cherry-pick $commit git push origin HEAD:$branch - gh pr create --title "[$GITHUB_REF_NAME] $title" \ - --body "Clean cherry-pick of #$NUMBER to the \`$GITHUB_REF_NAME\` branch." \ - --head $branch \ - --base $GITHUB_REF_NAME + pr_url=$(gh pr create --title "[$GITHUB_REF_NAME] $title" \ + --body "Clean cherry-pick of #$NUMBER to the \`$GITHUB_REF_NAME\` branch." \ + --head $branch \ + --base $GITHUB_REF_NAME) + echo "pr_url=$pr_url" >> $GITHUB_OUTPUT + + - name: Add backport label to PR + if: steps.create_pr.outputs.pr_url != '' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.create_pr.outputs.pr_url }} + run: | + gh pr edit "$PR_URL" --add-label "backport" diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index f06d3953ddb..b013efd27bc 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -21,6 +21,8 @@ jobs: github.event_name != 'merge_group' && !contains(github.event.pull_request.labels.*.name, 'Skip Changelog') && github.actor != 'otelbot[bot]' + env: + BASE_REF: ${{ github.base_ref }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -29,7 +31,7 @@ jobs: fetch-depth: 0 - name: Fetch base branch - run: git fetch origin ${{ github.base_ref }} --depth=1 + run: git fetch origin "$BASE_REF" --depth=1 - name: Ensure no direct changes to CHANGELOG.md run: | @@ -47,11 +49,13 @@ jobs: run: pip install towncrier==25.8.0 - name: Check for changelog fragment + env: + PR_NUMBER: ${{ github.event.pull_request.number }} run: | - if ! towncrier check --compare-with origin/${{ github.base_ref }}; then + if ! towncrier check --compare-with "origin/$BASE_REF"; then echo "" echo "No changelog fragment found for this PR." - echo "Add a file named .changelog/${{ github.event.pull_request.number }}." + echo "Add a file named .changelog/$PR_NUMBER." echo "where is one of: added, changed, deprecated, removed, fixed" echo "See CONTRIBUTING.md for details." echo "" @@ -63,7 +67,7 @@ jobs: env: PR_NUMBER: ${{ github.event.pull_request.number }} run: | - fragments=$(git diff --diff-filter=A --name-only origin/${{ github.base_ref }} -- '.changelog/*' | grep -v '/\.gitignore$' || true) + fragments=$(git diff --diff-filter=A --name-only "origin/$BASE_REF" -- '.changelog/*' | grep -v '/\.gitignore$' || true) [ -z "$fragments" ] && exit 0 invalid=() while IFS= read -r f; do diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 8fef9f78389..86d1cf99443 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -50,6 +50,8 @@ jobs: files: | **/*.md **/*.rst + json: true + escape_json: false - name: Install markdown-link-check if: steps.changed-files.outputs.any_changed == 'true' @@ -57,22 +59,28 @@ jobs: - name: Check links on push to main if: steps.changed-files.outputs.any_changed == 'true' && github.event_name == 'push' + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | + mapfile -t changed_files < <(jq -r '.[]' <<< "$CHANGED_FILES") markdown-link-check \ --verbose \ --config .github/workflows/check_links_config.json \ - ${{ steps.changed-files.outputs.all_changed_files }} \ + "${changed_files[@]}" \ || { echo "Check that anchor links are lowercase"; exit 1; } - name: Check new links only on pull requests and merge groups if: steps.changed-files.outputs.any_changed == 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | + mapfile -t changed_files < <(jq -r '.[]' <<< "$CHANGED_FILES") # Extract URLs only from added lines in the diff to avoid # rate limiting when checking all links in large files like # CHANGELOG.md. Only new/changed links are checked on PRs; # pushes to main still check all links in changed files. git diff "$DIFF_RANGE" -- \ - ${{ steps.changed-files.outputs.all_changed_files }} \ + "${changed_files[@]}" \ | grep '^+' | grep -v '^+++' \ | grep -oP 'https?://[^\s\)\]\"'"'"'`>]+' \ | sort -u > /tmp/new_links.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e927f4c3f1..a24b593f336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,11 +19,11 @@ concurrency: jobs: misc: - uses: ./.github/workflows/misc.yml + uses: $/.github/workflows/misc.yml lint: - uses: ./.github/workflows/lint.yml + uses: $/.github/workflows/lint.yml tests: - uses: ./.github/workflows/test.yml + uses: $/.github/workflows/test.yml contrib: uses: open-telemetry/opentelemetry-python-contrib/.github/workflows/core_contrib_test.yml@main with: diff --git a/.github/workflows/prepare-patch-release.yml b/.github/workflows/prepare-patch-release.yml index 8951be98eb3..419e4ff0e57 100644 --- a/.github/workflows/prepare-patch-release.yml +++ b/.github/workflows/prepare-patch-release.yml @@ -71,6 +71,7 @@ jobs: with: client-id: ${{ vars.OTELBOT_CLIENT_ID }} private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + permission-pull-requests: write - name: Create pull request id: create_pr @@ -93,8 +94,9 @@ jobs: if: steps.create_pr.outputs.pr_url != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.create_pr.outputs.pr_url }} run: | - gh pr edit ${{ steps.create_pr.outputs.pr_url }} --add-label "prepare-release" + gh pr edit "$PR_URL" --add-label "prepare-release" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -132,5 +134,6 @@ jobs: if: steps.backport_pr.outputs.pr_url != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.backport_pr.outputs.pr_url }} run: | - gh pr edit ${{ steps.backport_pr.outputs.pr_url }} --add-label "Skip Changelog" + gh pr edit "$PR_URL" --add-label "Skip Changelog" diff --git a/.github/workflows/prepare-release-branch.yml b/.github/workflows/prepare-release-branch.yml index 15db54d93ce..479efc11769 100644 --- a/.github/workflows/prepare-release-branch.yml +++ b/.github/workflows/prepare-release-branch.yml @@ -99,6 +99,7 @@ jobs: with: client-id: ${{ vars.OTELBOT_CLIENT_ID }} private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + permission-pull-requests: write - name: Create pull request against the release branch id: create_release_branch_pr @@ -121,8 +122,9 @@ jobs: if: steps.create_release_branch_pr.outputs.pr_url != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.create_release_branch_pr.outputs.pr_url }} run: | - gh pr edit ${{ steps.create_release_branch_pr.outputs.pr_url }} --add-label "prepare-release" + gh pr edit "$PR_URL" --add-label "prepare-release" create-pull-request-against-main: permissions: @@ -196,6 +198,7 @@ jobs: with: client-id: ${{ vars.OTELBOT_CLIENT_ID }} private-key: ${{ secrets.OTELBOT_PRIVATE_KEY }} + permission-pull-requests: write - name: Create pull request against main id: create_main_pr @@ -219,5 +222,6 @@ jobs: if: steps.create_main_pr.outputs.pr_url != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.create_main_pr.outputs.pr_url }} run: | - gh pr edit ${{ steps.create_main_pr.outputs.pr_url }} --add-label "prepare-release" --add-label "Skip Changelog" + gh pr edit "$PR_URL" --add-label "prepare-release" --add-label "Skip Changelog" diff --git a/.github/workflows/templates/ci.yml.j2 b/.github/workflows/templates/ci.yml.j2 index e89cded6200..3533eb8821d 100644 --- a/.github/workflows/templates/ci.yml.j2 +++ b/.github/workflows/templates/ci.yml.j2 @@ -19,11 +19,11 @@ concurrency: jobs: misc: - uses: ./.github/workflows/misc.yml + uses: $/.github/workflows/misc.yml lint: - uses: ./.github/workflows/lint.yml + uses: $/.github/workflows/lint.yml tests: - uses: ./.github/workflows/test.yml + uses: $/.github/workflows/test.yml contrib: uses: open-telemetry/opentelemetry-python-contrib/.github/workflows/core_contrib_test.yml@main with: diff --git a/.github/workflows/templates/test.yml.j2 b/.github/workflows/templates/test.yml.j2 index de752f7c110..993022831b0 100644 --- a/.github/workflows/templates/test.yml.j2 +++ b/.github/workflows/templates/test.yml.j2 @@ -20,7 +20,7 @@ env: contains(github.event.pull_request.labels.*.name, 'backport') && github.event.pull_request.base.ref || 'main' ) || 'main' }}{% endraw %} - WEAVER_VERSION: "v0.25.1" + WEAVER_VERSION: "v0.27.0" 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a383174b11d..bec1bef5219 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ env: contains(github.event.pull_request.labels.*.name, 'backport') && github.event.pull_request.base.ref || 'main' ) || 'main' }} - WEAVER_VERSION: "v0.25.1" + WEAVER_VERSION: "v0.27.0" 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 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 00000000000..cc1c90fd27a --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,20 @@ +name: Zizmor + +on: + push: + branches: + - main + - release/* + pull_request: + schedule: + - cron: '9 12 * * 4' # weekly at 12:09 UTC on Thursday + workflow_dispatch: + +permissions: {} + +jobs: + zizmor: + permissions: + contents: read # for actions/checkout + security-events: write # for zizmor to upload SARIF results + uses: open-telemetry/shared-workflows/.github/workflows/zizmor.yml@d9b812f9924a121c6a8276ea2f9e6f5b622cdd4d # v0.10.0 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000000..d7543d11c5d --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,20 @@ +rules: + adhoc-packages: + ignore: + # This isolated link-check job pins the CLI to v3.12.2. + - check-links.yml:56 + dangerous-triggers: + ignore: + # The project-board job uses PR metadata but never checks out or runs PR code. + - add-to-project.yml:3 + unpinned-uses: + config: + policies: + "*": hash-pin + # Contrib integration tests intentionally follow the current workflow on main. + "open-telemetry/opentelemetry-python-contrib/.github/workflows/core_contrib_test.yml": ref-pin + use-trusted-publishing: + ignore: + # TestPyPI and PyPI publishing use existing tokens until trusted publishers are configured. + - release.yml:97 + - release.yml:104 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 723b568c757..ed55127378e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## Version 1.45.1/0.66b1 (2026-10-06) + +### Fixed + +- `opentelemetry-api`: make `AnyValue` and `AttributeValue` explicit type + aliases + ([#5703](https://github.com/open-telemetry/opentelemetry-python/pull/5703)) +- `opentelemetry-sdk`: fix thread leak bug in resource initialization logic for + long running resource detectors + ([#5706](https://github.com/open-telemetry/opentelemetry-python/pull/5706)) +- `opentelemetry-exporter-otlp-proto-http`: restore `requests` as the default + HTTP transport so proxy environment variables such as `HTTPS_PROXY` are + honored again + ([#5714](https://github.com/open-telemetry/opentelemetry-python/pull/5714)) +- `opentelemetry-api`: fix quadratic time parsing of the `tracestate` header + and discard `tracestate` headers longer than 8192 characters + ([#5746](https://github.com/open-telemetry/opentelemetry-python/pull/5746)) + ## Version 1.45.0/0.66b0 (2026-09-25) ### Added diff --git a/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/generator.py b/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/generator.py index fc4089313fe..4cf99a3e8c9 100644 --- a/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/generator.py +++ b/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/generator.py @@ -299,7 +299,6 @@ def _generate_imports( std_imports = [ "builtins", "dataclasses", - "functools", "typing", ] if include_enum: @@ -310,12 +309,6 @@ def _generate_imports( writer.blank_line() - writer.assignment( - "_dataclass", - "functools.partial(dataclasses.dataclass, slots=True)", - ) - writer.blank_line() - # Collect all imports needed imports = self._collect_imports(proto_file) imports.add(f"import {self._get_codec_module_path()}") @@ -399,7 +392,7 @@ def _generate_message_class( msg_desc.name, bases=(f"{codec}.JsonMessage",), decorators=("typing.final",), - decorator_name="_dataclass", + slots=True, ): if msg_desc.field or msg_desc.nested_type or msg_desc.enum_type: writer.docstring([f"Generated from protobuf message {msg_desc.name}"]) @@ -541,7 +534,7 @@ def _generate_from_dict( "from_dict", ["cls", "data: builtins.dict[builtins.str, typing.Any]"], decorators=["builtins.classmethod"], - return_type=f'"{current_path}"', + return_type=f"{current_path}", ): writer.docstring( [ @@ -681,10 +674,9 @@ def _generate_deserialization_statements( ) elif field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM: enum_type = self._resolve_enum_type(field_desc.type_name, proto_file) - writer.writeln(f'{codec}.validate_type({var_name}, builtins.int, "{field_desc.name}")') writer.assignment( f'{target_dict}["{field_desc.name}"]', - f"{enum_type}({var_name})", + f'{codec}.decode_enum({var_name}, {enum_type}, "{field_desc.name}")', ) elif is_hex_encoded_field(field_desc.name): writer.assignment( @@ -738,7 +730,7 @@ def _get_deserialization_expr( return f"{msg_type}.from_dict({var_name})" if field_desc.type == descriptor.FieldDescriptorProto.TYPE_ENUM: enum_type = self._resolve_enum_type(field_desc.type_name, proto_file) - return f"{enum_type}({var_name})" + return f'{codec}.decode_enum({var_name}, {enum_type}, "{field_desc.name}")' if is_hex_encoded_field(field_desc.name): return f'{codec}.decode_hex({var_name}, "{field_desc.name}")' if is_int64_type(field_desc.type): diff --git a/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/runtime/json_codec.py b/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/runtime/json_codec.py index 2be8afead76..e528f4f0eb9 100644 --- a/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/runtime/json_codec.py +++ b/codegen/opentelemetry-codegen-json/src/opentelemetry/codegen/json/runtime/json_codec.py @@ -6,6 +6,7 @@ import abc import base64 import collections.abc +import enum import json import math import typing @@ -14,6 +15,7 @@ T = typing.TypeVar("T") M = typing.TypeVar("M", bound="JsonMessage") +EnumT = typing.TypeVar("EnumT", bound=enum.IntEnum) class JsonMessage(abc.ABC): @@ -237,3 +239,31 @@ def validate_type( """ if not isinstance(value, expected_types): raise TypeError(f"Field '{field_name}' expected {expected_types}, got {type(value).__name__}") + + +def decode_enum(value: int | str, enum_type: type[EnumT], field_name: str) -> EnumT: + """ + Decode a JSON enum value into an enum member. + + Per the ProtoJSON spec, parsers must accept both enum names (str) + and integer values (int). + + Args: + value: The enum name or integer value to decode. + enum_type: The enum class to decode into. + field_name: The name of the field being decoded (for error messages). + Returns: + The corresponding enum member. + """ + if isinstance(value, bool): + raise TypeError(f"Field '{field_name}' expected int or str, got bool") + validate_type(value, (int, str), field_name) + if isinstance(value, str): + try: + return enum_type[value] + except KeyError: + raise ValueError(f"Invalid enum name '{value}' for field '{field_name}'") from None + try: + return enum_type(value) + except ValueError: + raise ValueError(f"Invalid enum value {value} for field '{field_name}'") from None diff --git a/codegen/opentelemetry-codegen-json/tests/test_json_codec.py b/codegen/opentelemetry-codegen-json/tests/test_json_codec.py index 9c888026c0a..70f1e55fbef 100644 --- a/codegen/opentelemetry-codegen-json/tests/test_json_codec.py +++ b/codegen/opentelemetry-codegen-json/tests/test_json_codec.py @@ -1,12 +1,15 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 +import enum import math +from typing import Any import pytest # type: ignore from opentelemetry.codegen.json.runtime.json_codec import ( decode_base64, + decode_enum, decode_float, decode_hex, decode_int64, @@ -20,6 +23,12 @@ ) +class _Color(enum.IntEnum): + RED = 0 + GREEN = 1 + BLUE = 2 + + @pytest.mark.parametrize( "value, expected", [ @@ -185,3 +194,39 @@ def test_validate_type() -> None: match=r"Field 'field' expected \(, \), got str", ): validate_type("s", (int, float), "field") + + +@pytest.mark.parametrize( + "value, expected", + [ + (1, _Color.GREEN), + ("GREEN", _Color.GREEN), + (0, _Color.RED), + ("RED", _Color.RED), + ], +) +def test_decode_enum(value: int | str, expected: _Color) -> None: + assert decode_enum(value, _Color, "field") is expected + + +@pytest.mark.parametrize( + "value, expected_error", + [ + ([], TypeError), + (True, TypeError), + (False, TypeError), + (99, ValueError), + ("NOT_A_COLOR", ValueError), + ], +) +def test_decode_enum_errors(value: Any, expected_error: type[Exception]) -> None: + with pytest.raises(expected_error): + decode_enum(value, _Color, "field") + + +def test_decode_enum_error_messages_include_field_name() -> None: + with pytest.raises(ValueError, match="field"): + decode_enum(99, _Color, "field") + + with pytest.raises(ValueError, match="field"): + decode_enum("NOT_A_COLOR", _Color, "field") diff --git a/codegen/opentelemetry-codegen-json/tests/test_serde.py b/codegen/opentelemetry-codegen-json/tests/test_serde.py index 2e88011c7e6..e6f6e1ff641 100644 --- a/codegen/opentelemetry-codegen-json/tests/test_serde.py +++ b/codegen/opentelemetry-codegen-json/tests/test_serde.py @@ -103,6 +103,17 @@ def test_generated_message_roundtrip( assert new_msg == msg +def test_enum_field_accepts_name_and_int(test_v1_types: tuple[type[Any], type[Any]]) -> None: + TestMessage, _ = test_v1_types + + from_name = TestMessage.from_dict({"enumValue": "SUCCESS"}) + from_int = TestMessage.from_dict({"enumValue": 1}) + + assert from_name.enum_value == TestMessage.TestEnum.SUCCESS + assert from_int.enum_value == TestMessage.TestEnum.SUCCESS + assert from_name == from_int + + def test_cross_reference(common_v1_types: type[Any], trace_v1_types: type[Any]) -> None: InstrumentationScope = common_v1_types Span = trace_v1_types @@ -252,6 +263,25 @@ def test_nested_enum_suite(complex_v1_types: tuple[type[Any], ...]) -> None: assert new_msg.repeated_nested == msg.repeated_nested +def test_nested_enum_suite_accepts_names( + complex_v1_types: tuple[type[Any], ...], +) -> None: + NestedEnumSuite = complex_v1_types[3] + + msg = NestedEnumSuite.from_dict( + { + "nested": "NESTED_FOO", + "repeatedNested": ["NESTED_FOO", "NESTED_BAR"], + } + ) + + assert msg.nested == NestedEnumSuite.NestedEnum.NESTED_FOO + assert msg.repeated_nested == [ + NestedEnumSuite.NestedEnum.NESTED_FOO, + NestedEnumSuite.NestedEnum.NESTED_BAR, + ] + + def test_deeply_nested(complex_v1_types: tuple[type[Any], ...]) -> None: DeeplyNested = complex_v1_types[4] @@ -300,7 +330,8 @@ def test_defaults_and_none( ({"listStrings": "not a list"}, TypeError, "expected "), ({"name": 123}, TypeError, "expected "), ({"subMessage": "not a dict"}, TypeError, "expected "), - ({"enumValue": "SUCCESS"}, TypeError, "expected "), + ({"enumValue": []}, TypeError, "expected"), + ({"enumValue": "NOT_A_NAME"}, ValueError, None), ({"listMessages": [None]}, TypeError, "expected "), ], ) diff --git a/docs/api/baggage.rst b/docs/api/baggage.rst index 34712e78bd8..aa180b8b24a 100644 --- a/docs/api/baggage.rst +++ b/docs/api/baggage.rst @@ -1,6 +1,73 @@ opentelemetry.baggage package ======================================== +The baggage API stores name/value pairs in an OpenTelemetry +``Context``. The helper functions in :mod:`opentelemetry.baggage` +return updated ``Context`` values. They do not mutate or attach the +current context automatically. + +This distinction matters when reading baggage: + +* ``baggage.get_all()`` reads from the current context. +* ``baggage.get_all(context=ctx)`` reads from the explicit context. +* ``baggage.set_baggage(...)`` returns a context containing the new + baggage entry. + +For example: + +.. code-block:: python + + from opentelemetry import baggage + + ctx = baggage.set_baggage("tenant", "acme") + + print(baggage.get_all()) + # {} + + print(dict(baggage.get_all(context=ctx))) + # {'tenant': 'acme'} + +To make the returned context current for the current execution unit, +attach it and later detach it with the token returned by +``context.attach``: + +.. code-block:: python + + from opentelemetry import baggage, context + + ctx = baggage.set_baggage("tenant", "acme") + token = context.attach(ctx) + try: + print(dict(baggage.get_all())) + # {'tenant': 'acme'} + finally: + context.detach(token) + +Always pair ``context.attach`` with ``context.detach``. Dropping the +token can leak baggage into later work on the same execution unit. + +Passing ``context=...`` to another API is also different from attaching +that context. For example, ``tracer.start_as_current_span(..., +context=ctx)`` uses ``ctx`` to choose the parent span, but it does not +make ``ctx`` current for later calls to ``baggage.get_all()``. Attach +the context first when the current baggage should be visible inside the +span scope: + +.. code-block:: python + + from opentelemetry import baggage, context, trace + + tracer = trace.get_tracer(__name__) + ctx = baggage.set_baggage("tenant", "acme") + + token = context.attach(ctx) + try: + with tracer.start_as_current_span("work"): + print(dict(baggage.get_all())) + # {'tenant': 'acme'} + finally: + context.detach(token) + Subpackages ----------- diff --git a/docs/api/context.rst b/docs/api/context.rst index e73d8f674b2..037de971efe 100644 --- a/docs/api/context.rst +++ b/docs/api/context.rst @@ -1,6 +1,35 @@ opentelemetry.context package ============================= +OpenTelemetry context values are immutable snapshots. Functions such as +``set_value`` return a new ``Context`` object rather than mutating the +current one. + +There are two common ways to use a context: + +* Pass it explicitly to APIs that accept a ``context=...`` parameter. +* Attach it with ``context.attach`` so it becomes current for the + current execution unit, then restore the previous context with + ``context.detach``. + +For example: + +.. code-block:: python + + from opentelemetry import context + + ctx = context.set_value("key", "value") + + print(context.get_current().get("key")) + # None + + token = context.attach(ctx) + try: + print(context.get_current().get("key")) + # value + finally: + context.detach(token) + Module contents --------------- diff --git a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py index b59f47d28e4..6f97b6d4f9c 100644 --- a/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py +++ b/exporter/opentelemetry-exporter-otlp-json-common/tests/test_metrics_encoder.py @@ -126,6 +126,26 @@ def test_encode_exponential_histogram(self): self.assertEqual(dp.negative.offset, 1) self.assertEqual(dp.negative.bucket_counts, [1]) + def test_encode_histogram_without_min_max(self): + for make_metric, field_name, json_field_name in ( + (make_histogram, "histogram", "histogram"), + (make_exponential_histogram, "exponential_histogram", "exponentialHistogram"), + ): + with self.subTest(histogram_type=field_name): + metric = make_metric(min_value=None, max_value=None) + result = encode_metrics(make_metrics_data([metric])) + encoded = getattr(_get_first_metric(result), field_name) + self.assertIsNotNone(encoded) + point = encoded.data_points[0] + self.assertIsNone(point.min) + self.assertIsNone(point.max) + + result_json = json.loads(result.to_json()) + metric_json = result_json["resourceMetrics"][0]["scopeMetrics"][0]["metrics"][0] + point_json = metric_json[json_field_name]["dataPoints"][0] + self.assertNotIn("min", point_json) + self.assertNotIn("max", point_json) + def test_encode_exponential_histogram_empty_buckets(self): metric = make_exponential_histogram( attributes={}, diff --git a/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_metrics_encoder.py b/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_metrics_encoder.py index a1b40a0c2c9..37993260512 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_metrics_encoder.py +++ b/exporter/opentelemetry-exporter-otlp-proto-common/tests/test_metrics_encoder.py @@ -880,6 +880,76 @@ def test_encode_exponential_histogram(self): actual = encode_metrics(metrics_data) self.assertEqual(expected, actual) + def test_encode_histogram_without_min_max(self): + histogram_data_point = HistogramDataPoint( + attributes={}, + start_time_unix_nano=0, + time_unix_nano=1, + count=2, + sum=55, + bucket_counts=[1, 1], + explicit_bounds=[10.0], + min=None, + max=None, + ) + exponential_histogram_data_point = ExponentialHistogramDataPoint( + attributes={}, + start_time_unix_nano=0, + time_unix_nano=1, + count=2, + sum=55, + scale=4, + zero_count=0, + positive=Buckets(offset=6, bucket_counts=[1, 1]), + negative=Buckets(offset=0, bucket_counts=[]), + flags=0, + min=None, + max=None, + ) + metrics_data = MetricsData( + resource_metrics=[ + ResourceMetrics( + resource=Resource(attributes={}), + scope_metrics=[ + ScopeMetrics( + scope=SDKInstrumentationScope(name="first_name", version="first_version"), + metrics=[ + Metric( + name="histogram", + description="", + unit="", + data=HistogramType( + data_points=[histogram_data_point], + aggregation_temporality=AggregationTemporality.DELTA, + ), + ), + Metric( + name="exponential_histogram", + description="", + unit="", + data=ExponentialHistogramType( + data_points=[exponential_histogram_data_point], + aggregation_temporality=AggregationTemporality.DELTA, + ), + ), + ], + schema_url="", + ) + ], + schema_url="", + ) + ] + ) + + metrics = encode_metrics(metrics_data).resource_metrics[0].scope_metrics[0].metrics + for pt in ( + metrics[0].histogram.data_points[0], + metrics[1].exponential_histogram.data_points[0], + ): + with self.subTest(data_point=type(pt).__name__): + self.assertFalse(pt.HasField("min")) + self.assertFalse(pt.HasField("max")) + def test_encoding_exception_reraise(self): # this number is too big to fit in a signed 64-bit proto field and causes a ValueError big_number = 2**63 diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py index 590d4b88fd3..00dde4ceb04 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py @@ -59,10 +59,46 @@ --- """ +from logging import getLogger +from os import environ +from typing import overload + from .version import __version__ +_logger = getLogger(__name__) + _USER_AGENT_HEADER_VALUE = "OTel-OTLP-Exporter-Python/" + __version__ _OTLP_GRPC_CHANNEL_OPTIONS = [ # this will appear in the http User-Agent header ("grpc.primary_user_agent", _USER_AGENT_HEADER_VALUE) ] + + +@overload +def _timeout_from_env(*environ_keys: str, default: float) -> float: ... + + +@overload +def _timeout_from_env(*environ_keys: str, default: None = None) -> float | None: ... + + +def _timeout_from_env(*environ_keys: str, default: float | None = None) -> float | None: + """Return the first environment variable in ``environ_keys`` holding a + valid float, or ``default`` if none does. + + Unset or empty variables are skipped silently; variables with a + non-numeric value are skipped with a warning. + """ + for environ_key in environ_keys: + value = environ.get(environ_key) + if value is None or not value.strip(): + continue + try: + return float(value) + except ValueError: + _logger.warning( + "Invalid value %r for environment variable %s, ignoring it.", + value, + environ_key, + ) + return default diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py index c704990d048..e9b4a8e3345 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py @@ -8,6 +8,7 @@ from grpc import ChannelCredentials, Compression, StatusCode from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs +from opentelemetry.exporter.otlp.proto.grpc import _timeout_from_env from opentelemetry.exporter.otlp.proto.grpc.exporter import ( OTLPExporterMixin, _get_credentials, @@ -76,8 +77,7 @@ def __init__( OTEL_EXPORTER_OTLP_LOGS_CLIENT_CERTIFICATE, ) - environ_timeout = environ.get(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT) - environ_timeout = float(environ_timeout) if environ_timeout is not None else None + environ_timeout = _timeout_from_env(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT) compression = ( environ_to_compression(OTEL_EXPORTER_OTLP_LOGS_COMPRESSION) if compression is None else compression diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py index be135045c7e..4aea624602b 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py @@ -52,6 +52,7 @@ ) from opentelemetry.exporter.otlp.proto.grpc import ( _OTLP_GRPC_CHANNEL_OPTIONS, + _timeout_from_env, ) from opentelemetry.metrics import MeterProvider from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import ( @@ -329,7 +330,7 @@ def __init__( else: self._channel_options = tuple(_OTLP_GRPC_CHANNEL_OPTIONS) - self._timeout = timeout or float(environ.get(OTEL_EXPORTER_OTLP_TIMEOUT, 10)) + self._timeout = timeout or _timeout_from_env(OTEL_EXPORTER_OTLP_TIMEOUT, default=10) self._collector_kwargs = None self._compression = ( diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py index 35eef81753c..c83b1d53999 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py @@ -16,6 +16,7 @@ from opentelemetry.exporter.otlp.proto.common.metrics_encoder import ( encode_metrics, ) +from opentelemetry.exporter.otlp.proto.grpc import _timeout_from_env from opentelemetry.exporter.otlp.proto.grpc.exporter import ( # noqa: F401 OTLPExporterMixin, _get_credentials, @@ -118,8 +119,7 @@ def __init__( OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE, ) - environ_timeout = environ.get(OTEL_EXPORTER_OTLP_METRICS_TIMEOUT) - environ_timeout = float(environ_timeout) if environ_timeout is not None else None + environ_timeout = _timeout_from_env(OTEL_EXPORTER_OTLP_METRICS_TIMEOUT) compression = ( environ_to_compression(OTEL_EXPORTER_OTLP_METRICS_COMPRESSION) if compression is None else compression diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py index 4a09594421c..86d79f662d0 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py @@ -12,6 +12,7 @@ from opentelemetry.exporter.otlp.proto.common.trace_encoder import ( encode_spans, ) +from opentelemetry.exporter.otlp.proto.grpc import _timeout_from_env from opentelemetry.exporter.otlp.proto.grpc.exporter import ( # noqa: F401 OTLPExporterMixin, _get_credentials, @@ -104,8 +105,7 @@ def __init__( OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE, ) - environ_timeout = environ.get(OTEL_EXPORTER_OTLP_TRACES_TIMEOUT) - environ_timeout = float(environ_timeout) if environ_timeout is not None else None + environ_timeout = _timeout_from_env(OTEL_EXPORTER_OTLP_TRACES_TIMEOUT) compression = ( environ_to_compression(OTEL_EXPORTER_OTLP_TRACES_COMPRESSION) if compression is None else compression diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py index 4e21ee3ac50..1d8a47be84e 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py @@ -45,6 +45,7 @@ from opentelemetry.sdk.environment_variables import ( _OTEL_PYTHON_EXPORTER_OTLP_GRPC_CREDENTIAL_PROVIDER, OTEL_EXPORTER_OTLP_COMPRESSION, + OTEL_EXPORTER_OTLP_TIMEOUT, OTEL_PYTHON_SDK_INTERNAL_METRICS_ENABLED, ) from opentelemetry.sdk.metrics import MeterProvider @@ -533,6 +534,15 @@ def test_timeout_set_correctly(self): self.assertEqual(mock_trace_service.num_requests, 2) self.assertAlmostEqual(after - before, 1.4, delta=0.5) + @patch.dict("os.environ", {OTEL_EXPORTER_OTLP_TIMEOUT: "invalid"}) + def test_invalid_timeout_from_env_uses_default(self): + # pylint: disable=protected-access + with self.assertLogs(level=WARNING) as warning: + exporter = OTLPSpanExporterForTesting(insecure=True) + self.assertEqual(exporter._timeout, 10) + self.assertIn("Invalid value", warning.records[0].message) + self.assertIn(OTEL_EXPORTER_OTLP_TIMEOUT, warning.records[0].message) + def test_channel_options_set_correctly(self): """Test that gRPC channel options are set correctly for keepalive and reconnection""" # This test verifies that the channel is created with the right options diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py index 8f153ccc6b3..9f58ee9afd9 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-lines import os +from logging import WARNING from unittest import TestCase from unittest.mock import Mock, PropertyMock, patch @@ -159,6 +160,19 @@ def test_env_variables(self, mock_exporter_mixin): self.assertEqual(kwargs["compression"], Compression.Gzip) self.assertIsNone(kwargs["credentials"]) + @patch.dict( + "os.environ", + {OTEL_EXPORTER_OTLP_TRACES_TIMEOUT: "invalid"}, + ) + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.OTLPExporterMixin.__init__") + def test_env_variables_invalid_timeout(self, mock_exporter_mixin): + with self.assertLogs(level=WARNING) as warning: + OTLPSpanExporter() + _, kwargs = mock_exporter_mixin.call_args_list[0] + self.assertIsNone(kwargs["timeout"]) + self.assertIn("Invalid value", warning.records[0].message) + self.assertIn(OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, warning.records[0].message) + @patch.dict( "os.environ", { diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_timeout_from_env.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_timeout_from_env.py new file mode 100644 index 00000000000..e75ac1dcd03 --- /dev/null +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_timeout_from_env.py @@ -0,0 +1,88 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from logging import WARNING +from unittest.mock import patch + +import pytest + +from opentelemetry.exporter.otlp.proto.grpc import _timeout_from_env + + +@pytest.mark.parametrize( + "environ,keys,default,expected,warns_for", + [ + pytest.param( + {"TEST_TIMEOUT": "15"}, + ("TEST_TIMEOUT",), + 10, + 15, + None, + id="valid value", + ), + pytest.param( + {}, + ("TEST_TIMEOUT",), + 10, + 10, + None, + id="unset falls back to default", + ), + pytest.param( + {}, + ("TEST_TIMEOUT",), + None, + None, + None, + id="unset with no default returns None", + ), + pytest.param( + {"TEST_TIMEOUT": " "}, + ("TEST_TIMEOUT",), + 10, + 10, + None, + id="empty/whitespace falls back to default", + ), + pytest.param( + {"TEST_TIMEOUT": "abc"}, + ("TEST_TIMEOUT",), + 10, + 10, + "TEST_TIMEOUT", + id="invalid value warns and falls back to default", + ), + pytest.param( + {"TEST_SIGNAL_TIMEOUT": "5", "TEST_TIMEOUT": "15"}, + ("TEST_SIGNAL_TIMEOUT", "TEST_TIMEOUT"), + 10, + 5, + None, + id="first key takes priority", + ), + pytest.param( + {"TEST_SIGNAL_TIMEOUT": "abc", "TEST_TIMEOUT": "15"}, + ("TEST_SIGNAL_TIMEOUT", "TEST_TIMEOUT"), + 10, + 15, + "TEST_SIGNAL_TIMEOUT", + id="invalid first key warns and falls back to next key", + ), + ], +) +def test_timeout_from_env(caplog, environ, keys, default, expected, warns_for): + """``warns_for`` names the environment variable the warning must mention, + or is ``None`` when no warning is expected.""" + with ( + patch.dict("os.environ", environ, clear=True), + caplog.at_level(WARNING), + ): + result = _timeout_from_env(*keys, default=default) + + assert result == expected + if warns_for is None: + assert not caplog.records + else: + assert len(caplog.records) == 1 + assert "Invalid value" in caplog.records[0].message + assert warns_for in caplog.records[0].message diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 6bc3590f0f1..fb2dc44c4f2 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -15,6 +15,11 @@ from opentelemetry.util import types _logger = logging.getLogger(__name__) +_MAX_NESTING_DEPTH = 20 + + +class _AttributeValueTooDeepError(Exception): + """Raised when an attribute value exceeds the supported nesting depth.""" # Calling str(x) will use an object's `__str__` method if it exists, otherwise it will use it's `__repr__` method. @@ -52,6 +57,23 @@ def _clean_attribute_value( Returns: The recursively cleaned AnyValue. """ + try: + return _clean_attribute_value_impl(value, max_string_value_length, 0) + except _AttributeValueTooDeepError: + _logger.warning( + "Attribute value exceeds the maximum nesting depth of %d. Replacing value with None.", + _MAX_NESTING_DEPTH, + ) + return None + + +def _clean_attribute_value_impl( + value: types.AnyValue, + max_string_value_length: int | None, + depth: int, +) -> types.AnyValue: + if depth > _MAX_NESTING_DEPTH: + raise _AttributeValueTooDeepError if isinstance(value, (NoneType, bool, int, float, bytes)): return value if isinstance(value, str): @@ -63,28 +85,13 @@ def _clean_attribute_value( value = value[:max_string_value_length] return value if isinstance(value, Sequence): - return tuple(_clean_attribute_value(v, max_string_value_length) for v in value) + return tuple(_clean_attribute_value_impl(v, max_string_value_length, depth + 1) for v in value) if isinstance(value, Mapping): cleaned_mapping: dict[str, types.AnyValue] = {} for key, val in value.items(): - if not key: - _logger.warning( - "invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.", - key, - ) - continue - # Spec says to convert unknown types to strings if possible (here and below too). - if not isinstance(key, str): - _logger.warning( - "Invalid type `%s` for attribute key `%s`, must be a str. Key's `__str__/__repr__` method will be called if it exists, otherwise the key/value pair will be dropped.", - type(key), - key, - ) - if _is_non_custom_str(key): - key = str(key) - else: - continue - cleaned_mapping[key] = _clean_attribute_value(val, max_string_value_length) + cleaned_key = _clean_attribute_key(key) + if cleaned_key is not None: + cleaned_mapping[cleaned_key] = _clean_attribute_value_impl(val, max_string_value_length, depth + 1) return cleaned_mapping if TYPE_CHECKING: assert_never(value) @@ -98,6 +105,38 @@ def _clean_attribute_value( return None +def _clean_attribute_key(key: object) -> str | None: + if not key: + _logger.warning( + "invalid attribute key `%s`. must be non-empty string. Dropping key from attributes.", + key, + ) + return None + if not isinstance(key, str): + _logger.warning( + "Invalid type `%s` for attribute key `%s`, must be a str. Key's `__str__/__repr__` method will be called if it exists, otherwise the key/value pair will be dropped.", + type(key), + key, + ) + if _is_non_custom_str(key): + return str(key) + return None + return key + + +def _clean_attributes( + attributes: Mapping[str, types.AnyValue], + max_string_value_length: int | None, +) -> dict[str, types.AnyValue]: + """Clean top-level attributes while isolating invalid individual values.""" + cleaned_attributes: dict[str, types.AnyValue] = {} + for key, value in attributes.items(): + cleaned_key = _clean_attribute_key(key) + if cleaned_key is not None: + cleaned_attributes[cleaned_key] = _clean_attribute_value(value, max_string_value_length) + return cleaned_attributes + + class BoundedAttributes(MutableMapping[str, types.AnyValue]): """A dict with a fixed max capacity which cleans and potentially drops values to ensure they are valid attribute values. @@ -193,7 +232,7 @@ def _set_items(self, attributes: Mapping[str, types.AnyValue]) -> None: with self._lock: self.dropped += len(attributes) return - cleaned_attributes: Mapping[str, types.AnyValue] = _clean_attribute_value(attributes, self.max_value_len) + cleaned_attributes = _clean_attributes(attributes, self.max_value_len) with self._lock: self.dropped += len(attributes) - len(cleaned_attributes) for key, value in cleaned_attributes.items(): diff --git a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py index bb41c132f3a..bc069bd7831 100644 --- a/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py +++ b/opentelemetry-api/src/opentelemetry/trace/propagation/tracecontext.py @@ -1,13 +1,19 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 # +import logging import re from opentelemetry import trace from opentelemetry.context.context import Context from opentelemetry.propagators import textmap from opentelemetry.trace import format_span_id, format_trace_id -from opentelemetry.trace.span import TraceState +from opentelemetry.trace.span import ( + _TRACECONTEXT_MAXIMUM_TRACESTATE_LENGTH, + TraceState, +) + +_logger = logging.getLogger(__name__) class TraceContextTextMapPropagator(textmap.TextMapPropagator): @@ -57,6 +63,16 @@ def extract( tracestate_headers = getter.get(carrier, self._TRACESTATE_HEADER_NAME) if tracestate_headers is None: tracestate = None + elif ( + # multiple header fields are combined with "," (RFC 9110, section 5.3) + sum(len(header) for header in tracestate_headers) + len(tracestate_headers) - 1 + > _TRACECONTEXT_MAXIMUM_TRACESTATE_LENGTH + ): + _logger.warning( + "tracestate header exceeded the maximum length of %d characters", + _TRACECONTEXT_MAXIMUM_TRACESTATE_LENGTH, + ) + tracestate = None else: tracestate = TraceState.from_header(tracestate_headers) diff --git a/opentelemetry-api/src/opentelemetry/trace/span.py b/opentelemetry-api/src/opentelemetry/trace/span.py index 85f114c7d37..bf68050a19b 100644 --- a/opentelemetry-api/src/opentelemetry/trace/span.py +++ b/opentelemetry-api/src/opentelemetry/trace/span.py @@ -43,8 +43,8 @@ _TRACECONTEXT_MAXIMUM_TRACESTATE_KEYS = 32 -_delimiter_pattern = re.compile(r"[ \t]*,[ \t]*") -_member_pattern = re.compile(f"({_KEY_FORMAT})(=)({_VALUE_FORMAT})[ \t]*") +_TRACECONTEXT_MAXIMUM_TRACESTATE_LENGTH = 8192 +_member_pattern = re.compile(f"({_KEY_FORMAT})=({_VALUE_FORMAT})") _logger = logging.getLogger(__name__) @@ -397,8 +397,9 @@ def from_header(cls, header_list: list[str]) -> TraceState: """ pairs = {} # type: dict[str, str] for header in header_list: - members: list[str] = re.split(_delimiter_pattern, header) - for member in members: + for member in header.split(","): + # remove optional whitespace (RFC 9110, section 5.6.3) + member = member.strip(" \t") # empty members are valid, but no need to process further. if not member: continue @@ -409,8 +410,7 @@ def from_header(cls, header_list: list[str]) -> TraceState: member, ) return cls() - groups: tuple[str, ...] = match.groups() - key, _eq, value = groups + key, value = match.groups() # duplicate keys are not legal in header if key in pairs: return cls() diff --git a/opentelemetry-api/tests/attributes/test_attributes.py b/opentelemetry-api/tests/attributes/test_attributes.py index 2d9c8cb9c29..50ac268ec45 100644 --- a/opentelemetry-api/tests/attributes/test_attributes.py +++ b/opentelemetry-api/tests/attributes/test_attributes.py @@ -86,6 +86,58 @@ def test_clean_attribute_value_with_various_params(self): {"a": 1, "c": 3, "d": (2, 3), "bytes": b"\xff"}, ) + def test_cyclic_attribute_values_are_replaced_with_none(self): + cyclic_list = [1, 2] + cyclic_list.append(cyclic_list) + cyclic_mapping = {"value": 1} + cyclic_mapping["self"] = cyclic_mapping + + with self.assertLogs("opentelemetry", level="WARNING") as cm: + self.assertIsNone(_clean_attribute_value(cyclic_list, None)) + self.assertIsNone(_clean_attribute_value(cyclic_mapping, None)) + + self.assertEqual(len(cm.output), 2) + + def test_excessively_nested_attribute_values_are_replaced_with_none(self): + value = "value" + for _ in range(5000): + value = [value] + + with self.assertLogs("opentelemetry", level="WARNING"): + self.assertIsNone(_clean_attribute_value(value, None)) + + def test_attribute_values_at_supported_depth_are_preserved(self): + value = "value" + for _ in range(20): + value = [value] + + cleaned = _clean_attribute_value(value, None) + for _ in range(20): + self.assertIsInstance(cleaned, tuple) + cleaned = cleaned[0] + self.assertEqual(cleaned, "value") + + def test_cyclic_attribute_keeps_sibling_attributes(self): + cyclic = [] + cyclic.append(cyclic) + + with self.assertLogs("opentelemetry", level="WARNING"): + attributes = BoundedAttributes(attributes={"good": "kept", "cyclic": cyclic}) + + self.assertEqual(attributes["good"], "kept") + self.assertIsNone(attributes["cyclic"]) + + def test_cyclic_attribute_can_be_set_on_bounded_attributes(self): + cyclic = {} + cyclic["self"] = cyclic + attributes = BoundedAttributes(immutable=False) + + with self.assertLogs("opentelemetry", level="WARNING"): + attributes["cyclic"] = cyclic + + self.assertIn("cyclic", attributes) + self.assertIsNone(attributes["cyclic"]) + def test_same_key_value_overwritten(self): bdict = BoundedAttributes(1, {"name": "Firulais"}, immutable=False) self.assertEqual(bdict["name"], "Firulais") diff --git a/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py b/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py index e31443b0a56..951d33779c4 100644 --- a/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py +++ b/opentelemetry-api/tests/trace/propagation/test_tracecontexthttptextformat.py @@ -180,6 +180,46 @@ def test_tracestate_header_with_trailing_comma(self): ) self.assertEqual(span.get_span_context().trace_state["foo"], "1") + def test_tracestate_header_exceeds_maximum_length(self): + """Discard tracestate exceeding the maximum combined length, but keep traceparent.""" + cases = { + "single header": ["foo=1" + " " * 8188], + "combined headers": ["foo=1" + " " * 4091, "bar=2" + " " * 4091], + } + for name, tracestate in cases.items(): + with self.subTest(name): + with self.assertLogs(tracecontext.__name__, level="WARNING"): + span = trace.get_current_span( + FORMAT.extract( + { + "traceparent": ["00-12345678901234567890123456789012-1234567890123456-00"], + "tracestate": tracestate, + }, + ) + ) + span_context = span.get_span_context() + self.assertEqual(span_context.trace_id, self.TRACE_ID) + self.assertEqual(span_context.span_id, self.SPAN_ID) + self.assertEqual(len(span_context.trace_state), 0) + + def test_tracestate_header_at_maximum_length(self): + """Accept tracestate with a combined length equal to the maximum.""" + cases = { + "single header": ["foo=1" + " " * 8187], + "combined headers": ["foo=1" + " " * 4090, "bar=2" + " " * 4091], + } + for name, tracestate in cases.items(): + with self.subTest(name): + span = trace.get_current_span( + FORMAT.extract( + { + "traceparent": ["00-12345678901234567890123456789012-1234567890123456-00"], + "tracestate": tracestate, + }, + ) + ) + self.assertEqual(span.get_span_context().trace_state["foo"], "1") + def test_tracestate_keys(self): """Test for valid key patterns in the tracestate""" tracestate_value = ",".join( diff --git a/opentelemetry-api/tests/trace/test_tracestate.py b/opentelemetry-api/tests/trace/test_tracestate.py index 62e6f5fe29b..1d8fc283af0 100644 --- a/opentelemetry-api/tests/trace/test_tracestate.py +++ b/opentelemetry-api/tests/trace/test_tracestate.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # pylint: disable=no-member +import time import unittest from opentelemetry.trace.span import TraceState @@ -128,3 +129,28 @@ def test_trace_contains(self): self.assertIsNone(state.get("bar")) with self.assertRaises(KeyError): state["bar"] # pylint:disable=W0104 + + def test_tracestate_from_header_optional_whitespace(self): + cases = [ + ("foo=1 , bar=2", {"foo": "1", "bar": "2"}), + ("\tfoo=1,\tbar=2\t", {"foo": "1", "bar": "2"}), + (" foo=1", {"foo": "1"}), + ("foo=1, ,bar=2", {"foo": "1", "bar": "2"}), + ("foo=1,", {"foo": "1"}), + ("foo=a b", {"foo": "a b"}), + ] + for header, expected in cases: + with self.subTest(header=header): + state = TraceState.from_header([header]) + self.assertEqual(dict(state), expected) + + def test_tracestate_from_header_long_whitespace_run(self): + for whitespace in (" ", "\t"): + with self.subTest(whitespace=repr(whitespace)): + header = "a=b" + whitespace * 100_000 + "x" + start = time.perf_counter() + with self.assertLogs(level="WARNING"): + state = TraceState.from_header([header]) + elapsed = time.perf_counter() - start + self.assertEqual(len(state), 0) + self.assertLess(elapsed, 1) diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/_json_codec.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/_json_codec.py index 69f74ed657f..e528f4f0eb9 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/_json_codec.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/_json_codec.py @@ -6,6 +6,7 @@ import abc import base64 import collections.abc +import enum import json import math import typing @@ -14,6 +15,7 @@ T = typing.TypeVar("T") M = typing.TypeVar("M", bound="JsonMessage") +EnumT = typing.TypeVar("EnumT", bound=enum.IntEnum) class JsonMessage(abc.ABC): @@ -134,9 +136,7 @@ def decode_hex(value: str | None, field_name: str) -> bytes: try: return bytes.fromhex(value) except ValueError as error: - raise ValueError( - f"Invalid hex string for field '{field_name}': {error}" - ) from None + raise ValueError(f"Invalid hex string for field '{field_name}': {error}") from None def decode_base64(value: str | None, field_name: str) -> bytes: @@ -155,9 +155,7 @@ def decode_base64(value: str | None, field_name: str) -> bytes: try: return base64.b64decode(value) except Exception as error: - raise ValueError( - f"Invalid base64 string for field '{field_name}': {error}" - ) from None + raise ValueError(f"Invalid base64 string for field '{field_name}': {error}") from None def decode_int64(value: int | str | None, field_name: str) -> int: @@ -176,9 +174,7 @@ def decode_int64(value: int | str | None, field_name: str) -> int: try: return int(value) except (ValueError, TypeError): - raise ValueError( - f"Invalid int64 value for field '{field_name}': {value}" - ) from None + raise ValueError(f"Invalid int64 value for field '{field_name}': {value}") from None def decode_float(value: float | str | None, field_name: str) -> float: @@ -203,9 +199,7 @@ def decode_float(value: float | str | None, field_name: str) -> float: try: return float(value) except (ValueError, TypeError): - raise ValueError( - f"Invalid float value for field '{field_name}': {value}" - ) from None + raise ValueError(f"Invalid float value for field '{field_name}': {value}") from None def decode_repeated( @@ -244,7 +238,32 @@ def validate_type( field_name: The name of the field being validated (for error messages). """ if not isinstance(value, expected_types): - raise TypeError( - f"Field '{field_name}' expected {expected_types}, " - f"got {type(value).__name__}" - ) + raise TypeError(f"Field '{field_name}' expected {expected_types}, got {type(value).__name__}") + + +def decode_enum(value: int | str, enum_type: type[EnumT], field_name: str) -> EnumT: + """ + Decode a JSON enum value into an enum member. + + Per the ProtoJSON spec, parsers must accept both enum names (str) + and integer values (int). + + Args: + value: The enum name or integer value to decode. + enum_type: The enum class to decode into. + field_name: The name of the field being decoded (for error messages). + Returns: + The corresponding enum member. + """ + if isinstance(value, bool): + raise TypeError(f"Field '{field_name}' expected int or str, got bool") + validate_type(value, (int, str), field_name) + if isinstance(value, str): + try: + return enum_type[value] + except KeyError: + raise ValueError(f"Invalid enum name '{value}' for field '{field_name}'") from None + try: + return enum_type(value) + except ValueError: + raise ValueError(f"Invalid enum value {value} for field '{field_name}'") from None diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/logs/v1/logs_service.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/logs/v1/logs_service.py index eea5f8f5037..076811f6d7b 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/logs/v1/logs_service.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/logs/v1/logs_service.py @@ -8,17 +8,14 @@ import builtins import dataclasses -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.logs.v1.logs @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportLogsServiceRequest(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportLogsServiceRequest @@ -59,7 +56,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportLogsS @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportLogsServiceResponse(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportLogsServiceResponse @@ -100,7 +97,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportLogsS @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportLogsPartialSuccess(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportLogsPartialSuccess diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/metrics/v1/metrics_service.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/metrics/v1/metrics_service.py index 462b85da818..79d32f76d68 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/metrics/v1/metrics_service.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/metrics/v1/metrics_service.py @@ -8,17 +8,14 @@ import builtins import dataclasses -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.metrics.v1.metrics @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportMetricsServiceRequest(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportMetricsServiceRequest @@ -59,7 +56,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportMetri @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportMetricsServiceResponse(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportMetricsServiceResponse @@ -100,7 +97,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportMetri @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportMetricsPartialSuccess(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportMetricsPartialSuccess diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/profiles/v1development/profiles_service.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/profiles/v1development/profiles_service.py index cdb76f5b812..f348d2d3137 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/profiles/v1development/profiles_service.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/profiles/v1development/profiles_service.py @@ -8,17 +8,14 @@ import builtins import dataclasses -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.profiles.v1development.profiles @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportProfilesServiceRequest(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportProfilesServiceRequest @@ -64,7 +61,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportProfi @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportProfilesServiceResponse(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportProfilesServiceResponse @@ -105,7 +102,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportProfi @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportProfilesPartialSuccess(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportProfilesPartialSuccess diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/trace/v1/trace_service.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/trace/v1/trace_service.py index 3ee11dac0a0..8af8a342460 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/trace/v1/trace_service.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/collector/trace/v1/trace_service.py @@ -8,17 +8,14 @@ import builtins import dataclasses -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.trace.v1.trace @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportTraceServiceRequest(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportTraceServiceRequest @@ -59,7 +56,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportTrace @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportTraceServiceResponse(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportTraceServiceResponse @@ -100,7 +97,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ExportTrace @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExportTracePartialSuccess(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExportTracePartialSuccess diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/common/v1/common.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/common/v1/common.py index 7f229be74c6..ca55d67f71d 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/common/v1/common.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/common/v1/common.py @@ -8,16 +8,13 @@ import builtins import dataclasses -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class AnyValue(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message AnyValue @@ -96,7 +93,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> AnyValue: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ArrayValue(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ArrayValue @@ -137,7 +134,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ArrayValue: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class KeyValueList(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message KeyValueList @@ -178,7 +175,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> KeyValueLis @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class KeyValue(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message KeyValue @@ -231,7 +228,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> KeyValue: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class InstrumentationScope(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message InstrumentationScope @@ -290,7 +287,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Instrumenta @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class EntityRef(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message EntityRef diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/logs/v1/logs.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/logs/v1/logs.py index c0aa87b33d0..fdf1bb95897 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/logs/v1/logs.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/logs/v1/logs.py @@ -9,11 +9,8 @@ import builtins import dataclasses import enum -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.common.v1.common import opentelemetry.proto_json.resource.v1.resource @@ -61,7 +58,7 @@ class LogRecordFlags(enum.IntEnum): LOG_RECORD_FLAGS_TRACE_FLAGS_MASK = 255 @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class LogsData(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message LogsData @@ -102,7 +99,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> LogsData: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ResourceLogs(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ResourceLogs @@ -154,7 +151,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ResourceLog @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ScopeLogs(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ScopeLogs @@ -206,7 +203,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ScopeLogs: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class LogRecord(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message LogRecord @@ -275,8 +272,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> LogRecord: if (_value := data.get("observedTimeUnixNano")) is not None: _args["observed_time_unix_nano"] = opentelemetry.proto_json._json_codec.decode_int64(_value, "observed_time_unix_nano") if (_value := data.get("severityNumber")) is not None: - opentelemetry.proto_json._json_codec.validate_type(_value, builtins.int, "severity_number") - _args["severity_number"] = SeverityNumber(_value) + _args["severity_number"] = opentelemetry.proto_json._json_codec.decode_enum(_value, SeverityNumber, "severity_number") if (_value := data.get("severityText")) is not None: opentelemetry.proto_json._json_codec.validate_type(_value, builtins.str, "severity_text") _args["severity_text"] = _value diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/metrics/v1/metrics.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/metrics/v1/metrics.py index 32a4267eb5b..d77d378e32f 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/metrics/v1/metrics.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/metrics/v1/metrics.py @@ -9,11 +9,8 @@ import builtins import dataclasses import enum -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.common.v1.common import opentelemetry.proto_json.resource.v1.resource @@ -39,7 +36,7 @@ class DataPointFlags(enum.IntEnum): DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK = 1 @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class MetricsData(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message MetricsData @@ -80,7 +77,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> MetricsData @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ResourceMetrics(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ResourceMetrics @@ -132,7 +129,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ResourceMet @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ScopeMetrics(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ScopeMetrics @@ -184,7 +181,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ScopeMetric @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Metric(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Metric @@ -268,7 +265,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Metric: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Gauge(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Gauge @@ -309,7 +306,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Gauge: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Sum(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Sum @@ -352,8 +349,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Sum: if (_value := data.get("dataPoints")) is not None: _args["data_points"] = opentelemetry.proto_json._json_codec.decode_repeated(_value, lambda _v: NumberDataPoint.from_dict(_v), "data_points") if (_value := data.get("aggregationTemporality")) is not None: - opentelemetry.proto_json._json_codec.validate_type(_value, builtins.int, "aggregation_temporality") - _args["aggregation_temporality"] = AggregationTemporality(_value) + _args["aggregation_temporality"] = opentelemetry.proto_json._json_codec.decode_enum(_value, AggregationTemporality, "aggregation_temporality") if (_value := data.get("isMonotonic")) is not None: opentelemetry.proto_json._json_codec.validate_type(_value, builtins.bool, "is_monotonic") _args["is_monotonic"] = _value @@ -362,7 +358,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Sum: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Histogram(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Histogram @@ -402,14 +398,13 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Histogram: if (_value := data.get("dataPoints")) is not None: _args["data_points"] = opentelemetry.proto_json._json_codec.decode_repeated(_value, lambda _v: HistogramDataPoint.from_dict(_v), "data_points") if (_value := data.get("aggregationTemporality")) is not None: - opentelemetry.proto_json._json_codec.validate_type(_value, builtins.int, "aggregation_temporality") - _args["aggregation_temporality"] = AggregationTemporality(_value) + _args["aggregation_temporality"] = opentelemetry.proto_json._json_codec.decode_enum(_value, AggregationTemporality, "aggregation_temporality") return cls(**_args) @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExponentialHistogram(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExponentialHistogram @@ -449,14 +444,13 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Exponential if (_value := data.get("dataPoints")) is not None: _args["data_points"] = opentelemetry.proto_json._json_codec.decode_repeated(_value, lambda _v: ExponentialHistogramDataPoint.from_dict(_v), "data_points") if (_value := data.get("aggregationTemporality")) is not None: - opentelemetry.proto_json._json_codec.validate_type(_value, builtins.int, "aggregation_temporality") - _args["aggregation_temporality"] = AggregationTemporality(_value) + _args["aggregation_temporality"] = opentelemetry.proto_json._json_codec.decode_enum(_value, AggregationTemporality, "aggregation_temporality") return cls(**_args) @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Summary(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Summary @@ -497,7 +491,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Summary: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class NumberDataPoint(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message NumberDataPoint @@ -569,7 +563,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> NumberDataP @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class HistogramDataPoint(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message HistogramDataPoint @@ -661,14 +655,14 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> HistogramDa @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ExponentialHistogramDataPoint(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ExponentialHistogramDataPoint """ @typing.final - @_dataclass + @dataclasses.dataclass(slots=True) class Buckets(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Buckets @@ -815,14 +809,14 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Exponential @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class SummaryDataPoint(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message SummaryDataPoint """ @typing.final - @_dataclass + @dataclasses.dataclass(slots=True) class ValueAtQuantile(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ValueAtQuantile @@ -932,7 +926,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> SummaryData @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Exemplar(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Exemplar diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/profiles/v1development/profiles.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/profiles/v1development/profiles.py index 6cc79192b6a..40fb96041d4 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/profiles/v1development/profiles.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/profiles/v1development/profiles.py @@ -8,18 +8,15 @@ import builtins import dataclasses -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.common.v1.common import opentelemetry.proto_json.resource.v1.resource @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ProfilesDictionary(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ProfilesDictionary @@ -90,7 +87,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ProfilesDic @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ProfilesData(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ProfilesData @@ -136,7 +133,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ProfilesDat @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ResourceProfiles(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ResourceProfiles @@ -188,7 +185,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ResourcePro @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ScopeProfiles(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ScopeProfiles @@ -240,7 +237,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ScopeProfil @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Profile(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Profile @@ -333,7 +330,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Profile: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Link(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Link @@ -379,7 +376,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Link: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ValueType(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ValueType @@ -427,7 +424,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ValueType: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Sample(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Sample @@ -490,7 +487,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Sample: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Mapping(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Mapping @@ -552,7 +549,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Mapping: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Stack(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Stack @@ -593,7 +590,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Stack: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Location(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Location @@ -650,7 +647,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Location: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Line(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Line @@ -702,7 +699,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Line: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Function(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Function @@ -761,7 +758,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Function: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class KeyValueAndUnit(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message KeyValueAndUnit diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/resource/v1/resource.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/resource/v1/resource.py index 4b0b764b768..3863d114748 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/resource/v1/resource.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/resource/v1/resource.py @@ -8,17 +8,14 @@ import builtins import dataclasses -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.common.v1.common @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Resource(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Resource diff --git a/opentelemetry-proto-json/src/opentelemetry/proto_json/trace/v1/trace.py b/opentelemetry-proto-json/src/opentelemetry/proto_json/trace/v1/trace.py index ce6ec07138d..e903601354d 100644 --- a/opentelemetry-proto-json/src/opentelemetry/proto_json/trace/v1/trace.py +++ b/opentelemetry-proto-json/src/opentelemetry/proto_json/trace/v1/trace.py @@ -9,11 +9,8 @@ import builtins import dataclasses import enum -import functools import typing -_dataclass = functools.partial(dataclasses.dataclass, slots=True) - import opentelemetry.proto_json._json_codec import opentelemetry.proto_json.common.v1.common import opentelemetry.proto_json.resource.v1.resource @@ -31,7 +28,7 @@ class SpanFlags(enum.IntEnum): SPAN_FLAGS_CONTEXT_IS_REMOTE_MASK = 512 @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class TracesData(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message TracesData @@ -72,7 +69,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> TracesData: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ResourceSpans(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ResourceSpans @@ -124,7 +121,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ResourceSpa @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class ScopeSpans(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message ScopeSpans @@ -176,7 +173,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> ScopeSpans: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Span(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Span @@ -196,7 +193,7 @@ class SpanKind(enum.IntEnum): SPAN_KIND_CONSUMER = 5 @typing.final - @_dataclass + @dataclasses.dataclass(slots=True) class Event(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Event @@ -253,7 +250,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Span.Event: return cls(**_args) @typing.final - @_dataclass + @dataclasses.dataclass(slots=True) class Link(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Link @@ -409,8 +406,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Span: opentelemetry.proto_json._json_codec.validate_type(_value, builtins.str, "name") _args["name"] = _value if (_value := data.get("kind")) is not None: - opentelemetry.proto_json._json_codec.validate_type(_value, builtins.int, "kind") - _args["kind"] = Span.SpanKind(_value) + _args["kind"] = opentelemetry.proto_json._json_codec.decode_enum(_value, Span.SpanKind, "kind") if (_value := data.get("startTimeUnixNano")) is not None: _args["start_time_unix_nano"] = opentelemetry.proto_json._json_codec.decode_int64(_value, "start_time_unix_nano") if (_value := data.get("endTimeUnixNano")) is not None: @@ -437,7 +433,7 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Span: @typing.final -@_dataclass +@dataclasses.dataclass(slots=True) class Status(opentelemetry.proto_json._json_codec.JsonMessage): """ Generated from protobuf message Status @@ -488,7 +484,6 @@ def from_dict(cls, data: builtins.dict[builtins.str, typing.Any]) -> Status: opentelemetry.proto_json._json_codec.validate_type(_value, builtins.str, "message") _args["message"] = _value if (_value := data.get("code")) is not None: - opentelemetry.proto_json._json_codec.validate_type(_value, builtins.int, "code") - _args["code"] = Status.StatusCode(_value) + _args["code"] = opentelemetry.proto_json._json_codec.decode_enum(_value, Status.StatusCode, "code") return cls(**_args) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py index bc09fd1db0f..5b543d01e44 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py @@ -412,7 +412,7 @@ class SynchronousMultiLogRecordProcessor(LogRecordProcessor): added. """ - def __init__(self): + def __init__(self) -> None: # use a tuple to avoid race conditions when adding a new log and # iterating through it on "emit". self._log_record_processors = () # type: tuple[LogRecordProcessor, ...] @@ -489,14 +489,14 @@ class ConcurrentMultiLogRecordProcessor(LogRecordProcessor): and thus defining how many log processors can work in parallel. """ - def __init__(self, max_workers: int = 2): + def __init__(self, max_workers: int = 2) -> None: # use a tuple to avoid race conditions when adding a new log and # iterating through it on "emit". self._log_record_processors = () # type: tuple[LogRecordProcessor, ...] self._lock = threading.Lock() self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) - def add_log_record_processor(self, log_record_processor: LogRecordProcessor): + def add_log_record_processor(self, log_record_processor: LogRecordProcessor) -> None: with self._lock: self._log_record_processors += (log_record_processor,) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py index ac09219f8c8..5477ef664fb 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/aggregation.py @@ -528,8 +528,8 @@ def collect( sum=sum_, bucket_counts=tuple(value), explicit_bounds=self._boundaries, - min=min_, - max=max_, + min=min_ if self._record_min_max else None, + max=max_ if self._record_min_max else None, ) if value is None: @@ -558,8 +558,8 @@ def collect( sum=self._previous_sum, bucket_counts=tuple(self._previous_value), explicit_bounds=self._boundaries, - min=self._previous_min, - max=self._previous_max, + min=self._previous_min if self._record_min_max else None, + max=self._previous_max if self._record_min_max else None, ) return None @@ -814,8 +814,8 @@ def collect( ), # FIXME: Find the right value for flags flags=0, - min=min_, - max=max_, + min=min_ if self._record_min_max else None, + max=max_ if self._record_min_max else None, ) # Here collection_temporality is CUMULATIVE. @@ -956,8 +956,8 @@ def collect( ), # FIXME: Find the right value for flags flags=0, - min=self._previous_min, - max=self._previous_max, + min=self._previous_min if self._record_min_max else None, + max=self._previous_max if self._record_min_max else None, ) return None diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py index 4e9d5eea603..6ef73a8a0af 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/measurement.py @@ -8,7 +8,7 @@ from logging import getLogger from typing import TYPE_CHECKING -from opentelemetry.attributes import _clean_attribute_value +from opentelemetry.attributes import _clean_attributes from opentelemetry.context import Context from opentelemetry.util.types import Attributes @@ -44,7 +44,7 @@ def __post_init__(self) -> None: object.__setattr__( self, "attributes", - _clean_attribute_value(self.attributes, None), + _clean_attributes(self.attributes, None), ) else: _logger.warning( diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py index bb375fd7cf2..eeb84b6f8f1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/point.py @@ -44,8 +44,8 @@ class HistogramDataPoint: sum: int | float bucket_counts: Sequence[int] explicit_bounds: Sequence[float] - min: float - max: float + min: float | None + max: float | None exemplars: Sequence[Exemplar] = field(default_factory=list) def to_json(self, indent: int | None = 4) -> str: @@ -75,8 +75,8 @@ class ExponentialHistogramDataPoint: positive: Buckets negative: Buckets flags: int - min: float - max: float + min: float | None + max: float | None exemplars: Sequence[Exemplar] = field(default_factory=list) def to_json(self, indent: int | None = 4) -> str: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index 7c88f6145c5..9fa25576b9d 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -743,12 +743,10 @@ def get_aggregated_resources( """ detectors_merged_resource = initial_resource or Resource.create() - # The executor is not used as a context manager: exiting it would call - # `shutdown(wait=True)` and join still-running workers, so a detector that - # blocks longer than `timeout` would hold up the whole call regardless of - # `future.result(timeout=...)`. Shut down without waiting instead so the - # timeout actually bounds this call. A detector that outlives the timeout - # continues in its worker thread until detect() returns. + # A detector that exceeds `timeout` is skipped, but shutdown still joins + # its worker so no threads outlive this call. A detector that blocks + # indefinitely therefore blocks this call. `cancel_futures=True` drops + # detectors that have not started yet if a `raise_on_error` detector raises. executor = concurrent.futures.ThreadPoolExecutor(max_workers=4) try: futures = [executor.submit(detector.detect) for detector in detectors] @@ -773,6 +771,6 @@ def get_aggregated_resources( finally: detectors_merged_resource = detectors_merged_resource.merge(detected_resource) finally: - executor.shutdown(wait=False, cancel_futures=True) + executor.shutdown(wait=True, cancel_futures=True) return detectors_merged_resource diff --git a/opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponential_bucket_histogram_aggregation.py b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponential_bucket_histogram_aggregation.py index 78f03890220..5c4eb34cfc0 100644 --- a/opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponential_bucket_histogram_aggregation.py +++ b/opentelemetry-sdk/tests/metrics/exponential_histogram/test_exponential_bucket_histogram_aggregation.py @@ -128,25 +128,31 @@ def test_min_max(self): now = time_ns() - for record_min_max, expected_min, expected_max in [ - (True, 1, 9999), - (False, inf, -inf), + for record_min_max, expected_min, expected_max, expected_point_min, expected_point_max in [ + (True, 1, 9999, 1, 9999), + (False, inf, -inf, None, None), ]: - with self.subTest(record_min_max=record_min_max): - ctx = Context() - exponential_histogram_aggregation = _ExponentialBucketHistogramAggregation( - Mock(), - _default_reservoir_factory(_ExponentialBucketHistogramAggregation), - AggregationTemporality.CUMULATIVE, - 0, - record_min_max=record_min_max, - ) - - for value in [2, 4, 1, 9999]: - exponential_histogram_aggregation.aggregate(Measurement(value, now, Mock(), ctx)) - - self.assertEqual(exponential_histogram_aggregation._min, expected_min) - self.assertEqual(exponential_histogram_aggregation._max, expected_max) + for collection_temporality in AggregationTemporality.DELTA, AggregationTemporality.CUMULATIVE: + with self.subTest(record_min_max=record_min_max, collection_temporality=collection_temporality): + ctx = Context() + exponential_histogram_aggregation = _ExponentialBucketHistogramAggregation( + Mock(), + _default_reservoir_factory(_ExponentialBucketHistogramAggregation), + AggregationTemporality.DELTA, + 0, + record_min_max=record_min_max, + ) + + for value in [2, 4, 1, 9999]: + exponential_histogram_aggregation.aggregate(Measurement(value, now, Mock(), ctx)) + + self.assertEqual(exponential_histogram_aggregation._min, expected_min) + self.assertEqual(exponential_histogram_aggregation._max, expected_max) + + point = exponential_histogram_aggregation.collect(collection_temporality, 0) + + self.assertEqual(point.min, expected_point_min) + self.assertEqual(point.max, expected_point_max) def assertInEpsilon(self, first, second, epsilon): self.assertLessEqual(first, (second * (1 + epsilon))) diff --git a/opentelemetry-sdk/tests/metrics/integration_test/test_histogram_export.py b/opentelemetry-sdk/tests/metrics/integration_test/test_histogram_export.py index f07b14e76cf..c29ba5f3e97 100644 --- a/opentelemetry-sdk/tests/metrics/integration_test/test_histogram_export.py +++ b/opentelemetry-sdk/tests/metrics/integration_test/test_histogram_export.py @@ -3,16 +3,66 @@ from unittest import TestCase -from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics import Histogram, MeterProvider from opentelemetry.sdk.metrics._internal.exemplar import ( AlwaysOffExemplarFilter, AlwaysOnExemplarFilter, ) -from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + InMemoryMetricReader, +) +from opentelemetry.sdk.metrics.view import ( + ExplicitBucketHistogramAggregation, + ExponentialBucketHistogramAggregation, + View, +) from opentelemetry.sdk.resources import SERVICE_NAME, Resource class TestHistogramExport(TestCase): + def test_histogram_collection_without_min_max(self): + for aggregation_type in ExplicitBucketHistogramAggregation, ExponentialBucketHistogramAggregation: + for temporality in AggregationTemporality.DELTA, AggregationTemporality.CUMULATIVE: + with self.subTest(aggregation=aggregation_type.__name__, temporality=temporality): + reader = InMemoryMetricReader(preferred_temporality={Histogram: temporality}) + provider = MeterProvider( + metric_readers=[reader], + views=[ + View( + instrument_name="my_histogram", + aggregation=aggregation_type(record_min_max=False), + ) + ], + ) + try: + histogram = provider.get_meter("my-meter").create_histogram("my_histogram") + second_count, second_sum = (1, 20) if temporality == AggregationTemporality.DELTA else (3, 75) + for values, expected_count, expected_sum in ( + ((5, 50), 2, 55), + ((20,), second_count, second_sum), + ): + for value in values: + histogram.record(value) + metrics_data = reader.get_metrics_data() + self.assertIsNotNone(metrics_data) + metric = next( + metric + for resource_metrics in metrics_data.resource_metrics + for scope_metrics in resource_metrics.scope_metrics + for metric in scope_metrics.metrics + if metric.name == "my_histogram" + ) + self.assertEqual(metric.data.aggregation_temporality, temporality) + self.assertEqual(len(metric.data.data_points), 1) + point = metric.data.data_points[0] + self.assertIsNone(point.min) + self.assertIsNone(point.max) + self.assertEqual(point.count, expected_count) + self.assertEqual(point.sum, expected_sum) + finally: + provider.shutdown() + def test_histogram_counter_collection(self): in_memory_metric_reader = InMemoryMetricReader() diff --git a/opentelemetry-sdk/tests/metrics/test_aggregation.py b/opentelemetry-sdk/tests/metrics/test_aggregation.py index 2bdf17fe91f..3853fd1c295 100644 --- a/opentelemetry-sdk/tests/metrics/test_aggregation.py +++ b/opentelemetry-sdk/tests/metrics/test_aggregation.py @@ -3,6 +3,7 @@ # pylint: disable=protected-access +from json import loads from math import inf from time import sleep, time_ns from unittest import TestCase @@ -382,6 +383,33 @@ def test_min_max(self): self.assertEqual(explicit_bucket_histogram_aggregation._min, inf) self.assertEqual(explicit_bucket_histogram_aggregation._max, -inf) + def test_collect_without_min_max(self): + """ + With `record_min_max=False`, collected points leave min and max unset + """ + + for collection_temporality in AggregationTemporality.DELTA, AggregationTemporality.CUMULATIVE: + with self.subTest(collection_temporality=collection_temporality): + explicit_bucket_histogram_aggregation = _ExplicitBucketHistogramAggregation( + {}, + AggregationTemporality.DELTA, + 0, + _default_reservoir_factory(_ExplicitBucketHistogramAggregation), + record_min_max=False, + ) + + explicit_bucket_histogram_aggregation.aggregate(measurement(5)) + explicit_bucket_histogram_aggregation.aggregate(measurement(50)) + + histo = explicit_bucket_histogram_aggregation.collect(collection_temporality, 1) + + self.assertIsNone(histo.min) + self.assertIsNone(histo.max) + + point_json = loads(histo.to_json(), parse_constant=self.fail) + self.assertIsNone(point_json["min"]) + self.assertIsNone(point_json["max"]) + def test_collect(self): """ `_ExplicitBucketHistogramAggregation` collects sum metric points diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index 063770854c5..8b8612c8ff4 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -7,6 +7,7 @@ import platform import subprocess import sys +import threading import time import unittest import uuid @@ -472,14 +473,20 @@ def test_resource_detector_raise_error(self): resource_detector.raise_on_error = True self.assertRaises(Exception, get_aggregated_resources, [resource_detector]) - def test_aggregated_resources_timeout_bounds_wait(self): - # A detector that takes longer than the timeout must not hold up the - # call beyond the timeout: the skip warning fires, but the wait is bounded. + def test_aggregated_resources_joins_detector_threads(self): + # A detector that takes longer than the timeout is skipped, but its + # worker thread must be joined before the call returns so no threads + # outlive resource creation. + worker_threads = [] + + def slow_detect(): + worker_threads.append(threading.current_thread()) + time.sleep(0.5) + resource_detector = Mock(spec=ResourceDetector) - resource_detector.detect.side_effect = lambda: time.sleep(2) + resource_detector.detect.side_effect = slow_detect resource_detector.raise_on_error = False - start = time.time() with self.assertLogs(level=WARNING) as log_entry: self.assertEqual( get_aggregated_resources( @@ -489,10 +496,10 @@ def test_aggregated_resources_timeout_bounds_wait(self): ), _DEFAULT_RESOURCE, ) - elapsed = time.time() - start - self.assertLess(elapsed, 1.0) self.assertIn("took longer than", log_entry.output[0]) + self.assertEqual(len(worker_threads), 1) + self.assertFalse(worker_threads[0].is_alive()) def test_resource_detector_is_not_process_dependent_by_default(self): self.assertFalse(DefaultResourceDetector().is_process_dependent()) diff --git a/opentelemetry-sdk/tests/test_attribute_nesting_depth.py b/opentelemetry-sdk/tests/test_attribute_nesting_depth.py new file mode 100644 index 00000000000..e62c0be2393 --- /dev/null +++ b/opentelemetry-sdk/tests/test_attribute_nesting_depth.py @@ -0,0 +1,47 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import unittest + +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +def _cyclic_list(): + value = [1, 2] + value.append(value) + return value + + +class TestAttributeNestingDepth(unittest.TestCase): + def setUp(self): + self.exporter = InMemorySpanExporter() + self.provider = TracerProvider(shutdown_on_exit=False) + self.provider.add_span_processor(SimpleSpanProcessor(self.exporter)) + self.tracer = self.provider.get_tracer(__name__) + + def tearDown(self): + self.provider.shutdown() + + def test_span_attribute_does_not_raise(self): + with self.tracer.start_as_current_span("span") as span: + span.set_attribute("cyclic", _cyclic_list()) + + self.assertIsNone(self.exporter.get_finished_spans()[0].attributes["cyclic"]) + + def test_span_sibling_attributes_are_preserved(self): + with self.tracer.start_as_current_span("span") as span: + span.set_attributes({"good": "kept", "cyclic": _cyclic_list()}) + + attributes = self.exporter.get_finished_spans()[0].attributes + self.assertEqual(attributes["good"], "kept") + self.assertIsNone(attributes["cyclic"]) + + def test_resource_attribute_does_not_raise(self): + resource = Resource.create({"cyclic": _cyclic_list()}) + + self.assertIsNone(resource.attributes["cyclic"]) diff --git a/tests/opentelemetry-test-utils/src/opentelemetry/test/weaver_live_check.py b/tests/opentelemetry-test-utils/src/opentelemetry/test/weaver_live_check.py index 0783d6ff5ad..cef8e40cad7 100644 --- a/tests/opentelemetry-test-utils/src/opentelemetry/test/weaver_live_check.py +++ b/tests/opentelemetry-test-utils/src/opentelemetry/test/weaver_live_check.py @@ -9,14 +9,14 @@ import socket import subprocess import tempfile +import time from collections import defaultdict from collections.abc import Sequence from itertools import chain +from math import isfinite from typing import Any -from requests import Session, post -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry +from requests import RequestException, get, post from opentelemetry.semconv.schemas import Schemas @@ -184,7 +184,7 @@ class WeaverLiveCheck: This class is experimental and its API is subject to change without notice. - Requires the ``weaver`` binary on PATH: + Requires Weaver 0.27 or later on PATH: https://github.com/open-telemetry/weaver/releases Typical use as a context manager:: @@ -236,6 +236,7 @@ def __init__( otlp_port: int = 0, admin_port: int = 0, extra_args: Sequence[str] | None = None, + startup_timeout: float = 30, ): """Build the ``weaver registry live-check`` command. @@ -243,7 +244,13 @@ def __init__( managed flags (``--registry``, ``--otlp-grpc-port``, etc.) and lets callers pass additional weaver options — for example ``["--quiet"]`` or ``["--skip-policies"]`` — without subclassing. + + ``startup_timeout`` controls how long to wait for the health endpoint, + in seconds. It defaults to 30 seconds. """ + if not isfinite(startup_timeout) or startup_timeout <= 0: + raise ValueError("startup_timeout must be positive and finite") + self._startup_timeout = startup_timeout weaver_bin = shutil.which("weaver") if not weaver_bin: raise RuntimeError( @@ -254,6 +261,7 @@ def __init__( self._admin_port = admin_port or _find_free_port() self._ready = False self._stopped = False + self._result: tuple[LiveCheckReport, int] | None = None self._process: subprocess.Popen[bytes] | None = None self._stdout_path: str | None = None self._stderr_path: str | None = None @@ -319,32 +327,36 @@ def start(self) -> "WeaverLiveCheck": return self def _wait_for_ready(self) -> None: - retry = Retry( - total=10, - backoff_factor=1, - backoff_max=1, - # Any non-2xx response from /health means weaver isn't ready yet. - status_forcelist=list(range(300, 600)), - raise_on_status=True, - allowed_methods=["GET"], - ) - session = Session() - session.mount("http://", HTTPAdapter(max_retries=retry)) - try: - session.get(f"http://localhost:{self._admin_port}/health", timeout=5) - except Exception as exc: # pylint: disable=broad-except + deadline = time.monotonic() + self._startup_timeout + last_error: RequestException | None = None + while True: if self._process is not None and self._process.poll() is not None: raise RuntimeError( f"WeaverLiveCheck process exited unexpectedly (code {self._process.returncode})" - ) from exc - raise TimeoutError("WeaverLiveCheck did not become ready in time") from exc + ) from last_error + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"WeaverLiveCheck did not become ready within {self._startup_timeout} seconds" + ) from last_error + try: + response = get( + f"http://localhost:{self._admin_port}/health", + timeout=min(5, remaining), + allow_redirects=False, + ) + if 200 <= response.status_code < 300: + return + except RequestException as exc: + last_error = exc + time.sleep(min(0.25, max(0, deadline - time.monotonic()))) @property def otlp_endpoint(self) -> str: return f"http://localhost:{self._otlp_port}" def _do_stop(self, timeout: int) -> tuple["LiveCheckReport", int]: - """POST /stop, wait for the process to exit, return (report, exit_code). + """Stop collection, read the report, shut down, and return (report, exit_code). Raises for infrastructure errors (HTTP failure, process communication). Never raises for semconv violations. @@ -352,17 +364,39 @@ def _do_stop(self, timeout: int) -> tuple["LiveCheckReport", int]: if not self._ready: raise RuntimeError("WeaverLiveCheck process did not start successfully") try: - response = post(f"http://localhost:{self._admin_port}/stop", timeout=5) - response.raise_for_status() - report = LiveCheckReport(response.json()) - assert self._process is not None - exit_code = self._process.wait(timeout=timeout) + try: + response = post(f"http://localhost:{self._admin_port}/stop", timeout=timeout) + response.raise_for_status() + response = get(f"http://localhost:{self._admin_port}/report", timeout=timeout) + response.raise_for_status() + report = LiveCheckReport(response.json()) + except Exception: # pylint: disable=broad-except + try: + self._shutdown(timeout) + except Exception as exc: # pylint: disable=broad-except + logger.debug("Error shutting down weaver after report failure: %s", exc) + raise + exit_code = self._shutdown(timeout) except Exception as exc: # pylint: disable=broad-except logs = self._read_weaver_logs() logger.error("Error communicating with weaver: %s, logs: %s", exc, logs) raise return report, exit_code + def _shutdown(self, timeout: int) -> int: + response = post(f"http://localhost:{self._admin_port}/shutdown", timeout=timeout) + response.raise_for_status() + assert self._process is not None + return self._process.wait(timeout=timeout) + + def _end(self, timeout: int) -> tuple[LiveCheckReport, int]: + if not self._stopped: + self._stopped = True + self._result = self._do_stop(timeout) + if self._result is None: + raise RuntimeError("WeaverLiveCheck stopped without a completed report request") + return self._result + def end(self, timeout: int = 30) -> "LiveCheckReport": """Signal weaver to stop and return the full :class:`LiveCheckReport`. @@ -370,17 +404,17 @@ def end(self, timeout: int = 30) -> "LiveCheckReport": your own assertions against :attr:`LiveCheckReport.violations` or the raw report data. + Repeated calls return the cached report, including after :meth:`close`. + Raises :exc:`RuntimeError` if closed without requesting a report or if + an earlier report request or shutdown failed. + Raises :exc:`RuntimeError` for infrastructure problems (weaver failed to start, HTTP communication error, etc.). See https://github.com/open-telemetry/weaver/tree/main/crates/weaver_live_check#output for the report structure. """ - if self._stopped: - logger.warning("end() called after weaver already stopped; returning empty report") - return LiveCheckReport({}) - self._stopped = True - report, _ = self._do_stop(timeout) + report, _ = self._end(timeout) return report def end_and_check(self, timeout: int = 30) -> "LiveCheckReport": @@ -395,14 +429,14 @@ def end_and_check(self, timeout: int = 30) -> "LiveCheckReport": attached as :attr:`LiveCheckError.report`. Use :meth:`end` if you need the report regardless of violations. + Repeated calls check the cached result and raise again for violations. + Raises :exc:`RuntimeError` if closed without requesting a report or if + an earlier report request or shutdown failed. + Raises :exc:`RuntimeError` for infrastructure problems (weaver failed to start, HTTP communication error, etc.). """ - if self._stopped: - logger.warning("end_and_check() called after weaver already stopped; returning empty report") - return LiveCheckReport({}) - self._stopped = True - report, exit_code = self._do_stop(timeout) + report, exit_code = self._end(timeout) if exit_code == 0: # Success — no violations found, no errors communicating with weaver return report @@ -436,8 +470,8 @@ def _read(path: str | None) -> str: def close(self) -> None: """Stop weaver and clean up the process. - If weaver has not been stopped yet, sends the ``/stop`` signal and - waits for the process to exit. Never raises for semconv violations. + If weaver has not been stopped yet, collects the report and requests + shutdown before waiting for exit. Never raises for semconv violations. Idempotent — safe to call multiple times or after :meth:`end` / :meth:`end_and_check` has already been called. """ diff --git a/tests/opentelemetry-test-utils/tests/test_weaver_live_check_http.py b/tests/opentelemetry-test-utils/tests/test_weaver_live_check_http.py new file mode 100644 index 00000000000..effe5c74462 --- /dev/null +++ b/tests/opentelemetry-test-utils/tests/test_weaver_live_check_http.py @@ -0,0 +1,216 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access + +import subprocess +import unittest +from contextlib import ExitStack +from unittest.mock import Mock, call, patch + +from requests import ConnectionError, HTTPError, Timeout + +from opentelemetry.test.weaver_live_check import LiveCheckError, WeaverLiveCheck + + +class TestWeaverLiveCheckHTTP(unittest.TestCase): + def setUp(self): + module = "opentelemetry.test.weaver_live_check" + self.patch(f"{module}.shutil.which", return_value="weaver") + self.post = self.patch(f"{module}.post") + self.get = self.patch(f"{module}.get") + self.process = Mock() + self.process.wait.return_value = 0 + self.process.poll.return_value = 0 + self.weaver = WeaverLiveCheck(registry="registry", otlp_port=4317, admin_port=4320) + self.weaver._ready = True + self.weaver._process = self.process + self.stop_response = Mock() + self.shutdown_response = Mock() + self.post.side_effect = [self.stop_response, self.shutdown_response] + self.get.return_value.json.return_value = {"statistics": {"span_count": 1}} + self.calls = Mock() + self.calls.attach_mock(self.post, "post") + self.calls.attach_mock(self.get, "get") + self.calls.attach_mock(self.process.wait, "wait") + self.addCleanup(self.weaver.close) + + def patch(self, target, **kwargs): + patcher = patch(target, **kwargs) + result = patcher.start() + self.addCleanup(patcher.stop) + return result + + def assert_lifecycle(self, timeout=30): + self.assertEqual( + [c for c in self.calls.mock_calls if c[0] in ("post", "get", "wait")], + [ + call.post("http://localhost:4320/stop", timeout=timeout), + call.get("http://localhost:4320/report", timeout=timeout), + call.post("http://localhost:4320/shutdown", timeout=timeout), + call.wait(timeout=timeout), + ], + ) + self.stop_response.raise_for_status.assert_called_once_with() + self.stop_response.json.assert_not_called() + self.get.return_value.raise_for_status.assert_called_once_with() + self.shutdown_response.raise_for_status.assert_called_once_with() + + def test_end_reads_report_before_shutdown(self): + self.process.wait.return_value = 1 + report = self.weaver.end(timeout=17) + self.assertEqual(report["statistics"], {"span_count": 1}) + self.assertIs(self.weaver.end(), report) + self.assert_lifecycle(timeout=17) + self.weaver.close() + self.weaver.close() + self.assertIs(self.weaver.end(), report) + with self.assertRaises(LiveCheckError) as caught: + self.weaver.end_and_check() + self.assertIs(caught.exception.report, report) + self.assert_lifecycle(timeout=17) + self.process.terminate.assert_not_called() + + def test_end_and_check_success(self): + report = self.weaver.end_and_check() + self.assertEqual(report["statistics"], {"span_count": 1}) + self.assertIs(self.weaver.end_and_check(), report) + self.assertIs(self.weaver.end(), report) + self.assert_lifecycle() + + def test_end_and_check_violations(self): + self.process.wait.return_value = 1 + self.get.return_value.json.return_value = { + "live_check_result": {"all_advice": [{"level": "violation", "id": "test", "message": "Invalid attribute"}]} + } + with self.assertRaisesRegex(LiveCheckError, "Invalid attribute") as caught: + self.weaver.end_and_check() + self.assertEqual(caught.exception.report.violations[0]["id"], "test") + self.weaver.close() + self.assertIs(self.weaver.end(), caught.exception.report) + with self.assertRaises(LiveCheckError) as repeated: + self.weaver.end_and_check() + self.assertIs(repeated.exception.report, caught.exception.report) + self.assert_lifecycle() + + def assert_no_completed_report(self): + calls_before = list(self.calls.mock_calls) + for end in (self.weaver.end, self.weaver.end_and_check): + with self.subTest(method=end.__name__), self.assertRaisesRegex(RuntimeError, "without a completed report"): + end() + self.assertEqual(self.calls.mock_calls, calls_before) + + def test_close_shuts_down_without_raising_for_violations(self): + self.process.wait.return_value = 1 + self.weaver.close() + self.weaver.close() + self.assert_no_completed_report() + self.assert_lifecycle() + + def test_report_http_error_still_shuts_down(self): + self.get.return_value.raise_for_status.side_effect = HTTPError("report unavailable") + with self.assertLogs(level="ERROR"), self.assertRaisesRegex(HTTPError, "report unavailable"): + self.weaver.end() + self.post.assert_called_with("http://localhost:4320/shutdown", timeout=30) + self.process.wait.assert_any_call(timeout=30) + self.get.return_value.json.assert_not_called() + self.assert_no_completed_report() + + def test_report_decode_error_still_shuts_down(self): + self.get.return_value.json.side_effect = ValueError("invalid JSON") + with self.assertLogs(level="ERROR"), self.assertRaisesRegex(ValueError, "invalid JSON"): + self.weaver.end_and_check() + self.assert_no_completed_report() + self.post.assert_called_with("http://localhost:4320/shutdown", timeout=30) + self.process.wait.assert_any_call(timeout=30) + + def test_report_error_preserved_when_shutdown_also_fails(self): + self.get.side_effect = HTTPError("report unavailable") + self.shutdown_response.raise_for_status.side_effect = HTTPError("shutdown failed") + self.process.poll.return_value = None + with self.assertLogs(level="ERROR"), self.assertRaisesRegex(HTTPError, "report unavailable"): + self.weaver.end() + self.process.kill.assert_called_once_with() + self.process.wait.assert_called_once_with(timeout=5) + + def test_close_cleans_up_when_report_fails(self): + self.get.side_effect = HTTPError("report unavailable") + with self.assertLogs(level="ERROR"): + self.weaver.close() + self.post.assert_called_with("http://localhost:4320/shutdown", timeout=30) + self.process.wait.assert_any_call(timeout=30) + + def test_shutdown_timeout_kills_process(self): + self.process.wait.side_effect = [subprocess.TimeoutExpired("weaver", 30), 0] + self.process.poll.side_effect = [None, 0] + with self.assertLogs(level="ERROR"), self.assertRaises(subprocess.TimeoutExpired): + self.weaver.end() + self.process.kill.assert_called_once_with() + self.assertEqual(self.process.wait.call_args_list, [call(timeout=30), call(timeout=5)]) + self.assert_no_completed_report() + + +class TestWeaverReadiness(unittest.TestCase): + def setUp(self): + stack = ExitStack() + self.addCleanup(stack.close) + module = "opentelemetry.test.weaver_live_check" + stack.enter_context(patch(f"{module}.shutil.which", return_value="weaver")) + self.get = stack.enter_context(patch(f"{module}.get")) + self.clock = stack.enter_context(patch(f"{module}.time.monotonic", return_value=0)) + self.sleep = stack.enter_context(patch(f"{module}.time.sleep", side_effect=self.advance_clock)) + self.weaver = WeaverLiveCheck(registry="registry", otlp_port=4317, admin_port=4320, startup_timeout=0.5) + self.process = Mock() + self.process.poll.return_value = None + self.weaver._process = self.process + + def advance_clock(self, seconds): + self.clock.return_value += seconds + + def test_connection_refused_and_unhealthy_responses_are_quiet(self): + self.weaver._startup_timeout = 30 + self.get.side_effect = [ConnectionError("refused"), Mock(status_code=503), Mock(status_code=200)] + with self.assertNoLogs(level="WARNING"): + self.weaver._wait_for_ready() + self.assertEqual(self.get.call_count, 3) + self.assertEqual(self.sleep.call_count, 2) + + def test_custom_timeout_allows_slow_startup(self): + self.weaver = WeaverLiveCheck(registry="registry", otlp_port=4317, admin_port=4320, startup_timeout=60) + self.weaver._process = self.process + self.clock.side_effect = [0, 0, 40, 40] + self.get.side_effect = [ConnectionError("refused"), Mock(status_code=200)] + self.weaver._wait_for_ready() + self.assertEqual(self.get.call_count, 2) + + def test_timeout_limits_requests_and_sleep(self): + self.get.side_effect = Timeout("health timed out") + with self.assertRaisesRegex(TimeoutError, "within 0.5 seconds") as caught: + self.weaver._wait_for_ready() + self.assertIsInstance(caught.exception.__cause__, Timeout) + self.assertEqual( + self.get.call_args_list, + [ + call("http://localhost:4320/health", timeout=0.5, allow_redirects=False), + call("http://localhost:4320/health", timeout=0.25, allow_redirects=False), + ], + ) + self.assertEqual(self.sleep.call_args_list, [call(0.25), call(0.25)]) + + def test_redirect_is_not_ready(self): + self.get.return_value.status_code = 302 + with self.assertRaises(TimeoutError): + self.weaver._wait_for_ready() + + def test_process_exit_fails_without_waiting_for_deadline(self): + self.process.poll.side_effect = [None, 1] + self.process.returncode = 1 + self.get.side_effect = ConnectionError("refused") + with self.assertRaisesRegex(RuntimeError, r"exited unexpectedly \(code 1\)"): + self.weaver._wait_for_ready() + self.get.assert_called_once() + + def test_startup_timeout_must_be_positive_and_finite(self): + for timeout in (0, -1, float("nan"), float("inf"), float("-inf")): + with self.subTest(timeout=timeout), self.assertRaisesRegex(ValueError, "must be positive and finite"): + WeaverLiveCheck(startup_timeout=timeout)