From b99c0b57c009bee1ede70b4c91679c4dd028585f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 29 Oct 2026 23:45:29 -0400 Subject: [PATCH 1/3] feat(python): add typed metrics template package Python services that record metrics by hand-typed name, unit, and attribute keys drift from the registry. This package generates typed instruments on the vanilla opentelemetry-api, so the registry stays the single source of truth and importing the output needs no SDK. Signed-off-by: Yordis Prieto --- .config/mise/tasks/weaver/test/_default | 2 +- .config/mise/tasks/weaver/test/python | 4 + .github/workflows/checks.yml | 1 + AGENTS.md | 5 + CONTRIBUTING.md | 7 + README.md | 3 +- buildscripts/check_python_output.sh | 49 ++ buildscripts/python/mypy.ini | 5 + buildscripts/python/requirements.in | 6 + buildscripts/python/requirements.txt | 323 +++++++++ buildscripts/python/ruff.toml | 10 + mise.toml | 2 + templates/code/python/README.md | 210 ++++++ templates/code/python/__init__.py.j2 | 19 + templates/code/python/attributes.py.j2 | 34 + templates/code/python/header.j2 | 10 + templates/code/python/metrics.py.j2 | 186 ++++++ .../tests/attributes/expected/__init__.py | 12 + .../tests/attributes/expected/attributes.py | 155 +++++ .../tests/attributes/registry/manifest.yaml | 2 + .../tests/attributes/registry/model.yaml | 163 +++++ .../python/tests/attributes/test_consumer.py | 83 +++ .../python/tests/empty/expected/__init__.py | 12 + .../python/tests/empty/registry/manifest.yaml | 2 + .../python/tests/empty/registry/model.yaml | 1 + .../code/python/tests/empty/test_consumer.py | 17 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/myapp.yaml | 15 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 16 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/myapp.yaml | 13 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/myapp.yaml | 15 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 10 + .../error_boundaries_empty/expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 15 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 10 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 15 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 11 + .../error_enum_collision/expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../error_enum_collision/registry/model.yaml | 13 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 11 + .../expected-error.txt | 1 + .../error_enum_without_members/params.yaml | 2 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 11 + .../expected-error.txt | 1 + .../tests/error_filtered_required/params.yaml | 2 + .../registry/manifest.yaml | 2 + .../registry/myapp.yaml | 20 + .../expected-error.txt | 1 + .../params.yaml | 3 + .../registry/manifest.yaml | 2 + .../registry/myapp.yaml | 10 + .../expected-error.txt | 1 + .../params.yaml | 2 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 5 + .../expected-error.txt | 1 + .../params.yaml | 2 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 5 + .../expected-error.txt | 1 + .../params.yaml | 2 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 5 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 11 + .../error_invalid_name/expected-error.txt | 1 + .../error_invalid_name/registry/manifest.yaml | 2 + .../error_invalid_name/registry/model.yaml | 6 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 19 + .../error_metric_collision/expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 18 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 11 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 3 + .../registry/model.yaml | 11 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 3 + .../registry/model.yaml | 8 + .../error_missing_schema/expected-error.txt | 1 + .../registry/manifest.yaml | 1 + .../error_missing_schema/registry/model.yaml | 2 + .../tests/error_mixed_enum/expected-error.txt | 1 + .../error_mixed_enum/registry/manifest.yaml | 2 + .../error_mixed_enum/registry/model.yaml | 15 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 18 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 25 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 18 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 19 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 19 + .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 2 + .../tests/filtering_both/expected/__init__.py | 12 + .../filtering_both/expected/attributes.py | 22 + .../tests/filtering_both/expected/metrics.py | 201 ++++++ .../python/tests/filtering_both/params.yaml | 3 + .../filtering_both/registry/manifest.yaml | 2 + .../tests/filtering_both/registry/model.yaml | 120 ++++ .../tests/filtering_both/test_consumer.py | 114 ++++ .../expected/__init__.py | 12 + .../expected/attributes.py | 28 + .../expected/metrics.py | 302 +++++++++ .../filtering_exclude_deprecated/params.yaml | 2 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 120 ++++ .../test_consumer.py | 114 ++++ .../expected/__init__.py | 12 + .../expected/attributes.py | 32 + .../filtering_stable_only/expected/metrics.py | 306 +++++++++ .../tests/filtering_stable_only/params.yaml | 2 + .../registry/manifest.yaml | 2 + .../filtering_stable_only/registry/model.yaml | 120 ++++ .../filtering_stable_only/test_consumer.py | 114 ++++ .../filtering_unfiltered/expected/__init__.py | 12 + .../expected/attributes.py | 38 ++ .../filtering_unfiltered/expected/metrics.py | 407 ++++++++++++ .../registry/manifest.yaml | 2 + .../filtering_unfiltered/registry/model.yaml | 120 ++++ .../filtering_unfiltered/test_consumer.py | 128 ++++ .../histogram_boundaries/expected/__init__.py | 12 + .../histogram_boundaries/expected/metrics.py | 189 ++++++ .../registry/manifest.yaml | 2 + .../histogram_boundaries/registry/model.yaml | 51 ++ .../histogram_boundaries/test_consumer.py | 133 ++++ .../expected/__init__.py | 12 + .../expected/metrics.py | 174 +++++ .../histogram_boundaries_none/params.yaml | 2 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 51 ++ .../test_consumer.py | 118 ++++ .../expected/__init__.py | 12 + .../expected/metrics.py | 182 +++++ .../histogram_boundaries_params/params.yaml | 4 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 51 ++ .../test_consumer.py | 118 ++++ .../tests/imported/dependency/manifest.yaml | 2 + .../tests/imported/dependency/model.yaml | 30 + .../tests/imported/expected/__init__.py | 12 + .../tests/imported/expected/attributes.py | 6 + .../python/tests/imported/expected/metrics.py | 155 +++++ .../tests/imported/registry/manifest.yaml | 6 + .../python/tests/imported/registry/model.yaml | 77 +++ .../python/tests/imported/test_consumer.py | 98 +++ .../literal_escaping/expected/__init__.py | 12 + .../literal_escaping/expected/attributes.py | 27 + .../literal_escaping/expected/metrics.py | 115 ++++ .../literal_escaping/registry/manifest.yaml | 2 + .../literal_escaping/registry/model.yaml | 44 ++ .../tests/literal_escaping/test_consumer.py | 119 ++++ .../tests/long_names/expected/__init__.py | 12 + .../tests/long_names/expected/attributes.py | 40 ++ .../tests/long_names/expected/metrics.py | 212 ++++++ .../tests/long_names/registry/manifest.yaml | 2 + .../tests/long_names/registry/model.yaml | 75 +++ .../python/tests/long_names/test_consumer.py | 112 ++++ .../metric_attributes/expected/__init__.py | 12 + .../metric_attributes/expected/attributes.py | 109 +++ .../metric_attributes/expected/metrics.py | 596 +++++++++++++++++ .../metric_attributes/registry/manifest.yaml | 2 + .../metric_attributes/registry/model.yaml | 249 +++++++ .../tests/metric_attributes/test_consumer.py | 229 +++++++ .../python/tests/metrics/expected/__init__.py | 12 + .../tests/metrics/expected/attributes.py | 38 ++ .../python/tests/metrics/expected/metrics.py | 624 ++++++++++++++++++ .../tests/metrics/registry/manifest.yaml | 2 + .../python/tests/metrics/registry/model.yaml | 170 +++++ .../python/tests/metrics/test_consumer.py | 252 +++++++ .../python/tests/naming/expected/__init__.py | 12 + .../tests/naming/expected/attributes.py | 34 + .../python/tests/naming/expected/metrics.py | 224 +++++++ .../tests/naming/registry/manifest.yaml | 2 + .../python/tests/naming/registry/model.yaml | 75 +++ .../code/python/tests/naming/test_consumer.py | 107 +++ .../python/tests/params/expected/__init__.py | 15 + .../tests/params/expected/attributes.py | 26 + .../python/tests/params/expected/metrics.py | 112 ++++ .../code/python/tests/params/params.yaml | 7 + .../tests/params/registry/manifest.yaml | 2 + .../python/tests/params/registry/model.yaml | 40 ++ .../code/python/tests/params/test_consumer.py | 125 ++++ .../unsupported_signals/expected/__init__.py | 12 + .../expected/attributes.py | 32 + .../unsupported_signals/expected/metrics.py | 95 +++ .../registry/manifest.yaml | 2 + .../unsupported_signals/registry/model.yaml | 97 +++ .../unsupported_signals/test_consumer.py | 102 +++ .../vendor_prefix_off/expected/__init__.py | 12 + .../vendor_prefix_off/expected/attributes.py | 25 + .../vendor_prefix_off/expected/metrics.py | 111 ++++ .../vendor_prefix_off/registry/manifest.yaml | 2 + .../vendor_prefix_off/registry/model.yaml | 40 ++ .../tests/vendor_prefix_off/test_consumer.py | 102 +++ templates/code/python/weaver.yaml | 263 ++++++++ 230 files changed, 10426 insertions(+), 2 deletions(-) create mode 100755 .config/mise/tasks/weaver/test/python create mode 100755 buildscripts/check_python_output.sh create mode 100644 buildscripts/python/mypy.ini create mode 100644 buildscripts/python/requirements.in create mode 100644 buildscripts/python/requirements.txt create mode 100644 buildscripts/python/ruff.toml create mode 100644 templates/code/python/README.md create mode 100644 templates/code/python/__init__.py.j2 create mode 100644 templates/code/python/attributes.py.j2 create mode 100644 templates/code/python/header.j2 create mode 100644 templates/code/python/metrics.py.j2 create mode 100644 templates/code/python/tests/attributes/expected/__init__.py create mode 100644 templates/code/python/tests/attributes/expected/attributes.py create mode 100644 templates/code/python/tests/attributes/registry/manifest.yaml create mode 100644 templates/code/python/tests/attributes/registry/model.yaml create mode 100644 templates/code/python/tests/attributes/test_consumer.py create mode 100644 templates/code/python/tests/empty/expected/__init__.py create mode 100644 templates/code/python/tests/empty/registry/manifest.yaml create mode 100644 templates/code/python/tests/empty/registry/model.yaml create mode 100644 templates/code/python/tests/empty/test_consumer.py create mode 100644 templates/code/python/tests/error_aggregation_counter/expected-error.txt create mode 100644 templates/code/python/tests/error_aggregation_counter/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_aggregation_counter/registry/myapp.yaml create mode 100644 templates/code/python/tests/error_aggregation_fields/expected-error.txt create mode 100644 templates/code/python/tests/error_aggregation_fields/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_aggregation_fields/registry/model.yaml create mode 100644 templates/code/python/tests/error_aggregation_method/expected-error.txt create mode 100644 templates/code/python/tests/error_aggregation_method/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_aggregation_method/registry/myapp.yaml create mode 100644 templates/code/python/tests/error_aggregation_unsorted/expected-error.txt create mode 100644 templates/code/python/tests/error_aggregation_unsorted/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_aggregation_unsorted/registry/myapp.yaml create mode 100644 templates/code/python/tests/error_attribute_collision/expected-error.txt create mode 100644 templates/code/python/tests/error_attribute_collision/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_attribute_collision/registry/model.yaml create mode 100644 templates/code/python/tests/error_boundaries_empty/expected-error.txt create mode 100644 templates/code/python/tests/error_boundaries_empty/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_boundaries_empty/registry/model.yaml create mode 100644 templates/code/python/tests/error_cross_namespace_collision/expected-error.txt create mode 100644 templates/code/python/tests/error_cross_namespace_collision/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_cross_namespace_collision/registry/model.yaml create mode 100644 templates/code/python/tests/error_duplicate_enum_value/expected-error.txt create mode 100644 templates/code/python/tests/error_duplicate_enum_value/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_duplicate_enum_value/registry/model.yaml create mode 100644 templates/code/python/tests/error_enum_class_collision/expected-error.txt create mode 100644 templates/code/python/tests/error_enum_class_collision/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_enum_class_collision/registry/model.yaml create mode 100644 templates/code/python/tests/error_enum_collision/expected-error.txt create mode 100644 templates/code/python/tests/error_enum_collision/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_enum_collision/registry/model.yaml create mode 100644 templates/code/python/tests/error_enum_member_identifier/expected-error.txt create mode 100644 templates/code/python/tests/error_enum_member_identifier/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_enum_member_identifier/registry/model.yaml create mode 100644 templates/code/python/tests/error_enum_without_members/expected-error.txt create mode 100644 templates/code/python/tests/error_enum_without_members/params.yaml create mode 100644 templates/code/python/tests/error_enum_without_members/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_enum_without_members/registry/model.yaml create mode 100644 templates/code/python/tests/error_filtered_required/expected-error.txt create mode 100644 templates/code/python/tests/error_filtered_required/params.yaml create mode 100644 templates/code/python/tests/error_filtered_required/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_filtered_required/registry/myapp.yaml create mode 100644 templates/code/python/tests/error_histogram_boundaries_by_unit/expected-error.txt create mode 100644 templates/code/python/tests/error_histogram_boundaries_by_unit/params.yaml create mode 100644 templates/code/python/tests/error_histogram_boundaries_by_unit/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_histogram_boundaries_by_unit/registry/myapp.yaml create mode 100644 templates/code/python/tests/error_instrumentation_name_empty/expected-error.txt create mode 100644 templates/code/python/tests/error_instrumentation_name_empty/params.yaml create mode 100644 templates/code/python/tests/error_instrumentation_name_empty/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_instrumentation_name_empty/registry/model.yaml create mode 100644 templates/code/python/tests/error_instrumentation_name_type/expected-error.txt create mode 100644 templates/code/python/tests/error_instrumentation_name_type/params.yaml create mode 100644 templates/code/python/tests/error_instrumentation_name_type/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_instrumentation_name_type/registry/model.yaml create mode 100644 templates/code/python/tests/error_instrumentation_version_type/expected-error.txt create mode 100644 templates/code/python/tests/error_instrumentation_version_type/params.yaml create mode 100644 templates/code/python/tests/error_instrumentation_version_type/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_instrumentation_version_type/registry/model.yaml create mode 100644 templates/code/python/tests/error_integer_enum_overflow/expected-error.txt create mode 100644 templates/code/python/tests/error_integer_enum_overflow/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_integer_enum_overflow/registry/model.yaml create mode 100644 templates/code/python/tests/error_invalid_name/expected-error.txt create mode 100644 templates/code/python/tests/error_invalid_name/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_invalid_name/registry/model.yaml create mode 100644 templates/code/python/tests/error_keyword_parameter/expected-error.txt create mode 100644 templates/code/python/tests/error_keyword_parameter/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_keyword_parameter/registry/model.yaml create mode 100644 templates/code/python/tests/error_metric_collision/expected-error.txt create mode 100644 templates/code/python/tests/error_metric_collision/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_metric_collision/registry/model.yaml create mode 100644 templates/code/python/tests/error_metric_reserved_class/expected-error.txt create mode 100644 templates/code/python/tests/error_metric_reserved_class/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_metric_reserved_class/registry/model.yaml create mode 100644 templates/code/python/tests/error_metric_value_type/expected-error.txt create mode 100644 templates/code/python/tests/error_metric_value_type/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_metric_value_type/registry/model.yaml create mode 100644 templates/code/python/tests/error_missing_metric_value_type/expected-error.txt create mode 100644 templates/code/python/tests/error_missing_metric_value_type/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_missing_metric_value_type/registry/model.yaml create mode 100644 templates/code/python/tests/error_missing_schema/expected-error.txt create mode 100644 templates/code/python/tests/error_missing_schema/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_missing_schema/registry/model.yaml create mode 100644 templates/code/python/tests/error_mixed_enum/expected-error.txt create mode 100644 templates/code/python/tests/error_mixed_enum/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_mixed_enum/registry/model.yaml create mode 100644 templates/code/python/tests/error_observable_collision/expected-error.txt create mode 100644 templates/code/python/tests/error_observable_collision/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_observable_collision/registry/model.yaml create mode 100644 templates/code/python/tests/error_parameter_collision/expected-error.txt create mode 100644 templates/code/python/tests/error_parameter_collision/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_parameter_collision/registry/model.yaml create mode 100644 templates/code/python/tests/error_refinement_value_type/expected-error.txt create mode 100644 templates/code/python/tests/error_refinement_value_type/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_refinement_value_type/registry/model.yaml create mode 100644 templates/code/python/tests/error_reserved_parameter/expected-error.txt create mode 100644 templates/code/python/tests/error_reserved_parameter/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_reserved_parameter/registry/model.yaml create mode 100644 templates/code/python/tests/error_unsupported_attribute_type/expected-error.txt create mode 100644 templates/code/python/tests/error_unsupported_attribute_type/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_unsupported_attribute_type/registry/model.yaml create mode 100644 templates/code/python/tests/error_unversioned_schema/expected-error.txt create mode 100644 templates/code/python/tests/error_unversioned_schema/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_unversioned_schema/registry/model.yaml create mode 100644 templates/code/python/tests/filtering_both/expected/__init__.py create mode 100644 templates/code/python/tests/filtering_both/expected/attributes.py create mode 100644 templates/code/python/tests/filtering_both/expected/metrics.py create mode 100644 templates/code/python/tests/filtering_both/params.yaml create mode 100644 templates/code/python/tests/filtering_both/registry/manifest.yaml create mode 100644 templates/code/python/tests/filtering_both/registry/model.yaml create mode 100644 templates/code/python/tests/filtering_both/test_consumer.py create mode 100644 templates/code/python/tests/filtering_exclude_deprecated/expected/__init__.py create mode 100644 templates/code/python/tests/filtering_exclude_deprecated/expected/attributes.py create mode 100644 templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py create mode 100644 templates/code/python/tests/filtering_exclude_deprecated/params.yaml create mode 100644 templates/code/python/tests/filtering_exclude_deprecated/registry/manifest.yaml create mode 100644 templates/code/python/tests/filtering_exclude_deprecated/registry/model.yaml create mode 100644 templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py create mode 100644 templates/code/python/tests/filtering_stable_only/expected/__init__.py create mode 100644 templates/code/python/tests/filtering_stable_only/expected/attributes.py create mode 100644 templates/code/python/tests/filtering_stable_only/expected/metrics.py create mode 100644 templates/code/python/tests/filtering_stable_only/params.yaml create mode 100644 templates/code/python/tests/filtering_stable_only/registry/manifest.yaml create mode 100644 templates/code/python/tests/filtering_stable_only/registry/model.yaml create mode 100644 templates/code/python/tests/filtering_stable_only/test_consumer.py create mode 100644 templates/code/python/tests/filtering_unfiltered/expected/__init__.py create mode 100644 templates/code/python/tests/filtering_unfiltered/expected/attributes.py create mode 100644 templates/code/python/tests/filtering_unfiltered/expected/metrics.py create mode 100644 templates/code/python/tests/filtering_unfiltered/registry/manifest.yaml create mode 100644 templates/code/python/tests/filtering_unfiltered/registry/model.yaml create mode 100644 templates/code/python/tests/filtering_unfiltered/test_consumer.py create mode 100644 templates/code/python/tests/histogram_boundaries/expected/__init__.py create mode 100644 templates/code/python/tests/histogram_boundaries/expected/metrics.py create mode 100644 templates/code/python/tests/histogram_boundaries/registry/manifest.yaml create mode 100644 templates/code/python/tests/histogram_boundaries/registry/model.yaml create mode 100644 templates/code/python/tests/histogram_boundaries/test_consumer.py create mode 100644 templates/code/python/tests/histogram_boundaries_none/expected/__init__.py create mode 100644 templates/code/python/tests/histogram_boundaries_none/expected/metrics.py create mode 100644 templates/code/python/tests/histogram_boundaries_none/params.yaml create mode 100644 templates/code/python/tests/histogram_boundaries_none/registry/manifest.yaml create mode 100644 templates/code/python/tests/histogram_boundaries_none/registry/model.yaml create mode 100644 templates/code/python/tests/histogram_boundaries_none/test_consumer.py create mode 100644 templates/code/python/tests/histogram_boundaries_params/expected/__init__.py create mode 100644 templates/code/python/tests/histogram_boundaries_params/expected/metrics.py create mode 100644 templates/code/python/tests/histogram_boundaries_params/params.yaml create mode 100644 templates/code/python/tests/histogram_boundaries_params/registry/manifest.yaml create mode 100644 templates/code/python/tests/histogram_boundaries_params/registry/model.yaml create mode 100644 templates/code/python/tests/histogram_boundaries_params/test_consumer.py create mode 100644 templates/code/python/tests/imported/dependency/manifest.yaml create mode 100644 templates/code/python/tests/imported/dependency/model.yaml create mode 100644 templates/code/python/tests/imported/expected/__init__.py create mode 100644 templates/code/python/tests/imported/expected/attributes.py create mode 100644 templates/code/python/tests/imported/expected/metrics.py create mode 100644 templates/code/python/tests/imported/registry/manifest.yaml create mode 100644 templates/code/python/tests/imported/registry/model.yaml create mode 100644 templates/code/python/tests/imported/test_consumer.py create mode 100644 templates/code/python/tests/literal_escaping/expected/__init__.py create mode 100644 templates/code/python/tests/literal_escaping/expected/attributes.py create mode 100644 templates/code/python/tests/literal_escaping/expected/metrics.py create mode 100644 templates/code/python/tests/literal_escaping/registry/manifest.yaml create mode 100644 templates/code/python/tests/literal_escaping/registry/model.yaml create mode 100644 templates/code/python/tests/literal_escaping/test_consumer.py create mode 100644 templates/code/python/tests/long_names/expected/__init__.py create mode 100644 templates/code/python/tests/long_names/expected/attributes.py create mode 100644 templates/code/python/tests/long_names/expected/metrics.py create mode 100644 templates/code/python/tests/long_names/registry/manifest.yaml create mode 100644 templates/code/python/tests/long_names/registry/model.yaml create mode 100644 templates/code/python/tests/long_names/test_consumer.py create mode 100644 templates/code/python/tests/metric_attributes/expected/__init__.py create mode 100644 templates/code/python/tests/metric_attributes/expected/attributes.py create mode 100644 templates/code/python/tests/metric_attributes/expected/metrics.py create mode 100644 templates/code/python/tests/metric_attributes/registry/manifest.yaml create mode 100644 templates/code/python/tests/metric_attributes/registry/model.yaml create mode 100644 templates/code/python/tests/metric_attributes/test_consumer.py create mode 100644 templates/code/python/tests/metrics/expected/__init__.py create mode 100644 templates/code/python/tests/metrics/expected/attributes.py create mode 100644 templates/code/python/tests/metrics/expected/metrics.py create mode 100644 templates/code/python/tests/metrics/registry/manifest.yaml create mode 100644 templates/code/python/tests/metrics/registry/model.yaml create mode 100644 templates/code/python/tests/metrics/test_consumer.py create mode 100644 templates/code/python/tests/naming/expected/__init__.py create mode 100644 templates/code/python/tests/naming/expected/attributes.py create mode 100644 templates/code/python/tests/naming/expected/metrics.py create mode 100644 templates/code/python/tests/naming/registry/manifest.yaml create mode 100644 templates/code/python/tests/naming/registry/model.yaml create mode 100644 templates/code/python/tests/naming/test_consumer.py create mode 100644 templates/code/python/tests/params/expected/__init__.py create mode 100644 templates/code/python/tests/params/expected/attributes.py create mode 100644 templates/code/python/tests/params/expected/metrics.py create mode 100644 templates/code/python/tests/params/params.yaml create mode 100644 templates/code/python/tests/params/registry/manifest.yaml create mode 100644 templates/code/python/tests/params/registry/model.yaml create mode 100644 templates/code/python/tests/params/test_consumer.py create mode 100644 templates/code/python/tests/unsupported_signals/expected/__init__.py create mode 100644 templates/code/python/tests/unsupported_signals/expected/attributes.py create mode 100644 templates/code/python/tests/unsupported_signals/expected/metrics.py create mode 100644 templates/code/python/tests/unsupported_signals/registry/manifest.yaml create mode 100644 templates/code/python/tests/unsupported_signals/registry/model.yaml create mode 100644 templates/code/python/tests/unsupported_signals/test_consumer.py create mode 100644 templates/code/python/tests/vendor_prefix_off/expected/__init__.py create mode 100644 templates/code/python/tests/vendor_prefix_off/expected/attributes.py create mode 100644 templates/code/python/tests/vendor_prefix_off/expected/metrics.py create mode 100644 templates/code/python/tests/vendor_prefix_off/registry/manifest.yaml create mode 100644 templates/code/python/tests/vendor_prefix_off/registry/model.yaml create mode 100644 templates/code/python/tests/vendor_prefix_off/test_consumer.py create mode 100644 templates/code/python/weaver.yaml diff --git a/.config/mise/tasks/weaver/test/_default b/.config/mise/tasks/weaver/test/_default index d9f1a16..5cc6efc 100755 --- a/.config/mise/tasks/weaver/test/_default +++ b/.config/mise/tasks/weaver/test/_default @@ -1,3 +1,3 @@ #!/usr/bin/env bash #MISE description="Run every policy and template package test, and validate generated code" -#MISE depends=["weaver:test:policies", "weaver:test:templates", "weaver:test:go", "weaver:test:rust", "weaver:test:typescript_effect"] +#MISE depends=["weaver:test:policies", "weaver:test:templates", "weaver:test:go", "weaver:test:rust", "weaver:test:typescript_effect", "weaver:test:python"] diff --git a/.config/mise/tasks/weaver/test/python b/.config/mise/tasks/weaver/test/python new file mode 100755 index 0000000..6dbd356 --- /dev/null +++ b/.config/mise/tasks/weaver/test/python @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#MISE description="Type-check, lint, and run consumer tests against generated Python" +set -euo pipefail +exec ./buildscripts/check_python_output.sh diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7132555..58e020d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -40,6 +40,7 @@ jobs: - run: mise run weaver:test:go - run: mise run weaver:test:rust - run: mise run weaver:test:typescript_effect + - run: mise run weaver:test:python markdown: name: Markdown diff --git a/AGENTS.md b/AGENTS.md index aee9dd8..9bbb436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,11 @@ Each template package includes: Generated Go must pass `gofmt`, `go build`, and `go vet`, which `mise run weaver:test:go` checks. +Generated Python must pass `ruff format --check`, `ruff check`, and +`mypy --strict`, and each case's `test_consumer.py` must pass, which +`mise run weaver:test:python` checks against the pinned OpenTelemetry Python +API and SDK. + Generated Rust must pass formatting, consumer tests, and Clippy, which `mise run weaver:test:rust` checks against the pinned OpenTelemetry Rust API and SDK. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43845c8..d287538 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,13 @@ temporary crates and runs formatting, consumer tests, and Clippy. Put consumer tests in a case's `test.rs`, importing the generated crate as `generated_semconv`. +For the Python package, `mise run weaver:test:python` runs formatting, lint, +and `mypy --strict` over every expected module and runs each case's +`test_consumer.py` with an SDK `InMemoryMetricReader`. Consumers import the +generated package as `semconv`. Requirements are locked with hashes in +`buildscripts/python/` and installed in a disposable virtual environment outside +the checkout. + For the Effect TypeScript package, `mise run weaver:test:typescript_effect` type-checks every expected module and runs each case's `consumer.test.ts` against the pinned Effect dependency. Consumers import `./metrics.ts` and may diff --git a/README.md b/README.md index e9473c4..5f7afb2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ is versioned and released from this repository, ready to be referenced by tag. **The packages turn a semantic convention registry into code and keep that registry fit for generation.** Templates generate typed telemetry helpers, such -as typed attribute values and signal helpers on the OpenTelemetry Go and +as typed attribute values and signal helpers on the OpenTelemetry Go, Python and Rust APIs. Policies run during `weaver registry check` and reject conventions a template cannot render, before any code is written. @@ -31,6 +31,7 @@ Templates live under [`templates`](templates) and policies under does and the params it accepts. - [Go code generation](templates/code/go/README.md) +- [Python metrics](templates/code/python/README.md) - [Rust code generation](templates/code/rust/README.md) - [Effect TypeScript metrics](templates/code/typescript_effect/README.md) diff --git a/buildscripts/check_python_output.sh b/buildscripts/check_python_output.sh new file mode 100755 index 0000000..6b82697 --- /dev/null +++ b/buildscripts/check_python_output.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(pwd)" +PACKAGE_DIR="${ROOT}/templates/code/python" +TOOLING_DIR="${ROOT}/buildscripts/python" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/weaver-python.XXXXXX")" +trap 'rm -rf "${WORK_DIR}"' EXIT +export PYTHONDONTWRITEBYTECODE=1 + +for test_dir in "${PACKAGE_DIR}"/tests/*/; do + test_name="$(basename "${test_dir}")" + [[ -f "${test_dir}expected-error.txt" ]] && continue + if [[ ! -f "${test_dir}expected/__init__.py" ]]; then + echo " FAIL: ${test_name} is missing expected/__init__.py." + exit 1 + fi + if [[ ! -f "${test_dir}test_consumer.py" ]]; then + echo " FAIL: ${test_name} is missing test_consumer.py." + exit 1 + fi + mkdir -p "${WORK_DIR}/cases/${test_name}/semconv" + cp -R "${test_dir}expected/." "${WORK_DIR}/cases/${test_name}/semconv/" + cp "${test_dir}test_consumer.py" "${WORK_DIR}/cases/${test_name}/" +done + +uv venv --quiet --python "$(command -v python)" "${WORK_DIR}/venv" +uv pip install --quiet --python "${WORK_DIR}/venv/bin/python" --require-hashes --no-deps -r "${TOOLING_DIR}/requirements.txt" +BIN="${WORK_DIR}/venv/bin" + +echo "-> Checking ruff format ..." +"${BIN}/ruff" format --check --config "${TOOLING_DIR}/ruff.toml" "${WORK_DIR}/cases" +echo "-> Checking ruff check ..." +"${BIN}/ruff" check --no-cache --config "${TOOLING_DIR}/ruff.toml" "${WORK_DIR}/cases" + +shopt -s nullglob +cases=("${WORK_DIR}"/cases/*/) +if [[ ${#cases[@]} -eq 0 ]]; then + echo 'FAIL: no Python cases found' + exit 1 +fi +for case_dir in "${cases[@]}"; do + echo "-> Checking Python [$(basename "${case_dir}")] ..." + ( + cd "${case_dir}" + "${BIN}/mypy" --config-file "${TOOLING_DIR}/mypy.ini" --cache-dir "${WORK_DIR}/mypy-cache" semconv test_consumer.py + "${BIN}/python" -m pytest --quiet --disable-warnings -p no:cacheprovider test_consumer.py + ) +done diff --git a/buildscripts/python/mypy.ini b/buildscripts/python/mypy.ini new file mode 100644 index 0000000..51f09c8 --- /dev/null +++ b/buildscripts/python/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +python_version = 3.12 +strict = True +warn_unused_ignores = True +cache_dir = .mypy_cache diff --git a/buildscripts/python/requirements.in b/buildscripts/python/requirements.in new file mode 100644 index 0000000..5b00eae --- /dev/null +++ b/buildscripts/python/requirements.in @@ -0,0 +1,6 @@ +# Regenerate requirements.txt with: mise exec -- uv pip compile --generate-hashes --python-version 3.12 --no-header -o requirements.txt requirements.in +mypy==2.4.1 +opentelemetry-api==1.45.2 +opentelemetry-sdk==1.45.2 +pytest==9.2.0 +ruff==0.17.3 diff --git a/buildscripts/python/requirements.txt b/buildscripts/python/requirements.txt new file mode 100644 index 0000000..df12d3c --- /dev/null +++ b/buildscripts/python/requirements.txt @@ -0,0 +1,323 @@ +ast-serialize==0.13.1 \ + --hash=sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5 \ + --hash=sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8 \ + --hash=sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd \ + --hash=sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605 \ + --hash=sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a \ + --hash=sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6 \ + --hash=sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5 \ + --hash=sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568 \ + --hash=sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6 \ + --hash=sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4 \ + --hash=sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47 \ + --hash=sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c \ + --hash=sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456 \ + --hash=sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025 \ + --hash=sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769 \ + --hash=sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43 \ + --hash=sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489 \ + --hash=sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede \ + --hash=sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a \ + --hash=sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4 \ + --hash=sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593 \ + --hash=sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd \ + --hash=sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad \ + --hash=sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8 \ + --hash=sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011 \ + --hash=sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d \ + --hash=sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a \ + --hash=sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82 \ + --hash=sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8 \ + --hash=sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96 \ + --hash=sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117 \ + --hash=sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd \ + --hash=sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611 \ + --hash=sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98 \ + --hash=sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b \ + --hash=sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51 \ + --hash=sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef \ + --hash=sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331 \ + --hash=sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8 \ + --hash=sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3 \ + --hash=sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226 \ + --hash=sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e \ + --hash=sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6 \ + --hash=sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49 \ + --hash=sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7 \ + --hash=sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c \ + --hash=sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe \ + --hash=sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5 \ + --hash=sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a \ + --hash=sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6 \ + --hash=sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9 \ + --hash=sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8 \ + --hash=sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7 \ + --hash=sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372 \ + --hash=sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037 \ + --hash=sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa \ + --hash=sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0 + # via mypy +iniconfig==2.3.1 \ + --hash=sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960 \ + --hash=sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7 + # via pytest +librt==0.16.0 \ + --hash=sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e \ + --hash=sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd \ + --hash=sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188 \ + --hash=sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0 \ + --hash=sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4 \ + --hash=sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155 \ + --hash=sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905 \ + --hash=sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9 \ + --hash=sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134 \ + --hash=sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92 \ + --hash=sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6 \ + --hash=sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839 \ + --hash=sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11 \ + --hash=sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678 \ + --hash=sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b \ + --hash=sha256:20fe0bf9053885e21c62b3e091fb5e73e1c54d1daf2e70eb388d70763bcd4220 \ + --hash=sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2 \ + --hash=sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940 \ + --hash=sha256:273d00be33792a15189331df10f1f1331621b043881e66c6c4377f7f776e1291 \ + --hash=sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1 \ + --hash=sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978 \ + --hash=sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0 \ + --hash=sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b \ + --hash=sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5 \ + --hash=sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1 \ + --hash=sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc \ + --hash=sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d \ + --hash=sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07 \ + --hash=sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9 \ + --hash=sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55 \ + --hash=sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70 \ + --hash=sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302 \ + --hash=sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459 \ + --hash=sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96 \ + --hash=sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152 \ + --hash=sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c \ + --hash=sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41 \ + --hash=sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143 \ + --hash=sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e \ + --hash=sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63 \ + --hash=sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262 \ + --hash=sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0 \ + --hash=sha256:4b6183e2e2e0ee00aac2ec07c7f7d151c97e85666b304f574b71cff0f9fccc4e \ + --hash=sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048 \ + --hash=sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a \ + --hash=sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7 \ + --hash=sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf \ + --hash=sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e \ + --hash=sha256:5750a105b42a416f930edc59054927a406effb2550cd5bab92ad7a5842ed5d05 \ + --hash=sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154 \ + --hash=sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96 \ + --hash=sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4 \ + --hash=sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659 \ + --hash=sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d \ + --hash=sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e \ + --hash=sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d \ + --hash=sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b \ + --hash=sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd \ + --hash=sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a \ + --hash=sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121 \ + --hash=sha256:6a63610fa76524edfa605b5b259a603915c7a6e10e54f003e5503030506e81de \ + --hash=sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726 \ + --hash=sha256:6c8893eae2fd13c5488d94056f3e6e5cf3142bfb1c4acaf136cb33d760c5964b \ + --hash=sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4 \ + --hash=sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff \ + --hash=sha256:71b93b42784e25b975079573c642a8fedb049a7bb31d70a51721b1666b3b2ced \ + --hash=sha256:7393c9a48dcce4817dbd4b0d8ff6237efe9b0a0609f5b0adaef315f8541726b5 \ + --hash=sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d \ + --hash=sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd \ + --hash=sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba \ + --hash=sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32 \ + --hash=sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db \ + --hash=sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91 \ + --hash=sha256:845a511b60ca43b9880dcc84a9784c891d6a2098c829130b320846c69c9c0c68 \ + --hash=sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2 \ + --hash=sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99 \ + --hash=sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866 \ + --hash=sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b \ + --hash=sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81 \ + --hash=sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a \ + --hash=sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087 \ + --hash=sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1 \ + --hash=sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143 \ + --hash=sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602 \ + --hash=sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7 \ + --hash=sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc \ + --hash=sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6 \ + --hash=sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248 \ + --hash=sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a \ + --hash=sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e \ + --hash=sha256:aa9357a1b4d4fc787bb718a59cb1112c28c8a976d6bfa268b71cc0a4ab8f3a94 \ + --hash=sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc \ + --hash=sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a \ + --hash=sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef \ + --hash=sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86 \ + --hash=sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3 \ + --hash=sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7 \ + --hash=sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9 \ + --hash=sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b \ + --hash=sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971 \ + --hash=sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc \ + --hash=sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f \ + --hash=sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a \ + --hash=sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1 \ + --hash=sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8 \ + --hash=sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560 \ + --hash=sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a \ + --hash=sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04 \ + --hash=sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671 \ + --hash=sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421 \ + --hash=sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667 \ + --hash=sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65 \ + --hash=sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071 \ + --hash=sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d \ + --hash=sha256:d608f0bf3b8cbddd0067fe02cb8cab7d13e8eb9386b23a1843d4363044fd9e22 \ + --hash=sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42 \ + --hash=sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd \ + --hash=sha256:df183721229ae51eef90108c115b43e98cb169b6155d34480338e5fc6616df00 \ + --hash=sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7 \ + --hash=sha256:e1967e36ac4cae0c7e9615ad32e1a513cdacff79f9e8afb28bedc91caf48b4f3 \ + --hash=sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743 \ + --hash=sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d \ + --hash=sha256:e9ce0bc440e7fd09b5f51f372f0f6640658f854b8fb05260f819cc669c93c42d \ + --hash=sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c \ + --hash=sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b \ + --hash=sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e \ + --hash=sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a \ + --hash=sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b \ + --hash=sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311 \ + --hash=sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3 \ + --hash=sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc \ + --hash=sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144 \ + --hash=sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1 \ + --hash=sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba \ + --hash=sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18 \ + --hash=sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742 \ + --hash=sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f + # via mypy +mypy==2.4.1 \ + --hash=sha256:01b7718552d017f88d1151f3cc9d6556d778a90bd58060a2c3d87d070f9099fb \ + --hash=sha256:0724cf95fabc87ca4a2d5b7f276b43f51afce8a3804bde704490b008e377e50b \ + --hash=sha256:080cf64a60b90a73435542feecf4171904cc5710658165b40c6869115d4aa100 \ + --hash=sha256:0c5bf267645351887b72903fd2f5fbddcf85234e0e6c4ad94e2fcfb4f0c3f971 \ + --hash=sha256:0e95ad0bec03ced9a73b42514208abd24c789e67f0e1eba476e276dd7a2dea3a \ + --hash=sha256:0f0a8b8f5dee799195a3f2670a5b2617ac7193d3563f52dbef42ad6770c6a9db \ + --hash=sha256:133886ef1a7a8cf673df55d865e41580c6c46aa9b8e31e1325dfe315c038f745 \ + --hash=sha256:1784648d6ec8c2b5465e3f26241b845a0b3cb4e56c993c64abe16989e7698999 \ + --hash=sha256:1fb70621535c22fd4b1a0450d37ed1d6c5eb39656a1cebfb189084c12de54ba7 \ + --hash=sha256:2875280687e64740e6ddcef43bd4c5852ad45ff34549740f6aea3f0373870b93 \ + --hash=sha256:2b4e0aa029ad0c1d42cc494daf86d1e0efd15dab4db7bd096ece29e8a9689c6a \ + --hash=sha256:3467b1d13000812ab85dc2a3834b9379ec77798ca1bcabe434e23fb21fead692 \ + --hash=sha256:3f1613bfe6726169a5680ec3dad5fe1f6d96285c12aded51ad432eadedd39d45 \ + --hash=sha256:4af5ad558033bf71a44a1be149ed61633522824f84c1b6bc2445d3fe2d2a5296 \ + --hash=sha256:4c194c5d878130fca1ae662dfb1feb6274afbab0b40032c80147377e0ea803d5 \ + --hash=sha256:4e18d0da3898b3bcd16192f15a206e7b8b8dd0f4a0b3e51aa5bd5c2afe749e2c \ + --hash=sha256:51f742d4154824837ca6e92aa81f00eec222700a3c337bd258e5562a3880d75a \ + --hash=sha256:63b590965691e540a335fa74dadf31a7069142f6b72e234689bbd244edf55623 \ + --hash=sha256:63c6ff0e7ea546943001a91639c9f0c64b60d239268fa5d29e4a6c87f82c2373 \ + --hash=sha256:680ba5d9f0e3fe0c746b3d14df15f2a718e4bc308a1145616a1ea691ce7c354b \ + --hash=sha256:6f7225951a277dd0d6add900731d487174b7d24ac133c09d670ed63b4d8cb90d \ + --hash=sha256:70196470ac300f444a302a738541101893c2c57804078b33690286a7899149e9 \ + --hash=sha256:70971cc0bcb1d3f03d2eb68fa430a6481ccc03558dc001d9534d7e5fa274c392 \ + --hash=sha256:71eda06e5bae51976ce869fda429159c9cec678bd02f25bb197f0265c8f991be \ + --hash=sha256:727890a32f1858c09549eb55a8b234fe990d460450b28f03e8213ef925f1247c \ + --hash=sha256:7344869cad05f073b8cae2826774fa0161e3eb2f5c685b76813574008d77d750 \ + --hash=sha256:79a1e79199e980fb41f219cb3d4746d25c64b0b623840d3ed57df1cdc13a6502 \ + --hash=sha256:8e437811ebd26d3e6258efb562a711b385c139be597dffed5e735c801d15c77f \ + --hash=sha256:9d7dafd56179d296651df9e1844b91c9517c379398f017a2a435d077a28943c1 \ + --hash=sha256:9ed1195502cca32be5ef756b1693477f4991db2f2e34eaa90cc4ad6bea4f9531 \ + --hash=sha256:a20ff6cd2af7a670c64f089bfa5fbcebe8ff3bf896c5e6ac7c9862c7960e4494 \ + --hash=sha256:a2d4a51db92059eecc2c33b2a5194b28585ebfd917292c015fe87a77dbff6cd2 \ + --hash=sha256:a5b5da107dc2c93fb0a22f7a45dbaea5dc131afc7056b5468d78e819f9a138e6 \ + --hash=sha256:acdd82cb3a2eb10720e87706a128d75785d005a8c47e36a4973221524f41fc56 \ + --hash=sha256:b69a15fc2e75858d6b15e91ed3a0dcff1a533265183b64734138791fe0f49aa5 \ + --hash=sha256:be4c03f33c55500191df2ed4a9b5e7edbdd1fff067fe361a5a9b1b5d3262da6e \ + --hash=sha256:beb254fec368c4e7f7f82a5f310c6470b893ec88a90b66426da971c6826b0453 \ + --hash=sha256:c3a4d0c47053cc90035dd3a2c321112b593ad42935d0cbfd747288e1564fa8ca \ + --hash=sha256:c45485061ba08f700acc12cfc8b177e6997e6bc353605babfc0abb081e079456 \ + --hash=sha256:c535efcb91f7e66f41b9bd2b5dbd78900a7609ce88f4c020d668e4c82d082b0b \ + --hash=sha256:c6c7210e40dd423af7d65cbe428b5906d11dc60659337bdae4e5fdf02ce32ed7 \ + --hash=sha256:d4c343b2a720741a016d720b255e75581a50e2bede61d53250484e18562a1d64 \ + --hash=sha256:d63737426a048a27705a4eedb828669f4bc0873612a1b46b0b9fe892b2ebaaaf \ + --hash=sha256:d6e081b466e88d4343adffe11aff0f02df34b5b9070b4b700053a0b7294c0be1 \ + --hash=sha256:d923cec43316f0e6736b8298fd00abeaaf348a091c20e7296b3aebceecb63eca \ + --hash=sha256:db599d03e7d60d15f2e0a11665c18bc52a88699fcab990fdf8cecd5be8ac801c \ + --hash=sha256:de95648e0a6d46fa9929b956ed2c87c5dc9933d4d59945e6390e9d753c74e7c1 \ + --hash=sha256:ea0714d2bccaf6d010c970e1d3ba2c9c2add79d2030aad2ef03167d788948276 \ + --hash=sha256:ec416d5d2ae689e681f5727e328723f18d8b5dc8c903a146b7851dc5727aad8a \ + --hash=sha256:ef8d5be90032261197ac388d945d8ff4f476c95880798a1911760af64ddfe71f \ + --hash=sha256:f44dd4c5ab22df21206dd926c98381ebdbf5bfd696df37dd38ded2d23143fcb6 \ + --hash=sha256:f9118a3f50de264b614861bb11814833a742138ac3c8e2e49a860b6977fb2704 + # via -r requirements.in +mypy-extensions==1.1.0 \ + --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ + --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 + # via mypy +opentelemetry-api==1.45.2 \ + --hash=sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34 \ + --hash=sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4 + # via + # -r requirements.in + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-sdk==1.45.2 \ + --hash=sha256:7eec4de13dd21ff3a1670dcb0e02079e692736f94d733dfdce5af461d884248d \ + --hash=sha256:ff974ea70ee4f1d51e2ec152db37c2b626c7bf82bb3f7d86114fd8704e7199c6 + # via -r requirements.in +opentelemetry-semantic-conventions==0.66b2 \ + --hash=sha256:4cbe61e5f9275d57ab31708446e7938ef4291ebbe6a7fca6f7a629c5acae87d8 \ + --hash=sha256:7da5f1a5dc4383eb66711d4d3d3e71824d9403ac1559fea021cc103c1a4c4976 + # via opentelemetry-sdk +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via pytest +pathspec==1.1.1 \ + --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ + --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 + # via mypy +pluggy==1.7.0 \ + --hash=sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec \ + --hash=sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8 + # via pytest +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via pytest +pytest==9.2.0 \ + --hash=sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588 \ + --hash=sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b + # via -r requirements.in +ruff==0.17.3 \ + --hash=sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2 \ + --hash=sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb \ + --hash=sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5 \ + --hash=sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532 \ + --hash=sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca \ + --hash=sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24 \ + --hash=sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a \ + --hash=sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe \ + --hash=sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f \ + --hash=sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e \ + --hash=sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9 \ + --hash=sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9 \ + --hash=sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad \ + --hash=sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4 \ + --hash=sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29 \ + --hash=sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef \ + --hash=sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1 \ + --hash=sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11 + # via -r requirements.in +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # mypy + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions diff --git a/buildscripts/python/ruff.toml b/buildscripts/python/ruff.toml new file mode 100644 index 0000000..99f0466 --- /dev/null +++ b/buildscripts/python/ruff.toml @@ -0,0 +1,10 @@ +target-version = "py312" +line-length = 88 + +[lint] +select = ["E", "F", "I", "UP", "B", "N", "SIM", "RUF"] +# Generated docstrings and string literals cannot be wrapped to the line length. +ignore = ["E501"] + +[lint.isort] +known-first-party = ["semconv"] diff --git a/mise.toml b/mise.toml index 61eff18..566ec1e 100644 --- a/mise.toml +++ b/mise.toml @@ -3,5 +3,7 @@ go = "1.27.1" jq = "1.8.1" node = "24.21.0" +python = "3.12.14" +uv = "0.13.4" rust = { version = "1.99.0", profile = "minimal", components = ["rustfmt", "clippy"] } "npm:prettier" = "3.9.9" diff --git a/templates/code/python/README.md b/templates/code/python/README.md new file mode 100644 index 0000000..e19e75d --- /dev/null +++ b/templates/code/python/README.md @@ -0,0 +1,210 @@ +# Python Template Package + +Generates typed attribute keys, enum classes, and metric instruments from a +semantic convention registry using the vanilla OpenTelemetry Python metrics API. + +Stability: Development. Generated code is tested against `opentelemetry-api` +and `opentelemetry-sdk` 1.45.2 on Python 3.12, and passes `mypy --strict`, +`ruff check`, and `ruff format --check`. + +Importing generated code needs only `opentelemetry-api`. The SDK is not +imported, so a service keeps its choice of SDK, exporters, and views. + +Spans, events, and entities are out of scope. The template does not render them +and does not fail on a registry that contains them. Attributes that only those +signals use still get key constants and enum classes. + +## Generate and use a package + +From a checkout of this repository: + +```sh +weaver registry generate \ + --v2 \ + -r {your registry} \ + -t templates \ + code/python \ + {your project}/semconv +``` + +For a released package, replace `-t templates` with +`-t 'https://github.com/TrogonStack/trogonstack-weaver-packages.git@v{version}[templates]'`. +The output directory becomes a Python package with three flat modules: +`__init__.py`, `attributes.py`, and `metrics.py`. Import it by the name of the +directory, here `semconv`. Add the one runtime dependency: + +```toml +[project] +requires-python = ">=3.12" +dependencies = ["opentelemetry-api>=1.30.0"] +``` + +Version 1.30.0 is the first release whose `create_histogram` accepts +`explicit_bucket_boundaries_advisory`. Weaver writes only the files it +produces, so empty the generated directory before regenerating after removing +conventions. Run `ruff format` after generation if your project formats +generated code. The output is already formatted for Ruff's default style, but a +very long attribute or metric name can make Ruff choose a different wrapping. +Generated code does not keep every line within 88 characters, so do not enforce +`E501` on it. + +## Generated API + +The package root carries the scope identity and the schema URL of the registry. + +```python +from semconv import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL +``` + +`SCHEMA_URL` is the registry's `schema_url`. `get_meter` in `semconv.metrics` +passes it as `schema_url` together with the instrumentation name and version, so +every instrument reports the schema it was generated from. A registry whose +`schema_url` does not end in a schema version fails generation. + +### Attributes + +Each attribute becomes a `Final` key constant named from the attribute key, with +the registry brief as its docstring. An enum attribute also becomes an +`enum.StrEnum`, `enum.IntEnum`, `float` plus `enum.Enum`, or `enum.Enum` class, +chosen by the value type of its members, with one member per registry member. + +```python +from semconv import attributes + +attributes.MYAPP_TASK_ID_KEY # "myapp.task.id" +attributes.MyappTaskState.QUEUED.value # "queued" +``` + +Constants are named `_KEY`. An enum class has the title-cased name of the +attribute, without the `_KEY` suffix. Deprecated attributes and members carry a +`Deprecated:` line in their docstring. + +### Metrics + +Each metric becomes one class. The class constants carry the registry values, so +code that records by name, unit, or description can read them from one place. + +```python +from semconv import metrics + +metrics.MyappTaskDuration.NAME # "myapp.task.duration" +metrics.MyappTaskDuration.UNIT # "s" +metrics.MyappTaskDuration.DESCRIPTION +metrics.MyappTaskDuration.KIND # metrics.InstrumentKind.HISTOGRAM +metrics.MyappTaskDuration.BUCKET_BOUNDARIES # tuple of floats, or None +``` + +The constructor creates the instrument on a meter. It uses `get_meter()` when +you pass none, or the meter you pass: + +```python +duration = metrics.MyappTaskDuration() +duration.record(0.42, task_id="task-1", task_state=attributes.MyappTaskState.QUEUED) +``` + +Attributes are keyword-only parameters, named from the attribute key without the +leading metric namespace. Required attributes are required parameters. Every +other requirement level is an optional parameter that defaults to `None` and is +left out of the recorded attributes when `None`. Enum members are recorded by +value, and array attributes are copied into a list at record time. + +| Instrument kind | Class method | Observable class method and constructor | +| --------------- | ------------ | ------------------------------------------------- | +| `counter` | `add` | `Observable.observation`, `Observable(callbacks)` | +| `updowncounter` | `add` | `Observable.observation`, `Observable(callbacks)` | +| `gauge` | `set` | `Observable.observation`, `Observable(callbacks)` | +| `histogram` | `record` | none, because the API has no observable histogram | + +Counters, up-down counters, and gauges also get a `Observable` class. +Pass callbacks to its constructor. Inside a callback, build each value with the +static `observation` method, which takes the same attributes as the synchronous +method and returns an `Observation`. + +```python +def read_depth(options: CallbackOptions) -> Iterable[Observation]: + yield metrics.MyappQueueDepthObservable.observation(depth(), queue_name="main") + + +metrics.MyappQueueDepthObservable([read_depth]) +``` + +A parameter that would collide with Python, such as a keyword or `amount`, +fails generation instead of being renamed. + +### Refinements + +A metric refinement gets its own class that keeps the name, unit, description, +value type, and boundaries of the metric it refines. Its attributes come from +the refinement, and its parameter names drop the namespace of the refinement id. +Refinements are emitted only when their base metric is emitted. + +### Histogram boundaries + +A histogram takes `explicit_bucket_boundaries_advisory` from, in order: + +1. `annotations.aggregation.parameters.boundaries`, when the metric sets + `annotations.aggregation.method: explicithistogram`. +2. `histogram_boundaries_by_unit` for the metric's unit. +3. Nothing, which leaves the SDK default boundaries in place. + +The boundaries are an advisory. A view that sets its own boundaries overrides +them. `BUCKET_BOUNDARIES` is `None` when no boundaries apply. + +## Parameters + +| Parameter | Default | Purpose | +| ------------------------------ | --------------------------- | ------------------------------------------------------------------------ | +| `instrumentation_name` | `generated-semconv` | Scope name passed to `get_meter`. | +| `instrumentation_version` | `""` | Scope version passed to `get_meter`; empty leaves the scope unversioned. | +| `vendor_prefixes` | `[]` | Leading vendor segments removed when deriving names. | +| `exclude_deprecated` | `false` | Exclude deprecated conventions and enum members. | +| `stable_only` | `false` | Include only stable conventions and enum members. | +| `histogram_boundaries_by_unit` | Duration boundaries for `s` | Default histogram boundaries by unit; `{}` disables them. | +| `header_source` | `""` | Source description in the generated header. | +| `module_description` | `""` | Docstring of the generated package. | +| `regenerate_command` | `""` | Regeneration instruction in the generated header. | + +With `vendor_prefixes: [acme]`, `acme.billing.invoice.id` renders as +`BILLING_INVOICE_ID_KEY`, exactly like `billing.invoice.id`. + +## Generation errors and limits + +Generation fails with a diagnostic, instead of emitting invalid Python, when: + +- the manifest has no schema URL that ends in a schema version +- a metric does not set `code_generation.metric_value_type` to `int` or `double` +- two attributes, enum members, metrics, or parameters render to the same + Python identifier, or one renders to a reserved Python symbol +- an attribute name is not ASCII letter-led dotted segments of letters, digits, + and underscores, or an enum member id is not a valid identifier +- an enum has no members left after filtering, duplicate values, mixed value + types, or an integer value outside 64 bits +- an attribute has a type the template cannot express, such as a template type + or `any` +- `exclude_deprecated` or `stable_only` leaves out an attribute a kept metric + requires +- `annotations.aggregation` is set on anything but a histogram, sets fields + other than `method` and `parameters.boundaries`, names a method other than + `explicithistogram`, or lists boundaries that are empty or not strictly + increasing +- a metric refinement sets a value type that differs from its base metric +- `instrumentation_name` is empty or not a string, or + `instrumentation_version` is not a string + +A conditionally required attribute stays optional, because Python cannot prove +the registry condition. + +## Validate a change + +```sh +mise run weaver:test:templates +mise run weaver:test:python +``` + +The Python check installs locked, hash-verified requirements into a disposable +virtual environment outside the checkout. For each case it copies the generated +fixture next to its `test_consumer.py`, then runs `ruff format --check`, +`ruff check`, `mypy --strict`, and `pytest`. Each consumer records through an +SDK `MeterProvider` with an `InMemoryMetricReader` and asserts names, units, +attributes, the schema URL, bucket boundaries, and that the generated constants +equal the registry values. Snapshot checks compare the original Weaver output. diff --git a/templates/code/python/__init__.py.j2 b/templates/code/python/__init__.py.j2 new file mode 100644 index 0000000..c371ca8 --- /dev/null +++ b/templates/code/python/__init__.py.j2 @@ -0,0 +1,19 @@ +{%- filter trim %} +{% import "header.j2" as h %} +{{ h.header() }} + +{% if params.module_description %} +"""{{ params.module_description | replace("\\", "\\\\") | replace('"', '\\"') | trim }}""" + +{% endif %} +from typing import Final + +SCHEMA_URL: Final = {{ ctx.schema_literal }} +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = {{ ctx.name_literal }} +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = {{ ctx.version_literal }} +"""Version of the instrumentation scope that `get_meter` creates.""" +{% endfilter -%}{{ "\n" -}} diff --git a/templates/code/python/attributes.py.j2 b/templates/code/python/attributes.py.j2 new file mode 100644 index 0000000..ccd476c --- /dev/null +++ b/templates/code/python/attributes.py.j2 @@ -0,0 +1,34 @@ +{%- filter trim %} +{% import "header.j2" as h %} +{{ h.header() }} + +{% if ctx.has_enums %} +import enum +{% endif %} +from typing import Final +{% for attribute in ctx.attributes %} +{% if attribute.is_enum %} + + +class {{ attribute.class_name }}({% if attribute.value_type == "string" %}enum.StrEnum{% elif attribute.value_type == "int" %}enum.IntEnum{% elif attribute.value_type == "double" %}float, enum.Enum{% else %}enum.Enum{% endif %}): + """Values of the `{{ attribute.key }}` attribute.""" + +{% for member in attribute.type.members %} + {{ member.constant }} = {{ member.literal }} +{% if member.doc %} + """{{ member.doc | indent(4) }}""" +{% endif %} +{% endfor %} +{% endif %} +{% if loop.first and not attribute.is_enum %} + +{% else %} + + +{% endif %} +{{ attribute.constant }}: Final = {{ attribute.key_literal }} +{% if attribute.doc %} +"""{{ attribute.doc }}""" +{% endif %} +{% endfor %} +{% endfilter -%}{{ "\n" -}} diff --git a/templates/code/python/header.j2 b/templates/code/python/header.j2 new file mode 100644 index 0000000..0b36bca --- /dev/null +++ b/templates/code/python/header.j2 @@ -0,0 +1,10 @@ +{%- macro header() -%} +{%- if params.header_source -%} +# Code generated by weaver from {{ params.header_source | replace("\n", " ") | replace("\r", " ") }}. DO NOT EDIT. +{%- else -%} +# Code generated by weaver. DO NOT EDIT. +{%- endif %} +{%- if params.regenerate_command %} +{{ "\n" }}# Run `{{ params.regenerate_command | replace("\n", " ") | replace("\r", " ") }}` after changing a convention. +{%- endif %} +{%- endmacro -%} diff --git a/templates/code/python/metrics.py.j2 b/templates/code/python/metrics.py.j2 new file mode 100644 index 0000000..737eb6f --- /dev/null +++ b/templates/code/python/metrics.py.j2 @@ -0,0 +1,186 @@ +{%- filter trim %} +{% import "header.j2" as h %} +{% macro parameters(metric) %} + *, +{% for attribute in metric.required_attributes %} + {{ attribute.field }}: {{ attribute.python_type }}, +{% endfor %} +{% for attribute in metric.optional_attributes %} + {{ attribute.field }}: {{ attribute.python_type }} | None = None, +{% endfor %} +{%- endmacro %} +{% macro attribute_dict(metric) %} +{% if metric.required_attributes %} + _attributes: dict[str, AttributeValue] = { +{% for attribute in metric.required_attributes %} +{% if attribute.is_array and attribute.dict_wrap %} + {{ attribute.key_expression }}: [ + *{{ attribute.field }} + ], +{% else %} + {{ attribute.key_expression }}: {{ attribute.value_expression }}, +{% endif %} +{% endfor %} + } +{% else %} + _attributes: dict[str, AttributeValue] = {} +{% endif %} +{% for attribute in metric.optional_attributes %} + if {{ attribute.field }} is not None: +{% if attribute.assign_wrap and attribute.is_array %} + _attributes[{{ attribute.key_expression }}] = [ + *{{ attribute.field }} + ] +{% elif attribute.assign_wrap and attribute.is_enum %} + _attributes[{{ attribute.key_expression }}] = ( + {{ attribute.value_expression }} + ) +{% else %} + _attributes[{{ attribute.key_expression }}] = {{ attribute.value_expression }} +{% endif %} +{% endfor %} +{%- endmacro %} +{% macro argument_docs(metric, summary) %} + """{{ summary }} + + Args: +{% for attribute in metric.attributes %} +{% if attribute.requirement_doc %} + {{ attribute.field }}: {{ attribute.requirement_doc | indent(16) }} +{% endif %} +{% endfor %} + """ +{%- endmacro %} +{{ h.header() }} + +import enum +{% if ctx.uses_sequence %} +from collections.abc import Sequence +{% endif %} +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import {{ ctx.api_imports | join(", ") }} +{% if ctx.has_attributes %} +from opentelemetry.util.types import AttributeValue +{% endif %} + +from . import {{ ctx.package_imports | join(", ") }} + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) +{% for metric in ctx.metrics %} + + +class {{ metric.class_name }}: +{% if metric.doc %} + """{{ metric.doc | indent(4) }}""" + +{% endif %} + NAME: Final = {{ (metric.instrument_name) | tojson }} + UNIT: Final = {{ metric.unit_literal }} + DESCRIPTION: Final = {{ metric.description_literal }} + KIND: Final = InstrumentKind.{{ metric.constant_kind }} +{% if metric.instrument == "histogram" %} +{% if metric.bucket_boundaries %} + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( +{% for boundary in metric.bucket_boundaries %} + {{ boundary }}, +{% endfor %} + ) +{% else %} + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None +{% endif %} +{% endif %} + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.{{ metric.create_method }}( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, +{% if metric.instrument == "histogram" %} + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, +{% endif %} + ) + +{% if metric.attributes %} + def {{ metric.record_method }}( + self, + amount: {{ metric.value_type }}, + /, +{{ parameters(metric) | trim("\n") }} + ) -> None: +{% if metric.attributes | selectattr("requirement_doc") | list %} +{{ argument_docs(metric, "Add to the counter." if metric.record_method == "add" else ("Set the gauge." if metric.record_method == "set" else "Record a measurement.")) | trim("\n") }} + +{% endif %} +{{ attribute_dict(metric) | trim("\n") }} + self._instrument.{{ metric.record_method }}(amount, _attributes) +{% else %} + def {{ metric.record_method }}(self, amount: {{ metric.value_type }}, /) -> None: + self._instrument.{{ metric.record_method }}(amount) +{% endif %} +{% if metric.instrument != "histogram" %} + + +class {{ metric.class_name }}Observable: + """Observable form of `{{ metric.class_name }}`, reported by callbacks.""" + + NAME: Final = {{ metric.class_name }}.NAME + UNIT: Final = {{ metric.class_name }}.UNIT + DESCRIPTION: Final = {{ metric.class_name }}.DESCRIPTION + KIND: Final = {{ metric.class_name }}.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.{{ metric.observable_method }}( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + +{% if metric.attributes %} + @staticmethod + def observation( + amount: {{ metric.value_type }}, + /, +{{ parameters(metric) | trim("\n") }} + ) -> Observation: +{% if metric.attributes | selectattr("requirement_doc") | list %} +{{ argument_docs(metric, "Build an observation.") | trim("\n") }} + +{% endif %} +{{ attribute_dict(metric) | trim("\n") }} + return Observation(amount, _attributes) +{% else %} + @staticmethod + def observation(amount: {{ metric.value_type }}, /) -> Observation: + return Observation(amount) +{% endif %} +{% endif %} +{% endfor %} +{% endfilter -%}{{ "\n" -}} diff --git a/templates/code/python/tests/attributes/expected/__init__.py b/templates/code/python/tests/attributes/expected/__init__.py new file mode 100644 index 0000000..5eb906c --- /dev/null +++ b/templates/code/python/tests/attributes/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/attributes/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/attributes/expected/attributes.py b/templates/code/python/tests/attributes/expected/attributes.py new file mode 100644 index 0000000..b5810fb --- /dev/null +++ b/templates/code/python/tests/attributes/expected/attributes.py @@ -0,0 +1,155 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class AuthLevel(enum.IntEnum): + """Values of the `auth.level` attribute.""" + + LOW = 1 + """Single factor.""" + HIGH = 2 + """Multiple factors.""" + + +AUTH_LEVEL_KEY: Final = "auth.level" +"""Assurance level of the authentication.""" + + +class AuthMethod(enum.StrEnum): + """Values of the `auth.method` attribute.""" + + PASSWORD = "password" + """A password was checked.""" + TOKEN = "token" + """A bearer token was checked.""" + + +AUTH_METHOD_KEY: Final = "auth.method" +"""How the user was authenticated.""" + + +AUTH_USER_ID_KEY: Final = "auth.user.id" +"""Identifier of the authenticated user.""" + + +MYAPP_TASK_ASSIGNED_BY_KEY: Final = "myapp.task.assigned_by" +"""Who assigned the task. + +The scheduler sets it on every task it passes along to `myapp.task.state`. Workers never set it themselves.""" + + +MYAPP_TASK_CANCELLED_KEY: Final = "myapp.task.cancelled" +"""Whether the task was cancelled.""" + + +class MyappTaskEscaped(enum.StrEnum): + """Values of the `myapp.task.escaped` attribute.""" + + QUOTED = 'quote" slash\\ newline\n tab\t backspace\b formfeed\f null\u0000 snowman☃' + """Escaped text.""" + + +MYAPP_TASK_ESCAPED_KEY: Final = "myapp.task.escaped" +"""Tests escaping in Python string literals.""" + + +MYAPP_TASK_FLAGS_KEY: Final = "myapp.task.flags" +"""Feature flags evaluated for the task.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task. + +The identifier is assigned by the scheduler and stays stable across retries.""" + + +class MyappTaskLane(enum.StrEnum): + """Values of the `myapp.task.lane` attribute.""" + + FAST = "fast" + """The fast lane. + + Deprecated: Lanes were replaced by priorities.""" + SLOW = "slow" + """The slow lane. + + Deprecated: Every task runs in the fast lane.""" + + +MYAPP_TASK_LANE_KEY: Final = "myapp.task.lane" +"""Lane the task was scheduled in. + +Deprecated: Lanes were replaced by priorities.""" + + +MYAPP_TASK_OWNER_KEY: Final = "myapp.task.owner" +"""Owner of the task. + +Deprecated: Replaced by `auth.user.id`.""" + + +class MyappTaskPreemptible(enum.Enum): + """Values of the `myapp.task.preemptible` attribute.""" + + ALLOWED = True + """The scheduler may preempt the task.""" + FORBIDDEN = False + """The task runs to completion.""" + + +MYAPP_TASK_PREEMPTIBLE_KEY: Final = "myapp.task.preemptible" +"""Whether the scheduler may preempt the task.""" + + +MYAPP_TASK_PROGRESS_KEY: Final = "myapp.task.progress" +"""Fraction of the task that has completed, between 0 and 1.""" + + +MYAPP_TASK_RETRIES_KEY: Final = "myapp.task.retries" +"""Number of times the task was retried.""" + + +class MyappTaskSampleRate(float, enum.Enum): + """Values of the `myapp.task.sample_rate` attribute.""" + + TENTH = 0.1 + """One task in ten is sampled.""" + ALL = 1.0 + """Every task is sampled.""" + + +MYAPP_TASK_SAMPLE_RATE_KEY: Final = "myapp.task.sample_rate" +"""Fraction of tasks whose telemetry is sampled.""" + + +MYAPP_TASK_SHARD_IDS_KEY: Final = "myapp.task.shard_ids" +"""Shards the task touched.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + RUNNING = "running" + """The task is running. + + A task stays running until it finishes or is cancelled.""" + PAUSED = "paused" + """The task was paused. + + Deprecated: Paused tasks are reported as queued.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" + + +MYAPP_TASK_TAGS_KEY: Final = "myapp.task.tags" +"""Tags attached to the task.""" + + +MYAPP_TASK_WEIGHTS_KEY: Final = "myapp.task.weights" +"""Scheduling weights applied to the task.""" diff --git a/templates/code/python/tests/attributes/registry/manifest.yaml b/templates/code/python/tests/attributes/registry/manifest.yaml new file mode 100644 index 0000000..f168c1f --- /dev/null +++ b/templates/code/python/tests/attributes/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: attributes +schema_url: https://example.com/schemas/attributes/1.0.0 diff --git a/templates/code/python/tests/attributes/registry/model.yaml b/templates/code/python/tests/attributes/registry/model.yaml new file mode 100644 index 0000000..733a8bf --- /dev/null +++ b/templates/code/python/tests/attributes/registry/model.yaml @@ -0,0 +1,163 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + note: The identifier is assigned by the scheduler and stays stable across retries. + examples: ["task_0001"] + - key: myapp.task.retries + type: int + stability: stable + brief: Number of times the task was retried. + examples: 3 + - key: myapp.task.progress + type: double + stability: development + brief: Fraction of the task that has completed, between 0 and 1. + examples: 0.5 + - key: myapp.task.cancelled + type: boolean + stability: stable + brief: Whether the task was cancelled. + examples: false + - key: myapp.task.tags + type: string[] + stability: stable + brief: Tags attached to the task. + examples: [["nightly", "batch"]] + - key: myapp.task.shard_ids + type: int[] + stability: stable + brief: Shards the task touched. + examples: [[1, 2]] + - key: myapp.task.weights + type: double[] + stability: development + brief: Scheduling weights applied to the task. + examples: [[0.25, 0.75]] + - key: myapp.task.flags + type: boolean[] + stability: development + brief: Feature flags evaluated for the task. + examples: [[true, false]] + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: running + value: running + stability: stable + brief: The task is running. + note: A task stays running until it finishes or is cancelled. + - id: paused + value: paused + stability: development + brief: The task was paused. + deprecated: + reason: obsoleted + note: Paused tasks are reported as queued. + stability: stable + brief: Current state of the task. + - key: myapp.task.owner + type: string + stability: development + brief: Owner of the task. + examples: ["solo"] + deprecated: + reason: renamed + renamed_to: auth.user.id + - key: auth.user.id + type: string + stability: stable + brief: Identifier of the authenticated user. + examples: ["u_123"] + - key: auth.method + type: + members: + - id: password + value: password + stability: stable + brief: A password was checked. + - id: token + value: token + stability: stable + brief: A bearer token was checked. + stability: stable + brief: How the user was authenticated. + - key: auth.level + type: + members: + - id: low + value: 1 + stability: stable + brief: Single factor. + - id: high + value: 2 + stability: stable + brief: Multiple factors. + stability: development + brief: Assurance level of the authentication. + - key: myapp.task.preemptible + type: + members: + - id: allowed + value: true + stability: stable + brief: The scheduler may preempt the task. + - id: forbidden + value: false + stability: stable + brief: The task runs to completion. + stability: stable + brief: Whether the scheduler may preempt the task. + - key: myapp.task.sample_rate + type: + members: + - id: tenth + value: 0.1 + stability: stable + brief: One task in ten is sampled. + - id: all + value: 1 + stability: stable + brief: Every task is sampled. + stability: stable + brief: Fraction of tasks whose telemetry is sampled. + - key: myapp.task.assigned_by + type: string + stability: stable + brief: Who assigned the task. + note: The scheduler sets it on every task it passes along to `myapp.task.state`. Workers never set it themselves. + examples: ["scheduler"] + - key: myapp.task.lane + type: + members: + - id: fast + value: fast + stability: stable + brief: The fast lane. + - id: slow + value: slow + stability: stable + brief: The slow lane. + deprecated: + reason: obsoleted + note: Every task runs in the fast lane. + stability: development + brief: Lane the task was scheduled in. + deprecated: + reason: obsoleted + note: Lanes were replaced by priorities. + - key: myapp.task.escaped + type: + members: + - id: quoted + value: "quote\" slash\\ newline\n tab\t backspace\b formfeed\f null\u0000 snowman☃" + stability: stable + brief: Escaped text. + stability: stable + brief: Tests escaping in Python string literals. diff --git a/templates/code/python/tests/attributes/test_consumer.py b/templates/code/python/tests/attributes/test_consumer.py new file mode 100644 index 0000000..6b32361 --- /dev/null +++ b/templates/code/python/tests/attributes/test_consumer.py @@ -0,0 +1,83 @@ +import enum + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, +) + + +def test_module_constants() -> None: + assert SCHEMA_URL == "https://example.com/schemas/attributes/1.0.0" + assert INSTRUMENTATION_NAME == "generated-semconv" + assert INSTRUMENTATION_VERSION == "" + + +def test_attribute_keys_equal_the_registry() -> None: + keys = { + name: getattr(attributes, name) + for name in dir(attributes) + if name.endswith("_KEY") + } + assert keys == { + "AUTH_LEVEL_KEY": "auth.level", + "AUTH_METHOD_KEY": "auth.method", + "AUTH_USER_ID_KEY": "auth.user.id", + "MYAPP_TASK_ASSIGNED_BY_KEY": "myapp.task.assigned_by", + "MYAPP_TASK_CANCELLED_KEY": "myapp.task.cancelled", + "MYAPP_TASK_ESCAPED_KEY": "myapp.task.escaped", + "MYAPP_TASK_FLAGS_KEY": "myapp.task.flags", + "MYAPP_TASK_ID_KEY": "myapp.task.id", + "MYAPP_TASK_LANE_KEY": "myapp.task.lane", + "MYAPP_TASK_OWNER_KEY": "myapp.task.owner", + "MYAPP_TASK_PREEMPTIBLE_KEY": "myapp.task.preemptible", + "MYAPP_TASK_PROGRESS_KEY": "myapp.task.progress", + "MYAPP_TASK_RETRIES_KEY": "myapp.task.retries", + "MYAPP_TASK_SAMPLE_RATE_KEY": "myapp.task.sample_rate", + "MYAPP_TASK_SHARD_IDS_KEY": "myapp.task.shard_ids", + "MYAPP_TASK_STATE_KEY": "myapp.task.state", + "MYAPP_TASK_TAGS_KEY": "myapp.task.tags", + "MYAPP_TASK_WEIGHTS_KEY": "myapp.task.weights", + } + + +def test_string_enum_members_are_strings() -> None: + assert issubclass(attributes.MyappTaskState, enum.StrEnum) + assert [member.value for member in attributes.MyappTaskState] == [ + "queued", + "running", + "paused", + ] + assert str(attributes.MyappTaskState.RUNNING) == "running" + assert attributes.AuthMethod("token") is attributes.AuthMethod.TOKEN + + +def test_integer_enum_members_are_integers() -> None: + assert issubclass(attributes.AuthLevel, enum.IntEnum) + assert [member.value for member in attributes.AuthLevel] == [1, 2] + assert attributes.AuthLevel.HIGH + 1 == 3 + + +def test_boolean_enum_members_are_booleans() -> None: + assert [member.value for member in attributes.MyappTaskPreemptible] == [True, False] + assert attributes.MyappTaskPreemptible.ALLOWED.value is True + assert attributes.MyappTaskPreemptible.FORBIDDEN.value is False + + +def test_double_enum_members_are_floats() -> None: + assert issubclass(attributes.MyappTaskSampleRate, float) + assert [member.value for member in attributes.MyappTaskSampleRate] == [0.1, 1.0] + assert isinstance(attributes.MyappTaskSampleRate.ALL.value, float) + + +def test_deprecated_enum_members_are_still_generated() -> None: + assert attributes.MyappTaskState.PAUSED.value == "paused" + assert attributes.MyappTaskLane.SLOW.value == "slow" + + +def test_member_values_survive_escaping() -> None: + assert ( + attributes.MyappTaskEscaped.QUOTED.value + == 'quote" slash\\ newline\n tab\t backspace\b formfeed\f null\x00 snowman☃' + ) diff --git a/templates/code/python/tests/empty/expected/__init__.py b/templates/code/python/tests/empty/expected/__init__.py new file mode 100644 index 0000000..6d4c148 --- /dev/null +++ b/templates/code/python/tests/empty/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/empty/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/empty/registry/manifest.yaml b/templates/code/python/tests/empty/registry/manifest.yaml new file mode 100644 index 0000000..e6ad68e --- /dev/null +++ b/templates/code/python/tests/empty/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: empty +schema_url: https://example.com/schemas/empty/1.0.0 diff --git a/templates/code/python/tests/empty/registry/model.yaml b/templates/code/python/tests/empty/registry/model.yaml new file mode 100644 index 0000000..607aa73 --- /dev/null +++ b/templates/code/python/tests/empty/registry/model.yaml @@ -0,0 +1 @@ +file_format: definition/2 diff --git a/templates/code/python/tests/empty/test_consumer.py b/templates/code/python/tests/empty/test_consumer.py new file mode 100644 index 0000000..967236c --- /dev/null +++ b/templates/code/python/tests/empty/test_consumer.py @@ -0,0 +1,17 @@ +import importlib + +import pytest + +from semconv import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL + + +def test_only_the_scope_constants_are_generated() -> None: + assert SCHEMA_URL == "https://example.com/schemas/empty/1.0.0" + assert INSTRUMENTATION_NAME == "generated-semconv" + assert INSTRUMENTATION_VERSION == "" + + +def test_no_metric_or_attribute_module_is_generated() -> None: + for module in ("attributes", "metrics"): + with pytest.raises(ModuleNotFoundError): + importlib.import_module(f"semconv.{module}") diff --git a/templates/code/python/tests/error_aggregation_counter/expected-error.txt b/templates/code/python/tests/error_aggregation_counter/expected-error.txt new file mode 100644 index 0000000..4f96546 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_counter/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.started sets annotations.aggregation, which the Python template supports only on a histogram diff --git a/templates/code/python/tests/error_aggregation_counter/registry/manifest.yaml b/templates/code/python/tests/error_aggregation_counter/registry/manifest.yaml new file mode 100644 index 0000000..01564f7 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_counter/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: aggregation_counter +schema_url: https://example.com/schemas/aggregation_counter/1.0.0 diff --git a/templates/code/python/tests/error_aggregation_counter/registry/myapp.yaml b/templates/code/python/tests/error_aggregation_counter/registry/myapp.yaml new file mode 100644 index 0000000..900b1ec --- /dev/null +++ b/templates/code/python/tests/error_aggregation_counter/registry/myapp.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + aggregation: + method: explicithistogram + parameters: + boundaries: [1, 10] diff --git a/templates/code/python/tests/error_aggregation_fields/expected-error.txt b/templates/code/python/tests/error_aggregation_fields/expected-error.txt new file mode 100644 index 0000000..765cabe --- /dev/null +++ b/templates/code/python/tests/error_aggregation_fields/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.duration sets annotations.aggregation fields other than method and parameters.boundaries diff --git a/templates/code/python/tests/error_aggregation_fields/registry/manifest.yaml b/templates/code/python/tests/error_aggregation_fields/registry/manifest.yaml new file mode 100644 index 0000000..fb1bfec --- /dev/null +++ b/templates/code/python/tests/error_aggregation_fields/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: aggregation_fields +schema_url: https://example.com/schemas/aggregation_fields/1.0.0 diff --git a/templates/code/python/tests/error_aggregation_fields/registry/model.yaml b/templates/code/python/tests/error_aggregation_fields/registry/model.yaml new file mode 100644 index 0000000..2551f73 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_fields/registry/model.yaml @@ -0,0 +1,16 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int + aggregation: + method: explicithistogram + parameters: + boundaries: [1, 2] + record_min_max: true diff --git a/templates/code/python/tests/error_aggregation_method/expected-error.txt b/templates/code/python/tests/error_aggregation_method/expected-error.txt new file mode 100644 index 0000000..e3d1d69 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_method/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.duration sets annotations.aggregation.method to histogram, but the Python template supports only explicithistogram diff --git a/templates/code/python/tests/error_aggregation_method/registry/manifest.yaml b/templates/code/python/tests/error_aggregation_method/registry/manifest.yaml new file mode 100644 index 0000000..4825255 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_method/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: aggregation_method +schema_url: https://example.com/schemas/aggregation_method/1.0.0 diff --git a/templates/code/python/tests/error_aggregation_method/registry/myapp.yaml b/templates/code/python/tests/error_aggregation_method/registry/myapp.yaml new file mode 100644 index 0000000..15edd9e --- /dev/null +++ b/templates/code/python/tests/error_aggregation_method/registry/myapp.yaml @@ -0,0 +1,13 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + annotations: + code_generation: + metric_value_type: double + aggregation: + method: histogram diff --git a/templates/code/python/tests/error_aggregation_unsorted/expected-error.txt b/templates/code/python/tests/error_aggregation_unsorted/expected-error.txt new file mode 100644 index 0000000..31ba0b3 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_unsorted/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.duration must list annotations.aggregation.parameters.boundaries in strictly increasing order diff --git a/templates/code/python/tests/error_aggregation_unsorted/registry/manifest.yaml b/templates/code/python/tests/error_aggregation_unsorted/registry/manifest.yaml new file mode 100644 index 0000000..5130c65 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_unsorted/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: aggregation_unsorted +schema_url: https://example.com/schemas/aggregation_unsorted/1.0.0 diff --git a/templates/code/python/tests/error_aggregation_unsorted/registry/myapp.yaml b/templates/code/python/tests/error_aggregation_unsorted/registry/myapp.yaml new file mode 100644 index 0000000..243ba97 --- /dev/null +++ b/templates/code/python/tests/error_aggregation_unsorted/registry/myapp.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + annotations: + code_generation: + metric_value_type: double + aggregation: + method: explicithistogram + parameters: + boundaries: [0.1, 1, 1, 0.5] diff --git a/templates/code/python/tests/error_attribute_collision/expected-error.txt b/templates/code/python/tests/error_attribute_collision/expected-error.txt new file mode 100644 index 0000000..54f652d --- /dev/null +++ b/templates/code/python/tests/error_attribute_collision/expected-error.txt @@ -0,0 +1 @@ +module attributes has attributes that render to the same Python identifier: demo.foo.bar and demo.foo_bar diff --git a/templates/code/python/tests/error_attribute_collision/registry/manifest.yaml b/templates/code/python/tests/error_attribute_collision/registry/manifest.yaml new file mode 100644 index 0000000..568e913 --- /dev/null +++ b/templates/code/python/tests/error_attribute_collision/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: attribute_collision +schema_url: https://example.com/schemas/attribute_collision/1.0.0 diff --git a/templates/code/python/tests/error_attribute_collision/registry/model.yaml b/templates/code/python/tests/error_attribute_collision/registry/model.yaml new file mode 100644 index 0000000..8a0c9dd --- /dev/null +++ b/templates/code/python/tests/error_attribute_collision/registry/model.yaml @@ -0,0 +1,10 @@ +file_format: definition/2 +attributes: + - key: demo.foo_bar + stability: stable + brief: Synthetic attribute. + type: string + - key: demo.foo.bar + stability: stable + brief: Synthetic attribute. + type: string diff --git a/templates/code/python/tests/error_boundaries_empty/expected-error.txt b/templates/code/python/tests/error_boundaries_empty/expected-error.txt new file mode 100644 index 0000000..d16cdaf --- /dev/null +++ b/templates/code/python/tests/error_boundaries_empty/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.duration must set annotations.aggregation.parameters.boundaries to a non-empty list of numbers diff --git a/templates/code/python/tests/error_boundaries_empty/registry/manifest.yaml b/templates/code/python/tests/error_boundaries_empty/registry/manifest.yaml new file mode 100644 index 0000000..83be9a2 --- /dev/null +++ b/templates/code/python/tests/error_boundaries_empty/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: boundaries_empty +schema_url: https://example.com/schemas/boundaries_empty/1.0.0 diff --git a/templates/code/python/tests/error_boundaries_empty/registry/model.yaml b/templates/code/python/tests/error_boundaries_empty/registry/model.yaml new file mode 100644 index 0000000..7220a61 --- /dev/null +++ b/templates/code/python/tests/error_boundaries_empty/registry/model.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int + aggregation: + method: explicithistogram + parameters: + boundaries: [] diff --git a/templates/code/python/tests/error_cross_namespace_collision/expected-error.txt b/templates/code/python/tests/error_cross_namespace_collision/expected-error.txt new file mode 100644 index 0000000..62877a6 --- /dev/null +++ b/templates/code/python/tests/error_cross_namespace_collision/expected-error.txt @@ -0,0 +1 @@ +module attributes has attributes that render to the same Python identifier: my.app_task and my_app.task diff --git a/templates/code/python/tests/error_cross_namespace_collision/registry/manifest.yaml b/templates/code/python/tests/error_cross_namespace_collision/registry/manifest.yaml new file mode 100644 index 0000000..d75cd19 --- /dev/null +++ b/templates/code/python/tests/error_cross_namespace_collision/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: cross_namespace_collision +schema_url: https://example.com/schemas/cross_namespace_collision/1.0.0 diff --git a/templates/code/python/tests/error_cross_namespace_collision/registry/model.yaml b/templates/code/python/tests/error_cross_namespace_collision/registry/model.yaml new file mode 100644 index 0000000..0bfb698 --- /dev/null +++ b/templates/code/python/tests/error_cross_namespace_collision/registry/model.yaml @@ -0,0 +1,10 @@ +file_format: definition/2 +attributes: + - key: my_app.task + stability: stable + brief: Synthetic attribute. + type: string + - key: my.app_task + stability: stable + brief: Synthetic attribute. + type: string diff --git a/templates/code/python/tests/error_duplicate_enum_value/expected-error.txt b/templates/code/python/tests/error_duplicate_enum_value/expected-error.txt new file mode 100644 index 0000000..e19fee5 --- /dev/null +++ b/templates/code/python/tests/error_duplicate_enum_value/expected-error.txt @@ -0,0 +1 @@ +attribute demo.state has enum members with the same value: first and second diff --git a/templates/code/python/tests/error_duplicate_enum_value/registry/manifest.yaml b/templates/code/python/tests/error_duplicate_enum_value/registry/manifest.yaml new file mode 100644 index 0000000..eda0c6b --- /dev/null +++ b/templates/code/python/tests/error_duplicate_enum_value/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: duplicate_enum_value +schema_url: https://example.com/schemas/duplicate_enum_value/1.0.0 diff --git a/templates/code/python/tests/error_duplicate_enum_value/registry/model.yaml b/templates/code/python/tests/error_duplicate_enum_value/registry/model.yaml new file mode 100644 index 0000000..dec2c20 --- /dev/null +++ b/templates/code/python/tests/error_duplicate_enum_value/registry/model.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +attributes: + - key: demo.state + stability: stable + brief: Synthetic attribute. + type: + members: + - id: first + value: a + stability: stable + brief: First. + - id: second + value: a + stability: stable + brief: Second. diff --git a/templates/code/python/tests/error_enum_class_collision/expected-error.txt b/templates/code/python/tests/error_enum_class_collision/expected-error.txt new file mode 100644 index 0000000..4555d2e --- /dev/null +++ b/templates/code/python/tests/error_enum_class_collision/expected-error.txt @@ -0,0 +1 @@ +module attributes has attributes that render to the same Python identifier: final and a reserved Python symbol diff --git a/templates/code/python/tests/error_enum_class_collision/registry/manifest.yaml b/templates/code/python/tests/error_enum_class_collision/registry/manifest.yaml new file mode 100644 index 0000000..1ea0d52 --- /dev/null +++ b/templates/code/python/tests/error_enum_class_collision/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: enum_class_collision +schema_url: https://example.com/schemas/enum_class_collision/1.0.0 diff --git a/templates/code/python/tests/error_enum_class_collision/registry/model.yaml b/templates/code/python/tests/error_enum_class_collision/registry/model.yaml new file mode 100644 index 0000000..bb1b498 --- /dev/null +++ b/templates/code/python/tests/error_enum_class_collision/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +attributes: + - key: final + stability: stable + brief: Synthetic attribute. + type: + members: + - id: first + value: a + stability: stable + brief: First. diff --git a/templates/code/python/tests/error_enum_collision/expected-error.txt b/templates/code/python/tests/error_enum_collision/expected-error.txt new file mode 100644 index 0000000..cb2329d --- /dev/null +++ b/templates/code/python/tests/error_enum_collision/expected-error.txt @@ -0,0 +1 @@ +attribute demo.state has enum members that render to the same Python identifier: foo_bar and foo.bar diff --git a/templates/code/python/tests/error_enum_collision/registry/manifest.yaml b/templates/code/python/tests/error_enum_collision/registry/manifest.yaml new file mode 100644 index 0000000..650ee69 --- /dev/null +++ b/templates/code/python/tests/error_enum_collision/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: enum_collision +schema_url: https://example.com/schemas/enum_collision/1.0.0 diff --git a/templates/code/python/tests/error_enum_collision/registry/model.yaml b/templates/code/python/tests/error_enum_collision/registry/model.yaml new file mode 100644 index 0000000..a133af8 --- /dev/null +++ b/templates/code/python/tests/error_enum_collision/registry/model.yaml @@ -0,0 +1,13 @@ +file_format: definition/2 +attributes: + - key: demo.state + stability: stable + brief: Synthetic attribute. + type: + members: + - id: foo_bar + value: a + brief: First. + - id: foo.bar + value: b + brief: Next. diff --git a/templates/code/python/tests/error_enum_member_identifier/expected-error.txt b/templates/code/python/tests/error_enum_member_identifier/expected-error.txt new file mode 100644 index 0000000..bad733a --- /dev/null +++ b/templates/code/python/tests/error_enum_member_identifier/expected-error.txt @@ -0,0 +1 @@ +enum member 2xx_ok of demo.status is not a supported Python identifier diff --git a/templates/code/python/tests/error_enum_member_identifier/registry/manifest.yaml b/templates/code/python/tests/error_enum_member_identifier/registry/manifest.yaml new file mode 100644 index 0000000..a0e91bb --- /dev/null +++ b/templates/code/python/tests/error_enum_member_identifier/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: enum_member_identifier +schema_url: https://example.com/schemas/enum_member_identifier/1.0.0 diff --git a/templates/code/python/tests/error_enum_member_identifier/registry/model.yaml b/templates/code/python/tests/error_enum_member_identifier/registry/model.yaml new file mode 100644 index 0000000..b659b8a --- /dev/null +++ b/templates/code/python/tests/error_enum_member_identifier/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +attributes: + - key: demo.status + stability: stable + brief: Synthetic attribute. + type: + members: + - id: 2xx_ok + value: 2xx + stability: stable + brief: Starts with a digit. diff --git a/templates/code/python/tests/error_enum_without_members/expected-error.txt b/templates/code/python/tests/error_enum_without_members/expected-error.txt new file mode 100644 index 0000000..16d2223 --- /dev/null +++ b/templates/code/python/tests/error_enum_without_members/expected-error.txt @@ -0,0 +1 @@ +attribute myapp.task.state has no enum members left after exclude_deprecated or stable_only diff --git a/templates/code/python/tests/error_enum_without_members/params.yaml b/templates/code/python/tests/error_enum_without_members/params.yaml new file mode 100644 index 0000000..7606ce0 --- /dev/null +++ b/templates/code/python/tests/error_enum_without_members/params.yaml @@ -0,0 +1,2 @@ +params: + stable_only: true diff --git a/templates/code/python/tests/error_enum_without_members/registry/manifest.yaml b/templates/code/python/tests/error_enum_without_members/registry/manifest.yaml new file mode 100644 index 0000000..4d824bd --- /dev/null +++ b/templates/code/python/tests/error_enum_without_members/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: enum_without_members +schema_url: https://example.com/schemas/enum_without_members/1.0.0 diff --git a/templates/code/python/tests/error_enum_without_members/registry/model.yaml b/templates/code/python/tests/error_enum_without_members/registry/model.yaml new file mode 100644 index 0000000..33d5241 --- /dev/null +++ b/templates/code/python/tests/error_enum_without_members/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +attributes: + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: development + brief: The task is waiting to run. + stability: stable + brief: Current state of the task. diff --git a/templates/code/python/tests/error_filtered_required/expected-error.txt b/templates/code/python/tests/error_filtered_required/expected-error.txt new file mode 100644 index 0000000..b91b482 --- /dev/null +++ b/templates/code/python/tests/error_filtered_required/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.started requires myapp.task.id, which exclude_deprecated or stable_only leaves out diff --git a/templates/code/python/tests/error_filtered_required/params.yaml b/templates/code/python/tests/error_filtered_required/params.yaml new file mode 100644 index 0000000..7606ce0 --- /dev/null +++ b/templates/code/python/tests/error_filtered_required/params.yaml @@ -0,0 +1,2 @@ +params: + stable_only: true diff --git a/templates/code/python/tests/error_filtered_required/registry/manifest.yaml b/templates/code/python/tests/error_filtered_required/registry/manifest.yaml new file mode 100644 index 0000000..bcce10e --- /dev/null +++ b/templates/code/python/tests/error_filtered_required/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: filtered_required +schema_url: https://example.com/schemas/filtered_required/1.0.0 diff --git a/templates/code/python/tests/error_filtered_required/registry/myapp.yaml b/templates/code/python/tests/error_filtered_required/registry/myapp.yaml new file mode 100644 index 0000000..8b9004e --- /dev/null +++ b/templates/code/python/tests/error_filtered_required/registry/myapp.yaml @@ -0,0 +1,20 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: development + brief: Unique identifier of the task. + examples: ["task_0001"] +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required diff --git a/templates/code/python/tests/error_histogram_boundaries_by_unit/expected-error.txt b/templates/code/python/tests/error_histogram_boundaries_by_unit/expected-error.txt new file mode 100644 index 0000000..ddd4782 --- /dev/null +++ b/templates/code/python/tests/error_histogram_boundaries_by_unit/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.queue_wait must list histogram_boundaries_by_unit for unit min in strictly increasing order diff --git a/templates/code/python/tests/error_histogram_boundaries_by_unit/params.yaml b/templates/code/python/tests/error_histogram_boundaries_by_unit/params.yaml new file mode 100644 index 0000000..91e5edb --- /dev/null +++ b/templates/code/python/tests/error_histogram_boundaries_by_unit/params.yaml @@ -0,0 +1,3 @@ +params: + histogram_boundaries_by_unit: + min: [10, 5, 1] diff --git a/templates/code/python/tests/error_histogram_boundaries_by_unit/registry/manifest.yaml b/templates/code/python/tests/error_histogram_boundaries_by_unit/registry/manifest.yaml new file mode 100644 index 0000000..437da86 --- /dev/null +++ b/templates/code/python/tests/error_histogram_boundaries_by_unit/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: histogram_boundaries_by_unit +schema_url: https://example.com/schemas/histogram_boundaries_by_unit/1.0.0 diff --git a/templates/code/python/tests/error_histogram_boundaries_by_unit/registry/myapp.yaml b/templates/code/python/tests/error_histogram_boundaries_by_unit/registry/myapp.yaml new file mode 100644 index 0000000..7dea0ce --- /dev/null +++ b/templates/code/python/tests/error_histogram_boundaries_by_unit/registry/myapp.yaml @@ -0,0 +1,10 @@ +file_format: definition/2 +metrics: + - name: myapp.task.queue_wait + instrument: histogram + unit: min + stability: stable + brief: Time a task waited in the queue, in minutes. + annotations: + code_generation: + metric_value_type: double diff --git a/templates/code/python/tests/error_instrumentation_name_empty/expected-error.txt b/templates/code/python/tests/error_instrumentation_name_empty/expected-error.txt new file mode 100644 index 0000000..29bd62a --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_empty/expected-error.txt @@ -0,0 +1 @@ +instrumentation_name must be a nonempty string diff --git a/templates/code/python/tests/error_instrumentation_name_empty/params.yaml b/templates/code/python/tests/error_instrumentation_name_empty/params.yaml new file mode 100644 index 0000000..2e72054 --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_empty/params.yaml @@ -0,0 +1,2 @@ +params: + instrumentation_name: "" diff --git a/templates/code/python/tests/error_instrumentation_name_empty/registry/manifest.yaml b/templates/code/python/tests/error_instrumentation_name_empty/registry/manifest.yaml new file mode 100644 index 0000000..a9a68a8 --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_empty/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: instrumentation_name_empty +schema_url: https://example.com/schemas/instrumentation_name_empty/1.0.0 diff --git a/templates/code/python/tests/error_instrumentation_name_empty/registry/model.yaml b/templates/code/python/tests/error_instrumentation_name_empty/registry/model.yaml new file mode 100644 index 0000000..b9120fb --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_empty/registry/model.yaml @@ -0,0 +1,5 @@ +file_format: definition/2 +events: + - name: probe.tick + stability: stable + brief: A synthetic tick occurred. diff --git a/templates/code/python/tests/error_instrumentation_name_type/expected-error.txt b/templates/code/python/tests/error_instrumentation_name_type/expected-error.txt new file mode 100644 index 0000000..29bd62a --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_type/expected-error.txt @@ -0,0 +1 @@ +instrumentation_name must be a nonempty string diff --git a/templates/code/python/tests/error_instrumentation_name_type/params.yaml b/templates/code/python/tests/error_instrumentation_name_type/params.yaml new file mode 100644 index 0000000..d1f6288 --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_type/params.yaml @@ -0,0 +1,2 @@ +params: + instrumentation_name: true diff --git a/templates/code/python/tests/error_instrumentation_name_type/registry/manifest.yaml b/templates/code/python/tests/error_instrumentation_name_type/registry/manifest.yaml new file mode 100644 index 0000000..8b31c83 --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_type/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: instrumentation_name_type +schema_url: https://example.com/schemas/instrumentation_name_type/1.0.0 diff --git a/templates/code/python/tests/error_instrumentation_name_type/registry/model.yaml b/templates/code/python/tests/error_instrumentation_name_type/registry/model.yaml new file mode 100644 index 0000000..b9120fb --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_name_type/registry/model.yaml @@ -0,0 +1,5 @@ +file_format: definition/2 +events: + - name: probe.tick + stability: stable + brief: A synthetic tick occurred. diff --git a/templates/code/python/tests/error_instrumentation_version_type/expected-error.txt b/templates/code/python/tests/error_instrumentation_version_type/expected-error.txt new file mode 100644 index 0000000..101ce01 --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_version_type/expected-error.txt @@ -0,0 +1 @@ +instrumentation_version must be a string diff --git a/templates/code/python/tests/error_instrumentation_version_type/params.yaml b/templates/code/python/tests/error_instrumentation_version_type/params.yaml new file mode 100644 index 0000000..e1f53ac --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_version_type/params.yaml @@ -0,0 +1,2 @@ +params: + instrumentation_version: false diff --git a/templates/code/python/tests/error_instrumentation_version_type/registry/manifest.yaml b/templates/code/python/tests/error_instrumentation_version_type/registry/manifest.yaml new file mode 100644 index 0000000..18dd328 --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_version_type/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: instrumentation_version_type +schema_url: https://example.com/schemas/instrumentation_version_type/1.0.0 diff --git a/templates/code/python/tests/error_instrumentation_version_type/registry/model.yaml b/templates/code/python/tests/error_instrumentation_version_type/registry/model.yaml new file mode 100644 index 0000000..b9120fb --- /dev/null +++ b/templates/code/python/tests/error_instrumentation_version_type/registry/model.yaml @@ -0,0 +1,5 @@ +file_format: definition/2 +events: + - name: probe.tick + stability: stable + brief: A synthetic tick occurred. diff --git a/templates/code/python/tests/error_integer_enum_overflow/expected-error.txt b/templates/code/python/tests/error_integer_enum_overflow/expected-error.txt new file mode 100644 index 0000000..b6fdfd0 --- /dev/null +++ b/templates/code/python/tests/error_integer_enum_overflow/expected-error.txt @@ -0,0 +1 @@ +attribute demo.mode has an enum value that cannot be represented by a 64-bit integer diff --git a/templates/code/python/tests/error_integer_enum_overflow/registry/manifest.yaml b/templates/code/python/tests/error_integer_enum_overflow/registry/manifest.yaml new file mode 100644 index 0000000..9a83fe1 --- /dev/null +++ b/templates/code/python/tests/error_integer_enum_overflow/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: integer_enum_overflow +schema_url: https://example.com/schemas/integer_enum_overflow/1.0.0 diff --git a/templates/code/python/tests/error_integer_enum_overflow/registry/model.yaml b/templates/code/python/tests/error_integer_enum_overflow/registry/model.yaml new file mode 100644 index 0000000..8dfd87c --- /dev/null +++ b/templates/code/python/tests/error_integer_enum_overflow/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +attributes: + - key: demo.mode + stability: stable + brief: Synthetic mode. + type: + members: + - id: enormous + value: 9223372036854775808 + stability: stable + brief: Synthetic enum value. diff --git a/templates/code/python/tests/error_invalid_name/expected-error.txt b/templates/code/python/tests/error_invalid_name/expected-error.txt new file mode 100644 index 0000000..bf8db26 --- /dev/null +++ b/templates/code/python/tests/error_invalid_name/expected-error.txt @@ -0,0 +1 @@ +attribute demo.bad-name is not a supported Python attribute name diff --git a/templates/code/python/tests/error_invalid_name/registry/manifest.yaml b/templates/code/python/tests/error_invalid_name/registry/manifest.yaml new file mode 100644 index 0000000..92dfb1f --- /dev/null +++ b/templates/code/python/tests/error_invalid_name/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: invalid_name +schema_url: https://example.com/schemas/invalid_name/1.0.0 diff --git a/templates/code/python/tests/error_invalid_name/registry/model.yaml b/templates/code/python/tests/error_invalid_name/registry/model.yaml new file mode 100644 index 0000000..2c01271 --- /dev/null +++ b/templates/code/python/tests/error_invalid_name/registry/model.yaml @@ -0,0 +1,6 @@ +file_format: definition/2 +attributes: + - key: demo.bad-name + stability: stable + brief: Synthetic attribute. + type: string diff --git a/templates/code/python/tests/error_keyword_parameter/expected-error.txt b/templates/code/python/tests/error_keyword_parameter/expected-error.txt new file mode 100644 index 0000000..e889f89 --- /dev/null +++ b/templates/code/python/tests/error_keyword_parameter/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.started has attribute myapp.import, which becomes the reserved Python parameter import diff --git a/templates/code/python/tests/error_keyword_parameter/registry/manifest.yaml b/templates/code/python/tests/error_keyword_parameter/registry/manifest.yaml new file mode 100644 index 0000000..29862bf --- /dev/null +++ b/templates/code/python/tests/error_keyword_parameter/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: keyword_parameter +schema_url: https://example.com/schemas/keyword_parameter/1.0.0 diff --git a/templates/code/python/tests/error_keyword_parameter/registry/model.yaml b/templates/code/python/tests/error_keyword_parameter/registry/model.yaml new file mode 100644 index 0000000..9a1fab5 --- /dev/null +++ b/templates/code/python/tests/error_keyword_parameter/registry/model.yaml @@ -0,0 +1,19 @@ +file_format: definition/2 +attributes: + - key: myapp.import + type: string + stability: stable + brief: Synthetic attribute. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.import + requirement_level: required diff --git a/templates/code/python/tests/error_metric_collision/expected-error.txt b/templates/code/python/tests/error_metric_collision/expected-error.txt new file mode 100644 index 0000000..50e917f --- /dev/null +++ b/templates/code/python/tests/error_metric_collision/expected-error.txt @@ -0,0 +1 @@ +module metrics has metrics that render to the same Python identifier: myapp.task.started and myapp.task_started diff --git a/templates/code/python/tests/error_metric_collision/registry/manifest.yaml b/templates/code/python/tests/error_metric_collision/registry/manifest.yaml new file mode 100644 index 0000000..741b906 --- /dev/null +++ b/templates/code/python/tests/error_metric_collision/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: metric_collision +schema_url: https://example.com/schemas/metric_collision/1.0.0 diff --git a/templates/code/python/tests/error_metric_collision/registry/model.yaml b/templates/code/python/tests/error_metric_collision/registry/model.yaml new file mode 100644 index 0000000..c45a39c --- /dev/null +++ b/templates/code/python/tests/error_metric_collision/registry/model.yaml @@ -0,0 +1,18 @@ +file_format: definition/2 +metrics: + - name: myapp.task.started + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task_started + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started, under another name. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/error_metric_reserved_class/expected-error.txt b/templates/code/python/tests/error_metric_reserved_class/expected-error.txt new file mode 100644 index 0000000..fe0a498 --- /dev/null +++ b/templates/code/python/tests/error_metric_reserved_class/expected-error.txt @@ -0,0 +1 @@ +module metrics has metrics that render to the same Python identifier: a reserved Python symbol and meter diff --git a/templates/code/python/tests/error_metric_reserved_class/registry/manifest.yaml b/templates/code/python/tests/error_metric_reserved_class/registry/manifest.yaml new file mode 100644 index 0000000..01494ee --- /dev/null +++ b/templates/code/python/tests/error_metric_reserved_class/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: metric_reserved_class +schema_url: https://example.com/schemas/metric_reserved_class/1.0.0 diff --git a/templates/code/python/tests/error_metric_reserved_class/registry/model.yaml b/templates/code/python/tests/error_metric_reserved_class/registry/model.yaml new file mode 100644 index 0000000..8330760 --- /dev/null +++ b/templates/code/python/tests/error_metric_reserved_class/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +metrics: + - name: meter + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/error_metric_value_type/expected-error.txt b/templates/code/python/tests/error_metric_value_type/expected-error.txt new file mode 100644 index 0000000..6043237 --- /dev/null +++ b/templates/code/python/tests/error_metric_value_type/expected-error.txt @@ -0,0 +1 @@ +must set code_generation.metric_value_type to int or double diff --git a/templates/code/python/tests/error_metric_value_type/registry/manifest.yaml b/templates/code/python/tests/error_metric_value_type/registry/manifest.yaml new file mode 100644 index 0000000..d0ae600 --- /dev/null +++ b/templates/code/python/tests/error_metric_value_type/registry/manifest.yaml @@ -0,0 +1,3 @@ +name: metric_value_type +semconv_version: 1.0.0 +schema_base_url: https://example.com/schemas/ diff --git a/templates/code/python/tests/error_metric_value_type/registry/model.yaml b/templates/code/python/tests/error_metric_value_type/registry/model.yaml new file mode 100644 index 0000000..91b80d7 --- /dev/null +++ b/templates/code/python/tests/error_metric_value_type/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +metrics: + - name: myapp.task.started + brief: A task count. + stability: stable + requirement_level: recommended + instrument: counter + unit: "{task}" + annotations: + code_generation: + metric_value_type: boolean diff --git a/templates/code/python/tests/error_missing_metric_value_type/expected-error.txt b/templates/code/python/tests/error_missing_metric_value_type/expected-error.txt new file mode 100644 index 0000000..6043237 --- /dev/null +++ b/templates/code/python/tests/error_missing_metric_value_type/expected-error.txt @@ -0,0 +1 @@ +must set code_generation.metric_value_type to int or double diff --git a/templates/code/python/tests/error_missing_metric_value_type/registry/manifest.yaml b/templates/code/python/tests/error_missing_metric_value_type/registry/manifest.yaml new file mode 100644 index 0000000..181313e --- /dev/null +++ b/templates/code/python/tests/error_missing_metric_value_type/registry/manifest.yaml @@ -0,0 +1,3 @@ +name: missing_metric_value_type +semconv_version: 1.0.0 +schema_base_url: https://example.com/schemas/ diff --git a/templates/code/python/tests/error_missing_metric_value_type/registry/model.yaml b/templates/code/python/tests/error_missing_metric_value_type/registry/model.yaml new file mode 100644 index 0000000..33e25d7 --- /dev/null +++ b/templates/code/python/tests/error_missing_metric_value_type/registry/model.yaml @@ -0,0 +1,8 @@ +file_format: definition/2 +metrics: + - name: myapp.task.started + brief: A task count. + stability: stable + requirement_level: recommended + instrument: counter + unit: "{task}" diff --git a/templates/code/python/tests/error_missing_schema/expected-error.txt b/templates/code/python/tests/error_missing_schema/expected-error.txt new file mode 100644 index 0000000..a365ed6 --- /dev/null +++ b/templates/code/python/tests/error_missing_schema/expected-error.txt @@ -0,0 +1 @@ +Either 'schema_url' or both 'schema_base_url' and 'semconv_version' must be provided diff --git a/templates/code/python/tests/error_missing_schema/registry/manifest.yaml b/templates/code/python/tests/error_missing_schema/registry/manifest.yaml new file mode 100644 index 0000000..f6d30d9 --- /dev/null +++ b/templates/code/python/tests/error_missing_schema/registry/manifest.yaml @@ -0,0 +1 @@ +name: missing_schema diff --git a/templates/code/python/tests/error_missing_schema/registry/model.yaml b/templates/code/python/tests/error_missing_schema/registry/model.yaml new file mode 100644 index 0000000..b281eab --- /dev/null +++ b/templates/code/python/tests/error_missing_schema/registry/model.yaml @@ -0,0 +1,2 @@ +file_format: definition/2 +attributes: [] diff --git a/templates/code/python/tests/error_mixed_enum/expected-error.txt b/templates/code/python/tests/error_mixed_enum/expected-error.txt new file mode 100644 index 0000000..5365ab1 --- /dev/null +++ b/templates/code/python/tests/error_mixed_enum/expected-error.txt @@ -0,0 +1 @@ +attribute demo.mode has enum members of more than one value type diff --git a/templates/code/python/tests/error_mixed_enum/registry/manifest.yaml b/templates/code/python/tests/error_mixed_enum/registry/manifest.yaml new file mode 100644 index 0000000..3939f63 --- /dev/null +++ b/templates/code/python/tests/error_mixed_enum/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: mixed_enum +schema_url: https://example.com/schemas/mixed_enum/1.0.0 diff --git a/templates/code/python/tests/error_mixed_enum/registry/model.yaml b/templates/code/python/tests/error_mixed_enum/registry/model.yaml new file mode 100644 index 0000000..e64d5e7 --- /dev/null +++ b/templates/code/python/tests/error_mixed_enum/registry/model.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +attributes: + - key: demo.mode + stability: stable + brief: Synthetic mode. + type: + members: + - id: text + value: text + stability: stable + brief: Synthetic enum value. + - id: number + value: 42 + stability: stable + brief: Synthetic enum value. diff --git a/templates/code/python/tests/error_observable_collision/expected-error.txt b/templates/code/python/tests/error_observable_collision/expected-error.txt new file mode 100644 index 0000000..7dc999e --- /dev/null +++ b/templates/code/python/tests/error_observable_collision/expected-error.txt @@ -0,0 +1 @@ +module metrics has metrics that render to the same Python identifier: myapp.task.started and myapp.task.started.observable diff --git a/templates/code/python/tests/error_observable_collision/registry/manifest.yaml b/templates/code/python/tests/error_observable_collision/registry/manifest.yaml new file mode 100644 index 0000000..c3d8c01 --- /dev/null +++ b/templates/code/python/tests/error_observable_collision/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: observable_collision +schema_url: https://example.com/schemas/observable_collision/1.0.0 diff --git a/templates/code/python/tests/error_observable_collision/registry/model.yaml b/templates/code/python/tests/error_observable_collision/registry/model.yaml new file mode 100644 index 0000000..e5fbb0c --- /dev/null +++ b/templates/code/python/tests/error_observable_collision/registry/model.yaml @@ -0,0 +1,18 @@ +file_format: definition/2 +metrics: + - name: myapp.task.started + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.started.observable + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started, observed. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/error_parameter_collision/expected-error.txt b/templates/code/python/tests/error_parameter_collision/expected-error.txt new file mode 100644 index 0000000..3d2f37b --- /dev/null +++ b/templates/code/python/tests/error_parameter_collision/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.started has attributes that render to the same Python parameter: myapp.task.id and task.id diff --git a/templates/code/python/tests/error_parameter_collision/registry/manifest.yaml b/templates/code/python/tests/error_parameter_collision/registry/manifest.yaml new file mode 100644 index 0000000..b6242bb --- /dev/null +++ b/templates/code/python/tests/error_parameter_collision/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: parameter_collision +schema_url: https://example.com/schemas/parameter_collision/1.0.0 diff --git a/templates/code/python/tests/error_parameter_collision/registry/model.yaml b/templates/code/python/tests/error_parameter_collision/registry/model.yaml new file mode 100644 index 0000000..0ed0ebb --- /dev/null +++ b/templates/code/python/tests/error_parameter_collision/registry/model.yaml @@ -0,0 +1,25 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Synthetic attribute. + - key: task.id + type: string + stability: stable + brief: Synthetic attribute. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: task.id + requirement_level: required diff --git a/templates/code/python/tests/error_refinement_value_type/expected-error.txt b/templates/code/python/tests/error_refinement_value_type/expected-error.txt new file mode 100644 index 0000000..b086b96 --- /dev/null +++ b/templates/code/python/tests/error_refinement_value_type/expected-error.txt @@ -0,0 +1 @@ +metric refinement myapp.task.retry.duration sets a different code_generation.metric_value_type than myapp.task.duration diff --git a/templates/code/python/tests/error_refinement_value_type/registry/manifest.yaml b/templates/code/python/tests/error_refinement_value_type/registry/manifest.yaml new file mode 100644 index 0000000..6034e59 --- /dev/null +++ b/templates/code/python/tests/error_refinement_value_type/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: refinement_value_type +schema_url: https://example.com/schemas/refinement_value_type/1.0.0 diff --git a/templates/code/python/tests/error_refinement_value_type/registry/model.yaml b/templates/code/python/tests/error_refinement_value_type/registry/model.yaml new file mode 100644 index 0000000..cc22be3 --- /dev/null +++ b/templates/code/python/tests/error_refinement_value_type/registry/model.yaml @@ -0,0 +1,18 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + annotations: + code_generation: + metric_value_type: double +metric_refinements: + - id: myapp.task.retry.duration + ref: myapp.task.duration + stability: stable + brief: Time a retried task took from start to finish. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/error_reserved_parameter/expected-error.txt b/templates/code/python/tests/error_reserved_parameter/expected-error.txt new file mode 100644 index 0000000..5e0c9f6 --- /dev/null +++ b/templates/code/python/tests/error_reserved_parameter/expected-error.txt @@ -0,0 +1 @@ +metric myapp.task.started has attribute myapp.amount, which becomes the reserved Python parameter amount diff --git a/templates/code/python/tests/error_reserved_parameter/registry/manifest.yaml b/templates/code/python/tests/error_reserved_parameter/registry/manifest.yaml new file mode 100644 index 0000000..5f954f0 --- /dev/null +++ b/templates/code/python/tests/error_reserved_parameter/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: reserved_parameter +schema_url: https://example.com/schemas/reserved_parameter/1.0.0 diff --git a/templates/code/python/tests/error_reserved_parameter/registry/model.yaml b/templates/code/python/tests/error_reserved_parameter/registry/model.yaml new file mode 100644 index 0000000..527071e --- /dev/null +++ b/templates/code/python/tests/error_reserved_parameter/registry/model.yaml @@ -0,0 +1,19 @@ +file_format: definition/2 +attributes: + - key: myapp.amount + type: string + stability: stable + brief: Synthetic attribute. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.amount + requirement_level: required diff --git a/templates/code/python/tests/error_unsupported_attribute_type/expected-error.txt b/templates/code/python/tests/error_unsupported_attribute_type/expected-error.txt new file mode 100644 index 0000000..1d7d315 --- /dev/null +++ b/templates/code/python/tests/error_unsupported_attribute_type/expected-error.txt @@ -0,0 +1 @@ +attribute myapp.anything has an unsupported Python value type diff --git a/templates/code/python/tests/error_unsupported_attribute_type/registry/manifest.yaml b/templates/code/python/tests/error_unsupported_attribute_type/registry/manifest.yaml new file mode 100644 index 0000000..f694f1e --- /dev/null +++ b/templates/code/python/tests/error_unsupported_attribute_type/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: unsupported_attribute_type +schema_url: https://example.com/schemas/unsupported_attribute_type/1.0.0 diff --git a/templates/code/python/tests/error_unsupported_attribute_type/registry/model.yaml b/templates/code/python/tests/error_unsupported_attribute_type/registry/model.yaml new file mode 100644 index 0000000..a574be8 --- /dev/null +++ b/templates/code/python/tests/error_unsupported_attribute_type/registry/model.yaml @@ -0,0 +1,19 @@ +file_format: definition/2 +attributes: + - key: myapp.anything + type: any + stability: stable + brief: Synthetic attribute. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.anything + requirement_level: required diff --git a/templates/code/python/tests/error_unversioned_schema/expected-error.txt b/templates/code/python/tests/error_unversioned_schema/expected-error.txt new file mode 100644 index 0000000..c2bdbcc --- /dev/null +++ b/templates/code/python/tests/error_unversioned_schema/expected-error.txt @@ -0,0 +1 @@ +the registry schema_url must name a schema version diff --git a/templates/code/python/tests/error_unversioned_schema/registry/manifest.yaml b/templates/code/python/tests/error_unversioned_schema/registry/manifest.yaml new file mode 100644 index 0000000..dbbb0e4 --- /dev/null +++ b/templates/code/python/tests/error_unversioned_schema/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: unversioned_schema +schema_url: https://example.com/schemas/demo diff --git a/templates/code/python/tests/error_unversioned_schema/registry/model.yaml b/templates/code/python/tests/error_unversioned_schema/registry/model.yaml new file mode 100644 index 0000000..b281eab --- /dev/null +++ b/templates/code/python/tests/error_unversioned_schema/registry/model.yaml @@ -0,0 +1,2 @@ +file_format: definition/2 +attributes: [] diff --git a/templates/code/python/tests/filtering_both/expected/__init__.py b/templates/code/python/tests/filtering_both/expected/__init__.py new file mode 100644 index 0000000..2a49e24 --- /dev/null +++ b/templates/code/python/tests/filtering_both/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/filtering_both/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/filtering_both/expected/attributes.py b/templates/code/python/tests/filtering_both/expected/attributes.py new file mode 100644 index 0000000..5c7dad4 --- /dev/null +++ b/templates/code/python/tests/filtering_both/expected/attributes.py @@ -0,0 +1,22 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + +MYAPP_TASK_ASSIGNEE_KEY: Final = "myapp.task.assignee" +"""Person the task is assigned to.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" diff --git a/templates/code/python/tests/filtering_both/expected/metrics.py b/templates/code/python/tests/filtering_both/expected/metrics.py new file mode 100644 index 0000000..954ae27 --- /dev/null +++ b/templates/code/python/tests/filtering_both/expected/metrics.py @@ -0,0 +1,201 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskDuration: + """Time a task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskRetryDuration: + """Time a retried task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskStarted: + """Number of tasks started.""" + + NAME: Final = "myapp.task.started" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks started." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + self._instrument.add(amount, _attributes) + + +class MyappTaskStartedObservable: + """Observable form of `MyappTaskStarted`, reported by callbacks.""" + + NAME: Final = MyappTaskStarted.NAME + UNIT: Final = MyappTaskStarted.UNIT + DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION + KIND: Final = MyappTaskStarted.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/filtering_both/params.yaml b/templates/code/python/tests/filtering_both/params.yaml new file mode 100644 index 0000000..0a3b4d2 --- /dev/null +++ b/templates/code/python/tests/filtering_both/params.yaml @@ -0,0 +1,3 @@ +params: + stable_only: true + exclude_deprecated: true diff --git a/templates/code/python/tests/filtering_both/registry/manifest.yaml b/templates/code/python/tests/filtering_both/registry/manifest.yaml new file mode 100644 index 0000000..c8aacd3 --- /dev/null +++ b/templates/code/python/tests/filtering_both/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: filtering_both +schema_url: https://example.com/schemas/filtering_both/1.0.0 diff --git a/templates/code/python/tests/filtering_both/registry/model.yaml b/templates/code/python/tests/filtering_both/registry/model.yaml new file mode 100644 index 0000000..edd9a7e --- /dev/null +++ b/templates/code/python/tests/filtering_both/registry/model.yaml @@ -0,0 +1,120 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + - key: myapp.task.priority + type: int + stability: development + brief: Priority the scheduler gave the task. + - key: myapp.task.owner + type: string + stability: stable + brief: Owner of the task. + deprecated: + reason: renamed + renamed_to: myapp.task.assignee + - key: myapp.task.assignee + type: string + stability: stable + brief: Person the task is assigned to. + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: running + value: running + stability: development + brief: The task is running. + - id: paused + value: paused + stability: stable + brief: The task was paused. + deprecated: + reason: obsoleted + note: Paused tasks are reported as queued. + stability: stable + brief: Current state of the task. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: myapp.task.priority + requirement_level: recommended + - ref: myapp.task.owner + requirement_level: recommended + - ref: myapp.task.assignee + requirement_level: recommended + - ref: myapp.task.state + requirement_level: recommended + - name: myapp.task.queued + requirement_level: recommended + instrument: updowncounter + unit: "{task}" + stability: development + brief: Number of tasks waiting to run. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.retired + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks retired. + annotations: + code_generation: + metric_value_type: int + deprecated: + reason: renamed + renamed_to: myapp.task.started + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: myapp.task.id + requirement_level: required +metric_refinements: + - id: myapp.task.retry.duration + ref: myapp.task.duration + stability: stable + brief: Time a retried task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.preview.duration + ref: myapp.task.duration + stability: development + brief: Time a preview task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.legacy.duration + ref: myapp.task.duration + stability: stable + brief: Time a legacy task took from start to finish. + deprecated: + reason: obsoleted + note: Legacy tasks no longer exist. + attributes: + - ref: myapp.task.id + requirement_level: required diff --git a/templates/code/python/tests/filtering_both/test_consumer.py b/templates/code/python/tests/filtering_both/test_consumer.py new file mode 100644 index 0000000..48bbfc2 --- /dev/null +++ b/templates/code/python/tests/filtering_both/test_consumer.py @@ -0,0 +1,114 @@ +import inspect + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +PRESENT = ["Duration", "RetryDuration", "Started"] +ABSENT = ["LegacyDuration", "PreviewDuration", "Queued", "Retired"] +OBSERVABLES = ["Started"] +UNEXPECTED_OBSERVABLES = ["QueuedObservable", "RetiredObservable"] +CONSTANTS = ["ASSIGNEE", "ID", "STATE"] +ABSENT_CONSTANTS = ["OWNER", "PRIORITY"] +MEMBERS = ["QUEUED"] +ABSENT_MEMBERS = ["RUNNING", "PAUSED"] +STARTED_PARAMETERS = ["task_id", "task_assignee", "task_state"] + + +def test_only_the_expected_metric_classes_exist() -> None: + for suffix in PRESENT: + assert hasattr(metrics, f"MyappTask{suffix}") + for suffix in ABSENT: + assert not hasattr(metrics, f"MyappTask{suffix}") + for suffix in OBSERVABLES: + assert hasattr(metrics, f"MyappTask{suffix}Observable") + for suffix in UNEXPECTED_OBSERVABLES: + assert not hasattr(metrics, f"MyappTask{suffix}") + + +def test_only_the_expected_attribute_constants_exist() -> None: + for suffix in CONSTANTS: + assert getattr(attributes, f"MYAPP_TASK_{suffix}_KEY") == ( + "myapp.task." + suffix.lower() + ) + for suffix in ABSENT_CONSTANTS: + assert not hasattr(attributes, f"MYAPP_TASK_{suffix}_KEY") + + +def test_only_the_expected_enum_members_exist() -> None: + assert [member.name for member in attributes.MyappTaskState] == MEMBERS + for member in ABSENT_MEMBERS: + assert member not in attributes.MyappTaskState.__members__ + + +def test_metric_signatures_follow_the_filters() -> None: + parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters + assert [key for key in parameters if key not in ("self", "amount")] == ( + STARTED_PARAMETERS + ) + assert parameters["task_id"].default is inspect.Parameter.empty + for key in STARTED_PARAMETERS[1:]: + assert parameters[key].default is None + + +def test_surviving_metrics_record() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskStarted(meter).add(2, task_id="task-1") + metrics.MyappTaskDuration(meter).record(0.5, task_id="task-1") + metrics.MyappTaskRetryDuration(meter).record(1.5, task_id="task-2") + collected = collect(reader) + (started,) = number_points(collected["myapp.task.started"]) + assert started.value == 2 + assert attributes_of(started) == {"myapp.task.id": "task-1"} + points = histogram_points(collected["myapp.task.duration"]) + assert sorted(point.sum for point in points) == [0.5, 1.5] diff --git a/templates/code/python/tests/filtering_exclude_deprecated/expected/__init__.py b/templates/code/python/tests/filtering_exclude_deprecated/expected/__init__.py new file mode 100644 index 0000000..f583b0f --- /dev/null +++ b/templates/code/python/tests/filtering_exclude_deprecated/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/filtering_exclude_deprecated/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/filtering_exclude_deprecated/expected/attributes.py b/templates/code/python/tests/filtering_exclude_deprecated/expected/attributes.py new file mode 100644 index 0000000..89bc0e1 --- /dev/null +++ b/templates/code/python/tests/filtering_exclude_deprecated/expected/attributes.py @@ -0,0 +1,28 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + +MYAPP_TASK_ASSIGNEE_KEY: Final = "myapp.task.assignee" +"""Person the task is assigned to.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task.""" + + +MYAPP_TASK_PRIORITY_KEY: Final = "myapp.task.priority" +"""Priority the scheduler gave the task.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + RUNNING = "running" + """The task is running.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" diff --git a/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py b/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py new file mode 100644 index 0000000..ed0287c --- /dev/null +++ b/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py @@ -0,0 +1,302 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskDuration: + """Time a task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskPreviewDuration: + """Time a preview task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskQueued: + """Number of tasks waiting to run.""" + + NAME: Final = "myapp.task.queued" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks waiting to run." + KIND: Final = InstrumentKind.UP_DOWN_COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_up_down_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: int, /) -> None: + self._instrument.add(amount) + + +class MyappTaskQueuedObservable: + """Observable form of `MyappTaskQueued`, reported by callbacks.""" + + NAME: Final = MyappTaskQueued.NAME + UNIT: Final = MyappTaskQueued.UNIT + DESCRIPTION: Final = MyappTaskQueued.DESCRIPTION + KIND: Final = MyappTaskQueued.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_up_down_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskRetryDuration: + """Time a retried task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskStarted: + """Number of tasks started.""" + + NAME: Final = "myapp.task.started" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks started." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_priority: int | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_priority is not None: + _attributes[attributes.MYAPP_TASK_PRIORITY_KEY] = task_priority + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + self._instrument.add(amount, _attributes) + + +class MyappTaskStartedObservable: + """Observable form of `MyappTaskStarted`, reported by callbacks.""" + + NAME: Final = MyappTaskStarted.NAME + UNIT: Final = MyappTaskStarted.UNIT + DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION + KIND: Final = MyappTaskStarted.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_priority: int | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_priority is not None: + _attributes[attributes.MYAPP_TASK_PRIORITY_KEY] = task_priority + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/filtering_exclude_deprecated/params.yaml b/templates/code/python/tests/filtering_exclude_deprecated/params.yaml new file mode 100644 index 0000000..cfe38dd --- /dev/null +++ b/templates/code/python/tests/filtering_exclude_deprecated/params.yaml @@ -0,0 +1,2 @@ +params: + exclude_deprecated: true diff --git a/templates/code/python/tests/filtering_exclude_deprecated/registry/manifest.yaml b/templates/code/python/tests/filtering_exclude_deprecated/registry/manifest.yaml new file mode 100644 index 0000000..d6c70fc --- /dev/null +++ b/templates/code/python/tests/filtering_exclude_deprecated/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: filtering_exclude_deprecated +schema_url: https://example.com/schemas/filtering_exclude_deprecated/1.0.0 diff --git a/templates/code/python/tests/filtering_exclude_deprecated/registry/model.yaml b/templates/code/python/tests/filtering_exclude_deprecated/registry/model.yaml new file mode 100644 index 0000000..edd9a7e --- /dev/null +++ b/templates/code/python/tests/filtering_exclude_deprecated/registry/model.yaml @@ -0,0 +1,120 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + - key: myapp.task.priority + type: int + stability: development + brief: Priority the scheduler gave the task. + - key: myapp.task.owner + type: string + stability: stable + brief: Owner of the task. + deprecated: + reason: renamed + renamed_to: myapp.task.assignee + - key: myapp.task.assignee + type: string + stability: stable + brief: Person the task is assigned to. + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: running + value: running + stability: development + brief: The task is running. + - id: paused + value: paused + stability: stable + brief: The task was paused. + deprecated: + reason: obsoleted + note: Paused tasks are reported as queued. + stability: stable + brief: Current state of the task. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: myapp.task.priority + requirement_level: recommended + - ref: myapp.task.owner + requirement_level: recommended + - ref: myapp.task.assignee + requirement_level: recommended + - ref: myapp.task.state + requirement_level: recommended + - name: myapp.task.queued + requirement_level: recommended + instrument: updowncounter + unit: "{task}" + stability: development + brief: Number of tasks waiting to run. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.retired + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks retired. + annotations: + code_generation: + metric_value_type: int + deprecated: + reason: renamed + renamed_to: myapp.task.started + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: myapp.task.id + requirement_level: required +metric_refinements: + - id: myapp.task.retry.duration + ref: myapp.task.duration + stability: stable + brief: Time a retried task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.preview.duration + ref: myapp.task.duration + stability: development + brief: Time a preview task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.legacy.duration + ref: myapp.task.duration + stability: stable + brief: Time a legacy task took from start to finish. + deprecated: + reason: obsoleted + note: Legacy tasks no longer exist. + attributes: + - ref: myapp.task.id + requirement_level: required diff --git a/templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py b/templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py new file mode 100644 index 0000000..9df94cf --- /dev/null +++ b/templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py @@ -0,0 +1,114 @@ +import inspect + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +PRESENT = ["Duration", "PreviewDuration", "Queued", "RetryDuration", "Started"] +ABSENT = ["LegacyDuration", "Retired"] +OBSERVABLES = ["Queued", "Started"] +UNEXPECTED_OBSERVABLES = ["RetiredObservable"] +CONSTANTS = ["ASSIGNEE", "ID", "PRIORITY", "STATE"] +ABSENT_CONSTANTS = ["OWNER"] +MEMBERS = ["QUEUED", "RUNNING"] +ABSENT_MEMBERS = ["PAUSED"] +STARTED_PARAMETERS = ["task_id", "task_assignee", "task_priority", "task_state"] + + +def test_only_the_expected_metric_classes_exist() -> None: + for suffix in PRESENT: + assert hasattr(metrics, f"MyappTask{suffix}") + for suffix in ABSENT: + assert not hasattr(metrics, f"MyappTask{suffix}") + for suffix in OBSERVABLES: + assert hasattr(metrics, f"MyappTask{suffix}Observable") + for suffix in UNEXPECTED_OBSERVABLES: + assert not hasattr(metrics, f"MyappTask{suffix}") + + +def test_only_the_expected_attribute_constants_exist() -> None: + for suffix in CONSTANTS: + assert getattr(attributes, f"MYAPP_TASK_{suffix}_KEY") == ( + "myapp.task." + suffix.lower() + ) + for suffix in ABSENT_CONSTANTS: + assert not hasattr(attributes, f"MYAPP_TASK_{suffix}_KEY") + + +def test_only_the_expected_enum_members_exist() -> None: + assert [member.name for member in attributes.MyappTaskState] == MEMBERS + for member in ABSENT_MEMBERS: + assert member not in attributes.MyappTaskState.__members__ + + +def test_metric_signatures_follow_the_filters() -> None: + parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters + assert [key for key in parameters if key not in ("self", "amount")] == ( + STARTED_PARAMETERS + ) + assert parameters["task_id"].default is inspect.Parameter.empty + for key in STARTED_PARAMETERS[1:]: + assert parameters[key].default is None + + +def test_surviving_metrics_record() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskStarted(meter).add(2, task_id="task-1") + metrics.MyappTaskDuration(meter).record(0.5, task_id="task-1") + metrics.MyappTaskRetryDuration(meter).record(1.5, task_id="task-2") + collected = collect(reader) + (started,) = number_points(collected["myapp.task.started"]) + assert started.value == 2 + assert attributes_of(started) == {"myapp.task.id": "task-1"} + points = histogram_points(collected["myapp.task.duration"]) + assert sorted(point.sum for point in points) == [0.5, 1.5] diff --git a/templates/code/python/tests/filtering_stable_only/expected/__init__.py b/templates/code/python/tests/filtering_stable_only/expected/__init__.py new file mode 100644 index 0000000..75da580 --- /dev/null +++ b/templates/code/python/tests/filtering_stable_only/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/filtering_stable_only/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/filtering_stable_only/expected/attributes.py b/templates/code/python/tests/filtering_stable_only/expected/attributes.py new file mode 100644 index 0000000..2355357 --- /dev/null +++ b/templates/code/python/tests/filtering_stable_only/expected/attributes.py @@ -0,0 +1,32 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + +MYAPP_TASK_ASSIGNEE_KEY: Final = "myapp.task.assignee" +"""Person the task is assigned to.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task.""" + + +MYAPP_TASK_OWNER_KEY: Final = "myapp.task.owner" +"""Owner of the task. + +Deprecated: Replaced by `myapp.task.assignee`.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + PAUSED = "paused" + """The task was paused. + + Deprecated: Paused tasks are reported as queued.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" diff --git a/templates/code/python/tests/filtering_stable_only/expected/metrics.py b/templates/code/python/tests/filtering_stable_only/expected/metrics.py new file mode 100644 index 0000000..173cbae --- /dev/null +++ b/templates/code/python/tests/filtering_stable_only/expected/metrics.py @@ -0,0 +1,306 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskDuration: + """Time a task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskLegacyDuration: + """Time a legacy task took from start to finish. + + Deprecated: Legacy tasks no longer exist.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskRetired: + """Number of tasks retired. + + Deprecated: Replaced by `myapp.task.started`.""" + + NAME: Final = "myapp.task.retired" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks retired." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: int, /) -> None: + self._instrument.add(amount) + + +class MyappTaskRetiredObservable: + """Observable form of `MyappTaskRetired`, reported by callbacks.""" + + NAME: Final = MyappTaskRetired.NAME + UNIT: Final = MyappTaskRetired.UNIT + DESCRIPTION: Final = MyappTaskRetired.DESCRIPTION + KIND: Final = MyappTaskRetired.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskRetryDuration: + """Time a retried task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskStarted: + """Number of tasks started.""" + + NAME: Final = "myapp.task.started" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks started." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_owner: str | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_owner is not None: + _attributes[attributes.MYAPP_TASK_OWNER_KEY] = task_owner + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + self._instrument.add(amount, _attributes) + + +class MyappTaskStartedObservable: + """Observable form of `MyappTaskStarted`, reported by callbacks.""" + + NAME: Final = MyappTaskStarted.NAME + UNIT: Final = MyappTaskStarted.UNIT + DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION + KIND: Final = MyappTaskStarted.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_owner: str | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_owner is not None: + _attributes[attributes.MYAPP_TASK_OWNER_KEY] = task_owner + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/filtering_stable_only/params.yaml b/templates/code/python/tests/filtering_stable_only/params.yaml new file mode 100644 index 0000000..7606ce0 --- /dev/null +++ b/templates/code/python/tests/filtering_stable_only/params.yaml @@ -0,0 +1,2 @@ +params: + stable_only: true diff --git a/templates/code/python/tests/filtering_stable_only/registry/manifest.yaml b/templates/code/python/tests/filtering_stable_only/registry/manifest.yaml new file mode 100644 index 0000000..2fb6eb8 --- /dev/null +++ b/templates/code/python/tests/filtering_stable_only/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: filtering_stable_only +schema_url: https://example.com/schemas/filtering_stable_only/1.0.0 diff --git a/templates/code/python/tests/filtering_stable_only/registry/model.yaml b/templates/code/python/tests/filtering_stable_only/registry/model.yaml new file mode 100644 index 0000000..edd9a7e --- /dev/null +++ b/templates/code/python/tests/filtering_stable_only/registry/model.yaml @@ -0,0 +1,120 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + - key: myapp.task.priority + type: int + stability: development + brief: Priority the scheduler gave the task. + - key: myapp.task.owner + type: string + stability: stable + brief: Owner of the task. + deprecated: + reason: renamed + renamed_to: myapp.task.assignee + - key: myapp.task.assignee + type: string + stability: stable + brief: Person the task is assigned to. + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: running + value: running + stability: development + brief: The task is running. + - id: paused + value: paused + stability: stable + brief: The task was paused. + deprecated: + reason: obsoleted + note: Paused tasks are reported as queued. + stability: stable + brief: Current state of the task. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: myapp.task.priority + requirement_level: recommended + - ref: myapp.task.owner + requirement_level: recommended + - ref: myapp.task.assignee + requirement_level: recommended + - ref: myapp.task.state + requirement_level: recommended + - name: myapp.task.queued + requirement_level: recommended + instrument: updowncounter + unit: "{task}" + stability: development + brief: Number of tasks waiting to run. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.retired + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks retired. + annotations: + code_generation: + metric_value_type: int + deprecated: + reason: renamed + renamed_to: myapp.task.started + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: myapp.task.id + requirement_level: required +metric_refinements: + - id: myapp.task.retry.duration + ref: myapp.task.duration + stability: stable + brief: Time a retried task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.preview.duration + ref: myapp.task.duration + stability: development + brief: Time a preview task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.legacy.duration + ref: myapp.task.duration + stability: stable + brief: Time a legacy task took from start to finish. + deprecated: + reason: obsoleted + note: Legacy tasks no longer exist. + attributes: + - ref: myapp.task.id + requirement_level: required diff --git a/templates/code/python/tests/filtering_stable_only/test_consumer.py b/templates/code/python/tests/filtering_stable_only/test_consumer.py new file mode 100644 index 0000000..c0b5359 --- /dev/null +++ b/templates/code/python/tests/filtering_stable_only/test_consumer.py @@ -0,0 +1,114 @@ +import inspect + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +PRESENT = ["Duration", "LegacyDuration", "Retired", "RetryDuration", "Started"] +ABSENT = ["PreviewDuration", "Queued"] +OBSERVABLES = ["Retired", "Started"] +UNEXPECTED_OBSERVABLES = ["QueuedObservable"] +CONSTANTS = ["ASSIGNEE", "ID", "OWNER", "STATE"] +ABSENT_CONSTANTS = ["PRIORITY"] +MEMBERS = ["QUEUED", "PAUSED"] +ABSENT_MEMBERS = ["RUNNING"] +STARTED_PARAMETERS = ["task_id", "task_assignee", "task_owner", "task_state"] + + +def test_only_the_expected_metric_classes_exist() -> None: + for suffix in PRESENT: + assert hasattr(metrics, f"MyappTask{suffix}") + for suffix in ABSENT: + assert not hasattr(metrics, f"MyappTask{suffix}") + for suffix in OBSERVABLES: + assert hasattr(metrics, f"MyappTask{suffix}Observable") + for suffix in UNEXPECTED_OBSERVABLES: + assert not hasattr(metrics, f"MyappTask{suffix}") + + +def test_only_the_expected_attribute_constants_exist() -> None: + for suffix in CONSTANTS: + assert getattr(attributes, f"MYAPP_TASK_{suffix}_KEY") == ( + "myapp.task." + suffix.lower() + ) + for suffix in ABSENT_CONSTANTS: + assert not hasattr(attributes, f"MYAPP_TASK_{suffix}_KEY") + + +def test_only_the_expected_enum_members_exist() -> None: + assert [member.name for member in attributes.MyappTaskState] == MEMBERS + for member in ABSENT_MEMBERS: + assert member not in attributes.MyappTaskState.__members__ + + +def test_metric_signatures_follow_the_filters() -> None: + parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters + assert [key for key in parameters if key not in ("self", "amount")] == ( + STARTED_PARAMETERS + ) + assert parameters["task_id"].default is inspect.Parameter.empty + for key in STARTED_PARAMETERS[1:]: + assert parameters[key].default is None + + +def test_surviving_metrics_record() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskStarted(meter).add(2, task_id="task-1") + metrics.MyappTaskDuration(meter).record(0.5, task_id="task-1") + metrics.MyappTaskRetryDuration(meter).record(1.5, task_id="task-2") + collected = collect(reader) + (started,) = number_points(collected["myapp.task.started"]) + assert started.value == 2 + assert attributes_of(started) == {"myapp.task.id": "task-1"} + points = histogram_points(collected["myapp.task.duration"]) + assert sorted(point.sum for point in points) == [0.5, 1.5] diff --git a/templates/code/python/tests/filtering_unfiltered/expected/__init__.py b/templates/code/python/tests/filtering_unfiltered/expected/__init__.py new file mode 100644 index 0000000..769b952 --- /dev/null +++ b/templates/code/python/tests/filtering_unfiltered/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/filtering_unfiltered/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/filtering_unfiltered/expected/attributes.py b/templates/code/python/tests/filtering_unfiltered/expected/attributes.py new file mode 100644 index 0000000..cabcc5b --- /dev/null +++ b/templates/code/python/tests/filtering_unfiltered/expected/attributes.py @@ -0,0 +1,38 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + +MYAPP_TASK_ASSIGNEE_KEY: Final = "myapp.task.assignee" +"""Person the task is assigned to.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task.""" + + +MYAPP_TASK_OWNER_KEY: Final = "myapp.task.owner" +"""Owner of the task. + +Deprecated: Replaced by `myapp.task.assignee`.""" + + +MYAPP_TASK_PRIORITY_KEY: Final = "myapp.task.priority" +"""Priority the scheduler gave the task.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + RUNNING = "running" + """The task is running.""" + PAUSED = "paused" + """The task was paused. + + Deprecated: Paused tasks are reported as queued.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" diff --git a/templates/code/python/tests/filtering_unfiltered/expected/metrics.py b/templates/code/python/tests/filtering_unfiltered/expected/metrics.py new file mode 100644 index 0000000..07f4629 --- /dev/null +++ b/templates/code/python/tests/filtering_unfiltered/expected/metrics.py @@ -0,0 +1,407 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskDuration: + """Time a task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskLegacyDuration: + """Time a legacy task took from start to finish. + + Deprecated: Legacy tasks no longer exist.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskPreviewDuration: + """Time a preview task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskQueued: + """Number of tasks waiting to run.""" + + NAME: Final = "myapp.task.queued" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks waiting to run." + KIND: Final = InstrumentKind.UP_DOWN_COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_up_down_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: int, /) -> None: + self._instrument.add(amount) + + +class MyappTaskQueuedObservable: + """Observable form of `MyappTaskQueued`, reported by callbacks.""" + + NAME: Final = MyappTaskQueued.NAME + UNIT: Final = MyappTaskQueued.UNIT + DESCRIPTION: Final = MyappTaskQueued.DESCRIPTION + KIND: Final = MyappTaskQueued.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_up_down_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskRetired: + """Number of tasks retired. + + Deprecated: Replaced by `myapp.task.started`.""" + + NAME: Final = "myapp.task.retired" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks retired." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: int, /) -> None: + self._instrument.add(amount) + + +class MyappTaskRetiredObservable: + """Observable form of `MyappTaskRetired`, reported by callbacks.""" + + NAME: Final = MyappTaskRetired.NAME + UNIT: Final = MyappTaskRetired.UNIT + DESCRIPTION: Final = MyappTaskRetired.DESCRIPTION + KIND: Final = MyappTaskRetired.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskRetryDuration: + """Time a retried task took from start to finish.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.record(amount, _attributes) + + +class MyappTaskStarted: + """Number of tasks started.""" + + NAME: Final = "myapp.task.started" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks started." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_owner: str | None = None, + task_priority: int | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_owner is not None: + _attributes[attributes.MYAPP_TASK_OWNER_KEY] = task_owner + if task_priority is not None: + _attributes[attributes.MYAPP_TASK_PRIORITY_KEY] = task_priority + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + self._instrument.add(amount, _attributes) + + +class MyappTaskStartedObservable: + """Observable form of `MyappTaskStarted`, reported by callbacks.""" + + NAME: Final = MyappTaskStarted.NAME + UNIT: Final = MyappTaskStarted.UNIT + DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION + KIND: Final = MyappTaskStarted.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_id: str, + task_assignee: str | None = None, + task_owner: str | None = None, + task_priority: int | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if task_assignee is not None: + _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee + if task_owner is not None: + _attributes[attributes.MYAPP_TASK_OWNER_KEY] = task_owner + if task_priority is not None: + _attributes[attributes.MYAPP_TASK_PRIORITY_KEY] = task_priority + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/filtering_unfiltered/registry/manifest.yaml b/templates/code/python/tests/filtering_unfiltered/registry/manifest.yaml new file mode 100644 index 0000000..d97b0ab --- /dev/null +++ b/templates/code/python/tests/filtering_unfiltered/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: filtering_unfiltered +schema_url: https://example.com/schemas/filtering_unfiltered/1.0.0 diff --git a/templates/code/python/tests/filtering_unfiltered/registry/model.yaml b/templates/code/python/tests/filtering_unfiltered/registry/model.yaml new file mode 100644 index 0000000..edd9a7e --- /dev/null +++ b/templates/code/python/tests/filtering_unfiltered/registry/model.yaml @@ -0,0 +1,120 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + - key: myapp.task.priority + type: int + stability: development + brief: Priority the scheduler gave the task. + - key: myapp.task.owner + type: string + stability: stable + brief: Owner of the task. + deprecated: + reason: renamed + renamed_to: myapp.task.assignee + - key: myapp.task.assignee + type: string + stability: stable + brief: Person the task is assigned to. + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: running + value: running + stability: development + brief: The task is running. + - id: paused + value: paused + stability: stable + brief: The task was paused. + deprecated: + reason: obsoleted + note: Paused tasks are reported as queued. + stability: stable + brief: Current state of the task. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: myapp.task.priority + requirement_level: recommended + - ref: myapp.task.owner + requirement_level: recommended + - ref: myapp.task.assignee + requirement_level: recommended + - ref: myapp.task.state + requirement_level: recommended + - name: myapp.task.queued + requirement_level: recommended + instrument: updowncounter + unit: "{task}" + stability: development + brief: Number of tasks waiting to run. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.retired + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks retired. + annotations: + code_generation: + metric_value_type: int + deprecated: + reason: renamed + renamed_to: myapp.task.started + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: myapp.task.id + requirement_level: required +metric_refinements: + - id: myapp.task.retry.duration + ref: myapp.task.duration + stability: stable + brief: Time a retried task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.preview.duration + ref: myapp.task.duration + stability: development + brief: Time a preview task took from start to finish. + attributes: + - ref: myapp.task.id + requirement_level: required + - id: myapp.task.legacy.duration + ref: myapp.task.duration + stability: stable + brief: Time a legacy task took from start to finish. + deprecated: + reason: obsoleted + note: Legacy tasks no longer exist. + attributes: + - ref: myapp.task.id + requirement_level: required diff --git a/templates/code/python/tests/filtering_unfiltered/test_consumer.py b/templates/code/python/tests/filtering_unfiltered/test_consumer.py new file mode 100644 index 0000000..2fc9bb3 --- /dev/null +++ b/templates/code/python/tests/filtering_unfiltered/test_consumer.py @@ -0,0 +1,128 @@ +import inspect + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +PRESENT = [ + "Duration", + "LegacyDuration", + "PreviewDuration", + "Queued", + "Retired", + "RetryDuration", + "Started", +] +ABSENT: list[str] = [] +OBSERVABLES = ["Queued", "Retired", "Started"] +UNEXPECTED_OBSERVABLES: list[str] = [] +CONSTANTS = ["ASSIGNEE", "ID", "OWNER", "PRIORITY", "STATE"] +ABSENT_CONSTANTS: list[str] = [] +MEMBERS = ["QUEUED", "RUNNING", "PAUSED"] +ABSENT_MEMBERS: list[str] = [] +STARTED_PARAMETERS = [ + "task_id", + "task_assignee", + "task_owner", + "task_priority", + "task_state", +] + + +def test_only_the_expected_metric_classes_exist() -> None: + for suffix in PRESENT: + assert hasattr(metrics, f"MyappTask{suffix}") + for suffix in ABSENT: + assert not hasattr(metrics, f"MyappTask{suffix}") + for suffix in OBSERVABLES: + assert hasattr(metrics, f"MyappTask{suffix}Observable") + for suffix in UNEXPECTED_OBSERVABLES: + assert not hasattr(metrics, f"MyappTask{suffix}") + + +def test_only_the_expected_attribute_constants_exist() -> None: + for suffix in CONSTANTS: + assert getattr(attributes, f"MYAPP_TASK_{suffix}_KEY") == ( + "myapp.task." + suffix.lower() + ) + for suffix in ABSENT_CONSTANTS: + assert not hasattr(attributes, f"MYAPP_TASK_{suffix}_KEY") + + +def test_only_the_expected_enum_members_exist() -> None: + assert [member.name for member in attributes.MyappTaskState] == MEMBERS + for member in ABSENT_MEMBERS: + assert member not in attributes.MyappTaskState.__members__ + + +def test_metric_signatures_follow_the_filters() -> None: + parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters + assert [key for key in parameters if key not in ("self", "amount")] == ( + STARTED_PARAMETERS + ) + assert parameters["task_id"].default is inspect.Parameter.empty + for key in STARTED_PARAMETERS[1:]: + assert parameters[key].default is None + + +def test_surviving_metrics_record() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskStarted(meter).add(2, task_id="task-1") + metrics.MyappTaskDuration(meter).record(0.5, task_id="task-1") + metrics.MyappTaskRetryDuration(meter).record(1.5, task_id="task-2") + collected = collect(reader) + (started,) = number_points(collected["myapp.task.started"]) + assert started.value == 2 + assert attributes_of(started) == {"myapp.task.id": "task-1"} + points = histogram_points(collected["myapp.task.duration"]) + assert sorted(point.sum for point in points) == [0.5, 1.5] diff --git a/templates/code/python/tests/histogram_boundaries/expected/__init__.py b/templates/code/python/tests/histogram_boundaries/expected/__init__.py new file mode 100644 index 0000000..7757b28 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/histogram_boundaries/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/histogram_boundaries/expected/metrics.py b/templates/code/python/tests/histogram_boundaries/expected/metrics.py new file mode 100644 index 0000000..161f5d4 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries/expected/metrics.py @@ -0,0 +1,189 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskDuration: + """Time a task took from start to finish, with the default boundaries of its unit.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish, with the default boundaries of its unit." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) + + +class MyappTaskPayload: + """Task payload size.""" + + NAME: Final = "myapp.task.payload" + UNIT: Final = "By" + DESCRIPTION: Final = "Task payload size." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: int, /) -> None: + self._instrument.record(amount) + + +class MyappTaskQueueWait: + """Time a task waited in the queue.""" + + NAME: Final = "myapp.task.queue_wait" + UNIT: Final = "ms" + DESCRIPTION: Final = "Time a task waited in the queue." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) + + +class MyappTaskRetries: + """A counter never receives boundaries.""" + + NAME: Final = "myapp.task.retries" + UNIT: Final = "{retry}" + DESCRIPTION: Final = "A counter never receives boundaries." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: int, /) -> None: + self._instrument.add(amount) + + +class MyappTaskRetriesObservable: + """Observable form of `MyappTaskRetries`, reported by callbacks.""" + + NAME: Final = MyappTaskRetries.NAME + UNIT: Final = MyappTaskRetries.UNIT + DESCRIPTION: Final = MyappTaskRetries.DESCRIPTION + KIND: Final = MyappTaskRetries.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskStep: + """Time one step took, with boundaries from the registry.""" + + NAME: Final = "myapp.task.step" + UNIT: Final = "s" + DESCRIPTION: Final = "Time one step took, with boundaries from the registry." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.0, + 0.5, + 1.5, + 3.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) diff --git a/templates/code/python/tests/histogram_boundaries/registry/manifest.yaml b/templates/code/python/tests/histogram_boundaries/registry/manifest.yaml new file mode 100644 index 0000000..1f3994b --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: histogram_boundaries +schema_url: https://example.com/schemas/histogram_boundaries/1.0.0 diff --git a/templates/code/python/tests/histogram_boundaries/registry/model.yaml b/templates/code/python/tests/histogram_boundaries/registry/model.yaml new file mode 100644 index 0000000..925338f --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries/registry/model.yaml @@ -0,0 +1,51 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish, with the default boundaries of its unit. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.queue_wait + requirement_level: recommended + instrument: histogram + unit: ms + stability: stable + brief: Time a task waited in the queue. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.payload + requirement_level: recommended + instrument: histogram + unit: By + stability: stable + brief: Task payload size. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.step + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time one step took, with boundaries from the registry. + annotations: + code_generation: + metric_value_type: double + aggregation: + method: explicithistogram + parameters: + boundaries: [0, 0.5, 1.5, 3] + - name: myapp.task.retries + requirement_level: recommended + instrument: counter + unit: "{retry}" + stability: stable + brief: A counter never receives boundaries. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/histogram_boundaries/test_consumer.py b/templates/code/python/tests/histogram_boundaries/test_consumer.py new file mode 100644 index 0000000..330fc47 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries/test_consumer.py @@ -0,0 +1,133 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, metrics + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +SDK_DEFAULT_BOUNDARIES = ( + 0.0, + 5.0, + 10.0, + 25.0, + 50.0, + 75.0, + 100.0, + 250.0, + 500.0, + 750.0, + 1000.0, + 2500.0, + 5000.0, + 7500.0, + 10000.0, +) +DURATION_BOUNDARIES: tuple[float, ...] | None = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, +) +QUEUE_WAIT_BOUNDARIES: tuple[float, ...] | None = None +PAYLOAD_BOUNDARIES: tuple[float, ...] | None = None +STEP_BOUNDARIES: tuple[float, ...] | None = (0.0, 0.5, 1.5, 3.0) + + +def advertised(boundaries: tuple[float, ...] | None) -> tuple[float, ...]: + return SDK_DEFAULT_BOUNDARIES if boundaries is None else boundaries + + +def test_the_constants_follow_the_unit_and_the_annotation() -> None: + assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == DURATION_BOUNDARIES + assert metrics.MyappTaskQueueWait.BUCKET_BOUNDARIES == QUEUE_WAIT_BOUNDARIES + assert metrics.MyappTaskPayload.BUCKET_BOUNDARIES == PAYLOAD_BOUNDARIES + assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == STEP_BOUNDARIES + + +def test_a_counter_has_no_bucket_boundaries() -> None: + assert not hasattr(metrics.MyappTaskRetries, "BUCKET_BOUNDARIES") + assert not hasattr(metrics.MyappTaskRetriesObservable, "BUCKET_BOUNDARIES") + + +def test_the_sdk_reports_the_advertised_boundaries() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskDuration(meter).record(0.4) + metrics.MyappTaskQueueWait(meter).record(3.0) + metrics.MyappTaskPayload(meter).record(512) + metrics.MyappTaskStep(meter).record(1.0) + collected = collect(reader) + expected = { + "myapp.task.duration": DURATION_BOUNDARIES, + "myapp.task.queue_wait": QUEUE_WAIT_BOUNDARIES, + "myapp.task.payload": PAYLOAD_BOUNDARIES, + "myapp.task.step": STEP_BOUNDARIES, + } + for name, boundaries in expected.items(): + (point,) = histogram_points(collected[name]) + assert tuple(point.explicit_bounds) == advertised(boundaries), name + assert sum(point.bucket_counts) == 1 + assert len(point.bucket_counts) == len(point.explicit_bounds) + 1 + + +def test_a_value_lands_in_the_bucket_of_the_advertised_boundaries() -> None: + reader, provider = new_provider() + metrics.MyappTaskStep(metrics.get_meter(provider)).record(1.0) + (point,) = histogram_points(collect(reader)["myapp.task.step"]) + assert tuple(point.explicit_bounds) == (0.0, 0.5, 1.5, 3.0) + assert tuple(point.bucket_counts) == (0, 0, 1, 0, 0) + + +def test_the_annotation_overrides_the_unit_default() -> None: + assert metrics.MyappTaskStep.UNIT == "s" + assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == (0.0, 0.5, 1.5, 3.0) diff --git a/templates/code/python/tests/histogram_boundaries_none/expected/__init__.py b/templates/code/python/tests/histogram_boundaries_none/expected/__init__.py new file mode 100644 index 0000000..89ee496 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_none/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/histogram_boundaries_none/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py b/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py new file mode 100644 index 0000000..cde4825 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py @@ -0,0 +1,174 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskDuration: + """Time a task took from start to finish, with the default boundaries of its unit.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish, with the default boundaries of its unit." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) + + +class MyappTaskPayload: + """Task payload size.""" + + NAME: Final = "myapp.task.payload" + UNIT: Final = "By" + DESCRIPTION: Final = "Task payload size." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: int, /) -> None: + self._instrument.record(amount) + + +class MyappTaskQueueWait: + """Time a task waited in the queue.""" + + NAME: Final = "myapp.task.queue_wait" + UNIT: Final = "ms" + DESCRIPTION: Final = "Time a task waited in the queue." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) + + +class MyappTaskRetries: + """A counter never receives boundaries.""" + + NAME: Final = "myapp.task.retries" + UNIT: Final = "{retry}" + DESCRIPTION: Final = "A counter never receives boundaries." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: int, /) -> None: + self._instrument.add(amount) + + +class MyappTaskRetriesObservable: + """Observable form of `MyappTaskRetries`, reported by callbacks.""" + + NAME: Final = MyappTaskRetries.NAME + UNIT: Final = MyappTaskRetries.UNIT + DESCRIPTION: Final = MyappTaskRetries.DESCRIPTION + KIND: Final = MyappTaskRetries.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskStep: + """Time one step took, with boundaries from the registry.""" + + NAME: Final = "myapp.task.step" + UNIT: Final = "s" + DESCRIPTION: Final = "Time one step took, with boundaries from the registry." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.0, + 0.5, + 1.5, + 3.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) diff --git a/templates/code/python/tests/histogram_boundaries_none/params.yaml b/templates/code/python/tests/histogram_boundaries_none/params.yaml new file mode 100644 index 0000000..a1e0f1b --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_none/params.yaml @@ -0,0 +1,2 @@ +params: + histogram_boundaries_by_unit: {} diff --git a/templates/code/python/tests/histogram_boundaries_none/registry/manifest.yaml b/templates/code/python/tests/histogram_boundaries_none/registry/manifest.yaml new file mode 100644 index 0000000..987defa --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_none/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: histogram_boundaries_none +schema_url: https://example.com/schemas/histogram_boundaries_none/1.0.0 diff --git a/templates/code/python/tests/histogram_boundaries_none/registry/model.yaml b/templates/code/python/tests/histogram_boundaries_none/registry/model.yaml new file mode 100644 index 0000000..925338f --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_none/registry/model.yaml @@ -0,0 +1,51 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish, with the default boundaries of its unit. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.queue_wait + requirement_level: recommended + instrument: histogram + unit: ms + stability: stable + brief: Time a task waited in the queue. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.payload + requirement_level: recommended + instrument: histogram + unit: By + stability: stable + brief: Task payload size. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.step + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time one step took, with boundaries from the registry. + annotations: + code_generation: + metric_value_type: double + aggregation: + method: explicithistogram + parameters: + boundaries: [0, 0.5, 1.5, 3] + - name: myapp.task.retries + requirement_level: recommended + instrument: counter + unit: "{retry}" + stability: stable + brief: A counter never receives boundaries. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/histogram_boundaries_none/test_consumer.py b/templates/code/python/tests/histogram_boundaries_none/test_consumer.py new file mode 100644 index 0000000..a7196d2 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_none/test_consumer.py @@ -0,0 +1,118 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, metrics + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +SDK_DEFAULT_BOUNDARIES = ( + 0.0, + 5.0, + 10.0, + 25.0, + 50.0, + 75.0, + 100.0, + 250.0, + 500.0, + 750.0, + 1000.0, + 2500.0, + 5000.0, + 7500.0, + 10000.0, +) +DURATION_BOUNDARIES: tuple[float, ...] | None = None +QUEUE_WAIT_BOUNDARIES: tuple[float, ...] | None = None +PAYLOAD_BOUNDARIES: tuple[float, ...] | None = None +STEP_BOUNDARIES: tuple[float, ...] | None = (0.0, 0.5, 1.5, 3.0) + + +def advertised(boundaries: tuple[float, ...] | None) -> tuple[float, ...]: + return SDK_DEFAULT_BOUNDARIES if boundaries is None else boundaries + + +def test_the_constants_follow_the_unit_and_the_annotation() -> None: + assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == DURATION_BOUNDARIES + assert metrics.MyappTaskQueueWait.BUCKET_BOUNDARIES == QUEUE_WAIT_BOUNDARIES + assert metrics.MyappTaskPayload.BUCKET_BOUNDARIES == PAYLOAD_BOUNDARIES + assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == STEP_BOUNDARIES + + +def test_a_counter_has_no_bucket_boundaries() -> None: + assert not hasattr(metrics.MyappTaskRetries, "BUCKET_BOUNDARIES") + assert not hasattr(metrics.MyappTaskRetriesObservable, "BUCKET_BOUNDARIES") + + +def test_the_sdk_reports_the_advertised_boundaries() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskDuration(meter).record(0.4) + metrics.MyappTaskQueueWait(meter).record(3.0) + metrics.MyappTaskPayload(meter).record(512) + metrics.MyappTaskStep(meter).record(1.0) + collected = collect(reader) + expected = { + "myapp.task.duration": DURATION_BOUNDARIES, + "myapp.task.queue_wait": QUEUE_WAIT_BOUNDARIES, + "myapp.task.payload": PAYLOAD_BOUNDARIES, + "myapp.task.step": STEP_BOUNDARIES, + } + for name, boundaries in expected.items(): + (point,) = histogram_points(collected[name]) + assert tuple(point.explicit_bounds) == advertised(boundaries), name + assert sum(point.bucket_counts) == 1 + assert len(point.bucket_counts) == len(point.explicit_bounds) + 1 + + +def test_a_value_lands_in_the_bucket_of_the_advertised_boundaries() -> None: + reader, provider = new_provider() + metrics.MyappTaskStep(metrics.get_meter(provider)).record(1.0) + (point,) = histogram_points(collect(reader)["myapp.task.step"]) + assert tuple(point.explicit_bounds) == (0.0, 0.5, 1.5, 3.0) + assert tuple(point.bucket_counts) == (0, 0, 1, 0, 0) + + +def test_the_annotation_overrides_the_unit_default() -> None: + assert metrics.MyappTaskStep.UNIT == "s" + assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == (0.0, 0.5, 1.5, 3.0) diff --git a/templates/code/python/tests/histogram_boundaries_params/expected/__init__.py b/templates/code/python/tests/histogram_boundaries_params/expected/__init__.py new file mode 100644 index 0000000..32f3dfa --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_params/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/histogram_boundaries_params/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py b/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py new file mode 100644 index 0000000..21368f1 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py @@ -0,0 +1,182 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskDuration: + """Time a task took from start to finish, with the default boundaries of its unit.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish, with the default boundaries of its unit." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.5, + 1.5, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) + + +class MyappTaskPayload: + """Task payload size.""" + + NAME: Final = "myapp.task.payload" + UNIT: Final = "By" + DESCRIPTION: Final = "Task payload size." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: int, /) -> None: + self._instrument.record(amount) + + +class MyappTaskQueueWait: + """Time a task waited in the queue.""" + + NAME: Final = "myapp.task.queue_wait" + UNIT: Final = "ms" + DESCRIPTION: Final = "Time a task waited in the queue." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 1.0, + 5.0, + 25.0, + 100.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) + + +class MyappTaskRetries: + """A counter never receives boundaries.""" + + NAME: Final = "myapp.task.retries" + UNIT: Final = "{retry}" + DESCRIPTION: Final = "A counter never receives boundaries." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: int, /) -> None: + self._instrument.add(amount) + + +class MyappTaskRetriesObservable: + """Observable form of `MyappTaskRetries`, reported by callbacks.""" + + NAME: Final = MyappTaskRetries.NAME + UNIT: Final = MyappTaskRetries.UNIT + DESCRIPTION: Final = MyappTaskRetries.DESCRIPTION + KIND: Final = MyappTaskRetries.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskStep: + """Time one step took, with boundaries from the registry.""" + + NAME: Final = "myapp.task.step" + UNIT: Final = "s" + DESCRIPTION: Final = "Time one step took, with boundaries from the registry." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.0, + 0.5, + 1.5, + 3.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: float, /) -> None: + self._instrument.record(amount) diff --git a/templates/code/python/tests/histogram_boundaries_params/params.yaml b/templates/code/python/tests/histogram_boundaries_params/params.yaml new file mode 100644 index 0000000..e889d8f --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_params/params.yaml @@ -0,0 +1,4 @@ +params: + histogram_boundaries_by_unit: + ms: [1, 5, 25, 100] + s: [0.5, 1.5] diff --git a/templates/code/python/tests/histogram_boundaries_params/registry/manifest.yaml b/templates/code/python/tests/histogram_boundaries_params/registry/manifest.yaml new file mode 100644 index 0000000..8d32223 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_params/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: histogram_boundaries_params +schema_url: https://example.com/schemas/histogram_boundaries_params/1.0.0 diff --git a/templates/code/python/tests/histogram_boundaries_params/registry/model.yaml b/templates/code/python/tests/histogram_boundaries_params/registry/model.yaml new file mode 100644 index 0000000..925338f --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_params/registry/model.yaml @@ -0,0 +1,51 @@ +file_format: definition/2 +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish, with the default boundaries of its unit. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.queue_wait + requirement_level: recommended + instrument: histogram + unit: ms + stability: stable + brief: Time a task waited in the queue. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.payload + requirement_level: recommended + instrument: histogram + unit: By + stability: stable + brief: Task payload size. + annotations: + code_generation: + metric_value_type: int + - name: myapp.task.step + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time one step took, with boundaries from the registry. + annotations: + code_generation: + metric_value_type: double + aggregation: + method: explicithistogram + parameters: + boundaries: [0, 0.5, 1.5, 3] + - name: myapp.task.retries + requirement_level: recommended + instrument: counter + unit: "{retry}" + stability: stable + brief: A counter never receives boundaries. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/histogram_boundaries_params/test_consumer.py b/templates/code/python/tests/histogram_boundaries_params/test_consumer.py new file mode 100644 index 0000000..3aa8431 --- /dev/null +++ b/templates/code/python/tests/histogram_boundaries_params/test_consumer.py @@ -0,0 +1,118 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, metrics + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +SDK_DEFAULT_BOUNDARIES = ( + 0.0, + 5.0, + 10.0, + 25.0, + 50.0, + 75.0, + 100.0, + 250.0, + 500.0, + 750.0, + 1000.0, + 2500.0, + 5000.0, + 7500.0, + 10000.0, +) +DURATION_BOUNDARIES: tuple[float, ...] | None = (0.5, 1.5) +QUEUE_WAIT_BOUNDARIES: tuple[float, ...] | None = (1.0, 5.0, 25.0, 100.0) +PAYLOAD_BOUNDARIES: tuple[float, ...] | None = None +STEP_BOUNDARIES: tuple[float, ...] | None = (0.0, 0.5, 1.5, 3.0) + + +def advertised(boundaries: tuple[float, ...] | None) -> tuple[float, ...]: + return SDK_DEFAULT_BOUNDARIES if boundaries is None else boundaries + + +def test_the_constants_follow_the_unit_and_the_annotation() -> None: + assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == DURATION_BOUNDARIES + assert metrics.MyappTaskQueueWait.BUCKET_BOUNDARIES == QUEUE_WAIT_BOUNDARIES + assert metrics.MyappTaskPayload.BUCKET_BOUNDARIES == PAYLOAD_BOUNDARIES + assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == STEP_BOUNDARIES + + +def test_a_counter_has_no_bucket_boundaries() -> None: + assert not hasattr(metrics.MyappTaskRetries, "BUCKET_BOUNDARIES") + assert not hasattr(metrics.MyappTaskRetriesObservable, "BUCKET_BOUNDARIES") + + +def test_the_sdk_reports_the_advertised_boundaries() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskDuration(meter).record(0.4) + metrics.MyappTaskQueueWait(meter).record(3.0) + metrics.MyappTaskPayload(meter).record(512) + metrics.MyappTaskStep(meter).record(1.0) + collected = collect(reader) + expected = { + "myapp.task.duration": DURATION_BOUNDARIES, + "myapp.task.queue_wait": QUEUE_WAIT_BOUNDARIES, + "myapp.task.payload": PAYLOAD_BOUNDARIES, + "myapp.task.step": STEP_BOUNDARIES, + } + for name, boundaries in expected.items(): + (point,) = histogram_points(collected[name]) + assert tuple(point.explicit_bounds) == advertised(boundaries), name + assert sum(point.bucket_counts) == 1 + assert len(point.bucket_counts) == len(point.explicit_bounds) + 1 + + +def test_a_value_lands_in_the_bucket_of_the_advertised_boundaries() -> None: + reader, provider = new_provider() + metrics.MyappTaskStep(metrics.get_meter(provider)).record(1.0) + (point,) = histogram_points(collect(reader)["myapp.task.step"]) + assert tuple(point.explicit_bounds) == (0.0, 0.5, 1.5, 3.0) + assert tuple(point.bucket_counts) == (0, 0, 1, 0, 0) + + +def test_the_annotation_overrides_the_unit_default() -> None: + assert metrics.MyappTaskStep.UNIT == "s" + assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == (0.0, 0.5, 1.5, 3.0) diff --git a/templates/code/python/tests/imported/dependency/manifest.yaml b/templates/code/python/tests/imported/dependency/manifest.yaml new file mode 100644 index 0000000..6fe2cd0 --- /dev/null +++ b/templates/code/python/tests/imported/dependency/manifest.yaml @@ -0,0 +1,2 @@ +name: upstream +schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/imported/dependency/model.yaml b/templates/code/python/tests/imported/dependency/model.yaml new file mode 100644 index 0000000..109e05e --- /dev/null +++ b/templates/code/python/tests/imported/dependency/model.yaml @@ -0,0 +1,30 @@ +file_format: definition/2 +attributes: + - key: error.type + type: + members: + - id: timeout + value: timeout + stability: stable + brief: The operation timed out. + - id: other + value: _OTHER + stability: stable + brief: Any other error. + stability: stable + brief: Describes a class of error. + - key: server.address + type: string + stability: stable + brief: Server domain name or IP address. + examples: ["example.com"] + - key: server.port + type: int + stability: stable + brief: Server port number. + examples: [443] + - key: http.request.method_list + type: string[] + stability: stable + brief: Methods attempted, in order. + examples: [["GET", "POST"]] diff --git a/templates/code/python/tests/imported/expected/__init__.py b/templates/code/python/tests/imported/expected/__init__.py new file mode 100644 index 0000000..6c6fcb0 --- /dev/null +++ b/templates/code/python/tests/imported/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/myapp/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/imported/expected/attributes.py b/templates/code/python/tests/imported/expected/attributes.py new file mode 100644 index 0000000..f51a147 --- /dev/null +++ b/templates/code/python/tests/imported/expected/attributes.py @@ -0,0 +1,6 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task.""" diff --git a/templates/code/python/tests/imported/expected/metrics.py b/templates/code/python/tests/imported/expected/metrics.py new file mode 100644 index 0000000..b641839 --- /dev/null +++ b/templates/code/python/tests/imported/expected/metrics.py @@ -0,0 +1,155 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class EdgeUpstreamDuration: + """Time spent waiting on an upstream server.""" + + NAME: Final = "edge.upstream.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time spent waiting on an upstream server." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + server_address: str, + server_port: int | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + "server.address": server_address, + } + if server_port is not None: + _attributes["server.port"] = server_port + self._instrument.record(amount, _attributes) + + +class MyappTaskFailed: + """Number of tasks that failed.""" + + NAME: Final = "myapp.task.failed" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks that failed." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + error_type: str, + task_id: str, + http_request_method_list: Sequence[str] | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + "error.type": error_type, + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if http_request_method_list is not None: + _attributes["http.request.method_list"] = [*http_request_method_list] + self._instrument.add(amount, _attributes) + + +class MyappTaskFailedObservable: + """Observable form of `MyappTaskFailed`, reported by callbacks.""" + + NAME: Final = MyappTaskFailed.NAME + UNIT: Final = MyappTaskFailed.UNIT + DESCRIPTION: Final = MyappTaskFailed.DESCRIPTION + KIND: Final = MyappTaskFailed.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + error_type: str, + task_id: str, + http_request_method_list: Sequence[str] | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + "error.type": error_type, + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if http_request_method_list is not None: + _attributes["http.request.method_list"] = [*http_request_method_list] + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/imported/registry/manifest.yaml b/templates/code/python/tests/imported/registry/manifest.yaml new file mode 100644 index 0000000..d1bbe08 --- /dev/null +++ b/templates/code/python/tests/imported/registry/manifest.yaml @@ -0,0 +1,6 @@ +name: myapp +schema_url: https://example.com/schemas/myapp/1.0.0 +dependencies: + - name: upstream + registry_path: dependency + schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/imported/registry/model.yaml b/templates/code/python/tests/imported/registry/model.yaml new file mode 100644 index 0000000..3fc83d6 --- /dev/null +++ b/templates/code/python/tests/imported/registry/model.yaml @@ -0,0 +1,77 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + examples: ["task_0001"] +metrics: + - name: myapp.task.failed + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks that failed. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: error.type + requirement_level: required + - ref: http.request.method_list + requirement_level: recommended + - name: edge.upstream.duration + instrument: histogram + unit: s + stability: stable + brief: Time spent waiting on an upstream server. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: server.address + requirement_level: required + - ref: server.port + requirement_level: recommended +spans: + - type: edge.upstream.request + kind: client + requirement_level: recommended + stability: stable + brief: A request to an upstream server. + name: + note: "`{server.address}:{server.port}`" + attributes: + - ref: server.address + requirement_level: required + - ref: server.port + requirement_level: required + - ref: error.type + requirement_level: + conditionally_required: If the request failed. + sampling_relevant: true + - ref: http.request.method_list + requirement_level: recommended + - type: edge.upstream.methods + kind: client + requirement_level: recommended + stability: stable + brief: A request named from attempted methods. + name: + note: "`{http.request.method_list}`" + attributes: + - ref: http.request.method_list + requirement_level: required +events: + - name: edge.upstream.failed + requirement_level: recommended + stability: stable + brief: A request to an upstream server failed. + attributes: + - ref: error.type + requirement_level: required + - ref: server.address + requirement_level: recommended + - ref: http.request.method_list + requirement_level: recommended diff --git a/templates/code/python/tests/imported/test_consumer.py b/templates/code/python/tests/imported/test_consumer.py new file mode 100644 index 0000000..b277dc9 --- /dev/null +++ b/templates/code/python/tests/imported/test_consumer.py @@ -0,0 +1,98 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def test_a_local_attribute_uses_its_constant() -> None: + assert attributes.MYAPP_TASK_ID_KEY == "myapp.task.id" + assert not hasattr(attributes, "ERROR_TYPE_KEY") + assert not hasattr(attributes, "SERVER_ADDRESS_KEY") + + +def test_imported_attributes_use_literal_keys_and_registry_types() -> None: + reader, provider = new_provider() + metrics.MyappTaskFailed(metrics.get_meter(provider)).add( + 1, + error_type="timeout", + task_id="task-1", + http_request_method_list=["GET", "POST"], + ) + (point,) = number_points(collect(reader)["myapp.task.failed"]) + assert attributes_of(point) == { + "error.type": "timeout", + "myapp.task.id": "task-1", + "http.request.method_list": ("GET", "POST"), + } + + +def test_imported_optional_attributes_are_omitted_when_none() -> None: + reader, provider = new_provider() + duration = metrics.EdgeUpstreamDuration(metrics.get_meter(provider)) + duration.record(0.25, server_address="upstream.example.com") + duration.record(0.5, server_address="upstream.example.com", server_port=443) + points = histogram_points(collect(reader)["edge.upstream.duration"]) + assert len(points) == 2 + recorded = sorted( + sorted(attributes_of(point).items(), key=lambda item: item[0]) + for point in points + ) + assert recorded == [ + [("server.address", "upstream.example.com")], + [("server.address", "upstream.example.com"), ("server.port", 443)], + ] + + +def test_imported_attributes_work_in_observations() -> None: + observation = metrics.MyappTaskFailedObservable.observation( + 2, error_type="boom", task_id="task-1" + ) + assert observation.value == 2 + assert observation.attributes == {"error.type": "boom", "myapp.task.id": "task-1"} diff --git a/templates/code/python/tests/literal_escaping/expected/__init__.py b/templates/code/python/tests/literal_escaping/expected/__init__.py new file mode 100644 index 0000000..810bf32 --- /dev/null +++ b/templates/code/python/tests/literal_escaping/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/literal_escaping/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/literal_escaping/expected/attributes.py b/templates/code/python/tests/literal_escaping/expected/attributes.py new file mode 100644 index 0000000..c984699 --- /dev/null +++ b/templates/code/python/tests/literal_escaping/expected/attributes.py @@ -0,0 +1,27 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class DemoMode(enum.StrEnum): + """Values of the `demo.mode` attribute.""" + + QUOTED = 'it\'s "quoted"' + """Mixes both quote characters.""" + PATH = "C:\\temp\\new" + """Holds backslashes.""" + UNICODE = "café ☃" + """Holds non-ASCII characters.""" + + +DEMO_MODE_KEY: Final = "demo.mode" +"""Mode with awkward values.""" + + +DEMO_TEXT_KEY: Final = "demo.text" +"""Says \"hello\" and \\\\ it's a backslash. + +Contains a triple quote \"\"\" and a backslash at the end \\ +on two lines, with trailing spaces +and a tab here.""" diff --git a/templates/code/python/tests/literal_escaping/expected/metrics.py b/templates/code/python/tests/literal_escaping/expected/metrics.py new file mode 100644 index 0000000..1884e68 --- /dev/null +++ b/templates/code/python/tests/literal_escaping/expected/metrics.py @@ -0,0 +1,115 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class DemoWork: + """Counts \"quoted\" work with a \\\\ backslash. + + Ends with a backslash \\\\""" + + NAME: Final = "demo.work" + UNIT: Final = '{"unit"}' + DESCRIPTION: Final = 'Counts "quoted" work with a \\\\ backslash.' + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + mode: attributes.DemoMode | None = None, + text: str | None = None, + ) -> None: + """Add to the counter. + + Args: + text: Conditionally required: When it's set, as in \"demo.mode\" \\\\ + """ + + _attributes: dict[str, AttributeValue] = {} + if mode is not None: + _attributes[attributes.DEMO_MODE_KEY] = mode.value + if text is not None: + _attributes[attributes.DEMO_TEXT_KEY] = text + self._instrument.add(amount, _attributes) + + +class DemoWorkObservable: + """Observable form of `DemoWork`, reported by callbacks.""" + + NAME: Final = DemoWork.NAME + UNIT: Final = DemoWork.UNIT + DESCRIPTION: Final = DemoWork.DESCRIPTION + KIND: Final = DemoWork.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + mode: attributes.DemoMode | None = None, + text: str | None = None, + ) -> Observation: + """Build an observation. + + Args: + text: Conditionally required: When it's set, as in \"demo.mode\" \\\\ + """ + + _attributes: dict[str, AttributeValue] = {} + if mode is not None: + _attributes[attributes.DEMO_MODE_KEY] = mode.value + if text is not None: + _attributes[attributes.DEMO_TEXT_KEY] = text + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/literal_escaping/registry/manifest.yaml b/templates/code/python/tests/literal_escaping/registry/manifest.yaml new file mode 100644 index 0000000..b7d9210 --- /dev/null +++ b/templates/code/python/tests/literal_escaping/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: literal_escaping +schema_url: https://example.com/schemas/literal_escaping/1.0.0 diff --git a/templates/code/python/tests/literal_escaping/registry/model.yaml b/templates/code/python/tests/literal_escaping/registry/model.yaml new file mode 100644 index 0000000..0f4f7d1 --- /dev/null +++ b/templates/code/python/tests/literal_escaping/registry/model.yaml @@ -0,0 +1,44 @@ +file_format: definition/2 +attributes: + - key: demo.text + type: string + stability: stable + brief: 'Says "hello" and \\ it''s a backslash.' + note: |- + Contains a triple quote """ and a backslash at the end \ + on two lines, with trailing spaces + and a tab here. + - key: demo.mode + type: + members: + - id: quoted + value: "it's \"quoted\"" + stability: stable + brief: Mixes both quote characters. + - id: path + value: 'C:\temp\new' + stability: stable + brief: Holds backslashes. + - id: unicode + value: "caf\u00e9 \u2603" + stability: stable + brief: Holds non-ASCII characters. + stability: stable + brief: Mode with awkward values. +metrics: + - name: demo.work + requirement_level: recommended + instrument: counter + unit: '{"unit"}' + stability: stable + brief: 'Counts "quoted" work with a \\ backslash.' + note: Ends with a backslash \\ + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: demo.text + requirement_level: + conditionally_required: 'When it''s set, as in "demo.mode" \\' + - ref: demo.mode + requirement_level: recommended diff --git a/templates/code/python/tests/literal_escaping/test_consumer.py b/templates/code/python/tests/literal_escaping/test_consumer.py new file mode 100644 index 0000000..bbd2ed6 --- /dev/null +++ b/templates/code/python/tests/literal_escaping/test_consumer.py @@ -0,0 +1,119 @@ +import ast +import inspect +from types import ModuleType + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def constant_docstring(module: ModuleType, constant: str) -> str | None: + tree = ast.parse(inspect.getsource(module)) + statements = tree.body + for index, statement in enumerate(statements): + if ( + isinstance(statement, ast.AnnAssign) + and isinstance(statement.target, ast.Name) + and statement.target.id == constant + ): + following = statements[index + 1] + assert isinstance(following, ast.Expr) + docstring = ast.literal_eval(following.value) + assert isinstance(docstring, str) + return docstring + return None + + +def test_enum_values_round_trip() -> None: + assert attributes.DemoMode.QUOTED.value == 'it\'s "quoted"' + assert attributes.DemoMode.PATH.value == "C:\\temp\\new" + assert attributes.DemoMode.UNICODE.value == "caf\u00e9 \u2603" + + +def test_name_unit_and_description_round_trip() -> None: + assert metrics.DemoWork.UNIT == '{"unit"}' + assert metrics.DemoWork.DESCRIPTION == 'Counts "quoted" work with a \\\\ backslash.' + reader, provider = new_provider() + metrics.DemoWork(metrics.get_meter(provider)).add(1) + metric = collect(reader)["demo.work"] + assert metric.unit == '{"unit"}' + assert metric.description == 'Counts "quoted" work with a \\\\ backslash.' + + +def test_enum_values_reach_the_sdk_unchanged() -> None: + reader, provider = new_provider() + work = metrics.DemoWork(metrics.get_meter(provider)) + for mode in attributes.DemoMode: + work.add(1, mode=mode) + points = number_points(collect(reader)["demo.work"]) + assert sorted(str(attributes_of(point)["demo.mode"]) for point in points) == sorted( + mode.value for mode in attributes.DemoMode + ) + + +def test_attribute_docstrings_are_escaped_and_normalized() -> None: + assert constant_docstring(attributes, "DEMO_TEXT_KEY") == ( + 'Says "hello" and \\\\ it\'s a backslash.\n\n' + 'Contains a triple quote """ and a backslash at the end \\\n' + "on two lines, with trailing spaces\n" + "and a tab here." + ) + + +def test_class_and_method_docstrings_are_escaped() -> None: + assert inspect.getdoc(metrics.DemoWork) == ( + 'Counts "quoted" work with a \\\\ backslash.\n\nEnds with a backslash \\\\' + ) + assert inspect.getdoc(metrics.DemoWork.add) == ( + "Add to the counter.\n\n" + "Args:\n" + ' text: Conditionally required: When it\'s set, as in "demo.mode" \\\\' + ) diff --git a/templates/code/python/tests/long_names/expected/__init__.py b/templates/code/python/tests/long_names/expected/__init__.py new file mode 100644 index 0000000..2fa8645 --- /dev/null +++ b/templates/code/python/tests/long_names/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/long_names/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/long_names/expected/attributes.py b/templates/code/python/tests/long_names/expected/attributes.py new file mode 100644 index 0000000..f9a1f97 --- /dev/null +++ b/templates/code/python/tests/long_names/expected/attributes.py @@ -0,0 +1,40 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class MyappTaskConfigEnvKind(enum.StrEnum): + """Values of the `myapp.task.config_env_kind` attribute.""" + + PRODUCTION = "production" + """Production.""" + STAGING = "staging" + """Staging.""" + + +MYAPP_TASK_CONFIG_ENV_KIND_KEY: Final = "myapp.task.config_env_kind" +"""Long enum.""" + + +MYAPP_TASK_CONFIG_PROFILE_IDENTIFIER_KEY: Final = "myapp.task.config_profile_identifier" +"""Long array.""" + + +MYAPP_TASK_CONFIG_PROFILE_REVISION_KEY: Final = "myapp.task.config_profile_revision" +"""Long array.""" + + +class MyappTaskEnvironment(enum.StrEnum): + """Values of the `myapp.task.environment` attribute.""" + + PRODUCTION = "production" + """Production.""" + + +MYAPP_TASK_ENVIRONMENT_KEY: Final = "myapp.task.environment" +"""Short enum.""" + + +MYAPP_TASK_TAGS_KEY: Final = "myapp.task.tags" +"""Short array.""" diff --git a/templates/code/python/tests/long_names/expected/metrics.py b/templates/code/python/tests/long_names/expected/metrics.py new file mode 100644 index 0000000..1e1fcab --- /dev/null +++ b/templates/code/python/tests/long_names/expected/metrics.py @@ -0,0 +1,212 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskLong: + """A metric whose parameters make long lines.""" + + NAME: Final = "myapp.task.long" + UNIT: Final = "{task}" + DESCRIPTION: Final = "A metric whose parameters make long lines." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_config_env_kind: attributes.MyappTaskConfigEnvKind, + task_config_profile_identifier: Sequence[str], + task_environment: attributes.MyappTaskEnvironment, + task_tags: Sequence[str], + task_config_profile_revision: Sequence[str] | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_CONFIG_ENV_KIND_KEY: task_config_env_kind.value, + attributes.MYAPP_TASK_CONFIG_PROFILE_IDENTIFIER_KEY: [ + *task_config_profile_identifier + ], + attributes.MYAPP_TASK_ENVIRONMENT_KEY: task_environment.value, + attributes.MYAPP_TASK_TAGS_KEY: [*task_tags], + } + if task_config_profile_revision is not None: + _attributes[attributes.MYAPP_TASK_CONFIG_PROFILE_REVISION_KEY] = [ + *task_config_profile_revision + ] + self._instrument.add(amount, _attributes) + + +class MyappTaskLongObservable: + """Observable form of `MyappTaskLong`, reported by callbacks.""" + + NAME: Final = MyappTaskLong.NAME + UNIT: Final = MyappTaskLong.UNIT + DESCRIPTION: Final = MyappTaskLong.DESCRIPTION + KIND: Final = MyappTaskLong.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_config_env_kind: attributes.MyappTaskConfigEnvKind, + task_config_profile_identifier: Sequence[str], + task_environment: attributes.MyappTaskEnvironment, + task_tags: Sequence[str], + task_config_profile_revision: Sequence[str] | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_CONFIG_ENV_KIND_KEY: task_config_env_kind.value, + attributes.MYAPP_TASK_CONFIG_PROFILE_IDENTIFIER_KEY: [ + *task_config_profile_identifier + ], + attributes.MYAPP_TASK_ENVIRONMENT_KEY: task_environment.value, + attributes.MYAPP_TASK_TAGS_KEY: [*task_tags], + } + if task_config_profile_revision is not None: + _attributes[attributes.MYAPP_TASK_CONFIG_PROFILE_REVISION_KEY] = [ + *task_config_profile_revision + ] + return Observation(amount, _attributes) + + +class MyappTaskLongOptional: + """A metric whose optional parameters make long lines.""" + + NAME: Final = "myapp.task.long.optional" + UNIT: Final = "{task}" + DESCRIPTION: Final = "A metric whose optional parameters make long lines." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_config_env_kind: attributes.MyappTaskConfigEnvKind | None = None, + task_config_profile_identifier: Sequence[str] | None = None, + task_environment: attributes.MyappTaskEnvironment | None = None, + task_tags: Sequence[str] | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = {} + if task_config_env_kind is not None: + _attributes[attributes.MYAPP_TASK_CONFIG_ENV_KIND_KEY] = ( + task_config_env_kind.value + ) + if task_config_profile_identifier is not None: + _attributes[attributes.MYAPP_TASK_CONFIG_PROFILE_IDENTIFIER_KEY] = [ + *task_config_profile_identifier + ] + if task_environment is not None: + _attributes[attributes.MYAPP_TASK_ENVIRONMENT_KEY] = task_environment.value + if task_tags is not None: + _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] + self._instrument.add(amount, _attributes) + + +class MyappTaskLongOptionalObservable: + """Observable form of `MyappTaskLongOptional`, reported by callbacks.""" + + NAME: Final = MyappTaskLongOptional.NAME + UNIT: Final = MyappTaskLongOptional.UNIT + DESCRIPTION: Final = MyappTaskLongOptional.DESCRIPTION + KIND: Final = MyappTaskLongOptional.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_config_env_kind: attributes.MyappTaskConfigEnvKind | None = None, + task_config_profile_identifier: Sequence[str] | None = None, + task_environment: attributes.MyappTaskEnvironment | None = None, + task_tags: Sequence[str] | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = {} + if task_config_env_kind is not None: + _attributes[attributes.MYAPP_TASK_CONFIG_ENV_KIND_KEY] = ( + task_config_env_kind.value + ) + if task_config_profile_identifier is not None: + _attributes[attributes.MYAPP_TASK_CONFIG_PROFILE_IDENTIFIER_KEY] = [ + *task_config_profile_identifier + ] + if task_environment is not None: + _attributes[attributes.MYAPP_TASK_ENVIRONMENT_KEY] = task_environment.value + if task_tags is not None: + _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/long_names/registry/manifest.yaml b/templates/code/python/tests/long_names/registry/manifest.yaml new file mode 100644 index 0000000..fd482db --- /dev/null +++ b/templates/code/python/tests/long_names/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: long_names +schema_url: https://example.com/schemas/long_names/1.0.0 diff --git a/templates/code/python/tests/long_names/registry/model.yaml b/templates/code/python/tests/long_names/registry/model.yaml new file mode 100644 index 0000000..7ab1fad --- /dev/null +++ b/templates/code/python/tests/long_names/registry/model.yaml @@ -0,0 +1,75 @@ +file_format: definition/2 +attributes: + - key: myapp.task.config_profile_identifier + type: string[] + stability: stable + brief: Long array. + - key: myapp.task.config_profile_revision + type: string[] + stability: stable + brief: Long array. + - key: myapp.task.tags + type: string[] + stability: stable + brief: Short array. + - key: myapp.task.config_env_kind + type: + members: + - id: production + value: production + stability: stable + brief: Production. + - id: staging + value: staging + stability: stable + brief: Staging. + stability: stable + brief: Long enum. + - key: myapp.task.environment + type: + members: + - id: production + value: production + stability: stable + brief: Production. + stability: stable + brief: Short enum. +metrics: + - name: myapp.task.long + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: A metric whose parameters make long lines. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.config_profile_identifier + requirement_level: required + - ref: myapp.task.config_env_kind + requirement_level: required + - ref: myapp.task.tags + requirement_level: required + - ref: myapp.task.environment + requirement_level: required + - ref: myapp.task.config_profile_revision + requirement_level: recommended + - name: myapp.task.long.optional + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: A metric whose optional parameters make long lines. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.config_profile_identifier + requirement_level: recommended + - ref: myapp.task.config_env_kind + requirement_level: recommended + - ref: myapp.task.tags + requirement_level: recommended + - ref: myapp.task.environment + requirement_level: recommended diff --git a/templates/code/python/tests/long_names/test_consumer.py b/templates/code/python/tests/long_names/test_consumer.py new file mode 100644 index 0000000..0f7ed5c --- /dev/null +++ b/templates/code/python/tests/long_names/test_consumer.py @@ -0,0 +1,112 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +PROFILE = ["alpha", "beta"] + + +def test_wrapped_required_assignments_record_every_attribute() -> None: + reader, provider = new_provider() + metrics.MyappTaskLong(metrics.get_meter(provider)).add( + 1, + task_config_env_kind=attributes.MyappTaskConfigEnvKind.STAGING, + task_config_profile_identifier=PROFILE, + task_environment=attributes.MyappTaskEnvironment.PRODUCTION, + task_tags=["a"], + task_config_profile_revision=["r1"], + ) + (point,) = number_points(collect(reader)["myapp.task.long"]) + assert attributes_of(point) == { + "myapp.task.config_env_kind": "staging", + "myapp.task.config_profile_identifier": ("alpha", "beta"), + "myapp.task.environment": "production", + "myapp.task.tags": ("a",), + "myapp.task.config_profile_revision": ("r1",), + } + + +def test_wrapped_optional_assignments_are_omitted_when_none() -> None: + reader, provider = new_provider() + optional = metrics.MyappTaskLongOptional(metrics.get_meter(provider)) + optional.add(1) + optional.add( + 2, + task_config_env_kind=attributes.MyappTaskConfigEnvKind.PRODUCTION, + task_config_profile_identifier=PROFILE, + task_environment=attributes.MyappTaskEnvironment.PRODUCTION, + task_tags=[], + ) + points = number_points(collect(reader)["myapp.task.long.optional"]) + by_value = {point.value: attributes_of(point) for point in points} + assert by_value[1] == {} + assert by_value[2] == { + "myapp.task.config_env_kind": "production", + "myapp.task.config_profile_identifier": ("alpha", "beta"), + "myapp.task.environment": "production", + "myapp.task.tags": (), + } + + +def test_wrapped_observations_carry_the_same_attributes() -> None: + observation = metrics.MyappTaskLongObservable.observation( + 1, + task_config_env_kind=attributes.MyappTaskConfigEnvKind.PRODUCTION, + task_config_profile_identifier=PROFILE, + task_environment=attributes.MyappTaskEnvironment.PRODUCTION, + task_tags=["a"], + ) + assert observation.attributes == { + "myapp.task.config_env_kind": "production", + "myapp.task.config_profile_identifier": ["alpha", "beta"], + "myapp.task.environment": "production", + "myapp.task.tags": ["a"], + } diff --git a/templates/code/python/tests/metric_attributes/expected/__init__.py b/templates/code/python/tests/metric_attributes/expected/__init__.py new file mode 100644 index 0000000..bda6a1e --- /dev/null +++ b/templates/code/python/tests/metric_attributes/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/metric_attributes/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/metric_attributes/expected/attributes.py b/templates/code/python/tests/metric_attributes/expected/attributes.py new file mode 100644 index 0000000..ae1c8f6 --- /dev/null +++ b/templates/code/python/tests/metric_attributes/expected/attributes.py @@ -0,0 +1,109 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class AuthLevel(enum.IntEnum): + """Values of the `auth.level` attribute.""" + + LOW = 1 + """Single factor.""" + HIGH = 2 + """Multiple factors.""" + + +AUTH_LEVEL_KEY: Final = "auth.level" +"""Assurance level of the authentication.""" + + +class AuthMethod(enum.StrEnum): + """Values of the `auth.method` attribute.""" + + PASSWORD = "password" + """A password was checked.""" + TOKEN = "token" + """A bearer token was checked.""" + + +AUTH_METHOD_KEY: Final = "auth.method" +"""How the user was authenticated.""" + + +MYAPP_TASK_CANCELLED_KEY: Final = "myapp.task.cancelled" +"""Whether the task was cancelled.""" + + +MYAPP_TASK_FLAGS_KEY: Final = "myapp.task.flags" +"""Feature flags evaluated for the task.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task. + +The identifier is assigned by the scheduler and stays stable across retries.""" + + +class MyappTaskPreemptible(enum.Enum): + """Values of the `myapp.task.preemptible` attribute.""" + + ALLOWED = True + """The scheduler may preempt the task.""" + FORBIDDEN = False + """The task runs to completion.""" + + +MYAPP_TASK_PREEMPTIBLE_KEY: Final = "myapp.task.preemptible" +"""Whether the scheduler may preempt the task.""" + + +MYAPP_TASK_PROGRESS_KEY: Final = "myapp.task.progress" +"""Fraction of the task that has completed, between 0 and 1.""" + + +MYAPP_TASK_RETRIES_KEY: Final = "myapp.task.retries" +"""Number of times the task was retried.""" + + +class MyappTaskSampleRate(float, enum.Enum): + """Values of the `myapp.task.sample_rate` attribute.""" + + TENTH = 0.1 + """One task in ten is sampled.""" + ALL = 1.0 + """Every task is sampled.""" + + +MYAPP_TASK_SAMPLE_RATE_KEY: Final = "myapp.task.sample_rate" +"""Fraction of tasks whose telemetry is sampled.""" + + +MYAPP_TASK_SHARD_IDS_KEY: Final = "myapp.task.shard_ids" +"""Shards the task touched.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + RUNNING = "running" + """The task is running. + + A task stays running until it finishes or is cancelled.""" + PAUSED = "paused" + """The task was paused. + + Deprecated: Paused tasks are reported as queued.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" + + +MYAPP_TASK_TAGS_KEY: Final = "myapp.task.tags" +"""Tags attached to the task.""" + + +MYAPP_TASK_WEIGHTS_KEY: Final = "myapp.task.weights" +"""Scheduling weights applied to the task.""" diff --git a/templates/code/python/tests/metric_attributes/expected/metrics.py b/templates/code/python/tests/metric_attributes/expected/metrics.py new file mode 100644 index 0000000..96c7984 --- /dev/null +++ b/templates/code/python/tests/metric_attributes/expected/metrics.py @@ -0,0 +1,596 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskArraysOptional: + """Every array attribute type, all optional.""" + + NAME: Final = "myapp.task.arrays.optional" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Every array attribute type, all optional." + KIND: Final = InstrumentKind.GAUGE + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_gauge( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def set( + self, + amount: float, + /, + *, + task_flags: Sequence[bool] | None = None, + task_shard_ids: Sequence[int] | None = None, + task_tags: Sequence[str] | None = None, + task_weights: Sequence[float] | None = None, + ) -> None: + """Set the gauge. + + Args: + task_flags: Opt-in: the convention records it only when a user asks for it. + task_shard_ids: Opt-in: the convention records it only when a user asks for it. + task_tags: Opt-in: the convention records it only when a user asks for it. + task_weights: Opt-in: the convention records it only when a user asks for it. + """ + + _attributes: dict[str, AttributeValue] = {} + if task_flags is not None: + _attributes[attributes.MYAPP_TASK_FLAGS_KEY] = [*task_flags] + if task_shard_ids is not None: + _attributes[attributes.MYAPP_TASK_SHARD_IDS_KEY] = [*task_shard_ids] + if task_tags is not None: + _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] + if task_weights is not None: + _attributes[attributes.MYAPP_TASK_WEIGHTS_KEY] = [*task_weights] + self._instrument.set(amount, _attributes) + + +class MyappTaskArraysOptionalObservable: + """Observable form of `MyappTaskArraysOptional`, reported by callbacks.""" + + NAME: Final = MyappTaskArraysOptional.NAME + UNIT: Final = MyappTaskArraysOptional.UNIT + DESCRIPTION: Final = MyappTaskArraysOptional.DESCRIPTION + KIND: Final = MyappTaskArraysOptional.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_gauge( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: float, + /, + *, + task_flags: Sequence[bool] | None = None, + task_shard_ids: Sequence[int] | None = None, + task_tags: Sequence[str] | None = None, + task_weights: Sequence[float] | None = None, + ) -> Observation: + """Build an observation. + + Args: + task_flags: Opt-in: the convention records it only when a user asks for it. + task_shard_ids: Opt-in: the convention records it only when a user asks for it. + task_tags: Opt-in: the convention records it only when a user asks for it. + task_weights: Opt-in: the convention records it only when a user asks for it. + """ + + _attributes: dict[str, AttributeValue] = {} + if task_flags is not None: + _attributes[attributes.MYAPP_TASK_FLAGS_KEY] = [*task_flags] + if task_shard_ids is not None: + _attributes[attributes.MYAPP_TASK_SHARD_IDS_KEY] = [*task_shard_ids] + if task_tags is not None: + _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] + if task_weights is not None: + _attributes[attributes.MYAPP_TASK_WEIGHTS_KEY] = [*task_weights] + return Observation(amount, _attributes) + + +class MyappTaskArraysRequired: + """Every array attribute type, all required.""" + + NAME: Final = "myapp.task.arrays.required" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Every array attribute type, all required." + KIND: Final = InstrumentKind.UP_DOWN_COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_up_down_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: float, + /, + *, + task_flags: Sequence[bool], + task_shard_ids: Sequence[int], + task_tags: Sequence[str], + task_weights: Sequence[float], + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_FLAGS_KEY: [*task_flags], + attributes.MYAPP_TASK_SHARD_IDS_KEY: [*task_shard_ids], + attributes.MYAPP_TASK_TAGS_KEY: [*task_tags], + attributes.MYAPP_TASK_WEIGHTS_KEY: [*task_weights], + } + self._instrument.add(amount, _attributes) + + +class MyappTaskArraysRequiredObservable: + """Observable form of `MyappTaskArraysRequired`, reported by callbacks.""" + + NAME: Final = MyappTaskArraysRequired.NAME + UNIT: Final = MyappTaskArraysRequired.UNIT + DESCRIPTION: Final = MyappTaskArraysRequired.DESCRIPTION + KIND: Final = MyappTaskArraysRequired.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_up_down_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: float, + /, + *, + task_flags: Sequence[bool], + task_shard_ids: Sequence[int], + task_tags: Sequence[str], + task_weights: Sequence[float], + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_FLAGS_KEY: [*task_flags], + attributes.MYAPP_TASK_SHARD_IDS_KEY: [*task_shard_ids], + attributes.MYAPP_TASK_TAGS_KEY: [*task_tags], + attributes.MYAPP_TASK_WEIGHTS_KEY: [*task_weights], + } + return Observation(amount, _attributes) + + +class MyappTaskEnumsOptional: + """Every enum attribute type, all optional.""" + + NAME: Final = "myapp.task.enums.optional" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Every enum attribute type, all optional." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + auth_level: attributes.AuthLevel | None = None, + auth_method: attributes.AuthMethod | None = None, + task_preemptible: attributes.MyappTaskPreemptible | None = None, + task_sample_rate: attributes.MyappTaskSampleRate | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = {} + if auth_level is not None: + _attributes[attributes.AUTH_LEVEL_KEY] = auth_level.value + if auth_method is not None: + _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value + if task_preemptible is not None: + _attributes[attributes.MYAPP_TASK_PREEMPTIBLE_KEY] = task_preemptible.value + if task_sample_rate is not None: + _attributes[attributes.MYAPP_TASK_SAMPLE_RATE_KEY] = task_sample_rate.value + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + self._instrument.add(amount, _attributes) + + +class MyappTaskEnumsOptionalObservable: + """Observable form of `MyappTaskEnumsOptional`, reported by callbacks.""" + + NAME: Final = MyappTaskEnumsOptional.NAME + UNIT: Final = MyappTaskEnumsOptional.UNIT + DESCRIPTION: Final = MyappTaskEnumsOptional.DESCRIPTION + KIND: Final = MyappTaskEnumsOptional.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + auth_level: attributes.AuthLevel | None = None, + auth_method: attributes.AuthMethod | None = None, + task_preemptible: attributes.MyappTaskPreemptible | None = None, + task_sample_rate: attributes.MyappTaskSampleRate | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = {} + if auth_level is not None: + _attributes[attributes.AUTH_LEVEL_KEY] = auth_level.value + if auth_method is not None: + _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value + if task_preemptible is not None: + _attributes[attributes.MYAPP_TASK_PREEMPTIBLE_KEY] = task_preemptible.value + if task_sample_rate is not None: + _attributes[attributes.MYAPP_TASK_SAMPLE_RATE_KEY] = task_sample_rate.value + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + return Observation(amount, _attributes) + + +class MyappTaskEnumsRequired: + """Every enum attribute type, all required.""" + + NAME: Final = "myapp.task.enums.required" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Every enum attribute type, all required." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + auth_level: attributes.AuthLevel, + auth_method: attributes.AuthMethod, + task_preemptible: attributes.MyappTaskPreemptible, + task_sample_rate: attributes.MyappTaskSampleRate, + task_state: attributes.MyappTaskState, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.AUTH_LEVEL_KEY: auth_level.value, + attributes.AUTH_METHOD_KEY: auth_method.value, + attributes.MYAPP_TASK_PREEMPTIBLE_KEY: task_preemptible.value, + attributes.MYAPP_TASK_SAMPLE_RATE_KEY: task_sample_rate.value, + attributes.MYAPP_TASK_STATE_KEY: task_state.value, + } + self._instrument.add(amount, _attributes) + + +class MyappTaskEnumsRequiredObservable: + """Observable form of `MyappTaskEnumsRequired`, reported by callbacks.""" + + NAME: Final = MyappTaskEnumsRequired.NAME + UNIT: Final = MyappTaskEnumsRequired.UNIT + DESCRIPTION: Final = MyappTaskEnumsRequired.DESCRIPTION + KIND: Final = MyappTaskEnumsRequired.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + auth_level: attributes.AuthLevel, + auth_method: attributes.AuthMethod, + task_preemptible: attributes.MyappTaskPreemptible, + task_sample_rate: attributes.MyappTaskSampleRate, + task_state: attributes.MyappTaskState, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.AUTH_LEVEL_KEY: auth_level.value, + attributes.AUTH_METHOD_KEY: auth_method.value, + attributes.MYAPP_TASK_PREEMPTIBLE_KEY: task_preemptible.value, + attributes.MYAPP_TASK_SAMPLE_RATE_KEY: task_sample_rate.value, + attributes.MYAPP_TASK_STATE_KEY: task_state.value, + } + return Observation(amount, _attributes) + + +class MyappTaskMixed: + """Required and optional attributes together.""" + + NAME: Final = "myapp.task.mixed" + UNIT: Final = "s" + DESCRIPTION: Final = "Required and optional attributes together." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + auth_method: attributes.AuthMethod, + task_id: str, + auth_level: attributes.AuthLevel | None = None, + task_retries: int | None = None, + task_tags: Sequence[str] | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.AUTH_METHOD_KEY: auth_method.value, + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if auth_level is not None: + _attributes[attributes.AUTH_LEVEL_KEY] = auth_level.value + if task_retries is not None: + _attributes[attributes.MYAPP_TASK_RETRIES_KEY] = task_retries + if task_tags is not None: + _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] + self._instrument.record(amount, _attributes) + + +class MyappTaskScalarsOptional: + """Every scalar attribute type, all optional.""" + + NAME: Final = "myapp.task.scalars.optional" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Every scalar attribute type, all optional." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_cancelled: bool | None = None, + task_id: str | None = None, + task_progress: float | None = None, + task_retries: int | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = {} + if task_cancelled is not None: + _attributes[attributes.MYAPP_TASK_CANCELLED_KEY] = task_cancelled + if task_id is not None: + _attributes[attributes.MYAPP_TASK_ID_KEY] = task_id + if task_progress is not None: + _attributes[attributes.MYAPP_TASK_PROGRESS_KEY] = task_progress + if task_retries is not None: + _attributes[attributes.MYAPP_TASK_RETRIES_KEY] = task_retries + self._instrument.add(amount, _attributes) + + +class MyappTaskScalarsOptionalObservable: + """Observable form of `MyappTaskScalarsOptional`, reported by callbacks.""" + + NAME: Final = MyappTaskScalarsOptional.NAME + UNIT: Final = MyappTaskScalarsOptional.UNIT + DESCRIPTION: Final = MyappTaskScalarsOptional.DESCRIPTION + KIND: Final = MyappTaskScalarsOptional.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_cancelled: bool | None = None, + task_id: str | None = None, + task_progress: float | None = None, + task_retries: int | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = {} + if task_cancelled is not None: + _attributes[attributes.MYAPP_TASK_CANCELLED_KEY] = task_cancelled + if task_id is not None: + _attributes[attributes.MYAPP_TASK_ID_KEY] = task_id + if task_progress is not None: + _attributes[attributes.MYAPP_TASK_PROGRESS_KEY] = task_progress + if task_retries is not None: + _attributes[attributes.MYAPP_TASK_RETRIES_KEY] = task_retries + return Observation(amount, _attributes) + + +class MyappTaskScalarsRequired: + """Every scalar attribute type, all required.""" + + NAME: Final = "myapp.task.scalars.required" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Every scalar attribute type, all required." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_cancelled: bool, + task_id: str, + task_progress: float, + task_retries: int, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_CANCELLED_KEY: task_cancelled, + attributes.MYAPP_TASK_ID_KEY: task_id, + attributes.MYAPP_TASK_PROGRESS_KEY: task_progress, + attributes.MYAPP_TASK_RETRIES_KEY: task_retries, + } + self._instrument.add(amount, _attributes) + + +class MyappTaskScalarsRequiredObservable: + """Observable form of `MyappTaskScalarsRequired`, reported by callbacks.""" + + NAME: Final = MyappTaskScalarsRequired.NAME + UNIT: Final = MyappTaskScalarsRequired.UNIT + DESCRIPTION: Final = MyappTaskScalarsRequired.DESCRIPTION + KIND: Final = MyappTaskScalarsRequired.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_cancelled: bool, + task_id: str, + task_progress: float, + task_retries: int, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_CANCELLED_KEY: task_cancelled, + attributes.MYAPP_TASK_ID_KEY: task_id, + attributes.MYAPP_TASK_PROGRESS_KEY: task_progress, + attributes.MYAPP_TASK_RETRIES_KEY: task_retries, + } + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/metric_attributes/registry/manifest.yaml b/templates/code/python/tests/metric_attributes/registry/manifest.yaml new file mode 100644 index 0000000..ca300dc --- /dev/null +++ b/templates/code/python/tests/metric_attributes/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: metric_attributes +schema_url: https://example.com/schemas/metric_attributes/1.0.0 diff --git a/templates/code/python/tests/metric_attributes/registry/model.yaml b/templates/code/python/tests/metric_attributes/registry/model.yaml new file mode 100644 index 0000000..426d561 --- /dev/null +++ b/templates/code/python/tests/metric_attributes/registry/model.yaml @@ -0,0 +1,249 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + note: The identifier is assigned by the scheduler and stays stable across retries. + examples: ["task_0001"] + - key: myapp.task.retries + type: int + stability: stable + brief: Number of times the task was retried. + examples: 3 + - key: myapp.task.progress + type: double + stability: development + brief: Fraction of the task that has completed, between 0 and 1. + examples: 0.5 + - key: myapp.task.cancelled + type: boolean + stability: stable + brief: Whether the task was cancelled. + examples: false + - key: myapp.task.tags + type: string[] + stability: stable + brief: Tags attached to the task. + examples: [["nightly", "batch"]] + - key: myapp.task.shard_ids + type: int[] + stability: stable + brief: Shards the task touched. + examples: [[1, 2]] + - key: myapp.task.weights + type: double[] + stability: development + brief: Scheduling weights applied to the task. + examples: [[0.25, 0.75]] + - key: myapp.task.flags + type: boolean[] + stability: development + brief: Feature flags evaluated for the task. + examples: [[true, false]] + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: running + value: running + stability: stable + brief: The task is running. + note: A task stays running until it finishes or is cancelled. + - id: paused + value: paused + stability: development + brief: The task was paused. + deprecated: + reason: obsoleted + note: Paused tasks are reported as queued. + stability: stable + brief: Current state of the task. + - key: auth.method + type: + members: + - id: password + value: password + stability: stable + brief: A password was checked. + - id: token + value: token + stability: stable + brief: A bearer token was checked. + stability: stable + brief: How the user was authenticated. + - key: auth.level + type: + members: + - id: low + value: 1 + stability: stable + brief: Single factor. + - id: high + value: 2 + stability: stable + brief: Multiple factors. + stability: development + brief: Assurance level of the authentication. + - key: myapp.task.preemptible + type: + members: + - id: allowed + value: true + stability: stable + brief: The scheduler may preempt the task. + - id: forbidden + value: false + stability: stable + brief: The task runs to completion. + stability: stable + brief: Whether the scheduler may preempt the task. + - key: myapp.task.sample_rate + type: + members: + - id: tenth + value: 0.1 + stability: stable + brief: One task in ten is sampled. + - id: all + value: 1 + stability: stable + brief: Every task is sampled. + stability: stable + brief: Fraction of tasks whose telemetry is sampled. +metrics: + - name: myapp.task.scalars.required + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Every scalar attribute type, all required. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: myapp.task.retries + requirement_level: required + - ref: myapp.task.progress + requirement_level: required + - ref: myapp.task.cancelled + requirement_level: required + - name: myapp.task.scalars.optional + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Every scalar attribute type, all optional. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: recommended + - ref: myapp.task.retries + requirement_level: recommended + - ref: myapp.task.progress + requirement_level: recommended + - ref: myapp.task.cancelled + requirement_level: recommended + - name: myapp.task.arrays.required + requirement_level: recommended + instrument: updowncounter + unit: "{task}" + stability: stable + brief: Every array attribute type, all required. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: myapp.task.tags + requirement_level: required + - ref: myapp.task.shard_ids + requirement_level: required + - ref: myapp.task.weights + requirement_level: required + - ref: myapp.task.flags + requirement_level: required + - name: myapp.task.arrays.optional + requirement_level: recommended + instrument: gauge + unit: "{task}" + stability: stable + brief: Every array attribute type, all optional. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: myapp.task.tags + requirement_level: opt_in + - ref: myapp.task.shard_ids + requirement_level: opt_in + - ref: myapp.task.weights + requirement_level: opt_in + - ref: myapp.task.flags + requirement_level: opt_in + - name: myapp.task.enums.required + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Every enum attribute type, all required. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.state + requirement_level: required + - ref: auth.method + requirement_level: required + - ref: auth.level + requirement_level: required + - ref: myapp.task.preemptible + requirement_level: required + - ref: myapp.task.sample_rate + requirement_level: required + - name: myapp.task.enums.optional + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Every enum attribute type, all optional. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.state + requirement_level: recommended + - ref: auth.method + requirement_level: recommended + - ref: auth.level + requirement_level: recommended + - ref: myapp.task.preemptible + requirement_level: recommended + - ref: myapp.task.sample_rate + requirement_level: recommended + - name: myapp.task.mixed + requirement_level: recommended + instrument: histogram + unit: "s" + stability: stable + brief: Required and optional attributes together. + annotations: + code_generation: + metric_value_type: double + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: auth.method + requirement_level: required + - ref: myapp.task.retries + requirement_level: recommended + - ref: myapp.task.tags + requirement_level: recommended + - ref: auth.level + requirement_level: recommended diff --git a/templates/code/python/tests/metric_attributes/test_consumer.py b/templates/code/python/tests/metric_attributes/test_consumer.py new file mode 100644 index 0000000..ec05a6b --- /dev/null +++ b/templates/code/python/tests/metric_attributes/test_consumer.py @@ -0,0 +1,229 @@ +import pytest +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def test_every_scalar_type_is_recorded_as_given() -> None: + reader, provider = new_provider() + metrics.MyappTaskScalarsRequired(metrics.get_meter(provider)).add( + 1, + task_cancelled=False, + task_id="task-1", + task_progress=0.5, + task_retries=3, + ) + (point,) = number_points(collect(reader)["myapp.task.scalars.required"]) + recorded = attributes_of(point) + assert recorded == { + "myapp.task.cancelled": False, + "myapp.task.id": "task-1", + "myapp.task.progress": 0.5, + "myapp.task.retries": 3, + } + assert isinstance(recorded["myapp.task.cancelled"], bool) + assert isinstance(recorded["myapp.task.progress"], float) + + +def test_optional_scalars_are_omitted_when_none() -> None: + reader, provider = new_provider() + optional = metrics.MyappTaskScalarsOptional(metrics.get_meter(provider)) + optional.add(1) + optional.add(1, task_cancelled=True, task_retries=0) + optional.add(1, task_id="task-1") + points = number_points(collect(reader)["myapp.task.scalars.optional"]) + assert sorted(sorted(attributes_of(point).items()) for point in points) == [ + [], + [("myapp.task.cancelled", True), ("myapp.task.retries", 0)], + [("myapp.task.id", "task-1")], + ] + + +def test_falsy_optional_values_are_not_omitted() -> None: + reader, provider = new_provider() + metrics.MyappTaskScalarsOptional(metrics.get_meter(provider)).add( + 1, + task_cancelled=False, + task_id="", + task_progress=0.0, + task_retries=0, + ) + (point,) = number_points(collect(reader)["myapp.task.scalars.optional"]) + assert attributes_of(point) == { + "myapp.task.cancelled": False, + "myapp.task.id": "", + "myapp.task.progress": 0.0, + "myapp.task.retries": 0, + } + + +def test_array_attributes_are_copied_into_sequences() -> None: + reader, provider = new_provider() + tags = ["nightly", "batch"] + metrics.MyappTaskArraysRequired(metrics.get_meter(provider)).add( + 2, + task_flags=[True, False], + task_shard_ids=(1, 2), + task_tags=tags, + task_weights=[0.25, 0.75], + ) + tags.append("late") + (point,) = number_points(collect(reader)["myapp.task.arrays.required"]) + assert attributes_of(point) == { + "myapp.task.flags": (True, False), + "myapp.task.shard_ids": (1, 2), + "myapp.task.tags": ("nightly", "batch"), + "myapp.task.weights": (0.25, 0.75), + } + + +def test_optional_arrays_are_omitted_when_none() -> None: + reader, provider = new_provider() + gauge = metrics.MyappTaskArraysOptional(metrics.get_meter(provider)) + gauge.set(1.5) + gauge.set(2.5, task_tags=[], task_shard_ids=[7]) + points = number_points(collect(reader)["myapp.task.arrays.optional"]) + assert sorted( + (point.value, sorted(attributes_of(point).items())) for point in points + ) == [ + (1.5, []), + (2.5, [("myapp.task.shard_ids", (7,)), ("myapp.task.tags", ())]), + ] + + +def test_every_enum_type_is_recorded_by_value() -> None: + reader, provider = new_provider() + metrics.MyappTaskEnumsRequired(metrics.get_meter(provider)).add( + 1, + auth_level=attributes.AuthLevel.HIGH, + auth_method=attributes.AuthMethod.TOKEN, + task_preemptible=attributes.MyappTaskPreemptible.FORBIDDEN, + task_sample_rate=attributes.MyappTaskSampleRate.TENTH, + task_state=attributes.MyappTaskState.RUNNING, + ) + (point,) = number_points(collect(reader)["myapp.task.enums.required"]) + recorded = attributes_of(point) + assert recorded == { + "auth.level": 2, + "auth.method": "token", + "myapp.task.preemptible": False, + "myapp.task.sample_rate": 0.1, + "myapp.task.state": "running", + } + assert type(recorded["auth.method"]) is str + assert type(recorded["auth.level"]) is int + assert type(recorded["myapp.task.preemptible"]) is bool + assert type(recorded["myapp.task.sample_rate"]) is float + + +def test_optional_enums_are_omitted_when_none() -> None: + reader, provider = new_provider() + optional = metrics.MyappTaskEnumsOptional(metrics.get_meter(provider)) + optional.add(1) + optional.add(1, task_state=attributes.MyappTaskState.QUEUED) + points = number_points(collect(reader)["myapp.task.enums.optional"]) + assert sorted(sorted(attributes_of(point).items()) for point in points) == [ + [], + [("myapp.task.state", "queued")], + ] + + +def test_required_and_optional_attributes_mix() -> None: + reader, provider = new_provider() + metrics.MyappTaskMixed(metrics.get_meter(provider)).record( + 0.75, + auth_method=attributes.AuthMethod.PASSWORD, + task_id="task-1", + task_tags=["a"], + ) + (point,) = histogram_points(collect(reader)["myapp.task.mixed"]) + assert attributes_of(point) == { + "auth.method": "password", + "myapp.task.id": "task-1", + "myapp.task.tags": ("a",), + } + assert point.sum == 0.75 + + +def test_observations_carry_the_same_attributes() -> None: + observation = metrics.MyappTaskEnumsRequiredObservable.observation( + 5, + auth_level=attributes.AuthLevel.LOW, + auth_method=attributes.AuthMethod.PASSWORD, + task_preemptible=attributes.MyappTaskPreemptible.ALLOWED, + task_sample_rate=attributes.MyappTaskSampleRate.ALL, + task_state=attributes.MyappTaskState.QUEUED, + ) + assert observation.value == 5 + assert observation.attributes == { + "auth.level": 1, + "auth.method": "password", + "myapp.task.preemptible": True, + "myapp.task.sample_rate": 1.0, + "myapp.task.state": "queued", + } + sparse = metrics.MyappTaskScalarsOptionalObservable.observation(1, task_id="x") + assert sparse.attributes == {"myapp.task.id": "x"} + + +def test_the_type_checker_rejects_misuse() -> None: + counter = metrics.MyappTaskScalarsRequired(metrics.get_meter(MeterProvider())) + with pytest.raises(TypeError): + counter.add(1, task_id="x") # type: ignore[call-arg] + with pytest.raises(TypeError): + counter.add( # type: ignore[call-arg] + 1, + task_cancelled=False, + task_id="x", + task_progress=0.0, + task_retries=0, + task_unknown=1, + ) diff --git a/templates/code/python/tests/metrics/expected/__init__.py b/templates/code/python/tests/metrics/expected/__init__.py new file mode 100644 index 0000000..b71fffa --- /dev/null +++ b/templates/code/python/tests/metrics/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/metrics/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/metrics/expected/attributes.py b/templates/code/python/tests/metrics/expected/attributes.py new file mode 100644 index 0000000..5acaeef --- /dev/null +++ b/templates/code/python/tests/metrics/expected/attributes.py @@ -0,0 +1,38 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class AuthMethod(enum.StrEnum): + """Values of the `auth.method` attribute.""" + + PASSWORD = "password" + """A password was checked.""" + TOKEN = "token" + """A bearer token was checked.""" + + +AUTH_METHOD_KEY: Final = "auth.method" +"""How the user was authenticated.""" + + +AUTH_SUCCESS_KEY: Final = "auth.success" +"""Whether the attempt succeeded.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + DONE = "done" + """The task finished.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" diff --git a/templates/code/python/tests/metrics/expected/metrics.py b/templates/code/python/tests/metrics/expected/metrics.py new file mode 100644 index 0000000..1b3fad1 --- /dev/null +++ b/templates/code/python/tests/metrics/expected/metrics.py @@ -0,0 +1,624 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class AuthAttempts: + """Number of authentication attempts.""" + + NAME: Final = "auth.attempts" + UNIT: Final = "{attempt}" + DESCRIPTION: Final = "Number of authentication attempts." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + method: attributes.AuthMethod, + success: bool, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.AUTH_METHOD_KEY: method.value, + attributes.AUTH_SUCCESS_KEY: success, + } + self._instrument.add(amount, _attributes) + + +class AuthAttemptsObservable: + """Observable form of `AuthAttempts`, reported by callbacks.""" + + NAME: Final = AuthAttempts.NAME + UNIT: Final = AuthAttempts.UNIT + DESCRIPTION: Final = AuthAttempts.DESCRIPTION + KIND: Final = AuthAttempts.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + method: attributes.AuthMethod, + success: bool, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.AUTH_METHOD_KEY: method.value, + attributes.AUTH_SUCCESS_KEY: success, + } + return Observation(amount, _attributes) + + +class MyappQueueDepth: + """Number of tasks waiting in the queue. + + Deprecated: Replaced by `myapp.task.active`.""" + + NAME: Final = "myapp.queue.depth" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks waiting in the queue." + KIND: Final = InstrumentKind.GAUGE + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_gauge( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def set(self, amount: int, /) -> None: + self._instrument.set(amount) + + +class MyappQueueDepthObservable: + """Observable form of `MyappQueueDepth`, reported by callbacks.""" + + NAME: Final = MyappQueueDepth.NAME + UNIT: Final = MyappQueueDepth.UNIT + DESCRIPTION: Final = MyappQueueDepth.DESCRIPTION + KIND: Final = MyappQueueDepth.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_gauge( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: int, /) -> Observation: + return Observation(amount) + + +class MyappTaskActive: + """Number of tasks currently running.""" + + NAME: Final = "myapp.task.active" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks currently running." + KIND: Final = InstrumentKind.UP_DOWN_COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_up_down_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_state: attributes.MyappTaskState, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_STATE_KEY: task_state.value, + } + self._instrument.add(amount, _attributes) + + +class MyappTaskActiveObservable: + """Observable form of `MyappTaskActive`, reported by callbacks.""" + + NAME: Final = MyappTaskActive.NAME + UNIT: Final = MyappTaskActive.UNIT + DESCRIPTION: Final = MyappTaskActive.DESCRIPTION + KIND: Final = MyappTaskActive.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_up_down_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_state: attributes.MyappTaskState, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_STATE_KEY: task_state.value, + } + return Observation(amount, _attributes) + + +class MyappTaskDuration: + """Time a task took from start to finish. + + Measured by the worker that ran the task.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.0, + 0.005, + 0.01, + 0.1, + 1.0, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + auth_method: attributes.AuthMethod | None = None, + task_state: attributes.MyappTaskState | None = None, + ) -> None: + """Record a measurement. + + Args: + auth_method: Opt-in: the convention records it only when a user asks for it. + task_state: Conditionally required: If the task reached a final state. + """ + + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + if auth_method is not None: + _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value + if task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value + self._instrument.record(amount, _attributes) + + +class MyappTaskFloatActive: + """Fractional active task adjustment.""" + + NAME: Final = "myapp.task.float.active" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Fractional active task adjustment." + KIND: Final = InstrumentKind.UP_DOWN_COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_up_down_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: float, /) -> None: + self._instrument.add(amount) + + +class MyappTaskFloatActiveObservable: + """Observable form of `MyappTaskFloatActive`, reported by callbacks.""" + + NAME: Final = MyappTaskFloatActive.NAME + UNIT: Final = MyappTaskFloatActive.UNIT + DESCRIPTION: Final = MyappTaskFloatActive.DESCRIPTION + KIND: Final = MyappTaskFloatActive.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_up_down_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: float, /) -> Observation: + return Observation(amount) + + +class MyappTaskFloatDepth: + """Fractional queue depth.""" + + NAME: Final = "myapp.task.float.depth" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Fractional queue depth." + KIND: Final = InstrumentKind.GAUGE + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_gauge( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def set(self, amount: float, /) -> None: + self._instrument.set(amount) + + +class MyappTaskFloatDepthObservable: + """Observable form of `MyappTaskFloatDepth`, reported by callbacks.""" + + NAME: Final = MyappTaskFloatDepth.NAME + UNIT: Final = MyappTaskFloatDepth.UNIT + DESCRIPTION: Final = MyappTaskFloatDepth.DESCRIPTION + KIND: Final = MyappTaskFloatDepth.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_gauge( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: float, /) -> Observation: + return Observation(amount) + + +class MyappTaskFloatStarted: + """Fractional tasks started.""" + + NAME: Final = "myapp.task.float.started" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Fractional tasks started." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: float, /) -> None: + self._instrument.add(amount) + + +class MyappTaskFloatStartedObservable: + """Observable form of `MyappTaskFloatStarted`, reported by callbacks.""" + + NAME: Final = MyappTaskFloatStarted.NAME + UNIT: Final = MyappTaskFloatStarted.UNIT + DESCRIPTION: Final = MyappTaskFloatStarted.DESCRIPTION + KIND: Final = MyappTaskFloatStarted.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: float, /) -> Observation: + return Observation(amount) + + +class MyappTaskPayload: + """Task payload size.""" + + NAME: Final = "myapp.task.payload" + UNIT: Final = "By" + DESCRIPTION: Final = "Task payload size." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 128.0, + 256.0, + 1024.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record(self, amount: int, /) -> None: + self._instrument.record(amount) + + +class MyappTaskRetryDuration: + """Time a retried task took from start to finish. + + Recorded only for tasks that ran more than once.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.0, + 0.005, + 0.01, + 0.1, + 1.0, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + task_id: str, + task_state: attributes.MyappTaskState, + auth_method: attributes.AuthMethod | None = None, + ) -> None: + """Record a measurement. + + Args: + auth_method: Opt-in: the convention records it only when a user asks for it. + """ + + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + attributes.MYAPP_TASK_STATE_KEY: task_state.value, + } + if auth_method is not None: + _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value + self._instrument.record(amount, _attributes) + + +class MyappTaskStarted: + """Number of tasks started. + + Opt-in: the convention records it only when a user asks for it.""" + + NAME: Final = "myapp.task.started" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks started." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_id: str | None = None, + ) -> None: + """Add to the counter. + + Args: + task_id: Recommended: When the scheduler assigned an id before the task started. + """ + + _attributes: dict[str, AttributeValue] = {} + if task_id is not None: + _attributes[attributes.MYAPP_TASK_ID_KEY] = task_id + self._instrument.add(amount, _attributes) + + +class MyappTaskStartedObservable: + """Observable form of `MyappTaskStarted`, reported by callbacks.""" + + NAME: Final = MyappTaskStarted.NAME + UNIT: Final = MyappTaskStarted.UNIT + DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION + KIND: Final = MyappTaskStarted.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_id: str | None = None, + ) -> Observation: + """Build an observation. + + Args: + task_id: Recommended: When the scheduler assigned an id before the task started. + """ + + _attributes: dict[str, AttributeValue] = {} + if task_id is not None: + _attributes[attributes.MYAPP_TASK_ID_KEY] = task_id + return Observation(amount, _attributes) + + +class WorkerTaskDuration: + """Time a task took on the worker that ran it. + + Measured by the worker that ran the task.""" + + NAME: Final = "myapp.task.duration" + UNIT: Final = "s" + DESCRIPTION: Final = "Time a task took from start to finish." + KIND: Final = InstrumentKind.HISTOGRAM + BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( + 0.0, + 0.005, + 0.01, + 0.1, + 1.0, + 10.0, + ) + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_histogram( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + ) + + def record( + self, + amount: float, + /, + *, + myapp_task_id: str, + auth_method: attributes.AuthMethod | None = None, + myapp_task_state: attributes.MyappTaskState | None = None, + ) -> None: + """Record a measurement. + + Args: + auth_method: Opt-in: the convention records it only when a user asks for it. + myapp_task_state: Conditionally required: If the task reached a final state. + """ + + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: myapp_task_id, + } + if auth_method is not None: + _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value + if myapp_task_state is not None: + _attributes[attributes.MYAPP_TASK_STATE_KEY] = myapp_task_state.value + self._instrument.record(amount, _attributes) diff --git a/templates/code/python/tests/metrics/registry/manifest.yaml b/templates/code/python/tests/metrics/registry/manifest.yaml new file mode 100644 index 0000000..6a5676e --- /dev/null +++ b/templates/code/python/tests/metrics/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: metrics +schema_url: https://example.com/schemas/metrics/1.0.0 diff --git a/templates/code/python/tests/metrics/registry/model.yaml b/templates/code/python/tests/metrics/registry/model.yaml new file mode 100644 index 0000000..12a099b --- /dev/null +++ b/templates/code/python/tests/metrics/registry/model.yaml @@ -0,0 +1,170 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + examples: ["task_0001"] + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: done + value: done + stability: stable + brief: The task finished. + stability: stable + brief: Current state of the task. + - key: auth.method + type: + members: + - id: password + value: password + stability: stable + brief: A password was checked. + - id: token + value: token + stability: stable + brief: A bearer token was checked. + stability: stable + brief: How the user was authenticated. + - key: auth.success + type: boolean + stability: stable + brief: Whether the attempt succeeded. + examples: true +metrics: + - name: myapp.task.duration + requirement_level: recommended + instrument: histogram + unit: s + stability: stable + brief: Time a task took from start to finish. + note: Measured by the worker that ran the task. + annotations: + code_generation: + metric_value_type: double + aggregation: + method: explicithistogram + parameters: + boundaries: [0, 0.005, 0.01, 0.1, 1, 10] + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: myapp.task.state + requirement_level: + conditionally_required: If the task reached a final state. + - ref: auth.method + requirement_level: opt_in + - name: myapp.task.started + requirement_level: opt_in + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: + recommended: When the scheduler assigned an id before the task started. + - name: myapp.task.active + requirement_level: recommended + instrument: updowncounter + unit: "{task}" + stability: development + brief: Number of tasks currently running. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.state + requirement_level: required + - name: myapp.queue.depth + requirement_level: recommended + instrument: gauge + unit: "{task}" + stability: development + brief: Number of tasks waiting in the queue. + annotations: + code_generation: + metric_value_type: int + deprecated: + reason: renamed + renamed_to: myapp.task.active + - name: auth.attempts + requirement_level: recommended + instrument: counter + unit: "{attempt}" + stability: stable + brief: Number of authentication attempts. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: auth.method + requirement_level: required + - ref: auth.success + requirement_level: required + - name: myapp.task.float.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Fractional tasks started. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.float.active + requirement_level: recommended + instrument: updowncounter + unit: "{task}" + stability: stable + brief: Fractional active task adjustment. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.float.depth + requirement_level: recommended + instrument: gauge + unit: "{task}" + stability: stable + brief: Fractional queue depth. + annotations: + code_generation: + metric_value_type: double + - name: myapp.task.payload + requirement_level: recommended + instrument: histogram + unit: By + stability: stable + brief: Task payload size. + annotations: + code_generation: + metric_value_type: int + aggregation: + method: explicithistogram + parameters: + boundaries: [128, 256, 1024] +metric_refinements: + - id: myapp.task.retry.duration + ref: myapp.task.duration + stability: stable + brief: Time a retried task took from start to finish. + note: Recorded only for tasks that ran more than once. + attributes: + - ref: myapp.task.id + requirement_level: required + - ref: myapp.task.state + requirement_level: required + - id: worker.task.duration + ref: myapp.task.duration + stability: stable + brief: Time a task took on the worker that ran it. + attributes: + - ref: myapp.task.id + requirement_level: required diff --git a/templates/code/python/tests/metrics/test_consumer.py b/templates/code/python/tests/metrics/test_consumer.py new file mode 100644 index 0000000..f767a07 --- /dev/null +++ b/templates/code/python/tests/metrics/test_consumer.py @@ -0,0 +1,252 @@ +from collections.abc import Iterable + +import pytest +from opentelemetry.metrics import CallbackOptions, Observation +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def test_scope_carries_the_schema_url() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + assert meter.schema_url == SCHEMA_URL + metrics.MyappTaskStarted(meter).add(1, task_id="a") + collect(reader) + + +def test_constants_match_the_registry() -> None: + assert SCHEMA_URL == "https://example.com/schemas/metrics/1.0.0" + assert metrics.MyappTaskDuration.NAME == "myapp.task.duration" + assert metrics.MyappTaskDuration.UNIT == "s" + assert metrics.MyappTaskDuration.DESCRIPTION == ( + "Time a task took from start to finish." + ) + assert metrics.MyappTaskDuration.KIND is metrics.InstrumentKind.HISTOGRAM + assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == ( + 0.0, + 0.005, + 0.01, + 0.1, + 1.0, + 10.0, + ) + assert metrics.MyappTaskStarted.KIND is metrics.InstrumentKind.COUNTER + assert metrics.MyappTaskActive.KIND is metrics.InstrumentKind.UP_DOWN_COUNTER + assert metrics.MyappQueueDepth.KIND is metrics.InstrumentKind.GAUGE + assert metrics.MyappTaskStarted.UNIT == "{task}" + assert metrics.InstrumentKind.UP_DOWN_COUNTER.value == "updowncounter" + assert attributes.MYAPP_TASK_ID_KEY == "myapp.task.id" + assert attributes.MYAPP_TASK_STATE_KEY == "myapp.task.state" + assert attributes.AUTH_SUCCESS_KEY == "auth.success" + assert attributes.MyappTaskState.QUEUED.value == "queued" + assert attributes.AuthMethod.TOKEN.value == "token" + + +def test_histogram_records_required_and_optional_attributes() -> None: + reader, provider = new_provider() + duration = metrics.MyappTaskDuration(metrics.get_meter(provider)) + duration.record(0.25, task_id="task-1") + duration.record( + 2.5, + task_id="task-2", + task_state=attributes.MyappTaskState.DONE, + auth_method=attributes.AuthMethod.TOKEN, + ) + metric = collect(reader)["myapp.task.duration"] + assert metric.unit == "s" + assert metric.description == "Time a task took from start to finish." + points = sorted(histogram_points(metric), key=lambda point: point.sum) + assert [attributes_of(point) for point in points] == [ + {"myapp.task.id": "task-1"}, + { + "myapp.task.id": "task-2", + "myapp.task.state": "done", + "auth.method": "token", + }, + ] + assert [point.sum for point in points] == [0.25, 2.5] + + +def test_histogram_reports_the_registry_boundaries() -> None: + reader, provider = new_provider() + metrics.MyappTaskDuration(metrics.get_meter(provider)).record(0.5, task_id="a") + (point,) = histogram_points(collect(reader)["myapp.task.duration"]) + assert point.explicit_bounds == (0.0, 0.005, 0.01, 0.1, 1.0, 10.0) + assert point.bucket_counts == (0, 0, 0, 0, 1, 0, 0) + + +def test_integer_histogram_reports_its_own_boundaries() -> None: + reader, provider = new_provider() + metrics.MyappTaskPayload(metrics.get_meter(provider)).record(200) + (point,) = histogram_points(collect(reader)["myapp.task.payload"]) + assert point.explicit_bounds == (128.0, 256.0, 1024.0) + assert metrics.MyappTaskPayload.UNIT == "By" + + +def test_counter_adds_attributes() -> None: + reader, provider = new_provider() + started = metrics.MyappTaskStarted(metrics.get_meter(provider)) + started.add(1) + started.add(2, task_id="task-1") + started.add(3, task_id="task-1") + points = number_points(collect(reader)["myapp.task.started"]) + totals = { + tuple(sorted(attributes_of(point).items())): point.value for point in points + } + assert totals == {(): 1, (("myapp.task.id", "task-1"),): 5} + + +def test_up_down_counter_takes_negative_amounts() -> None: + reader, provider = new_provider() + active = metrics.MyappTaskActive(metrics.get_meter(provider)) + active.add(3, task_state=attributes.MyappTaskState.QUEUED) + active.add(-1, task_state=attributes.MyappTaskState.QUEUED) + (point,) = number_points(collect(reader)["myapp.task.active"]) + assert point.value == 2 + assert attributes_of(point) == {"myapp.task.state": "queued"} + + +def test_gauge_keeps_the_last_value() -> None: + reader, provider = new_provider() + depth = metrics.MyappTaskFloatDepth(metrics.get_meter(provider)) + depth.set(1.5) + depth.set(2.5) + (point,) = number_points(collect(reader)["myapp.task.float.depth"]) + assert point.value == 2.5 + + +def test_float_instruments_take_fractions() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskFloatStarted(meter).add(0.5) + metrics.MyappTaskFloatActive(meter).add(-0.25) + collected = collect(reader) + assert number_points(collected["myapp.task.float.started"])[0].value == 0.5 + assert number_points(collected["myapp.task.float.active"])[0].value == -0.25 + + +def test_boolean_and_enum_attributes() -> None: + reader, provider = new_provider() + attempts = metrics.AuthAttempts(metrics.get_meter(provider)) + attempts.add(1, method=attributes.AuthMethod.PASSWORD, success=True) + attempts.add(1, method=attributes.AuthMethod.TOKEN, success=False) + points = number_points(collect(reader)["auth.attempts"]) + assert sorted( + ( + str(attributes_of(point)["auth.method"]), + bool(attributes_of(point)["auth.success"]), + ) + for point in points + ) == [("password", True), ("token", False)] + + +def test_refinements_record_into_the_base_instrument() -> None: + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskDuration(meter).record(1.0, task_id="base") + metrics.MyappTaskRetryDuration(meter).record( + 2.0, task_id="retry", task_state=attributes.MyappTaskState.DONE + ) + metrics.WorkerTaskDuration(meter).record(3.0, myapp_task_id="worker") + assert metrics.MyappTaskRetryDuration.NAME == "myapp.task.duration" + assert metrics.WorkerTaskDuration.NAME == "myapp.task.duration" + assert ( + metrics.WorkerTaskDuration.BUCKET_BOUNDARIES + == metrics.MyappTaskDuration.BUCKET_BOUNDARIES + ) + metric = collect(reader)["myapp.task.duration"] + ids = sorted( + str(attributes_of(point)["myapp.task.id"]) for point in histogram_points(metric) + ) + assert ids == ["base", "retry", "worker"] + + +def test_observable_instruments_report_through_callbacks() -> None: + def started(_: CallbackOptions) -> Iterable[Observation]: + yield metrics.MyappTaskStartedObservable.observation(7, task_id="task-1") + + def depth(_: CallbackOptions) -> Iterable[Observation]: + yield metrics.MyappQueueDepthObservable.observation(4) + + def active(_: CallbackOptions) -> Iterable[Observation]: + yield metrics.MyappTaskActiveObservable.observation( + 2, task_state=attributes.MyappTaskState.QUEUED + ) + + def float_depth(_: CallbackOptions) -> Iterable[Observation]: + yield metrics.MyappTaskFloatDepthObservable.observation(1.5) + + reader, provider = new_provider() + meter = metrics.get_meter(provider) + metrics.MyappTaskStartedObservable([started], meter) + metrics.MyappQueueDepthObservable([depth], meter) + metrics.MyappTaskActiveObservable([active], meter) + metrics.MyappTaskFloatDepthObservable([float_depth], meter) + collected = collect(reader) + (counter_point,) = number_points(collected["myapp.task.started"]) + assert counter_point.value == 7 + assert attributes_of(counter_point) == {"myapp.task.id": "task-1"} + assert number_points(collected["myapp.queue.depth"])[0].value == 4 + assert number_points(collected["myapp.task.active"])[0].value == 2 + assert number_points(collected["myapp.task.float.depth"])[0].value == 1.5 + assert metrics.MyappTaskStartedObservable.NAME == metrics.MyappTaskStarted.NAME + assert metrics.MyappTaskStartedObservable.UNIT == metrics.MyappTaskStarted.UNIT + + +def test_required_attributes_cannot_be_omitted() -> None: + started = metrics.MyappTaskActive(metrics.get_meter(MeterProvider())) + with pytest.raises(TypeError): + started.add(1) # type: ignore[call-arg] + with pytest.raises(TypeError): + started.add(1, "queued") # type: ignore[arg-type, call-arg] + with pytest.raises(AttributeError): + started.add(1, task_state="queued") # type: ignore[arg-type] diff --git a/templates/code/python/tests/naming/expected/__init__.py b/templates/code/python/tests/naming/expected/__init__.py new file mode 100644 index 0000000..852d8f2 --- /dev/null +++ b/templates/code/python/tests/naming/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/naming/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/naming/expected/attributes.py b/templates/code/python/tests/naming/expected/attributes.py new file mode 100644 index 0000000..504d200 --- /dev/null +++ b/templates/code/python/tests/naming/expected/attributes.py @@ -0,0 +1,34 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + +HOST_KEY: Final = "host" +"""Host name.""" + + +HTTP_REQUEST_METHOD_ORIGINAL_KEY: Final = "http.request.method_original" +"""Original method.""" + + +HTTP_RESPONSE_STATUS_CODE_KEY: Final = "http.response.status_code" +"""Status code.""" + + +class HttpRoute(enum.StrEnum): + """Values of the `http.route` attribute.""" + + ROOT = "/" + """The root route.""" + TWO_XX = "2xx" + """Starts with a digit.""" + CLASS = "class" + """A Python keyword in lower case.""" + + +HTTP_ROUTE_KEY: Final = "http.route" +"""Matched route.""" + + +K8S_POD_UID_KEY: Final = "k8s.pod.uid" +"""Pod identifier.""" diff --git a/templates/code/python/tests/naming/expected/metrics.py b/templates/code/python/tests/naming/expected/metrics.py new file mode 100644 index 0000000..90c9a47 --- /dev/null +++ b/templates/code/python/tests/naming/expected/metrics.py @@ -0,0 +1,224 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class HttpServerRequestCount: + """Requests with underscores in the name.""" + + NAME: Final = "http.server.request_count" + UNIT: Final = "{request}" + DESCRIPTION: Final = "Requests with underscores in the name." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + request_method_original: str, + response_status_code: int, + k8s_pod_uid: str | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.HTTP_REQUEST_METHOD_ORIGINAL_KEY: request_method_original, + attributes.HTTP_RESPONSE_STATUS_CODE_KEY: response_status_code, + } + if k8s_pod_uid is not None: + _attributes[attributes.K8S_POD_UID_KEY] = k8s_pod_uid + self._instrument.add(amount, _attributes) + + +class HttpServerRequestCountObservable: + """Observable form of `HttpServerRequestCount`, reported by callbacks.""" + + NAME: Final = HttpServerRequestCount.NAME + UNIT: Final = HttpServerRequestCount.UNIT + DESCRIPTION: Final = HttpServerRequestCount.DESCRIPTION + KIND: Final = HttpServerRequestCount.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + request_method_original: str, + response_status_code: int, + k8s_pod_uid: str | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.HTTP_REQUEST_METHOD_ORIGINAL_KEY: request_method_original, + attributes.HTTP_RESPONSE_STATUS_CODE_KEY: response_status_code, + } + if k8s_pod_uid is not None: + _attributes[attributes.K8S_POD_UID_KEY] = k8s_pod_uid + return Observation(amount, _attributes) + + +class K8sPodCpuTime: + """Metric name with a digit.""" + + NAME: Final = "k8s.pod.cpu.time" + UNIT: Final = "s" + DESCRIPTION: Final = "Metric name with a digit." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add(self, amount: float, /) -> None: + self._instrument.add(amount) + + +class K8sPodCpuTimeObservable: + """Observable form of `K8sPodCpuTime`, reported by callbacks.""" + + NAME: Final = K8sPodCpuTime.NAME + UNIT: Final = K8sPodCpuTime.UNIT + DESCRIPTION: Final = K8sPodCpuTime.DESCRIPTION + KIND: Final = K8sPodCpuTime.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation(amount: float, /) -> Observation: + return Observation(amount) + + +class Requests: + """A metric without a namespace.""" + + NAME: Final = "requests" + UNIT: Final = "{request}" + DESCRIPTION: Final = "A metric without a namespace." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + host: str, + http_route: attributes.HttpRoute | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.HOST_KEY: host, + } + if http_route is not None: + _attributes[attributes.HTTP_ROUTE_KEY] = http_route.value + self._instrument.add(amount, _attributes) + + +class RequestsObservable: + """Observable form of `Requests`, reported by callbacks.""" + + NAME: Final = Requests.NAME + UNIT: Final = Requests.UNIT + DESCRIPTION: Final = Requests.DESCRIPTION + KIND: Final = Requests.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + host: str, + http_route: attributes.HttpRoute | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.HOST_KEY: host, + } + if http_route is not None: + _attributes[attributes.HTTP_ROUTE_KEY] = http_route.value + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/naming/registry/manifest.yaml b/templates/code/python/tests/naming/registry/manifest.yaml new file mode 100644 index 0000000..b9a8901 --- /dev/null +++ b/templates/code/python/tests/naming/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: naming +schema_url: https://example.com/schemas/naming/1.0.0 diff --git a/templates/code/python/tests/naming/registry/model.yaml b/templates/code/python/tests/naming/registry/model.yaml new file mode 100644 index 0000000..4710403 --- /dev/null +++ b/templates/code/python/tests/naming/registry/model.yaml @@ -0,0 +1,75 @@ +file_format: definition/2 +attributes: + - key: host + type: string + stability: stable + brief: Host name. + - key: k8s.pod.uid + type: string + stability: stable + brief: Pod identifier. + - key: http.request.method_original + type: string + stability: stable + brief: Original method. + - key: http.response.status_code + type: int + stability: stable + brief: Status code. + - key: http.route + type: + members: + - id: root + value: / + stability: stable + brief: The root route. + - id: two_xx + value: 2xx + stability: stable + brief: Starts with a digit. + - id: class + value: class + stability: stable + brief: A Python keyword in lower case. + stability: stable + brief: Matched route. +metrics: + - name: requests + requirement_level: recommended + instrument: counter + unit: "{request}" + stability: stable + brief: A metric without a namespace. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: host + requirement_level: required + - ref: http.route + requirement_level: recommended + - name: http.server.request_count + requirement_level: recommended + instrument: counter + unit: "{request}" + stability: stable + brief: Requests with underscores in the name. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: http.request.method_original + requirement_level: required + - ref: http.response.status_code + requirement_level: required + - ref: k8s.pod.uid + requirement_level: recommended + - name: k8s.pod.cpu.time + requirement_level: recommended + instrument: counter + unit: s + stability: stable + brief: Metric name with a digit. + annotations: + code_generation: + metric_value_type: double diff --git a/templates/code/python/tests/naming/test_consumer.py b/templates/code/python/tests/naming/test_consumer.py new file mode 100644 index 0000000..df06c7f --- /dev/null +++ b/templates/code/python/tests/naming/test_consumer.py @@ -0,0 +1,107 @@ +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def test_keys_and_class_names_follow_the_registry_names() -> None: + assert attributes.HOST_KEY == "host" + assert attributes.K8S_POD_UID_KEY == "k8s.pod.uid" + assert attributes.HTTP_REQUEST_METHOD_ORIGINAL_KEY == "http.request.method_original" + assert metrics.Requests.NAME == "requests" + assert metrics.HttpServerRequestCount.NAME == "http.server.request_count" + assert metrics.K8sPodCpuTime.NAME == "k8s.pod.cpu.time" + + +def test_enum_members_survive_awkward_values() -> None: + members = {member.name: member.value for member in attributes.HttpRoute} + assert members == {"ROOT": "/", "TWO_XX": "2xx", "CLASS": "class"} + + +def test_a_metric_without_a_namespace_keeps_every_segment() -> None: + reader, provider = new_provider() + metrics.Requests(metrics.get_meter(provider)).add( + 1, host="a", http_route=attributes.HttpRoute.ROOT + ) + (point,) = number_points(collect(reader)["requests"]) + assert attributes_of(point) == {"host": "a", "http.route": "/"} + + +def test_a_leading_segment_is_dropped_from_parameter_names() -> None: + reader, provider = new_provider() + metrics.HttpServerRequestCount(metrics.get_meter(provider)).add( + 4, + request_method_original="get", + response_status_code=200, + k8s_pod_uid="pod-1", + ) + (point,) = number_points(collect(reader)["http.server.request_count"]) + assert point.value == 4 + assert attributes_of(point) == { + "http.request.method_original": "get", + "http.response.status_code": 200, + "k8s.pod.uid": "pod-1", + } + + +def test_digits_in_names_are_kept() -> None: + reader, provider = new_provider() + metrics.K8sPodCpuTime(metrics.get_meter(provider)).add(1.5) + (point,) = number_points(collect(reader)["k8s.pod.cpu.time"]) + assert point.value == 1.5 + + +def test_a_keyword_valued_member_is_recorded_by_value() -> None: + reader, provider = new_provider() + metrics.Requests(metrics.get_meter(provider)).add( + 1, host="a", http_route=attributes.HttpRoute.CLASS + ) + (point,) = number_points(collect(reader)["requests"]) + assert attributes_of(point)["http.route"] == "class" diff --git a/templates/code/python/tests/params/expected/__init__.py b/templates/code/python/tests/params/expected/__init__.py new file mode 100644 index 0000000..c46db6e --- /dev/null +++ b/templates/code/python/tests/params/expected/__init__.py @@ -0,0 +1,15 @@ +# Code generated by weaver from the synthetic billing registry. DO NOT EDIT. +# Run `mise run semconv:generate` after changing a convention. + +"""Typed instruments for the synthetic billing registry.""" + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/params/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "acme-billing" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "1.2.3" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/params/expected/attributes.py b/templates/code/python/tests/params/expected/attributes.py new file mode 100644 index 0000000..36f1cbc --- /dev/null +++ b/templates/code/python/tests/params/expected/attributes.py @@ -0,0 +1,26 @@ +# Code generated by weaver from the synthetic billing registry. DO NOT EDIT. +# Run `mise run semconv:generate` after changing a convention. + +import enum +from typing import Final + + +class BillingInvoiceCurrency(enum.StrEnum): + """Values of the `acme.billing.invoice.currency` attribute.""" + + USD = "USD" + """United States dollar.""" + EUR = "EUR" + """Euro.""" + + +BILLING_INVOICE_CURRENCY_KEY: Final = "acme.billing.invoice.currency" +"""Currency of the invoice.""" + + +BILLING_INVOICE_ID_KEY: Final = "acme.billing.invoice.id" +"""Identifier of the invoice.""" + + +BILLING_INVOICE_PAID_KEY: Final = "billing.invoice.paid" +"""Whether the invoice was paid.""" diff --git a/templates/code/python/tests/params/expected/metrics.py b/templates/code/python/tests/params/expected/metrics.py new file mode 100644 index 0000000..aac6ab2 --- /dev/null +++ b/templates/code/python/tests/params/expected/metrics.py @@ -0,0 +1,112 @@ +# Code generated by weaver from the synthetic billing registry. DO NOT EDIT. +# Run `mise run semconv:generate` after changing a convention. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class BillingInvoiceCreated: + """Number of invoices created.""" + + NAME: Final = "acme.billing.invoice.created" + UNIT: Final = "{invoice}" + DESCRIPTION: Final = "Number of invoices created." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + invoice_id: str, + invoice_currency: attributes.BillingInvoiceCurrency | None = None, + invoice_paid: bool | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.BILLING_INVOICE_ID_KEY: invoice_id, + } + if invoice_currency is not None: + _attributes[attributes.BILLING_INVOICE_CURRENCY_KEY] = ( + invoice_currency.value + ) + if invoice_paid is not None: + _attributes[attributes.BILLING_INVOICE_PAID_KEY] = invoice_paid + self._instrument.add(amount, _attributes) + + +class BillingInvoiceCreatedObservable: + """Observable form of `BillingInvoiceCreated`, reported by callbacks.""" + + NAME: Final = BillingInvoiceCreated.NAME + UNIT: Final = BillingInvoiceCreated.UNIT + DESCRIPTION: Final = BillingInvoiceCreated.DESCRIPTION + KIND: Final = BillingInvoiceCreated.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + invoice_id: str, + invoice_currency: attributes.BillingInvoiceCurrency | None = None, + invoice_paid: bool | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.BILLING_INVOICE_ID_KEY: invoice_id, + } + if invoice_currency is not None: + _attributes[attributes.BILLING_INVOICE_CURRENCY_KEY] = ( + invoice_currency.value + ) + if invoice_paid is not None: + _attributes[attributes.BILLING_INVOICE_PAID_KEY] = invoice_paid + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/params/params.yaml b/templates/code/python/tests/params/params.yaml new file mode 100644 index 0000000..a6a4ac0 --- /dev/null +++ b/templates/code/python/tests/params/params.yaml @@ -0,0 +1,7 @@ +params: + vendor_prefixes: [acme] + header_source: the synthetic billing registry + regenerate_command: mise run semconv:generate + module_description: Typed instruments for the synthetic billing registry. + instrumentation_name: acme-billing + instrumentation_version: 1.2.3 diff --git a/templates/code/python/tests/params/registry/manifest.yaml b/templates/code/python/tests/params/registry/manifest.yaml new file mode 100644 index 0000000..afaa82c --- /dev/null +++ b/templates/code/python/tests/params/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: params +schema_url: https://example.com/schemas/params/1.0.0 diff --git a/templates/code/python/tests/params/registry/model.yaml b/templates/code/python/tests/params/registry/model.yaml new file mode 100644 index 0000000..c58542d --- /dev/null +++ b/templates/code/python/tests/params/registry/model.yaml @@ -0,0 +1,40 @@ +file_format: definition/2 +attributes: + - key: acme.billing.invoice.id + type: string + stability: stable + brief: Identifier of the invoice. + - key: acme.billing.invoice.currency + type: + members: + - id: usd + value: USD + stability: stable + brief: United States dollar. + - id: eur + value: EUR + stability: stable + brief: Euro. + stability: stable + brief: Currency of the invoice. + - key: billing.invoice.paid + type: boolean + stability: stable + brief: Whether the invoice was paid. +metrics: + - name: acme.billing.invoice.created + requirement_level: recommended + instrument: counter + unit: "{invoice}" + stability: stable + brief: Number of invoices created. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: acme.billing.invoice.id + requirement_level: required + - ref: acme.billing.invoice.currency + requirement_level: recommended + - ref: billing.invoice.paid + requirement_level: recommended diff --git a/templates/code/python/tests/params/test_consumer.py b/templates/code/python/tests/params/test_consumer.py new file mode 100644 index 0000000..cbd2b42 --- /dev/null +++ b/templates/code/python/tests/params/test_consumer.py @@ -0,0 +1,125 @@ +import inspect + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +import semconv +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def test_instrumentation_identity_comes_from_the_params() -> None: + assert INSTRUMENTATION_NAME == "acme-billing" + assert INSTRUMENTATION_VERSION == "1.2.3" + assert SCHEMA_URL == "https://example.com/schemas/params/1.0.0" + + +def test_the_meter_carries_the_configured_identity() -> None: + reader, provider = new_provider() + metrics.BillingInvoiceCreated(metrics.get_meter(provider)).add( + 1, invoice_id="inv-1" + ) + data = reader.get_metrics_data() + assert data is not None + scope = data.resource_metrics[0].scope_metrics[0].scope + assert (scope.name, scope.version) == ("acme-billing", "1.2.3") + assert scope.schema_url == SCHEMA_URL + collect(reader) + + +def test_the_module_docstring_comes_from_the_params() -> None: + assert semconv.__doc__ == "Typed instruments for the synthetic billing registry." + + +def test_every_module_carries_the_configured_header() -> None: + header = ( + "# Code generated by weaver from the synthetic billing registry." + " DO NOT EDIT.\n" + "# Run `mise run semconv:generate` after changing a convention.\n" + ) + for module in (semconv, attributes, metrics): + source = inspect.getsource(module) + assert source.startswith(header) + + +def test_the_vendor_prefix_is_dropped_from_names() -> None: + assert attributes.BILLING_INVOICE_ID_KEY == "acme.billing.invoice.id" + assert attributes.BILLING_INVOICE_PAID_KEY == "billing.invoice.paid" + assert attributes.BillingInvoiceCurrency.EUR.value == "EUR" + assert metrics.BillingInvoiceCreated.NAME == "acme.billing.invoice.created" + assert not hasattr(attributes, "ACME_BILLING_INVOICE_ID_KEY") + assert not hasattr(metrics, "AcmeBillingInvoiceCreated") + + +def test_parameters_drop_the_vendor_prefix_and_the_namespace() -> None: + parameters = inspect.signature(metrics.BillingInvoiceCreated.add).parameters + assert list(parameters) == [ + "self", + "amount", + "invoice_id", + "invoice_currency", + "invoice_paid", + ] + + +def test_attributes_keep_their_registry_keys_when_recorded() -> None: + reader, provider = new_provider() + metrics.BillingInvoiceCreated(metrics.get_meter(provider)).add( + 3, + invoice_id="inv-1", + invoice_currency=attributes.BillingInvoiceCurrency.USD, + invoice_paid=True, + ) + (point,) = number_points(collect(reader)["acme.billing.invoice.created"]) + assert point.value == 3 + assert attributes_of(point) == { + "acme.billing.invoice.id": "inv-1", + "acme.billing.invoice.currency": "USD", + "billing.invoice.paid": True, + } diff --git a/templates/code/python/tests/unsupported_signals/expected/__init__.py b/templates/code/python/tests/unsupported_signals/expected/__init__.py new file mode 100644 index 0000000..b1aa366 --- /dev/null +++ b/templates/code/python/tests/unsupported_signals/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/unsupported_signals/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/unsupported_signals/expected/attributes.py b/templates/code/python/tests/unsupported_signals/expected/attributes.py new file mode 100644 index 0000000..9e77b9e --- /dev/null +++ b/templates/code/python/tests/unsupported_signals/expected/attributes.py @@ -0,0 +1,32 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + +MYAPP_HOST_NAME_KEY: Final = "myapp.host.name" +"""Name of the host.""" + + +MYAPP_HOST_TYPE_KEY: Final = "myapp.host.type" +"""Type of the host.""" + + +MYAPP_QUEUE_NAME_KEY: Final = "myapp.queue.name" +"""Name of the queue.""" + + +MYAPP_TASK_ID_KEY: Final = "myapp.task.id" +"""Unique identifier of the task.""" + + +class MyappTaskState(enum.StrEnum): + """Values of the `myapp.task.state` attribute.""" + + QUEUED = "queued" + """The task is waiting to run.""" + DONE = "done" + """The task finished.""" + + +MYAPP_TASK_STATE_KEY: Final = "myapp.task.state" +"""Current state of the task.""" diff --git a/templates/code/python/tests/unsupported_signals/expected/metrics.py b/templates/code/python/tests/unsupported_signals/expected/metrics.py new file mode 100644 index 0000000..4ea3648 --- /dev/null +++ b/templates/code/python/tests/unsupported_signals/expected/metrics.py @@ -0,0 +1,95 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class MyappTaskStarted: + """Number of tasks started.""" + + NAME: Final = "myapp.task.started" + UNIT: Final = "{task}" + DESCRIPTION: Final = "Number of tasks started." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + task_id: str, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + self._instrument.add(amount, _attributes) + + +class MyappTaskStartedObservable: + """Observable form of `MyappTaskStarted`, reported by callbacks.""" + + NAME: Final = MyappTaskStarted.NAME + UNIT: Final = MyappTaskStarted.UNIT + DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION + KIND: Final = MyappTaskStarted.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + task_id: str, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.MYAPP_TASK_ID_KEY: task_id, + } + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/unsupported_signals/registry/manifest.yaml b/templates/code/python/tests/unsupported_signals/registry/manifest.yaml new file mode 100644 index 0000000..9e4c153 --- /dev/null +++ b/templates/code/python/tests/unsupported_signals/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: unsupported_signals +schema_url: https://example.com/schemas/unsupported_signals/1.0.0 diff --git a/templates/code/python/tests/unsupported_signals/registry/model.yaml b/templates/code/python/tests/unsupported_signals/registry/model.yaml new file mode 100644 index 0000000..0f2ce2f --- /dev/null +++ b/templates/code/python/tests/unsupported_signals/registry/model.yaml @@ -0,0 +1,97 @@ +file_format: definition/2 +attributes: + - key: myapp.task.id + type: string + stability: stable + brief: Unique identifier of the task. + - key: myapp.task.state + type: + members: + - id: queued + value: queued + stability: stable + brief: The task is waiting to run. + - id: done + value: done + stability: stable + brief: The task finished. + stability: stable + brief: Current state of the task. + - key: myapp.queue.name + type: string + stability: stable + brief: Name of the queue. + - key: myapp.host.name + type: string + stability: stable + brief: Name of the host. + - key: myapp.host.type + type: string + stability: stable + brief: Type of the host. +metrics: + - name: myapp.task.started + requirement_level: recommended + instrument: counter + unit: "{task}" + stability: stable + brief: Number of tasks started. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: myapp.task.id + requirement_level: required +spans: + - type: myapp.task.run + kind: internal + requirement_level: recommended + stability: stable + brief: One run of a task. + name: + note: "`run {myapp.task.id}`" + attributes: + - ref: myapp.task.state + requirement_level: required + - ref: myapp.task.id + requirement_level: recommended +events: + - name: myapp.task.finished + requirement_level: recommended + stability: development + brief: A task reached a final state. + attributes: + - ref: myapp.task.state + requirement_level: required +entities: + - type: myapp.host + requirement_level: recommended + stability: stable + brief: A host running the application. + identity: + - ref: myapp.host.name + requirement_level: required + description: + - ref: myapp.host.type + requirement_level: recommended +span_refinements: + - id: myapp.task.run.queued + ref: myapp.task.run + attributes: + - ref: myapp.queue.name + requirement_level: required +event_refinements: + - id: myapp.task.finished.queued + ref: myapp.task.finished + brief: A queued task reached a final state. + attributes: + - ref: myapp.queue.name + requirement_level: required +entity_refinements: + - id: myapp.host.worker + ref: myapp.host + stability: stable + brief: A host dedicated to running worker processes. + description: + - ref: myapp.queue.name + requirement_level: required diff --git a/templates/code/python/tests/unsupported_signals/test_consumer.py b/templates/code/python/tests/unsupported_signals/test_consumer.py new file mode 100644 index 0000000..d9ee26f --- /dev/null +++ b/templates/code/python/tests/unsupported_signals/test_consumer.py @@ -0,0 +1,102 @@ +import inspect +from pathlib import Path + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +import semconv +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def test_only_the_three_modules_are_generated() -> None: + package = Path(semconv.__file__).parent + assert sorted(path.name for path in package.glob("*.py")) == [ + "__init__.py", + "attributes.py", + "metrics.py", + ] + + +def test_only_metric_classes_are_defined() -> None: + defined = { + name + for name, member in inspect.getmembers(metrics, inspect.isclass) + if member.__module__ == metrics.__name__ + } + assert defined == { + "InstrumentKind", + "MyappTaskStarted", + "MyappTaskStartedObservable", + } + + +def test_no_span_event_or_entity_symbol_exists() -> None: + for module in (semconv, attributes, metrics): + for name in dir(module): + lowered = name.lower() + assert "span" not in lowered, name + assert "event" not in lowered, name + assert "entity" not in lowered, name + assert "tracer" not in lowered, name + + +def test_attributes_used_only_by_other_signals_still_get_constants() -> None: + assert attributes.MYAPP_QUEUE_NAME_KEY == "myapp.queue.name" + assert attributes.MYAPP_HOST_NAME_KEY == "myapp.host.name" + assert attributes.MYAPP_HOST_TYPE_KEY == "myapp.host.type" + assert attributes.MyappTaskState.QUEUED.value == "queued" + + +def test_the_metric_still_records() -> None: + reader, provider = new_provider() + metrics.MyappTaskStarted(metrics.get_meter(provider)).add(3, task_id="task-1") + (point,) = number_points(collect(reader)["myapp.task.started"]) + assert point.value == 3 + assert attributes_of(point) == {"myapp.task.id": "task-1"} diff --git a/templates/code/python/tests/vendor_prefix_off/expected/__init__.py b/templates/code/python/tests/vendor_prefix_off/expected/__init__.py new file mode 100644 index 0000000..61c1d88 --- /dev/null +++ b/templates/code/python/tests/vendor_prefix_off/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/vendor_prefix_off/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/vendor_prefix_off/expected/attributes.py b/templates/code/python/tests/vendor_prefix_off/expected/attributes.py new file mode 100644 index 0000000..a113340 --- /dev/null +++ b/templates/code/python/tests/vendor_prefix_off/expected/attributes.py @@ -0,0 +1,25 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class AcmeBillingInvoiceCurrency(enum.StrEnum): + """Values of the `acme.billing.invoice.currency` attribute.""" + + USD = "USD" + """United States dollar.""" + EUR = "EUR" + """Euro.""" + + +ACME_BILLING_INVOICE_CURRENCY_KEY: Final = "acme.billing.invoice.currency" +"""Currency of the invoice.""" + + +ACME_BILLING_INVOICE_ID_KEY: Final = "acme.billing.invoice.id" +"""Identifier of the invoice.""" + + +BILLING_INVOICE_PAID_KEY: Final = "billing.invoice.paid" +"""Whether the invoice was paid.""" diff --git a/templates/code/python/tests/vendor_prefix_off/expected/metrics.py b/templates/code/python/tests/vendor_prefix_off/expected/metrics.py new file mode 100644 index 0000000..9754d7f --- /dev/null +++ b/templates/code/python/tests/vendor_prefix_off/expected/metrics.py @@ -0,0 +1,111 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +class InstrumentKind(enum.StrEnum): + """Kind of an OpenTelemetry instrument.""" + + COUNTER = "counter" + UP_DOWN_COUNTER = "updowncounter" + GAUGE = "gauge" + HISTOGRAM = "histogram" + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class AcmeBillingInvoiceCreated: + """Number of invoices created.""" + + NAME: Final = "acme.billing.invoice.created" + UNIT: Final = "{invoice}" + DESCRIPTION: Final = "Number of invoices created." + KIND: Final = InstrumentKind.COUNTER + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + def add( + self, + amount: int, + /, + *, + billing_invoice_id: str, + billing_invoice_currency: attributes.AcmeBillingInvoiceCurrency | None = None, + billing_invoice_paid: bool | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.ACME_BILLING_INVOICE_ID_KEY: billing_invoice_id, + } + if billing_invoice_currency is not None: + _attributes[attributes.ACME_BILLING_INVOICE_CURRENCY_KEY] = ( + billing_invoice_currency.value + ) + if billing_invoice_paid is not None: + _attributes[attributes.BILLING_INVOICE_PAID_KEY] = billing_invoice_paid + self._instrument.add(amount, _attributes) + + +class AcmeBillingInvoiceCreatedObservable: + """Observable form of `AcmeBillingInvoiceCreated`, reported by callbacks.""" + + NAME: Final = AcmeBillingInvoiceCreated.NAME + UNIT: Final = AcmeBillingInvoiceCreated.UNIT + DESCRIPTION: Final = AcmeBillingInvoiceCreated.DESCRIPTION + KIND: Final = AcmeBillingInvoiceCreated.KIND + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit=self.UNIT, + description=self.DESCRIPTION, + ) + + @staticmethod + def observation( + amount: int, + /, + *, + billing_invoice_id: str, + billing_invoice_currency: attributes.AcmeBillingInvoiceCurrency | None = None, + billing_invoice_paid: bool | None = None, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.ACME_BILLING_INVOICE_ID_KEY: billing_invoice_id, + } + if billing_invoice_currency is not None: + _attributes[attributes.ACME_BILLING_INVOICE_CURRENCY_KEY] = ( + billing_invoice_currency.value + ) + if billing_invoice_paid is not None: + _attributes[attributes.BILLING_INVOICE_PAID_KEY] = billing_invoice_paid + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/vendor_prefix_off/registry/manifest.yaml b/templates/code/python/tests/vendor_prefix_off/registry/manifest.yaml new file mode 100644 index 0000000..de7145c --- /dev/null +++ b/templates/code/python/tests/vendor_prefix_off/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: vendor_prefix_off +schema_url: https://example.com/schemas/vendor_prefix_off/1.0.0 diff --git a/templates/code/python/tests/vendor_prefix_off/registry/model.yaml b/templates/code/python/tests/vendor_prefix_off/registry/model.yaml new file mode 100644 index 0000000..c58542d --- /dev/null +++ b/templates/code/python/tests/vendor_prefix_off/registry/model.yaml @@ -0,0 +1,40 @@ +file_format: definition/2 +attributes: + - key: acme.billing.invoice.id + type: string + stability: stable + brief: Identifier of the invoice. + - key: acme.billing.invoice.currency + type: + members: + - id: usd + value: USD + stability: stable + brief: United States dollar. + - id: eur + value: EUR + stability: stable + brief: Euro. + stability: stable + brief: Currency of the invoice. + - key: billing.invoice.paid + type: boolean + stability: stable + brief: Whether the invoice was paid. +metrics: + - name: acme.billing.invoice.created + requirement_level: recommended + instrument: counter + unit: "{invoice}" + stability: stable + brief: Number of invoices created. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: acme.billing.invoice.id + requirement_level: required + - ref: acme.billing.invoice.currency + requirement_level: recommended + - ref: billing.invoice.paid + requirement_level: recommended diff --git a/templates/code/python/tests/vendor_prefix_off/test_consumer.py b/templates/code/python/tests/vendor_prefix_off/test_consumer.py new file mode 100644 index 0000000..1d6b9c1 --- /dev/null +++ b/templates/code/python/tests/vendor_prefix_off/test_consumer.py @@ -0,0 +1,102 @@ +import inspect + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ( + HistogramDataPoint, + InMemoryMetricReader, + Metric, + NumberDataPoint, +) +from opentelemetry.util.types import AttributeValue + +from semconv import ( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + SCHEMA_URL, + attributes, + metrics, +) + + +def collect(reader: InMemoryMetricReader) -> dict[str, Metric]: + data = reader.get_metrics_data() + assert data is not None + scopes = data.resource_metrics[0].scope_metrics + assert len(scopes) == 1 + scope = scopes[0].scope + assert scope.name == INSTRUMENTATION_NAME + assert scope.version == INSTRUMENTATION_VERSION + assert scope.schema_url == SCHEMA_URL + return {metric.name: metric for metric in scopes[0].metrics} + + +def new_provider() -> tuple[InMemoryMetricReader, MeterProvider]: + reader = InMemoryMetricReader() + return reader, MeterProvider(metric_readers=[reader]) + + +def attributes_of( + point: NumberDataPoint | HistogramDataPoint, +) -> dict[str, AttributeValue]: + return dict(point.attributes or {}) + + +def number_points(metric: Metric) -> list[NumberDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, NumberDataPoint) for point in points) + return [point for point in points if isinstance(point, NumberDataPoint)] + + +def histogram_points(metric: Metric) -> list[HistogramDataPoint]: + points = list(metric.data.data_points) + assert all(isinstance(point, HistogramDataPoint) for point in points) + return [point for point in points if isinstance(point, HistogramDataPoint)] + + +def test_the_default_identity_is_used_without_params() -> None: + assert INSTRUMENTATION_NAME == "generated-semconv" + assert INSTRUMENTATION_VERSION == "" + reader, provider = new_provider() + metrics.AcmeBillingInvoiceCreated(metrics.get_meter(provider)).add( + 1, billing_invoice_id="inv-1" + ) + collect(reader) + + +def test_the_vendor_segment_stays_in_names_by_default() -> None: + assert attributes.ACME_BILLING_INVOICE_ID_KEY == "acme.billing.invoice.id" + assert attributes.BILLING_INVOICE_PAID_KEY == "billing.invoice.paid" + assert attributes.AcmeBillingInvoiceCurrency.USD.value == "USD" + assert metrics.AcmeBillingInvoiceCreated.NAME == "acme.billing.invoice.created" + + +def test_parameters_drop_only_the_metric_namespace() -> None: + parameters = inspect.signature(metrics.AcmeBillingInvoiceCreated.add).parameters + assert list(parameters) == [ + "self", + "amount", + "billing_invoice_id", + "billing_invoice_currency", + "billing_invoice_paid", + ] + + +def test_the_default_header_has_no_run_line() -> None: + source = inspect.getsource(metrics) + assert source.startswith("# Code generated by weaver. DO NOT EDIT.\n\n") + + +def test_attributes_keep_their_registry_keys_when_recorded() -> None: + reader, provider = new_provider() + metrics.AcmeBillingInvoiceCreated(metrics.get_meter(provider)).add( + 1, + billing_invoice_id="inv-1", + billing_invoice_currency=attributes.AcmeBillingInvoiceCurrency.EUR, + billing_invoice_paid=False, + ) + (point,) = number_points(collect(reader)["acme.billing.invoice.created"]) + assert attributes_of(point) == { + "acme.billing.invoice.id": "inv-1", + "acme.billing.invoice.currency": "EUR", + "billing.invoice.paid": False, + } diff --git a/templates/code/python/weaver.yaml b/templates/code/python/weaver.yaml new file mode 100644 index 0000000..a7a3c4e --- /dev/null +++ b/templates/code/python/weaver.yaml @@ -0,0 +1,263 @@ +# Python code generation for a semantic convention registry. +# See README.md for what this package generates and full parameter docs. +# Override params per registry via `--param key=value` or `--params `. +whitespace_control: + trim_blocks: true + lstrip_blocks: true + +params: + # Scope name and version passed to `get_meter`. An empty version leaves the + # scope unversioned. + instrumentation_name: generated-semconv + instrumentation_version: "" + + # Extra paragraph for the package docstring. Omitted when empty. + module_description: "" + + # Generated file header. `header_source` names what the code was generated + # from and `regenerate_command` tells readers how to refresh it. Both are + # omitted from the header when empty. + header_source: "" + regenerate_command: "" + + # Leading name segments that are a vendor prefix rather than a namespace. + # With `[acme]`, `acme.billing.invoice.id` renders as `BILLING_INVOICE_ID_KEY`, + # exactly like `billing.invoice.id`. + vendor_prefixes: [] + + # Signal and attribute selection, forwarded to the semconv_grouped_* helpers. + exclude_deprecated: false + stable_only: false + + # Default explicit bucket boundaries by unit, for a histogram that sets no + # `annotations.aggregation`. A unit missing from the map uses the SDK default. + histogram_boundaries_by_unit: + s: [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10] + +templates: + - template: __init__.py.j2 + application_mode: single + file_name: __init__.py + filter: |- + def literal: tojson as $double | ([match("\""; "g")] | length) as $double_quotes | ([match("'"; "g")] | length) as $single_quotes + | if $double_quotes > $single_quotes then "'" + ($double[1:-1] | gsub("\\\\\""; "\"") | gsub("'"; "\\'")) + "'" else $double end; + if ($instrumentation_name | type) != "string" or ($instrumentation_name | length) == 0 + then error("instrumentation_name must be a nonempty string") + elif ($instrumentation_version | type) != "string" + then error("instrumentation_version must be a string") + elif ($vendor_prefixes | type) != "array" or ($vendor_prefixes | any(type != "string")) + then error("vendor_prefixes must be a list of strings") + elif ($histogram_boundaries_by_unit | type) != "object" + then error("histogram_boundaries_by_unit must be a map from unit to boundaries") + else . end + | .schema_url as $schema + | if $schema == null or $schema == "https://unknown/unknown" or ($schema | test("^https?://[^/]+(/[^/]+)*/[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$") | not) + then error("the registry schema_url must name a schema version") else . end + | {schema_literal: ($schema | literal), name_literal: ($instrumentation_name | literal), version_literal: ($instrumentation_version | literal)} + + - template: attributes.py.j2 + application_mode: each + file_name: attributes.py + filter: |- + def literal: tojson as $double | ([match("\""; "g")] | length) as $double_quotes | ([match("'"; "g")] | length) as $single_quotes + | if $double_quotes > $single_quotes then "'" + ($double[1:-1] | gsub("\\\\\""; "\"") | gsub("'"; "\\'")) + "'" else $double end; + def float_literal: tostring | if test("^-?[0-9]+$") then . + ".0" else . end; + def docstring: gsub("\r"; "") | gsub("[\u0000-\u0008\u000b-\u001f\u007f]"; "") | gsub("\t"; " ") | gsub("\\\\"; "\\\\") | gsub("\""; "\\\"") | split("\n") | map(sub("[ ]+$"; "")) | join("\n") | sub("^\\s+"; "") | sub("\\s+$"; ""); + def deprecation_text: if . == null then null else (.note // .renamed_to // .reason) end; + def doc($deprecated): [.brief, .note] | map(select(. != null and . != "")) | map(docstring) | join("\n\n") + | if $deprecated != null then (. + (if . == "" then "" else "\n\n" end) + "Deprecated: " + ($deprecated | docstring)) else . end; + def valid_name: test("^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)*$"); + def stripped: split(".") as $s | if ($s | length) > 1 and ($vendor_prefixes | any(. == $s[0])) then $s[1:] else $s end; + def parts: stripped | map(split("_")) | flatten | map(select(length > 0)); + def title: parts | map(.[0:1] as $first | ($first | ascii_upcase) + .[1:]) | join(""); + def upper: parts | map(ascii_upcase) | join("_"); + def value_type($key): + if type != "object" then . + elif (.members | length) == 0 then error("attribute \($key) has no enum members left after exclude_deprecated or stable_only, so no value of it could be built") + elif (.members | all(.value | type == "string")) then "string" + elif (.members | all(.value | type == "boolean")) then "boolean" + elif (.members | all(.value | type == "number" and . == floor)) then "int" + elif (.members | all(.value | type == "number")) then "double" + else error("attribute \($key) has enum members of more than one value type, which no single Python type can hold") end; + def member_literal($type): if $type == "string" then .value | literal elif $type == "boolean" then (if .value then "True" else "False" end) elif $type == "int" then .value | tostring else .value | float_literal end; + {v2: true, exclude_deprecated: $exclude_deprecated, stable_only: $stable_only} as $opts + | semconv_grouped_attributes($opts) + | map(.attributes) | add // [] + | map(if (.key | valid_name | not) then error("attribute \(.key) is not a supported Python attribute name") else . end) + | map(. + {constant: ((.key | upper) + "_KEY"), class_name: (.key | title)}) + | map(if (.key | parts | length) == 0 then error("attribute \(.key) has no name") else . end) + | map(. + {value_type: (.key as $key | .type | value_type($key)), key_literal: (.key | literal), deprecation: (.deprecated | deprecation_text)}) + | map(if (.value_type as $t | ["string", "int", "double", "boolean", "string[]", "int[]", "double[]", "boolean[]"] | any(. == $t) | not) + then error("attribute \(.key) has an unsupported Python value type") else . end) + | map(if .value_type == "int" and (.type | type) == "object" + and (.type.members | any(.value >= 9223372036854775808 or .value < -9223372036854775808 or (.value | tostring | test("^-?[0-9]+$") | not))) + then error("attribute \(.key) has an enum value that cannot be represented by a 64-bit integer") else . end) + | map(. + {doc: doc(.deprecation)}) + | map(if (.type | type) == "object" then + .key as $key | .value_type as $type | .deprecation as $attribute_deprecation | .type.members |= map( + if (.id | valid_name | not) then error("enum member \(.id) of \($key) is not a supported Python identifier") else . end + | . + {constant: (.id | upper), literal: member_literal($type), deprecation: (if .deprecated then (.deprecated | deprecation_text) else $attribute_deprecation end)} + | . + {doc: doc(.deprecation)}) + | (.type.members | group_by(.constant) | map(select(length > 1))) as $collisions + | if ($collisions | length) > 0 then error("attribute \($key) has enum members that render to the same Python identifier: \($collisions | map(map(.id) | join(" and ")) | join("; "))") else . end + | (.type.members | group_by(.literal) | map(select(length > 1))) as $duplicates + | if ($duplicates | length) > 0 then error("attribute \($key) has enum members with the same value: \($duplicates | map(map(.id) | join(" and ")) | join("; "))") else . end + else . end) + | map(. + {is_enum: ((.type | type) == "object")}) + | (map({symbol: .constant, name: .key}) + (map(select(.is_enum) | {symbol: .class_name, name: .key})) + + (["enum", "Final", "None", "True", "False"] | map({symbol: ., name: "a reserved Python symbol"})) + | group_by(.symbol) | map(select(length > 1) | map(.name) | join(" and ")) | unique) as $collisions + | if ($collisions | length) > 0 then error("module attributes has attributes that render to the same Python identifier: \($collisions | join("; "))") else . end + | if length == 0 then [] else [{attributes: sort_by(.key), has_enums: any(.[]; .is_enum)}] end + + - template: metrics.py.j2 + application_mode: each + file_name: metrics.py + filter: |- + def literal: tojson as $double | ([match("\""; "g")] | length) as $double_quotes | ([match("'"; "g")] | length) as $single_quotes + | if $double_quotes > $single_quotes then "'" + ($double[1:-1] | gsub("\\\\\""; "\"") | gsub("'"; "\\'")) + "'" else $double end; + def float_literal: tostring | if test("^-?[0-9]+$") then . + ".0" else . end; + def docstring: gsub("\r"; "") | gsub("[\u0000-\u0008\u000b-\u001f\u007f]"; "") | gsub("\t"; " ") | gsub("\\\\"; "\\\\") | gsub("\""; "\\\"") | split("\n") | map(sub("[ ]+$"; "")) | join("\n") | sub("^\\s+"; "") | sub("\\s+$"; ""); + def deprecation_text: if . == null then null else (.note // .renamed_to // .reason) end; + def valid_name: test("^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)*$"); + def stripped: split(".") as $s | if ($s | length) > 1 and ($vendor_prefixes | any(. == $s[0])) then $s[1:] else $s end; + def parts: stripped | map(split("_")) | flatten | map(select(length > 0)); + def title: parts | map(.[0:1] as $first | ($first | ascii_upcase) + .[1:]) | join(""); + def upper: parts | map(ascii_upcase) | join("_"); + def namespace: stripped | .[0]; + def field($metric_namespace): stripped | if length > 1 and .[0] == $metric_namespace then .[1:] else . end + | map(split("_")) | flatten | map(select(length > 0)) | map(ascii_downcase) | join("_"); + def enum_value_type($key): + .members as $members + | if ($members | length) == 0 + then error("attribute \($key) has no enum members left after exclude_deprecated or stable_only, so no value of it could be built") + elif ($members | all(.value | type == "string")) then "string" + elif ($members | all(.value | type == "boolean")) then "boolean" + elif ($members | all(.value | type == "number" and . == floor)) then "int" + elif ($members | all(.value | type == "number")) then "double" + else error("attribute \($key) has enum members of more than one value type, which no single Python type can hold") + end; + def python_type: {"string": "str", "int": "int", "double": "float", "boolean": "bool", "string[]": "Sequence[str]", "int[]": "Sequence[int]", "double[]": "Sequence[float]", "boolean[]": "Sequence[bool]"}[.]; + def kept($o): + stability_filter($o) | code_generation_exclude_filter($o) | deprecated_filter($o) + | map(.attributes = (if .attributes == null then .attributes else .attributes | stability_filter($o) | code_generation_exclude_filter($o) | deprecated_filter($o) end)); + def instruments($o): + (semconv_grouped_metrics($o) | map(.metrics) | add // []) as $metrics + | ($metrics | map(.name)) as $kept_bases + | (.registry.metrics // [] | map({key: .name, value: .}) | from_entries) as $bases + | $metrics + (.refinements.metrics // [] | map(select(.id != .name)) | kept($o) + | map(select(.name as $base | $kept_bases | any(. == $base))) + | map(. as $r | $bases[$r.name] as $b + | if ($r.annotations.code_generation.metric_value_type // null) != ($b.annotations.code_generation.metric_value_type // null) + then error("metric refinement \($r.id) sets a different code_generation.metric_value_type than \($r.name), but both record into one instrument") + elif ($r.annotations.aggregation // null) != ($b.annotations.aggregation // null) + then error("metric refinement \($r.id) sets a different annotations.aggregation than \($r.name), but both record into one instrument") + else . + {name: $r.id, refines: $r.name, instrument_name: $r.name, description: $b.brief, unit: $b.unit} end)); + def boundary_literals($metric; $source): + if type != "array" or length == 0 then error("metric \($metric) must set \($source) to a non-empty list of numbers") + elif any(type != "number" or isinfinite or isnan) then error("metric \($metric) must set \($source) to a non-empty list of finite numbers") + elif . as $b | any(range(1; length); $b[.] <= $b[. - 1]) then error("metric \($metric) must list \($source) in strictly increasing order") + else map(float_literal) end; + {v2: true, exclude_deprecated: $exclude_deprecated, stable_only: $stable_only} as $opts + | (semconv_grouped_attributes($opts) | map(.attributes) | add // [] | map(.key)) as $rendered + | (instruments({v2: true}) + | map({key: .name, value: [(.attributes // [])[] | select(.requirement_level == "required") | .key]}) + | from_entries) as $declared_required + | instruments($opts) + | map({instrument_name: .name, description: .brief} + .) + | map(if (.name | valid_name | not) then error("metric \(.name) is not a supported Python metric name") else . end) + | map(if (.name | parts | length) == 0 then error("metric \(.name) has no name") else . end) + | map(if (.annotations.code_generation.metric_value_type // "") as $t | ["int", "double"] | any(. == $t) | not + then error("metric \(.name) must set code_generation.metric_value_type to int or double") + elif (.instrument as $kind | ["counter", "updowncounter", "histogram", "gauge"] | any(. == $kind) | not) + then error("metric \(.name) has an unsupported Python instrument") else . end) + | map(.name as $metric + | (($declared_required[$metric] // []) - [(.attributes // [])[] | .key]) as $dropped + | if ($dropped | length) > 0 + then error("metric \($metric) requires \($dropped | join(", ")), which exclude_deprecated or stable_only leaves out, so its generated method could not set them") + else . end) + | map(.name as $metric + | .unit as $unit + | (.annotations.aggregation // null) as $aggregation + | ($aggregation | if type == "object" and (.parameters | type) == "object" then .parameters.boundaries else null end) as $bounds + | if $aggregation != null then + if .instrument != "histogram" + then error("metric \($metric) sets annotations.aggregation, which the Python template supports only on a histogram") + elif ($aggregation | type) != "object" or ($aggregation.parameters // {} | type) != "object" + then error("metric \($metric) must set annotations.aggregation to an object with a method and parameters") + elif $aggregation.method != "explicithistogram" + then error("metric \($metric) sets annotations.aggregation.method to \($aggregation.method | tostring), but the Python template supports only explicithistogram") + elif (($aggregation | keys) - ["method", "parameters"] | length) > 0 or (($aggregation.parameters // {} | keys) - ["boundaries"] | length) > 0 + then error("metric \($metric) sets annotations.aggregation fields other than method and parameters.boundaries, which the Python API cannot express") + else . + {bucket_boundaries: ($bounds | boundary_literals($metric; "annotations.aggregation.parameters.boundaries"))} end + elif .instrument == "histogram" and ($unit | type) == "string" and ($histogram_boundaries_by_unit | has($unit)) then + . + {bucket_boundaries: ($histogram_boundaries_by_unit[$unit] | boundary_literals($metric; "histogram_boundaries_by_unit for unit \($unit)"))} + else . end) + | map(. + {class_name: (.name | title), constant_kind: ({"counter": "COUNTER", "updowncounter": "UP_DOWN_COUNTER", "histogram": "HISTOGRAM", "gauge": "GAUGE"}[.instrument]), + value_type: (if .annotations.code_generation.metric_value_type == "int" then "int" else "float" end), + create_method: ({"counter": "create_counter", "updowncounter": "create_up_down_counter", "histogram": "create_histogram", "gauge": "create_gauge"}[.instrument]), + observable_method: ({"counter": "create_observable_counter", "updowncounter": "create_observable_up_down_counter", "gauge": "create_observable_gauge"}[.instrument]), + record_method: ({"counter": "add", "updowncounter": "add", "histogram": "record", "gauge": "set"}[.instrument]), + description_literal: ((.description // "") | gsub("^\\s+|\\s+$"; "") | literal), + unit_literal: ((.unit // "") | literal), + deprecation: (.deprecated | deprecation_text), + opt_in: (.requirement_level == "opt_in")}) + | map(. + {doc: ([.brief, .note] | map(select(. != null and . != "")) | map(docstring) | join("\n\n"))}) + | map(.deprecation as $deprecation | .doc |= (if $deprecation != null then . + (if . == "" then "" else "\n\n" end) + "Deprecated: " + ($deprecation | docstring) else . end)) + | map(.opt_in as $opt_in | .doc |= (if $opt_in then . + (if . == "" then "" else "\n\n" end) + "Opt-in: the convention records it only when a user asks for it." else . end)) + | map(.name as $metric + | (.name | namespace) as $metric_namespace + | .attributes |= ((. // []) | sort_by(.key) | map( + . as $attribute + | if (.key | valid_name | not) then error("attribute \(.key) is not a supported Python attribute name") else . end + | (.key as $k | $rendered | any(. == $k)) as $local + | (if (.type | type) == "object" then (.key as $key | .type | enum_value_type($key)) else .type end) as $value_type + | if ($value_type | python_type) == null + then error("metric \($metric) references attribute \(.key) of type \(.type | tostring), which the Python template does not render") + else . end + | . + {field: (.key | field($metric_namespace)), + local: $local, + is_enum: ($local and (.type | type) == "object"), + is_array: ($value_type | endswith("[]")), + key_expression: (if $local then "attributes." + ((.key | upper) + "_KEY") else (.key | literal) end), + python_type: (if $local and (.type | type) == "object" then "attributes." + (.key | title) else ($value_type | python_type) end), + requirement_doc: (.requirement_level + | if type == "object" and has("conditionally_required") then "Conditionally required: \(.conditionally_required)" + elif type == "object" and has("recommended") then "Recommended: \(.recommended)" + elif . == "opt_in" then "Opt-in: the convention records it only when a user asks for it." + else null end | if . == null then null else docstring end), + required: (.requirement_level == "required")} + | . + {value_expression: (if .is_enum then .field + ".value" elif .is_array then "[*" + .field + "]" else .field end)} + | . + {dict_wrap: ((12 + (.key_expression | length) + 2 + (.value_expression | length) + 1) > 88), + assign_wrap: ((24 + (.key_expression | length) + 4 + (.value_expression | length)) > 88)} + | if (.field | test("^[a-z][a-z0-9_]*$") | not) + then error("metric \($metric) has attribute \(.key), which cannot become a Python parameter") else . end + | if (.field as $field | ["False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield", "self", "amount", "attributes"] | any(. == $field)) + then error("metric \($metric) has attribute \(.key), which becomes the reserved Python parameter \(.field)") else . end))) + | map(.name as $metric + | (.attributes | group_by(.field) | map(select(length > 1) | map(.key) | join(" and "))) as $collisions + | if ($collisions | length) > 0 + then error("metric \($metric) has attributes that render to the same Python parameter: \($collisions | join("; "))") + else . end) + | map(.required_attributes = (.attributes | map(select(.required))) | .optional_attributes = (.attributes | map(select(.required | not)))) + | if length == 0 then [] else [.] end + | map( + ([.[] | .name as $metric | .class_name as $class | .instrument as $kind + | ([$class] + (if $kind == "histogram" then [] else [$class + "Observable"] end))[] + | {symbol: ., name: $metric}] + + (["InstrumentKind", "Final", "Sequence", "Meter", "MeterProvider", "CallbackT", "Observation", "AttributeValue", "SCHEMA_URL", "INSTRUMENTATION_NAME", "INSTRUMENTATION_VERSION", "None", "True", "False", "attributes", "get_meter", "otel_metrics", "enum"] + | map({symbol: ., name: "a reserved Python symbol"})) + | group_by(.symbol) + | map(select(length > 1) | map(.name) | unique | join(" and ")) + | unique) as $collisions + | if ($collisions | length) > 0 + then error("module metrics has metrics that render to the same Python identifier: \($collisions | join("; "))") + else . end + ) + | map(. as $metrics | {metrics: sort_by(.name), + uses_attributes: any(.[]; .attributes | any(.local)), + uses_sequence: (any(.[]; .attributes | any(.is_array or (.python_type | startswith("Sequence")))) or any(.[]; .instrument != "histogram")), + has_observable: any(.[]; .instrument != "histogram"), + has_attributes: any(.[]; .attributes | length > 0), + api_imports: (["CallbackT"] | if any($metrics[]; .instrument != "histogram") then . else [] end) + ["Meter", "MeterProvider"] + (["Observation"] | if any($metrics[]; .instrument != "histogram") then . else [] end), + package_imports: ["INSTRUMENTATION_NAME", "INSTRUMENTATION_VERSION", "SCHEMA_URL"] + (["attributes"] | if any($metrics[]; .attributes | any(.local)) then . else [] end)}) From f8599baed2b8750dc4b0cf2e029ba70c727da93f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 00:42:25 -0400 Subject: [PATCH 2/3] fix(python): expose only the metric name on generated classes Unit, description, kind and bucket boundaries stay inside the instrument, as the Go, Rust and TypeScript packages keep them, so callers have one registry constant to depend on. Signed-off-by: Yordis Prieto --- templates/code/python/README.md | 10 +- templates/code/python/metrics.py.j2 | 43 +--- .../tests/filtering_both/expected/metrics.py | 104 ++++----- .../expected/metrics.py | 158 +++++-------- .../filtering_stable_only/expected/metrics.py | 158 +++++-------- .../filtering_unfiltered/expected/metrics.py | 212 +++++++----------- .../histogram_boundaries/expected/metrics.py | 102 +++------ .../histogram_boundaries/test_consumer.py | 20 +- .../expected/metrics.py | 71 ++---- .../test_consumer.py | 20 +- .../expected/metrics.py | 89 +++----- .../test_consumer.py | 20 +- .../python/tests/imported/expected/metrics.py | 64 ++---- .../literal_escaping/expected/metrics.py | 24 +- .../tests/literal_escaping/test_consumer.py | 2 - .../tests/long_names/expected/metrics.py | 38 +--- .../metric_attributes/expected/metrics.py | 134 ++++------- .../python/tests/metrics/expected/metrics.py | 198 ++++++---------- .../python/tests/metrics/test_consumer.py | 30 +-- .../python/tests/naming/expected/metrics.py | 52 +---- .../python/tests/params/expected/metrics.py | 24 +- .../unsupported_signals/expected/metrics.py | 24 +- .../unsupported_signals/test_consumer.py | 1 - .../vendor_prefix_off/expected/metrics.py | 24 +- templates/code/python/weaver.yaml | 5 +- 25 files changed, 531 insertions(+), 1096 deletions(-) diff --git a/templates/code/python/README.md b/templates/code/python/README.md index e19e75d..8d9a475 100644 --- a/templates/code/python/README.md +++ b/templates/code/python/README.md @@ -81,17 +81,13 @@ attribute, without the `_KEY` suffix. Deprecated attributes and members carry a ### Metrics -Each metric becomes one class. The class constants carry the registry values, so -code that records by name, unit, or description can read them from one place. +Each metric becomes one class. `NAME` is the name the metric records under, for +code such as a view that selects the instrument by name. ```python from semconv import metrics metrics.MyappTaskDuration.NAME # "myapp.task.duration" -metrics.MyappTaskDuration.UNIT # "s" -metrics.MyappTaskDuration.DESCRIPTION -metrics.MyappTaskDuration.KIND # metrics.InstrumentKind.HISTOGRAM -metrics.MyappTaskDuration.BUCKET_BOUNDARIES # tuple of floats, or None ``` The constructor creates the instrument on a meter. It uses `get_meter()` when @@ -148,7 +144,7 @@ A histogram takes `explicit_bucket_boundaries_advisory` from, in order: 3. Nothing, which leaves the SDK default boundaries in place. The boundaries are an advisory. A view that sets its own boundaries overrides -them. `BUCKET_BOUNDARIES` is `None` when no boundaries apply. +them. ## Parameters diff --git a/templates/code/python/metrics.py.j2 b/templates/code/python/metrics.py.j2 index 737eb6f..eaf57f8 100644 --- a/templates/code/python/metrics.py.j2 +++ b/templates/code/python/metrics.py.j2 @@ -53,7 +53,6 @@ {%- endmacro %} {{ h.header() }} -import enum {% if ctx.uses_sequence %} from collections.abc import Sequence {% endif %} @@ -68,15 +67,6 @@ from opentelemetry.util.types import AttributeValue from . import {{ ctx.package_imports | join(", ") }} -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -94,30 +84,20 @@ class {{ metric.class_name }}: {% endif %} NAME: Final = {{ (metric.instrument_name) | tojson }} - UNIT: Final = {{ metric.unit_literal }} - DESCRIPTION: Final = {{ metric.description_literal }} - KIND: Final = InstrumentKind.{{ metric.constant_kind }} -{% if metric.instrument == "histogram" %} -{% if metric.bucket_boundaries %} - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( -{% for boundary in metric.bucket_boundaries %} - {{ boundary }}, -{% endfor %} - ) -{% else %} - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None -{% endif %} -{% endif %} def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.{{ metric.create_method }}( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, -{% if metric.instrument == "histogram" %} - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit={{ metric.unit_literal }}, + description={{ metric.description_literal }}, +{% if metric.instrument == "histogram" and metric.bucket_boundaries %} + explicit_bucket_boundaries_advisory=( +{% for boundary in metric.bucket_boundaries %} + {{ boundary }}, +{% endfor %} + ), {% endif %} ) @@ -145,9 +125,6 @@ class {{ metric.class_name }}Observable: """Observable form of `{{ metric.class_name }}`, reported by callbacks.""" NAME: Final = {{ metric.class_name }}.NAME - UNIT: Final = {{ metric.class_name }}.UNIT - DESCRIPTION: Final = {{ metric.class_name }}.DESCRIPTION - KIND: Final = {{ metric.class_name }}.KIND def __init__( self, @@ -159,8 +136,8 @@ class {{ metric.class_name }}Observable: self._instrument = meter.{{ metric.observable_method }}( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit={{ metric.unit_literal }}, + description={{ metric.description_literal }}, ) {% if metric.attributes %} diff --git a/templates/code/python/tests/filtering_both/expected/metrics.py b/templates/code/python/tests/filtering_both/expected/metrics.py index 954ae27..4cd19a9 100644 --- a/templates/code/python/tests/filtering_both/expected/metrics.py +++ b/templates/code/python/tests/filtering_both/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,34 +24,30 @@ class MyappTaskDuration: """Time a task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -81,34 +67,30 @@ class MyappTaskRetryDuration: """Time a retried task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -128,17 +110,14 @@ class MyappTaskStarted: """Number of tasks started.""" NAME: Final = "myapp.task.started" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks started." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) def add( @@ -164,9 +143,6 @@ class MyappTaskStartedObservable: """Observable form of `MyappTaskStarted`, reported by callbacks.""" NAME: Final = MyappTaskStarted.NAME - UNIT: Final = MyappTaskStarted.UNIT - DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION - KIND: Final = MyappTaskStarted.KIND def __init__( self, @@ -178,8 +154,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) @staticmethod diff --git a/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py b/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py index ed0287c..fdf6df7 100644 --- a/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py +++ b/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,34 +24,30 @@ class MyappTaskDuration: """Time a task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -81,34 +67,30 @@ class MyappTaskPreviewDuration: """Time a preview task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -128,17 +110,14 @@ class MyappTaskQueued: """Number of tasks waiting to run.""" NAME: Final = "myapp.task.queued" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks waiting to run." - KIND: Final = InstrumentKind.UP_DOWN_COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_up_down_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks waiting to run.", ) def add(self, amount: int, /) -> None: @@ -149,9 +128,6 @@ class MyappTaskQueuedObservable: """Observable form of `MyappTaskQueued`, reported by callbacks.""" NAME: Final = MyappTaskQueued.NAME - UNIT: Final = MyappTaskQueued.UNIT - DESCRIPTION: Final = MyappTaskQueued.DESCRIPTION - KIND: Final = MyappTaskQueued.KIND def __init__( self, @@ -163,8 +139,8 @@ def __init__( self._instrument = meter.create_observable_up_down_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks waiting to run.", ) @staticmethod @@ -176,34 +152,30 @@ class MyappTaskRetryDuration: """Time a retried task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -223,17 +195,14 @@ class MyappTaskStarted: """Number of tasks started.""" NAME: Final = "myapp.task.started" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks started." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) def add( @@ -262,9 +231,6 @@ class MyappTaskStartedObservable: """Observable form of `MyappTaskStarted`, reported by callbacks.""" NAME: Final = MyappTaskStarted.NAME - UNIT: Final = MyappTaskStarted.UNIT - DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION - KIND: Final = MyappTaskStarted.KIND def __init__( self, @@ -276,8 +242,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) @staticmethod diff --git a/templates/code/python/tests/filtering_stable_only/expected/metrics.py b/templates/code/python/tests/filtering_stable_only/expected/metrics.py index 173cbae..fdf958a 100644 --- a/templates/code/python/tests/filtering_stable_only/expected/metrics.py +++ b/templates/code/python/tests/filtering_stable_only/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,34 +24,30 @@ class MyappTaskDuration: """Time a task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -83,34 +69,30 @@ class MyappTaskLegacyDuration: Deprecated: Legacy tasks no longer exist.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -132,17 +114,14 @@ class MyappTaskRetired: Deprecated: Replaced by `myapp.task.started`.""" NAME: Final = "myapp.task.retired" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks retired." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks retired.", ) def add(self, amount: int, /) -> None: @@ -153,9 +132,6 @@ class MyappTaskRetiredObservable: """Observable form of `MyappTaskRetired`, reported by callbacks.""" NAME: Final = MyappTaskRetired.NAME - UNIT: Final = MyappTaskRetired.UNIT - DESCRIPTION: Final = MyappTaskRetired.DESCRIPTION - KIND: Final = MyappTaskRetired.KIND def __init__( self, @@ -167,8 +143,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks retired.", ) @staticmethod @@ -180,34 +156,30 @@ class MyappTaskRetryDuration: """Time a retried task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -227,17 +199,14 @@ class MyappTaskStarted: """Number of tasks started.""" NAME: Final = "myapp.task.started" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks started." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) def add( @@ -266,9 +235,6 @@ class MyappTaskStartedObservable: """Observable form of `MyappTaskStarted`, reported by callbacks.""" NAME: Final = MyappTaskStarted.NAME - UNIT: Final = MyappTaskStarted.UNIT - DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION - KIND: Final = MyappTaskStarted.KIND def __init__( self, @@ -280,8 +246,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) @staticmethod diff --git a/templates/code/python/tests/filtering_unfiltered/expected/metrics.py b/templates/code/python/tests/filtering_unfiltered/expected/metrics.py index 07f4629..6e939cb 100644 --- a/templates/code/python/tests/filtering_unfiltered/expected/metrics.py +++ b/templates/code/python/tests/filtering_unfiltered/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,34 +24,30 @@ class MyappTaskDuration: """Time a task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -83,34 +69,30 @@ class MyappTaskLegacyDuration: Deprecated: Legacy tasks no longer exist.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -130,34 +112,30 @@ class MyappTaskPreviewDuration: """Time a preview task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -177,17 +155,14 @@ class MyappTaskQueued: """Number of tasks waiting to run.""" NAME: Final = "myapp.task.queued" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks waiting to run." - KIND: Final = InstrumentKind.UP_DOWN_COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_up_down_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks waiting to run.", ) def add(self, amount: int, /) -> None: @@ -198,9 +173,6 @@ class MyappTaskQueuedObservable: """Observable form of `MyappTaskQueued`, reported by callbacks.""" NAME: Final = MyappTaskQueued.NAME - UNIT: Final = MyappTaskQueued.UNIT - DESCRIPTION: Final = MyappTaskQueued.DESCRIPTION - KIND: Final = MyappTaskQueued.KIND def __init__( self, @@ -212,8 +184,8 @@ def __init__( self._instrument = meter.create_observable_up_down_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks waiting to run.", ) @staticmethod @@ -227,17 +199,14 @@ class MyappTaskRetired: Deprecated: Replaced by `myapp.task.started`.""" NAME: Final = "myapp.task.retired" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks retired." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks retired.", ) def add(self, amount: int, /) -> None: @@ -248,9 +217,6 @@ class MyappTaskRetiredObservable: """Observable form of `MyappTaskRetired`, reported by callbacks.""" NAME: Final = MyappTaskRetired.NAME - UNIT: Final = MyappTaskRetired.UNIT - DESCRIPTION: Final = MyappTaskRetired.DESCRIPTION - KIND: Final = MyappTaskRetired.KIND def __init__( self, @@ -262,8 +228,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks retired.", ) @staticmethod @@ -275,34 +241,30 @@ class MyappTaskRetryDuration: """Time a retried task took from start to finish.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -322,17 +284,14 @@ class MyappTaskStarted: """Number of tasks started.""" NAME: Final = "myapp.task.started" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks started." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) def add( @@ -364,9 +323,6 @@ class MyappTaskStartedObservable: """Observable form of `MyappTaskStarted`, reported by callbacks.""" NAME: Final = MyappTaskStarted.NAME - UNIT: Final = MyappTaskStarted.UNIT - DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION - KIND: Final = MyappTaskStarted.KIND def __init__( self, @@ -378,8 +334,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) @staticmethod diff --git a/templates/code/python/tests/histogram_boundaries/expected/metrics.py b/templates/code/python/tests/histogram_boundaries/expected/metrics.py index 161f5d4..5aac0b1 100644 --- a/templates/code/python/tests/histogram_boundaries/expected/metrics.py +++ b/templates/code/python/tests/histogram_boundaries/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -10,15 +9,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -33,34 +23,30 @@ class MyappTaskDuration: """Time a task took from start to finish, with the default boundaries of its unit.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish, with the default boundaries of its unit." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish, with the default boundaries of its unit.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record(self, amount: float, /) -> None: @@ -71,19 +57,14 @@ class MyappTaskPayload: """Task payload size.""" NAME: Final = "myapp.task.payload" - UNIT: Final = "By" - DESCRIPTION: Final = "Task payload size." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="By", + description="Task payload size.", ) def record(self, amount: int, /) -> None: @@ -94,19 +75,14 @@ class MyappTaskQueueWait: """Time a task waited in the queue.""" NAME: Final = "myapp.task.queue_wait" - UNIT: Final = "ms" - DESCRIPTION: Final = "Time a task waited in the queue." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="ms", + description="Time a task waited in the queue.", ) def record(self, amount: float, /) -> None: @@ -117,17 +93,14 @@ class MyappTaskRetries: """A counter never receives boundaries.""" NAME: Final = "myapp.task.retries" - UNIT: Final = "{retry}" - DESCRIPTION: Final = "A counter never receives boundaries." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{retry}", + description="A counter never receives boundaries.", ) def add(self, amount: int, /) -> None: @@ -138,9 +111,6 @@ class MyappTaskRetriesObservable: """Observable form of `MyappTaskRetries`, reported by callbacks.""" NAME: Final = MyappTaskRetries.NAME - UNIT: Final = MyappTaskRetries.UNIT - DESCRIPTION: Final = MyappTaskRetries.DESCRIPTION - KIND: Final = MyappTaskRetries.KIND def __init__( self, @@ -152,8 +122,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{retry}", + description="A counter never receives boundaries.", ) @staticmethod @@ -165,24 +135,20 @@ class MyappTaskStep: """Time one step took, with boundaries from the registry.""" NAME: Final = "myapp.task.step" - UNIT: Final = "s" - DESCRIPTION: Final = "Time one step took, with boundaries from the registry." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.0, - 0.5, - 1.5, - 3.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time one step took, with boundaries from the registry.", + explicit_bucket_boundaries_advisory=( + 0.0, + 0.5, + 1.5, + 3.0, + ), ) def record(self, amount: float, /) -> None: diff --git a/templates/code/python/tests/histogram_boundaries/test_consumer.py b/templates/code/python/tests/histogram_boundaries/test_consumer.py index 330fc47..8c9f4ac 100644 --- a/templates/code/python/tests/histogram_boundaries/test_consumer.py +++ b/templates/code/python/tests/histogram_boundaries/test_consumer.py @@ -87,18 +87,6 @@ def advertised(boundaries: tuple[float, ...] | None) -> tuple[float, ...]: return SDK_DEFAULT_BOUNDARIES if boundaries is None else boundaries -def test_the_constants_follow_the_unit_and_the_annotation() -> None: - assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == DURATION_BOUNDARIES - assert metrics.MyappTaskQueueWait.BUCKET_BOUNDARIES == QUEUE_WAIT_BOUNDARIES - assert metrics.MyappTaskPayload.BUCKET_BOUNDARIES == PAYLOAD_BOUNDARIES - assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == STEP_BOUNDARIES - - -def test_a_counter_has_no_bucket_boundaries() -> None: - assert not hasattr(metrics.MyappTaskRetries, "BUCKET_BOUNDARIES") - assert not hasattr(metrics.MyappTaskRetriesObservable, "BUCKET_BOUNDARIES") - - def test_the_sdk_reports_the_advertised_boundaries() -> None: reader, provider = new_provider() meter = metrics.get_meter(provider) @@ -129,5 +117,9 @@ def test_a_value_lands_in_the_bucket_of_the_advertised_boundaries() -> None: def test_the_annotation_overrides_the_unit_default() -> None: - assert metrics.MyappTaskStep.UNIT == "s" - assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == (0.0, 0.5, 1.5, 3.0) + reader, provider = new_provider() + metrics.MyappTaskStep(metrics.get_meter(provider)).record(1.0) + metric = collect(reader)["myapp.task.step"] + assert metric.unit == "s" + (point,) = histogram_points(metric) + assert tuple(point.explicit_bounds) == (0.0, 0.5, 1.5, 3.0) diff --git a/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py b/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py index cde4825..c374d10 100644 --- a/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py +++ b/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -10,15 +9,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -33,19 +23,14 @@ class MyappTaskDuration: """Time a task took from start to finish, with the default boundaries of its unit.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish, with the default boundaries of its unit." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish, with the default boundaries of its unit.", ) def record(self, amount: float, /) -> None: @@ -56,19 +41,14 @@ class MyappTaskPayload: """Task payload size.""" NAME: Final = "myapp.task.payload" - UNIT: Final = "By" - DESCRIPTION: Final = "Task payload size." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="By", + description="Task payload size.", ) def record(self, amount: int, /) -> None: @@ -79,19 +59,14 @@ class MyappTaskQueueWait: """Time a task waited in the queue.""" NAME: Final = "myapp.task.queue_wait" - UNIT: Final = "ms" - DESCRIPTION: Final = "Time a task waited in the queue." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="ms", + description="Time a task waited in the queue.", ) def record(self, amount: float, /) -> None: @@ -102,17 +77,14 @@ class MyappTaskRetries: """A counter never receives boundaries.""" NAME: Final = "myapp.task.retries" - UNIT: Final = "{retry}" - DESCRIPTION: Final = "A counter never receives boundaries." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{retry}", + description="A counter never receives boundaries.", ) def add(self, amount: int, /) -> None: @@ -123,9 +95,6 @@ class MyappTaskRetriesObservable: """Observable form of `MyappTaskRetries`, reported by callbacks.""" NAME: Final = MyappTaskRetries.NAME - UNIT: Final = MyappTaskRetries.UNIT - DESCRIPTION: Final = MyappTaskRetries.DESCRIPTION - KIND: Final = MyappTaskRetries.KIND def __init__( self, @@ -137,8 +106,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{retry}", + description="A counter never receives boundaries.", ) @staticmethod @@ -150,24 +119,20 @@ class MyappTaskStep: """Time one step took, with boundaries from the registry.""" NAME: Final = "myapp.task.step" - UNIT: Final = "s" - DESCRIPTION: Final = "Time one step took, with boundaries from the registry." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.0, - 0.5, - 1.5, - 3.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time one step took, with boundaries from the registry.", + explicit_bucket_boundaries_advisory=( + 0.0, + 0.5, + 1.5, + 3.0, + ), ) def record(self, amount: float, /) -> None: diff --git a/templates/code/python/tests/histogram_boundaries_none/test_consumer.py b/templates/code/python/tests/histogram_boundaries_none/test_consumer.py index a7196d2..30d2841 100644 --- a/templates/code/python/tests/histogram_boundaries_none/test_consumer.py +++ b/templates/code/python/tests/histogram_boundaries_none/test_consumer.py @@ -72,18 +72,6 @@ def advertised(boundaries: tuple[float, ...] | None) -> tuple[float, ...]: return SDK_DEFAULT_BOUNDARIES if boundaries is None else boundaries -def test_the_constants_follow_the_unit_and_the_annotation() -> None: - assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == DURATION_BOUNDARIES - assert metrics.MyappTaskQueueWait.BUCKET_BOUNDARIES == QUEUE_WAIT_BOUNDARIES - assert metrics.MyappTaskPayload.BUCKET_BOUNDARIES == PAYLOAD_BOUNDARIES - assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == STEP_BOUNDARIES - - -def test_a_counter_has_no_bucket_boundaries() -> None: - assert not hasattr(metrics.MyappTaskRetries, "BUCKET_BOUNDARIES") - assert not hasattr(metrics.MyappTaskRetriesObservable, "BUCKET_BOUNDARIES") - - def test_the_sdk_reports_the_advertised_boundaries() -> None: reader, provider = new_provider() meter = metrics.get_meter(provider) @@ -114,5 +102,9 @@ def test_a_value_lands_in_the_bucket_of_the_advertised_boundaries() -> None: def test_the_annotation_overrides_the_unit_default() -> None: - assert metrics.MyappTaskStep.UNIT == "s" - assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == (0.0, 0.5, 1.5, 3.0) + reader, provider = new_provider() + metrics.MyappTaskStep(metrics.get_meter(provider)).record(1.0) + metric = collect(reader)["myapp.task.step"] + assert metric.unit == "s" + (point,) = histogram_points(metric) + assert tuple(point.explicit_bounds) == (0.0, 0.5, 1.5, 3.0) diff --git a/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py b/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py index 21368f1..2f0fa25 100644 --- a/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py +++ b/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -10,15 +9,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -33,22 +23,18 @@ class MyappTaskDuration: """Time a task took from start to finish, with the default boundaries of its unit.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish, with the default boundaries of its unit." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.5, - 1.5, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish, with the default boundaries of its unit.", + explicit_bucket_boundaries_advisory=( + 0.5, + 1.5, + ), ) def record(self, amount: float, /) -> None: @@ -59,19 +45,14 @@ class MyappTaskPayload: """Task payload size.""" NAME: Final = "myapp.task.payload" - UNIT: Final = "By" - DESCRIPTION: Final = "Task payload size." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = None def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="By", + description="Task payload size.", ) def record(self, amount: int, /) -> None: @@ -82,24 +63,20 @@ class MyappTaskQueueWait: """Time a task waited in the queue.""" NAME: Final = "myapp.task.queue_wait" - UNIT: Final = "ms" - DESCRIPTION: Final = "Time a task waited in the queue." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 1.0, - 5.0, - 25.0, - 100.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="ms", + description="Time a task waited in the queue.", + explicit_bucket_boundaries_advisory=( + 1.0, + 5.0, + 25.0, + 100.0, + ), ) def record(self, amount: float, /) -> None: @@ -110,17 +87,14 @@ class MyappTaskRetries: """A counter never receives boundaries.""" NAME: Final = "myapp.task.retries" - UNIT: Final = "{retry}" - DESCRIPTION: Final = "A counter never receives boundaries." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{retry}", + description="A counter never receives boundaries.", ) def add(self, amount: int, /) -> None: @@ -131,9 +105,6 @@ class MyappTaskRetriesObservable: """Observable form of `MyappTaskRetries`, reported by callbacks.""" NAME: Final = MyappTaskRetries.NAME - UNIT: Final = MyappTaskRetries.UNIT - DESCRIPTION: Final = MyappTaskRetries.DESCRIPTION - KIND: Final = MyappTaskRetries.KIND def __init__( self, @@ -145,8 +116,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{retry}", + description="A counter never receives boundaries.", ) @staticmethod @@ -158,24 +129,20 @@ class MyappTaskStep: """Time one step took, with boundaries from the registry.""" NAME: Final = "myapp.task.step" - UNIT: Final = "s" - DESCRIPTION: Final = "Time one step took, with boundaries from the registry." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.0, - 0.5, - 1.5, - 3.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time one step took, with boundaries from the registry.", + explicit_bucket_boundaries_advisory=( + 0.0, + 0.5, + 1.5, + 3.0, + ), ) def record(self, amount: float, /) -> None: diff --git a/templates/code/python/tests/histogram_boundaries_params/test_consumer.py b/templates/code/python/tests/histogram_boundaries_params/test_consumer.py index 3aa8431..62e4ca7 100644 --- a/templates/code/python/tests/histogram_boundaries_params/test_consumer.py +++ b/templates/code/python/tests/histogram_boundaries_params/test_consumer.py @@ -72,18 +72,6 @@ def advertised(boundaries: tuple[float, ...] | None) -> tuple[float, ...]: return SDK_DEFAULT_BOUNDARIES if boundaries is None else boundaries -def test_the_constants_follow_the_unit_and_the_annotation() -> None: - assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == DURATION_BOUNDARIES - assert metrics.MyappTaskQueueWait.BUCKET_BOUNDARIES == QUEUE_WAIT_BOUNDARIES - assert metrics.MyappTaskPayload.BUCKET_BOUNDARIES == PAYLOAD_BOUNDARIES - assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == STEP_BOUNDARIES - - -def test_a_counter_has_no_bucket_boundaries() -> None: - assert not hasattr(metrics.MyappTaskRetries, "BUCKET_BOUNDARIES") - assert not hasattr(metrics.MyappTaskRetriesObservable, "BUCKET_BOUNDARIES") - - def test_the_sdk_reports_the_advertised_boundaries() -> None: reader, provider = new_provider() meter = metrics.get_meter(provider) @@ -114,5 +102,9 @@ def test_a_value_lands_in_the_bucket_of_the_advertised_boundaries() -> None: def test_the_annotation_overrides_the_unit_default() -> None: - assert metrics.MyappTaskStep.UNIT == "s" - assert metrics.MyappTaskStep.BUCKET_BOUNDARIES == (0.0, 0.5, 1.5, 3.0) + reader, provider = new_provider() + metrics.MyappTaskStep(metrics.get_meter(provider)).record(1.0) + metric = collect(reader)["myapp.task.step"] + assert metric.unit == "s" + (point,) = histogram_points(metric) + assert tuple(point.explicit_bounds) == (0.0, 0.5, 1.5, 3.0) diff --git a/templates/code/python/tests/imported/expected/metrics.py b/templates/code/python/tests/imported/expected/metrics.py index b641839..eb2b82a 100644 --- a/templates/code/python/tests/imported/expected/metrics.py +++ b/templates/code/python/tests/imported/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,34 +24,30 @@ class EdgeUpstreamDuration: """Time spent waiting on an upstream server.""" NAME: Final = "edge.upstream.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time spent waiting on an upstream server." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time spent waiting on an upstream server.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -84,17 +70,14 @@ class MyappTaskFailed: """Number of tasks that failed.""" NAME: Final = "myapp.task.failed" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks that failed." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks that failed.", ) def add( @@ -119,9 +102,6 @@ class MyappTaskFailedObservable: """Observable form of `MyappTaskFailed`, reported by callbacks.""" NAME: Final = MyappTaskFailed.NAME - UNIT: Final = MyappTaskFailed.UNIT - DESCRIPTION: Final = MyappTaskFailed.DESCRIPTION - KIND: Final = MyappTaskFailed.KIND def __init__( self, @@ -133,8 +113,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks that failed.", ) @staticmethod diff --git a/templates/code/python/tests/literal_escaping/expected/metrics.py b/templates/code/python/tests/literal_escaping/expected/metrics.py index 1884e68..dea7fe2 100644 --- a/templates/code/python/tests/literal_escaping/expected/metrics.py +++ b/templates/code/python/tests/literal_escaping/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -36,17 +26,14 @@ class DemoWork: Ends with a backslash \\\\""" NAME: Final = "demo.work" - UNIT: Final = '{"unit"}' - DESCRIPTION: Final = 'Counts "quoted" work with a \\\\ backslash.' - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit='{"unit"}', + description='Counts "quoted" work with a \\\\ backslash.', ) def add( @@ -75,9 +62,6 @@ class DemoWorkObservable: """Observable form of `DemoWork`, reported by callbacks.""" NAME: Final = DemoWork.NAME - UNIT: Final = DemoWork.UNIT - DESCRIPTION: Final = DemoWork.DESCRIPTION - KIND: Final = DemoWork.KIND def __init__( self, @@ -89,8 +73,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit='{"unit"}', + description='Counts "quoted" work with a \\\\ backslash.', ) @staticmethod diff --git a/templates/code/python/tests/literal_escaping/test_consumer.py b/templates/code/python/tests/literal_escaping/test_consumer.py index bbd2ed6..3a9fb34 100644 --- a/templates/code/python/tests/literal_escaping/test_consumer.py +++ b/templates/code/python/tests/literal_escaping/test_consumer.py @@ -79,8 +79,6 @@ def test_enum_values_round_trip() -> None: def test_name_unit_and_description_round_trip() -> None: - assert metrics.DemoWork.UNIT == '{"unit"}' - assert metrics.DemoWork.DESCRIPTION == 'Counts "quoted" work with a \\\\ backslash.' reader, provider = new_provider() metrics.DemoWork(metrics.get_meter(provider)).add(1) metric = collect(reader)["demo.work"] diff --git a/templates/code/python/tests/long_names/expected/metrics.py b/templates/code/python/tests/long_names/expected/metrics.py index 1e1fcab..1a7c991 100644 --- a/templates/code/python/tests/long_names/expected/metrics.py +++ b/templates/code/python/tests/long_names/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,17 +24,14 @@ class MyappTaskLong: """A metric whose parameters make long lines.""" NAME: Final = "myapp.task.long" - UNIT: Final = "{task}" - DESCRIPTION: Final = "A metric whose parameters make long lines." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="A metric whose parameters make long lines.", ) def add( @@ -77,9 +64,6 @@ class MyappTaskLongObservable: """Observable form of `MyappTaskLong`, reported by callbacks.""" NAME: Final = MyappTaskLong.NAME - UNIT: Final = MyappTaskLong.UNIT - DESCRIPTION: Final = MyappTaskLong.DESCRIPTION - KIND: Final = MyappTaskLong.KIND def __init__( self, @@ -91,8 +75,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="A metric whose parameters make long lines.", ) @staticmethod @@ -125,17 +109,14 @@ class MyappTaskLongOptional: """A metric whose optional parameters make long lines.""" NAME: Final = "myapp.task.long.optional" - UNIT: Final = "{task}" - DESCRIPTION: Final = "A metric whose optional parameters make long lines." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="A metric whose optional parameters make long lines.", ) def add( @@ -168,9 +149,6 @@ class MyappTaskLongOptionalObservable: """Observable form of `MyappTaskLongOptional`, reported by callbacks.""" NAME: Final = MyappTaskLongOptional.NAME - UNIT: Final = MyappTaskLongOptional.UNIT - DESCRIPTION: Final = MyappTaskLongOptional.DESCRIPTION - KIND: Final = MyappTaskLongOptional.KIND def __init__( self, @@ -182,8 +160,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="A metric whose optional parameters make long lines.", ) @staticmethod diff --git a/templates/code/python/tests/metric_attributes/expected/metrics.py b/templates/code/python/tests/metric_attributes/expected/metrics.py index 96c7984..aa5bcf9 100644 --- a/templates/code/python/tests/metric_attributes/expected/metrics.py +++ b/templates/code/python/tests/metric_attributes/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,17 +24,14 @@ class MyappTaskArraysOptional: """Every array attribute type, all optional.""" NAME: Final = "myapp.task.arrays.optional" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Every array attribute type, all optional." - KIND: Final = InstrumentKind.GAUGE def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_gauge( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every array attribute type, all optional.", ) def set( @@ -82,9 +69,6 @@ class MyappTaskArraysOptionalObservable: """Observable form of `MyappTaskArraysOptional`, reported by callbacks.""" NAME: Final = MyappTaskArraysOptional.NAME - UNIT: Final = MyappTaskArraysOptional.UNIT - DESCRIPTION: Final = MyappTaskArraysOptional.DESCRIPTION - KIND: Final = MyappTaskArraysOptional.KIND def __init__( self, @@ -96,8 +80,8 @@ def __init__( self._instrument = meter.create_observable_gauge( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every array attribute type, all optional.", ) @staticmethod @@ -135,17 +119,14 @@ class MyappTaskArraysRequired: """Every array attribute type, all required.""" NAME: Final = "myapp.task.arrays.required" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Every array attribute type, all required." - KIND: Final = InstrumentKind.UP_DOWN_COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_up_down_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every array attribute type, all required.", ) def add( @@ -171,9 +152,6 @@ class MyappTaskArraysRequiredObservable: """Observable form of `MyappTaskArraysRequired`, reported by callbacks.""" NAME: Final = MyappTaskArraysRequired.NAME - UNIT: Final = MyappTaskArraysRequired.UNIT - DESCRIPTION: Final = MyappTaskArraysRequired.DESCRIPTION - KIND: Final = MyappTaskArraysRequired.KIND def __init__( self, @@ -185,8 +163,8 @@ def __init__( self._instrument = meter.create_observable_up_down_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every array attribute type, all required.", ) @staticmethod @@ -212,17 +190,14 @@ class MyappTaskEnumsOptional: """Every enum attribute type, all optional.""" NAME: Final = "myapp.task.enums.optional" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Every enum attribute type, all optional." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every enum attribute type, all optional.", ) def add( @@ -254,9 +229,6 @@ class MyappTaskEnumsOptionalObservable: """Observable form of `MyappTaskEnumsOptional`, reported by callbacks.""" NAME: Final = MyappTaskEnumsOptional.NAME - UNIT: Final = MyappTaskEnumsOptional.UNIT - DESCRIPTION: Final = MyappTaskEnumsOptional.DESCRIPTION - KIND: Final = MyappTaskEnumsOptional.KIND def __init__( self, @@ -268,8 +240,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every enum attribute type, all optional.", ) @staticmethod @@ -301,17 +273,14 @@ class MyappTaskEnumsRequired: """Every enum attribute type, all required.""" NAME: Final = "myapp.task.enums.required" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Every enum attribute type, all required." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every enum attribute type, all required.", ) def add( @@ -339,9 +308,6 @@ class MyappTaskEnumsRequiredObservable: """Observable form of `MyappTaskEnumsRequired`, reported by callbacks.""" NAME: Final = MyappTaskEnumsRequired.NAME - UNIT: Final = MyappTaskEnumsRequired.UNIT - DESCRIPTION: Final = MyappTaskEnumsRequired.DESCRIPTION - KIND: Final = MyappTaskEnumsRequired.KIND def __init__( self, @@ -353,8 +319,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every enum attribute type, all required.", ) @staticmethod @@ -382,34 +348,30 @@ class MyappTaskMixed: """Required and optional attributes together.""" NAME: Final = "myapp.task.mixed" - UNIT: Final = "s" - DESCRIPTION: Final = "Required and optional attributes together." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1.0, - 2.5, - 5.0, - 7.5, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Required and optional attributes together.", + explicit_bucket_boundaries_advisory=( + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), ) def record( @@ -440,17 +402,14 @@ class MyappTaskScalarsOptional: """Every scalar attribute type, all optional.""" NAME: Final = "myapp.task.scalars.optional" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Every scalar attribute type, all optional." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every scalar attribute type, all optional.", ) def add( @@ -479,9 +438,6 @@ class MyappTaskScalarsOptionalObservable: """Observable form of `MyappTaskScalarsOptional`, reported by callbacks.""" NAME: Final = MyappTaskScalarsOptional.NAME - UNIT: Final = MyappTaskScalarsOptional.UNIT - DESCRIPTION: Final = MyappTaskScalarsOptional.DESCRIPTION - KIND: Final = MyappTaskScalarsOptional.KIND def __init__( self, @@ -493,8 +449,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every scalar attribute type, all optional.", ) @staticmethod @@ -523,17 +479,14 @@ class MyappTaskScalarsRequired: """Every scalar attribute type, all required.""" NAME: Final = "myapp.task.scalars.required" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Every scalar attribute type, all required." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every scalar attribute type, all required.", ) def add( @@ -559,9 +512,6 @@ class MyappTaskScalarsRequiredObservable: """Observable form of `MyappTaskScalarsRequired`, reported by callbacks.""" NAME: Final = MyappTaskScalarsRequired.NAME - UNIT: Final = MyappTaskScalarsRequired.UNIT - DESCRIPTION: Final = MyappTaskScalarsRequired.DESCRIPTION - KIND: Final = MyappTaskScalarsRequired.KIND def __init__( self, @@ -573,8 +523,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Every scalar attribute type, all required.", ) @staticmethod diff --git a/templates/code/python/tests/metrics/expected/metrics.py b/templates/code/python/tests/metrics/expected/metrics.py index 1b3fad1..0b506ab 100644 --- a/templates/code/python/tests/metrics/expected/metrics.py +++ b/templates/code/python/tests/metrics/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,17 +24,14 @@ class AuthAttempts: """Number of authentication attempts.""" NAME: Final = "auth.attempts" - UNIT: Final = "{attempt}" - DESCRIPTION: Final = "Number of authentication attempts." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{attempt}", + description="Number of authentication attempts.", ) def add( @@ -66,9 +53,6 @@ class AuthAttemptsObservable: """Observable form of `AuthAttempts`, reported by callbacks.""" NAME: Final = AuthAttempts.NAME - UNIT: Final = AuthAttempts.UNIT - DESCRIPTION: Final = AuthAttempts.DESCRIPTION - KIND: Final = AuthAttempts.KIND def __init__( self, @@ -80,8 +64,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{attempt}", + description="Number of authentication attempts.", ) @staticmethod @@ -105,17 +89,14 @@ class MyappQueueDepth: Deprecated: Replaced by `myapp.task.active`.""" NAME: Final = "myapp.queue.depth" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks waiting in the queue." - KIND: Final = InstrumentKind.GAUGE def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_gauge( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks waiting in the queue.", ) def set(self, amount: int, /) -> None: @@ -126,9 +107,6 @@ class MyappQueueDepthObservable: """Observable form of `MyappQueueDepth`, reported by callbacks.""" NAME: Final = MyappQueueDepth.NAME - UNIT: Final = MyappQueueDepth.UNIT - DESCRIPTION: Final = MyappQueueDepth.DESCRIPTION - KIND: Final = MyappQueueDepth.KIND def __init__( self, @@ -140,8 +118,8 @@ def __init__( self._instrument = meter.create_observable_gauge( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks waiting in the queue.", ) @staticmethod @@ -153,17 +131,14 @@ class MyappTaskActive: """Number of tasks currently running.""" NAME: Final = "myapp.task.active" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks currently running." - KIND: Final = InstrumentKind.UP_DOWN_COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_up_down_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks currently running.", ) def add( @@ -183,9 +158,6 @@ class MyappTaskActiveObservable: """Observable form of `MyappTaskActive`, reported by callbacks.""" NAME: Final = MyappTaskActive.NAME - UNIT: Final = MyappTaskActive.UNIT - DESCRIPTION: Final = MyappTaskActive.DESCRIPTION - KIND: Final = MyappTaskActive.KIND def __init__( self, @@ -197,8 +169,8 @@ def __init__( self._instrument = meter.create_observable_up_down_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks currently running.", ) @staticmethod @@ -220,26 +192,22 @@ class MyappTaskDuration: Measured by the worker that ran the task.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.0, - 0.005, - 0.01, - 0.1, - 1.0, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.0, + 0.005, + 0.01, + 0.1, + 1.0, + 10.0, + ), ) def record( @@ -272,17 +240,14 @@ class MyappTaskFloatActive: """Fractional active task adjustment.""" NAME: Final = "myapp.task.float.active" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Fractional active task adjustment." - KIND: Final = InstrumentKind.UP_DOWN_COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_up_down_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Fractional active task adjustment.", ) def add(self, amount: float, /) -> None: @@ -293,9 +258,6 @@ class MyappTaskFloatActiveObservable: """Observable form of `MyappTaskFloatActive`, reported by callbacks.""" NAME: Final = MyappTaskFloatActive.NAME - UNIT: Final = MyappTaskFloatActive.UNIT - DESCRIPTION: Final = MyappTaskFloatActive.DESCRIPTION - KIND: Final = MyappTaskFloatActive.KIND def __init__( self, @@ -307,8 +269,8 @@ def __init__( self._instrument = meter.create_observable_up_down_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Fractional active task adjustment.", ) @staticmethod @@ -320,17 +282,14 @@ class MyappTaskFloatDepth: """Fractional queue depth.""" NAME: Final = "myapp.task.float.depth" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Fractional queue depth." - KIND: Final = InstrumentKind.GAUGE def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_gauge( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Fractional queue depth.", ) def set(self, amount: float, /) -> None: @@ -341,9 +300,6 @@ class MyappTaskFloatDepthObservable: """Observable form of `MyappTaskFloatDepth`, reported by callbacks.""" NAME: Final = MyappTaskFloatDepth.NAME - UNIT: Final = MyappTaskFloatDepth.UNIT - DESCRIPTION: Final = MyappTaskFloatDepth.DESCRIPTION - KIND: Final = MyappTaskFloatDepth.KIND def __init__( self, @@ -355,8 +311,8 @@ def __init__( self._instrument = meter.create_observable_gauge( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Fractional queue depth.", ) @staticmethod @@ -368,17 +324,14 @@ class MyappTaskFloatStarted: """Fractional tasks started.""" NAME: Final = "myapp.task.float.started" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Fractional tasks started." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Fractional tasks started.", ) def add(self, amount: float, /) -> None: @@ -389,9 +342,6 @@ class MyappTaskFloatStartedObservable: """Observable form of `MyappTaskFloatStarted`, reported by callbacks.""" NAME: Final = MyappTaskFloatStarted.NAME - UNIT: Final = MyappTaskFloatStarted.UNIT - DESCRIPTION: Final = MyappTaskFloatStarted.DESCRIPTION - KIND: Final = MyappTaskFloatStarted.KIND def __init__( self, @@ -403,8 +353,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Fractional tasks started.", ) @staticmethod @@ -416,23 +366,19 @@ class MyappTaskPayload: """Task payload size.""" NAME: Final = "myapp.task.payload" - UNIT: Final = "By" - DESCRIPTION: Final = "Task payload size." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 128.0, - 256.0, - 1024.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="By", + description="Task payload size.", + explicit_bucket_boundaries_advisory=( + 128.0, + 256.0, + 1024.0, + ), ) def record(self, amount: int, /) -> None: @@ -445,26 +391,22 @@ class MyappTaskRetryDuration: Recorded only for tasks that ran more than once.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.0, - 0.005, - 0.01, - 0.1, - 1.0, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.0, + 0.005, + 0.01, + 0.1, + 1.0, + 10.0, + ), ) def record( @@ -497,17 +439,14 @@ class MyappTaskStarted: Opt-in: the convention records it only when a user asks for it.""" NAME: Final = "myapp.task.started" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks started." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) def add( @@ -533,9 +472,6 @@ class MyappTaskStartedObservable: """Observable form of `MyappTaskStarted`, reported by callbacks.""" NAME: Final = MyappTaskStarted.NAME - UNIT: Final = MyappTaskStarted.UNIT - DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION - KIND: Final = MyappTaskStarted.KIND def __init__( self, @@ -547,8 +483,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) @staticmethod @@ -576,26 +512,22 @@ class WorkerTaskDuration: Measured by the worker that ran the task.""" NAME: Final = "myapp.task.duration" - UNIT: Final = "s" - DESCRIPTION: Final = "Time a task took from start to finish." - KIND: Final = InstrumentKind.HISTOGRAM - BUCKET_BOUNDARIES: Final[tuple[float, ...] | None] = ( - 0.0, - 0.005, - 0.01, - 0.1, - 1.0, - 10.0, - ) def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_histogram( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, - explicit_bucket_boundaries_advisory=self.BUCKET_BOUNDARIES, + unit="s", + description="Time a task took from start to finish.", + explicit_bucket_boundaries_advisory=( + 0.0, + 0.005, + 0.01, + 0.1, + 1.0, + 10.0, + ), ) def record( diff --git a/templates/code/python/tests/metrics/test_consumer.py b/templates/code/python/tests/metrics/test_consumer.py index f767a07..b74e399 100644 --- a/templates/code/python/tests/metrics/test_consumer.py +++ b/templates/code/python/tests/metrics/test_consumer.py @@ -66,24 +66,7 @@ def test_scope_carries_the_schema_url() -> None: def test_constants_match_the_registry() -> None: assert SCHEMA_URL == "https://example.com/schemas/metrics/1.0.0" assert metrics.MyappTaskDuration.NAME == "myapp.task.duration" - assert metrics.MyappTaskDuration.UNIT == "s" - assert metrics.MyappTaskDuration.DESCRIPTION == ( - "Time a task took from start to finish." - ) - assert metrics.MyappTaskDuration.KIND is metrics.InstrumentKind.HISTOGRAM - assert metrics.MyappTaskDuration.BUCKET_BOUNDARIES == ( - 0.0, - 0.005, - 0.01, - 0.1, - 1.0, - 10.0, - ) - assert metrics.MyappTaskStarted.KIND is metrics.InstrumentKind.COUNTER - assert metrics.MyappTaskActive.KIND is metrics.InstrumentKind.UP_DOWN_COUNTER - assert metrics.MyappQueueDepth.KIND is metrics.InstrumentKind.GAUGE - assert metrics.MyappTaskStarted.UNIT == "{task}" - assert metrics.InstrumentKind.UP_DOWN_COUNTER.value == "updowncounter" + assert metrics.MyappTaskStarted.NAME == "myapp.task.started" assert attributes.MYAPP_TASK_ID_KEY == "myapp.task.id" assert attributes.MYAPP_TASK_STATE_KEY == "myapp.task.state" assert attributes.AUTH_SUCCESS_KEY == "auth.success" @@ -127,9 +110,10 @@ def test_histogram_reports_the_registry_boundaries() -> None: def test_integer_histogram_reports_its_own_boundaries() -> None: reader, provider = new_provider() metrics.MyappTaskPayload(metrics.get_meter(provider)).record(200) - (point,) = histogram_points(collect(reader)["myapp.task.payload"]) + metric = collect(reader)["myapp.task.payload"] + assert metric.unit == "By" + (point,) = histogram_points(metric) assert point.explicit_bounds == (128.0, 256.0, 1024.0) - assert metrics.MyappTaskPayload.UNIT == "By" def test_counter_adds_attributes() -> None: @@ -199,10 +183,6 @@ def test_refinements_record_into_the_base_instrument() -> None: metrics.WorkerTaskDuration(meter).record(3.0, myapp_task_id="worker") assert metrics.MyappTaskRetryDuration.NAME == "myapp.task.duration" assert metrics.WorkerTaskDuration.NAME == "myapp.task.duration" - assert ( - metrics.WorkerTaskDuration.BUCKET_BOUNDARIES - == metrics.MyappTaskDuration.BUCKET_BOUNDARIES - ) metric = collect(reader)["myapp.task.duration"] ids = sorted( str(attributes_of(point)["myapp.task.id"]) for point in histogram_points(metric) @@ -239,7 +219,7 @@ def float_depth(_: CallbackOptions) -> Iterable[Observation]: assert number_points(collected["myapp.task.active"])[0].value == 2 assert number_points(collected["myapp.task.float.depth"])[0].value == 1.5 assert metrics.MyappTaskStartedObservable.NAME == metrics.MyappTaskStarted.NAME - assert metrics.MyappTaskStartedObservable.UNIT == metrics.MyappTaskStarted.UNIT + assert collected["myapp.task.started"].unit == "{task}" def test_required_attributes_cannot_be_omitted() -> None: diff --git a/templates/code/python/tests/naming/expected/metrics.py b/templates/code/python/tests/naming/expected/metrics.py index 90c9a47..398202e 100644 --- a/templates/code/python/tests/naming/expected/metrics.py +++ b/templates/code/python/tests/naming/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,17 +24,14 @@ class HttpServerRequestCount: """Requests with underscores in the name.""" NAME: Final = "http.server.request_count" - UNIT: Final = "{request}" - DESCRIPTION: Final = "Requests with underscores in the name." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{request}", + description="Requests with underscores in the name.", ) def add( @@ -69,9 +56,6 @@ class HttpServerRequestCountObservable: """Observable form of `HttpServerRequestCount`, reported by callbacks.""" NAME: Final = HttpServerRequestCount.NAME - UNIT: Final = HttpServerRequestCount.UNIT - DESCRIPTION: Final = HttpServerRequestCount.DESCRIPTION - KIND: Final = HttpServerRequestCount.KIND def __init__( self, @@ -83,8 +67,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{request}", + description="Requests with underscores in the name.", ) @staticmethod @@ -109,17 +93,14 @@ class K8sPodCpuTime: """Metric name with a digit.""" NAME: Final = "k8s.pod.cpu.time" - UNIT: Final = "s" - DESCRIPTION: Final = "Metric name with a digit." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="s", + description="Metric name with a digit.", ) def add(self, amount: float, /) -> None: @@ -130,9 +111,6 @@ class K8sPodCpuTimeObservable: """Observable form of `K8sPodCpuTime`, reported by callbacks.""" NAME: Final = K8sPodCpuTime.NAME - UNIT: Final = K8sPodCpuTime.UNIT - DESCRIPTION: Final = K8sPodCpuTime.DESCRIPTION - KIND: Final = K8sPodCpuTime.KIND def __init__( self, @@ -144,8 +122,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="s", + description="Metric name with a digit.", ) @staticmethod @@ -157,17 +135,14 @@ class Requests: """A metric without a namespace.""" NAME: Final = "requests" - UNIT: Final = "{request}" - DESCRIPTION: Final = "A metric without a namespace." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{request}", + description="A metric without a namespace.", ) def add( @@ -190,9 +165,6 @@ class RequestsObservable: """Observable form of `Requests`, reported by callbacks.""" NAME: Final = Requests.NAME - UNIT: Final = Requests.UNIT - DESCRIPTION: Final = Requests.DESCRIPTION - KIND: Final = Requests.KIND def __init__( self, @@ -204,8 +176,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{request}", + description="A metric without a namespace.", ) @staticmethod diff --git a/templates/code/python/tests/params/expected/metrics.py b/templates/code/python/tests/params/expected/metrics.py index aac6ab2..482574d 100644 --- a/templates/code/python/tests/params/expected/metrics.py +++ b/templates/code/python/tests/params/expected/metrics.py @@ -1,7 +1,6 @@ # Code generated by weaver from the synthetic billing registry. DO NOT EDIT. # Run `mise run semconv:generate` after changing a convention. -import enum from collections.abc import Sequence from typing import Final @@ -12,15 +11,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -35,17 +25,14 @@ class BillingInvoiceCreated: """Number of invoices created.""" NAME: Final = "acme.billing.invoice.created" - UNIT: Final = "{invoice}" - DESCRIPTION: Final = "Number of invoices created." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{invoice}", + description="Number of invoices created.", ) def add( @@ -73,9 +60,6 @@ class BillingInvoiceCreatedObservable: """Observable form of `BillingInvoiceCreated`, reported by callbacks.""" NAME: Final = BillingInvoiceCreated.NAME - UNIT: Final = BillingInvoiceCreated.UNIT - DESCRIPTION: Final = BillingInvoiceCreated.DESCRIPTION - KIND: Final = BillingInvoiceCreated.KIND def __init__( self, @@ -87,8 +71,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{invoice}", + description="Number of invoices created.", ) @staticmethod diff --git a/templates/code/python/tests/unsupported_signals/expected/metrics.py b/templates/code/python/tests/unsupported_signals/expected/metrics.py index 4ea3648..4d7520b 100644 --- a/templates/code/python/tests/unsupported_signals/expected/metrics.py +++ b/templates/code/python/tests/unsupported_signals/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,17 +24,14 @@ class MyappTaskStarted: """Number of tasks started.""" NAME: Final = "myapp.task.started" - UNIT: Final = "{task}" - DESCRIPTION: Final = "Number of tasks started." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) def add( @@ -64,9 +51,6 @@ class MyappTaskStartedObservable: """Observable form of `MyappTaskStarted`, reported by callbacks.""" NAME: Final = MyappTaskStarted.NAME - UNIT: Final = MyappTaskStarted.UNIT - DESCRIPTION: Final = MyappTaskStarted.DESCRIPTION - KIND: Final = MyappTaskStarted.KIND def __init__( self, @@ -78,8 +62,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{task}", + description="Number of tasks started.", ) @staticmethod diff --git a/templates/code/python/tests/unsupported_signals/test_consumer.py b/templates/code/python/tests/unsupported_signals/test_consumer.py index d9ee26f..a0d8a27 100644 --- a/templates/code/python/tests/unsupported_signals/test_consumer.py +++ b/templates/code/python/tests/unsupported_signals/test_consumer.py @@ -71,7 +71,6 @@ def test_only_metric_classes_are_defined() -> None: if member.__module__ == metrics.__name__ } assert defined == { - "InstrumentKind", "MyappTaskStarted", "MyappTaskStartedObservable", } diff --git a/templates/code/python/tests/vendor_prefix_off/expected/metrics.py b/templates/code/python/tests/vendor_prefix_off/expected/metrics.py index 9754d7f..5ecf0b7 100644 --- a/templates/code/python/tests/vendor_prefix_off/expected/metrics.py +++ b/templates/code/python/tests/vendor_prefix_off/expected/metrics.py @@ -1,6 +1,5 @@ # Code generated by weaver. DO NOT EDIT. -import enum from collections.abc import Sequence from typing import Final @@ -11,15 +10,6 @@ from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes -class InstrumentKind(enum.StrEnum): - """Kind of an OpenTelemetry instrument.""" - - COUNTER = "counter" - UP_DOWN_COUNTER = "updowncounter" - GAUGE = "gauge" - HISTOGRAM = "histogram" - - def get_meter(meter_provider: MeterProvider | None = None) -> Meter: """Return the meter that is tied to the schema URL of the registry.""" return otel_metrics.get_meter( @@ -34,17 +24,14 @@ class AcmeBillingInvoiceCreated: """Number of invoices created.""" NAME: Final = "acme.billing.invoice.created" - UNIT: Final = "{invoice}" - DESCRIPTION: Final = "Number of invoices created." - KIND: Final = InstrumentKind.COUNTER def __init__(self, meter: Meter | None = None) -> None: if meter is None: meter = get_meter() self._instrument = meter.create_counter( self.NAME, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{invoice}", + description="Number of invoices created.", ) def add( @@ -72,9 +59,6 @@ class AcmeBillingInvoiceCreatedObservable: """Observable form of `AcmeBillingInvoiceCreated`, reported by callbacks.""" NAME: Final = AcmeBillingInvoiceCreated.NAME - UNIT: Final = AcmeBillingInvoiceCreated.UNIT - DESCRIPTION: Final = AcmeBillingInvoiceCreated.DESCRIPTION - KIND: Final = AcmeBillingInvoiceCreated.KIND def __init__( self, @@ -86,8 +70,8 @@ def __init__( self._instrument = meter.create_observable_counter( self.NAME, callbacks=callbacks, - unit=self.UNIT, - description=self.DESCRIPTION, + unit="{invoice}", + description="Number of invoices created.", ) @staticmethod diff --git a/templates/code/python/weaver.yaml b/templates/code/python/weaver.yaml index a7a3c4e..0934631 100644 --- a/templates/code/python/weaver.yaml +++ b/templates/code/python/weaver.yaml @@ -193,8 +193,7 @@ templates: elif .instrument == "histogram" and ($unit | type) == "string" and ($histogram_boundaries_by_unit | has($unit)) then . + {bucket_boundaries: ($histogram_boundaries_by_unit[$unit] | boundary_literals($metric; "histogram_boundaries_by_unit for unit \($unit)"))} else . end) - | map(. + {class_name: (.name | title), constant_kind: ({"counter": "COUNTER", "updowncounter": "UP_DOWN_COUNTER", "histogram": "HISTOGRAM", "gauge": "GAUGE"}[.instrument]), - value_type: (if .annotations.code_generation.metric_value_type == "int" then "int" else "float" end), + | map(. + {class_name: (.name | title), value_type: (if .annotations.code_generation.metric_value_type == "int" then "int" else "float" end), create_method: ({"counter": "create_counter", "updowncounter": "create_up_down_counter", "histogram": "create_histogram", "gauge": "create_gauge"}[.instrument]), observable_method: ({"counter": "create_observable_counter", "updowncounter": "create_observable_up_down_counter", "gauge": "create_observable_gauge"}[.instrument]), record_method: ({"counter": "add", "updowncounter": "add", "histogram": "record", "gauge": "set"}[.instrument]), @@ -245,7 +244,7 @@ templates: ([.[] | .name as $metric | .class_name as $class | .instrument as $kind | ([$class] + (if $kind == "histogram" then [] else [$class + "Observable"] end))[] | {symbol: ., name: $metric}] - + (["InstrumentKind", "Final", "Sequence", "Meter", "MeterProvider", "CallbackT", "Observation", "AttributeValue", "SCHEMA_URL", "INSTRUMENTATION_NAME", "INSTRUMENTATION_VERSION", "None", "True", "False", "attributes", "get_meter", "otel_metrics", "enum"] + + (["Final", "Sequence", "Meter", "MeterProvider", "CallbackT", "Observation", "AttributeValue", "SCHEMA_URL", "INSTRUMENTATION_NAME", "INSTRUMENTATION_VERSION", "None", "True", "False", "attributes", "get_meter", "otel_metrics"] | map({symbol: ., name: "a reserved Python symbol"})) | group_by(.symbol) | map(select(length > 1) | map(.name) | unique | join(" and ")) From 04e009f6a7f2ef3a71985c8718d6e18a39dae186 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 31 Oct 2026 11:15:39 -0400 Subject: [PATCH 3/3] fix(python): preserve context and numeric enum types Signed-off-by: Yordis Prieto --- templates/code/python/README.md | 17 +++ templates/code/python/metrics.py.j2 | 14 +- .../tests/attributes/registry/model.yaml | 2 +- .../error_context_class/expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../error_context_class/registry/model.yaml | 11 ++ .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 19 +++ .../dependency/manifest.yaml | 2 + .../dependency/model.yaml | 15 +++ .../expected-error.txt | 1 + .../registry/manifest.yaml | 6 + .../registry/model.yaml | 14 ++ .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 11 ++ .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 11 ++ .../expected-error.txt | 1 + .../registry/manifest.yaml | 2 + .../registry/model.yaml | 15 +++ .../tests/filtering_both/expected/metrics.py | 10 +- .../tests/filtering_both/test_consumer.py | 4 +- .../expected/metrics.py | 17 ++- .../test_consumer.py | 4 +- .../filtering_stable_only/expected/metrics.py | 17 ++- .../filtering_stable_only/test_consumer.py | 4 +- .../filtering_unfiltered/expected/metrics.py | 24 ++-- .../filtering_unfiltered/test_consumer.py | 4 +- .../histogram_boundaries/expected/metrics.py | 21 +-- .../expected/metrics.py | 21 +-- .../expected/metrics.py | 21 +-- .../python/tests/imported/expected/metrics.py | 7 +- .../dependency/manifest.yaml | 2 + .../integer_enum_limits/dependency/model.yaml | 11 ++ .../integer_enum_limits/expected/__init__.py | 12 ++ .../expected/attributes.py | 25 ++++ .../integer_enum_limits/expected/metrics.py | 85 ++++++++++++ .../registry/manifest.yaml | 6 + .../integer_enum_limits/registry/model.yaml | 46 +++++++ .../integer_enum_limits/test_consumer.py | 36 +++++ .../dependency/manifest.yaml | 2 + .../dependency/model.yaml | 15 +++ .../integral_double_enum/expected/__init__.py | 12 ++ .../expected/attributes.py | 17 +++ .../integral_double_enum/expected/metrics.py | 85 ++++++++++++ .../registry/manifest.yaml | 6 + .../integral_double_enum/registry/model.yaml | 30 +++++ .../integral_double_enum/test_consumer.py | 31 +++++ .../literal_escaping/expected/metrics.py | 4 +- .../tests/long_names/expected/metrics.py | 7 +- .../metric_attributes/expected/metrics.py | 22 ++- .../metric_attributes/registry/model.yaml | 2 +- .../python/tests/metrics/expected/metrics.py | 39 +++--- .../python/tests/metrics/test_consumer.py | 125 +++++++++++++++++- .../python/tests/naming/expected/metrics.py | 11 +- .../python/tests/params/expected/metrics.py | 4 +- .../code/python/tests/params/test_consumer.py | 1 + .../unsupported_signals/expected/metrics.py | 4 +- .../vendor_prefix_off/expected/metrics.py | 4 +- .../tests/vendor_prefix_off/test_consumer.py | 1 + templates/code/python/weaver.yaml | 17 +-- 64 files changed, 857 insertions(+), 112 deletions(-) create mode 100644 templates/code/python/tests/error_context_class/expected-error.txt create mode 100644 templates/code/python/tests/error_context_class/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_context_class/registry/model.yaml create mode 100644 templates/code/python/tests/error_context_parameter/expected-error.txt create mode 100644 templates/code/python/tests/error_context_parameter/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_context_parameter/registry/model.yaml create mode 100644 templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/manifest.yaml create mode 100644 templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/model.yaml create mode 100644 templates/code/python/tests/error_imported_mixed_numeric_enum/expected-error.txt create mode 100644 templates/code/python/tests/error_imported_mixed_numeric_enum/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_imported_mixed_numeric_enum/registry/model.yaml create mode 100644 templates/code/python/tests/error_integer_enum_underflow/expected-error.txt create mode 100644 templates/code/python/tests/error_integer_enum_underflow/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_integer_enum_underflow/registry/model.yaml create mode 100644 templates/code/python/tests/error_integral_double_range/expected-error.txt create mode 100644 templates/code/python/tests/error_integral_double_range/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_integral_double_range/registry/model.yaml create mode 100644 templates/code/python/tests/error_mixed_numeric_enum/expected-error.txt create mode 100644 templates/code/python/tests/error_mixed_numeric_enum/registry/manifest.yaml create mode 100644 templates/code/python/tests/error_mixed_numeric_enum/registry/model.yaml create mode 100644 templates/code/python/tests/integer_enum_limits/dependency/manifest.yaml create mode 100644 templates/code/python/tests/integer_enum_limits/dependency/model.yaml create mode 100644 templates/code/python/tests/integer_enum_limits/expected/__init__.py create mode 100644 templates/code/python/tests/integer_enum_limits/expected/attributes.py create mode 100644 templates/code/python/tests/integer_enum_limits/expected/metrics.py create mode 100644 templates/code/python/tests/integer_enum_limits/registry/manifest.yaml create mode 100644 templates/code/python/tests/integer_enum_limits/registry/model.yaml create mode 100644 templates/code/python/tests/integer_enum_limits/test_consumer.py create mode 100644 templates/code/python/tests/integral_double_enum/dependency/manifest.yaml create mode 100644 templates/code/python/tests/integral_double_enum/dependency/model.yaml create mode 100644 templates/code/python/tests/integral_double_enum/expected/__init__.py create mode 100644 templates/code/python/tests/integral_double_enum/expected/attributes.py create mode 100644 templates/code/python/tests/integral_double_enum/expected/metrics.py create mode 100644 templates/code/python/tests/integral_double_enum/registry/manifest.yaml create mode 100644 templates/code/python/tests/integral_double_enum/registry/model.yaml create mode 100644 templates/code/python/tests/integral_double_enum/test_consumer.py diff --git a/templates/code/python/README.md b/templates/code/python/README.md index 8d9a475..d343e52 100644 --- a/templates/code/python/README.md +++ b/templates/code/python/README.md @@ -79,6 +79,18 @@ Constants are named `_KEY`. An enum class has the title-cased name of the attribute, without the `_KEY` suffix. Deprecated attributes and members carry a `Deprecated:` line in their docstring. +Integer enum values retain the full signed 64-bit range. Floating-point values +such as `1.0` and `1.0e3` produce floating-point enum members, even without a +fractional part. A generated numeric enum class whose members all have no +fractional part must stay within the signed 64-bit range. Weaver's resolver can +convert an overflowing integer to a double before generation, so the template +cannot distinguish that value from a floating-point value authored in the +registry. The range check rejects both forms conservatively. + +An enum must use one value type. Integer and floating-point members cannot be +mixed, because conversion to floating point can lose integer precision. Write +`1.0` when a floating-point enum includes a whole-number value. + ### Metrics Each metric becomes one class. `NAME` is the name the metric records under, for @@ -104,6 +116,11 @@ other requirement level is an optional parameter that defaults to `None` and is left out of the recorded attributes when `None`. Enum members are recorded by value, and array attributes are copied into a list at record time. +Synchronous methods accept an optional keyword-only `context` argument of type +`opentelemetry.context.Context`. It passes unchanged to the API. When omitted, +the SDK uses the current context. Required attributes remain required when a +context is supplied. Observable observations have no context argument. + | Instrument kind | Class method | Observable class method and constructor | | --------------- | ------------ | ------------------------------------------------- | | `counter` | `add` | `Observable.observation`, `Observable(callbacks)` | diff --git a/templates/code/python/metrics.py.j2 b/templates/code/python/metrics.py.j2 index eaf57f8..d3579e8 100644 --- a/templates/code/python/metrics.py.j2 +++ b/templates/code/python/metrics.py.j2 @@ -1,6 +1,6 @@ {%- filter trim %} {% import "header.j2" as h %} -{% macro parameters(metric) %} +{% macro parameters(metric, with_context=false) %} *, {% for attribute in metric.required_attributes %} {{ attribute.field }}: {{ attribute.python_type }}, @@ -8,6 +8,9 @@ {% for attribute in metric.optional_attributes %} {{ attribute.field }}: {{ attribute.python_type }} | None = None, {% endfor %} +{% if with_context %} + context: Context | None = None, +{% endif %} {%- endmacro %} {% macro attribute_dict(metric) %} {% if metric.required_attributes %} @@ -59,6 +62,7 @@ from collections.abc import Sequence from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import {{ ctx.api_imports | join(", ") }} {% if ctx.has_attributes %} from opentelemetry.util.types import AttributeValue @@ -106,17 +110,17 @@ class {{ metric.class_name }}: self, amount: {{ metric.value_type }}, /, -{{ parameters(metric) | trim("\n") }} +{{ parameters(metric, true) | trim("\n") }} ) -> None: {% if metric.attributes | selectattr("requirement_doc") | list %} {{ argument_docs(metric, "Add to the counter." if metric.record_method == "add" else ("Set the gauge." if metric.record_method == "set" else "Record a measurement.")) | trim("\n") }} {% endif %} {{ attribute_dict(metric) | trim("\n") }} - self._instrument.{{ metric.record_method }}(amount, _attributes) + self._instrument.{{ metric.record_method }}(amount, _attributes, context=context) {% else %} - def {{ metric.record_method }}(self, amount: {{ metric.value_type }}, /) -> None: - self._instrument.{{ metric.record_method }}(amount) + def {{ metric.record_method }}(self, amount: {{ metric.value_type }}, /, *, context: Context | None = None) -> None: + self._instrument.{{ metric.record_method }}(amount, context=context) {% endif %} {% if metric.instrument != "histogram" %} diff --git a/templates/code/python/tests/attributes/registry/model.yaml b/templates/code/python/tests/attributes/registry/model.yaml index 733a8bf..754606c 100644 --- a/templates/code/python/tests/attributes/registry/model.yaml +++ b/templates/code/python/tests/attributes/registry/model.yaml @@ -122,7 +122,7 @@ attributes: stability: stable brief: One task in ten is sampled. - id: all - value: 1 + value: 1.0 stability: stable brief: Every task is sampled. stability: stable diff --git a/templates/code/python/tests/error_context_class/expected-error.txt b/templates/code/python/tests/error_context_class/expected-error.txt new file mode 100644 index 0000000..010858b --- /dev/null +++ b/templates/code/python/tests/error_context_class/expected-error.txt @@ -0,0 +1 @@ +module metrics has metrics that render to the same Python identifier: a reserved Python symbol and context diff --git a/templates/code/python/tests/error_context_class/registry/manifest.yaml b/templates/code/python/tests/error_context_class/registry/manifest.yaml new file mode 100644 index 0000000..a59147d --- /dev/null +++ b/templates/code/python/tests/error_context_class/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: context-class +schema_url: https://example.com/schemas/context-class/1.0.0 diff --git a/templates/code/python/tests/error_context_class/registry/model.yaml b/templates/code/python/tests/error_context_class/registry/model.yaml new file mode 100644 index 0000000..9c6c02f --- /dev/null +++ b/templates/code/python/tests/error_context_class/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +metrics: + - name: context + requirement_level: recommended + instrument: counter + unit: "{record}" + stability: stable + brief: Synthetic metric. + annotations: + code_generation: + metric_value_type: int diff --git a/templates/code/python/tests/error_context_parameter/expected-error.txt b/templates/code/python/tests/error_context_parameter/expected-error.txt new file mode 100644 index 0000000..83756a1 --- /dev/null +++ b/templates/code/python/tests/error_context_parameter/expected-error.txt @@ -0,0 +1 @@ +metric demo.recorded has attribute demo.context, which becomes the reserved Python parameter context diff --git a/templates/code/python/tests/error_context_parameter/registry/manifest.yaml b/templates/code/python/tests/error_context_parameter/registry/manifest.yaml new file mode 100644 index 0000000..1c39e09 --- /dev/null +++ b/templates/code/python/tests/error_context_parameter/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: context-parameter +schema_url: https://example.com/schemas/context-parameter/1.0.0 diff --git a/templates/code/python/tests/error_context_parameter/registry/model.yaml b/templates/code/python/tests/error_context_parameter/registry/model.yaml new file mode 100644 index 0000000..8dbe6ef --- /dev/null +++ b/templates/code/python/tests/error_context_parameter/registry/model.yaml @@ -0,0 +1,19 @@ +file_format: definition/2 +attributes: + - key: demo.context + type: string + stability: stable + brief: Synthetic context label. + examples: [example] +metrics: + - name: demo.recorded + instrument: counter + unit: "{record}" + stability: stable + brief: Synthetic records. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: demo.context + requirement_level: required diff --git a/templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/manifest.yaml b/templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/manifest.yaml new file mode 100644 index 0000000..6fe2cd0 --- /dev/null +++ b/templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/manifest.yaml @@ -0,0 +1,2 @@ +name: upstream +schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/model.yaml b/templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/model.yaml new file mode 100644 index 0000000..08bdbab --- /dev/null +++ b/templates/code/python/tests/error_imported_mixed_numeric_enum/dependency/model.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +attributes: + - key: upstream.mode + stability: stable + brief: Synthetic imported numeric values. + type: + members: + - id: fractional + value: 0.5 + stability: stable + brief: A floating-point value. + - id: maximum + value: 9223372036854775807 + stability: stable + brief: An integer that cannot be converted to float exactly. diff --git a/templates/code/python/tests/error_imported_mixed_numeric_enum/expected-error.txt b/templates/code/python/tests/error_imported_mixed_numeric_enum/expected-error.txt new file mode 100644 index 0000000..ed187a8 --- /dev/null +++ b/templates/code/python/tests/error_imported_mixed_numeric_enum/expected-error.txt @@ -0,0 +1 @@ +attribute upstream.mode has enum members of more than one value type, which no single Python type can hold diff --git a/templates/code/python/tests/error_imported_mixed_numeric_enum/registry/manifest.yaml b/templates/code/python/tests/error_imported_mixed_numeric_enum/registry/manifest.yaml new file mode 100644 index 0000000..ca52180 --- /dev/null +++ b/templates/code/python/tests/error_imported_mixed_numeric_enum/registry/manifest.yaml @@ -0,0 +1,6 @@ +name: imported-mixed-numeric +schema_url: https://example.com/schemas/imported-mixed-numeric/1.0.0 +dependencies: + - name: upstream + registry_path: dependency + schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/error_imported_mixed_numeric_enum/registry/model.yaml b/templates/code/python/tests/error_imported_mixed_numeric_enum/registry/model.yaml new file mode 100644 index 0000000..b102f79 --- /dev/null +++ b/templates/code/python/tests/error_imported_mixed_numeric_enum/registry/model.yaml @@ -0,0 +1,14 @@ +file_format: definition/2 +metrics: + - name: demo.recorded + requirement_level: recommended + instrument: counter + unit: "{record}" + stability: stable + brief: Synthetic records. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: upstream.mode + requirement_level: required diff --git a/templates/code/python/tests/error_integer_enum_underflow/expected-error.txt b/templates/code/python/tests/error_integer_enum_underflow/expected-error.txt new file mode 100644 index 0000000..ed4de58 --- /dev/null +++ b/templates/code/python/tests/error_integer_enum_underflow/expected-error.txt @@ -0,0 +1 @@ +invalid type: integer `-9223372036854775809` as i128, expected any YAML value diff --git a/templates/code/python/tests/error_integer_enum_underflow/registry/manifest.yaml b/templates/code/python/tests/error_integer_enum_underflow/registry/manifest.yaml new file mode 100644 index 0000000..5df1667 --- /dev/null +++ b/templates/code/python/tests/error_integer_enum_underflow/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: underflow +schema_url: https://example.com/schemas/underflow/1.0.0 diff --git a/templates/code/python/tests/error_integer_enum_underflow/registry/model.yaml b/templates/code/python/tests/error_integer_enum_underflow/registry/model.yaml new file mode 100644 index 0000000..819adf9 --- /dev/null +++ b/templates/code/python/tests/error_integer_enum_underflow/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +attributes: + - key: demo.mode + stability: stable + brief: Synthetic mode. + type: + members: + - id: below_minimum + value: -9223372036854775809 + stability: stable + brief: An integer below the signed 64-bit minimum. diff --git a/templates/code/python/tests/error_integral_double_range/expected-error.txt b/templates/code/python/tests/error_integral_double_range/expected-error.txt new file mode 100644 index 0000000..b6fdfd0 --- /dev/null +++ b/templates/code/python/tests/error_integral_double_range/expected-error.txt @@ -0,0 +1 @@ +attribute demo.mode has an enum value that cannot be represented by a 64-bit integer diff --git a/templates/code/python/tests/error_integral_double_range/registry/manifest.yaml b/templates/code/python/tests/error_integral_double_range/registry/manifest.yaml new file mode 100644 index 0000000..e157689 --- /dev/null +++ b/templates/code/python/tests/error_integral_double_range/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: integral-double-range +schema_url: https://example.com/schemas/integral-double-range/1.0.0 diff --git a/templates/code/python/tests/error_integral_double_range/registry/model.yaml b/templates/code/python/tests/error_integral_double_range/registry/model.yaml new file mode 100644 index 0000000..13fce34 --- /dev/null +++ b/templates/code/python/tests/error_integral_double_range/registry/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +attributes: + - key: demo.mode + stability: stable + brief: Synthetic mode. + type: + members: + - id: large + value: 1.0e+20 + stability: stable + brief: A whole-number floating-point value outside the integer range. diff --git a/templates/code/python/tests/error_mixed_numeric_enum/expected-error.txt b/templates/code/python/tests/error_mixed_numeric_enum/expected-error.txt new file mode 100644 index 0000000..eee0ea2 --- /dev/null +++ b/templates/code/python/tests/error_mixed_numeric_enum/expected-error.txt @@ -0,0 +1 @@ +attribute demo.mode has enum members of more than one value type, which no single Python type can hold diff --git a/templates/code/python/tests/error_mixed_numeric_enum/registry/manifest.yaml b/templates/code/python/tests/error_mixed_numeric_enum/registry/manifest.yaml new file mode 100644 index 0000000..b36b9f9 --- /dev/null +++ b/templates/code/python/tests/error_mixed_numeric_enum/registry/manifest.yaml @@ -0,0 +1,2 @@ +name: mixed-numeric +schema_url: https://example.com/schemas/mixed-numeric/1.0.0 diff --git a/templates/code/python/tests/error_mixed_numeric_enum/registry/model.yaml b/templates/code/python/tests/error_mixed_numeric_enum/registry/model.yaml new file mode 100644 index 0000000..205b516 --- /dev/null +++ b/templates/code/python/tests/error_mixed_numeric_enum/registry/model.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +attributes: + - key: demo.mode + stability: stable + brief: Synthetic numeric values. + type: + members: + - id: fractional + value: 0.5 + stability: stable + brief: A floating-point value. + - id: maximum + value: 9223372036854775807 + stability: stable + brief: An integer that cannot be converted to float exactly. diff --git a/templates/code/python/tests/filtering_both/expected/metrics.py b/templates/code/python/tests/filtering_both/expected/metrics.py index 4cd19a9..f3760ad 100644 --- a/templates/code/python/tests/filtering_both/expected/metrics.py +++ b/templates/code/python/tests/filtering_both/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -56,11 +57,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskRetryDuration: @@ -99,11 +101,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskStarted: @@ -128,6 +131,7 @@ def add( task_id: str, task_assignee: str | None = None, task_state: attributes.MyappTaskState | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, @@ -136,7 +140,7 @@ def add( _attributes[attributes.MYAPP_TASK_ASSIGNEE_KEY] = task_assignee if task_state is not None: _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskStartedObservable: diff --git a/templates/code/python/tests/filtering_both/test_consumer.py b/templates/code/python/tests/filtering_both/test_consumer.py index 48bbfc2..dd65bcd 100644 --- a/templates/code/python/tests/filtering_both/test_consumer.py +++ b/templates/code/python/tests/filtering_both/test_consumer.py @@ -93,8 +93,10 @@ def test_only_the_expected_enum_members_exist() -> None: def test_metric_signatures_follow_the_filters() -> None: parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters assert [key for key in parameters if key not in ("self", "amount")] == ( - STARTED_PARAMETERS + [*STARTED_PARAMETERS, "context"] ) + assert parameters["context"].kind is inspect.Parameter.KEYWORD_ONLY + assert parameters["context"].default is None assert parameters["task_id"].default is inspect.Parameter.empty for key in STARTED_PARAMETERS[1:]: assert parameters[key].default is None diff --git a/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py b/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py index fdf6df7..1d16369 100644 --- a/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py +++ b/templates/code/python/tests/filtering_exclude_deprecated/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -56,11 +57,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskPreviewDuration: @@ -99,11 +101,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskQueued: @@ -120,8 +123,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Number of tasks waiting to run.", ) - def add(self, amount: int, /) -> None: - self._instrument.add(amount) + def add(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskQueuedObservable: @@ -184,11 +187,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskStarted: @@ -214,6 +218,7 @@ def add( task_assignee: str | None = None, task_priority: int | None = None, task_state: attributes.MyappTaskState | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, @@ -224,7 +229,7 @@ def add( _attributes[attributes.MYAPP_TASK_PRIORITY_KEY] = task_priority if task_state is not None: _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskStartedObservable: diff --git a/templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py b/templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py index 9df94cf..c0f0bd3 100644 --- a/templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py +++ b/templates/code/python/tests/filtering_exclude_deprecated/test_consumer.py @@ -93,8 +93,10 @@ def test_only_the_expected_enum_members_exist() -> None: def test_metric_signatures_follow_the_filters() -> None: parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters assert [key for key in parameters if key not in ("self", "amount")] == ( - STARTED_PARAMETERS + [*STARTED_PARAMETERS, "context"] ) + assert parameters["context"].kind is inspect.Parameter.KEYWORD_ONLY + assert parameters["context"].default is None assert parameters["task_id"].default is inspect.Parameter.empty for key in STARTED_PARAMETERS[1:]: assert parameters[key].default is None diff --git a/templates/code/python/tests/filtering_stable_only/expected/metrics.py b/templates/code/python/tests/filtering_stable_only/expected/metrics.py index fdf958a..7dc35ca 100644 --- a/templates/code/python/tests/filtering_stable_only/expected/metrics.py +++ b/templates/code/python/tests/filtering_stable_only/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -56,11 +57,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskLegacyDuration: @@ -101,11 +103,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskRetired: @@ -124,8 +127,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Number of tasks retired.", ) - def add(self, amount: int, /) -> None: - self._instrument.add(amount) + def add(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskRetiredObservable: @@ -188,11 +191,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskStarted: @@ -218,6 +222,7 @@ def add( task_assignee: str | None = None, task_owner: str | None = None, task_state: attributes.MyappTaskState | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, @@ -228,7 +233,7 @@ def add( _attributes[attributes.MYAPP_TASK_OWNER_KEY] = task_owner if task_state is not None: _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskStartedObservable: diff --git a/templates/code/python/tests/filtering_stable_only/test_consumer.py b/templates/code/python/tests/filtering_stable_only/test_consumer.py index c0b5359..c470842 100644 --- a/templates/code/python/tests/filtering_stable_only/test_consumer.py +++ b/templates/code/python/tests/filtering_stable_only/test_consumer.py @@ -93,8 +93,10 @@ def test_only_the_expected_enum_members_exist() -> None: def test_metric_signatures_follow_the_filters() -> None: parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters assert [key for key in parameters if key not in ("self", "amount")] == ( - STARTED_PARAMETERS + [*STARTED_PARAMETERS, "context"] ) + assert parameters["context"].kind is inspect.Parameter.KEYWORD_ONLY + assert parameters["context"].default is None assert parameters["task_id"].default is inspect.Parameter.empty for key in STARTED_PARAMETERS[1:]: assert parameters[key].default is None diff --git a/templates/code/python/tests/filtering_unfiltered/expected/metrics.py b/templates/code/python/tests/filtering_unfiltered/expected/metrics.py index 6e939cb..dde4db6 100644 --- a/templates/code/python/tests/filtering_unfiltered/expected/metrics.py +++ b/templates/code/python/tests/filtering_unfiltered/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -56,11 +57,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskLegacyDuration: @@ -101,11 +103,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskPreviewDuration: @@ -144,11 +147,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskQueued: @@ -165,8 +169,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Number of tasks waiting to run.", ) - def add(self, amount: int, /) -> None: - self._instrument.add(amount) + def add(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskQueuedObservable: @@ -209,8 +213,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Number of tasks retired.", ) - def add(self, amount: int, /) -> None: - self._instrument.add(amount) + def add(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskRetiredObservable: @@ -273,11 +277,12 @@ def record( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskStarted: @@ -304,6 +309,7 @@ def add( task_owner: str | None = None, task_priority: int | None = None, task_state: attributes.MyappTaskState | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, @@ -316,7 +322,7 @@ def add( _attributes[attributes.MYAPP_TASK_PRIORITY_KEY] = task_priority if task_state is not None: _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskStartedObservable: diff --git a/templates/code/python/tests/filtering_unfiltered/test_consumer.py b/templates/code/python/tests/filtering_unfiltered/test_consumer.py index 2fc9bb3..3bdb2a8 100644 --- a/templates/code/python/tests/filtering_unfiltered/test_consumer.py +++ b/templates/code/python/tests/filtering_unfiltered/test_consumer.py @@ -107,8 +107,10 @@ def test_only_the_expected_enum_members_exist() -> None: def test_metric_signatures_follow_the_filters() -> None: parameters = inspect.signature(metrics.MyappTaskStarted.add).parameters assert [key for key in parameters if key not in ("self", "amount")] == ( - STARTED_PARAMETERS + [*STARTED_PARAMETERS, "context"] ) + assert parameters["context"].kind is inspect.Parameter.KEYWORD_ONLY + assert parameters["context"].default is None assert parameters["task_id"].default is inspect.Parameter.empty for key in STARTED_PARAMETERS[1:]: assert parameters[key].default is None diff --git a/templates/code/python/tests/histogram_boundaries/expected/metrics.py b/templates/code/python/tests/histogram_boundaries/expected/metrics.py index 5aac0b1..24ab21f 100644 --- a/templates/code/python/tests/histogram_boundaries/expected/metrics.py +++ b/templates/code/python/tests/histogram_boundaries/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL @@ -49,8 +50,8 @@ def __init__(self, meter: Meter | None = None) -> None: ), ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskPayload: @@ -67,8 +68,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Task payload size.", ) - def record(self, amount: int, /) -> None: - self._instrument.record(amount) + def record(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskQueueWait: @@ -85,8 +86,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Time a task waited in the queue.", ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskRetries: @@ -103,8 +104,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="A counter never receives boundaries.", ) - def add(self, amount: int, /) -> None: - self._instrument.add(amount) + def add(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskRetriesObservable: @@ -151,5 +152,5 @@ def __init__(self, meter: Meter | None = None) -> None: ), ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) diff --git a/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py b/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py index c374d10..6e2cc9a 100644 --- a/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py +++ b/templates/code/python/tests/histogram_boundaries_none/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL @@ -33,8 +34,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Time a task took from start to finish, with the default boundaries of its unit.", ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskPayload: @@ -51,8 +52,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Task payload size.", ) - def record(self, amount: int, /) -> None: - self._instrument.record(amount) + def record(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskQueueWait: @@ -69,8 +70,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Time a task waited in the queue.", ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskRetries: @@ -87,8 +88,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="A counter never receives boundaries.", ) - def add(self, amount: int, /) -> None: - self._instrument.add(amount) + def add(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskRetriesObservable: @@ -135,5 +136,5 @@ def __init__(self, meter: Meter | None = None) -> None: ), ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) diff --git a/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py b/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py index 2f0fa25..732e3c7 100644 --- a/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py +++ b/templates/code/python/tests/histogram_boundaries_params/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL @@ -37,8 +38,8 @@ def __init__(self, meter: Meter | None = None) -> None: ), ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskPayload: @@ -55,8 +56,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Task payload size.", ) - def record(self, amount: int, /) -> None: - self._instrument.record(amount) + def record(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskQueueWait: @@ -79,8 +80,8 @@ def __init__(self, meter: Meter | None = None) -> None: ), ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskRetries: @@ -97,8 +98,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="A counter never receives boundaries.", ) - def add(self, amount: int, /) -> None: - self._instrument.add(amount) + def add(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskRetriesObservable: @@ -145,5 +146,5 @@ def __init__(self, meter: Meter | None = None) -> None: ), ) - def record(self, amount: float, /) -> None: - self._instrument.record(amount) + def record(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) diff --git a/templates/code/python/tests/imported/expected/metrics.py b/templates/code/python/tests/imported/expected/metrics.py index eb2b82a..70540f2 100644 --- a/templates/code/python/tests/imported/expected/metrics.py +++ b/templates/code/python/tests/imported/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -57,13 +58,14 @@ def record( *, server_address: str, server_port: int | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { "server.address": server_address, } if server_port is not None: _attributes["server.port"] = server_port - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskFailed: @@ -88,6 +90,7 @@ def add( error_type: str, task_id: str, http_request_method_list: Sequence[str] | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { "error.type": error_type, @@ -95,7 +98,7 @@ def add( } if http_request_method_list is not None: _attributes["http.request.method_list"] = [*http_request_method_list] - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskFailedObservable: diff --git a/templates/code/python/tests/integer_enum_limits/dependency/manifest.yaml b/templates/code/python/tests/integer_enum_limits/dependency/manifest.yaml new file mode 100644 index 0000000..6fe2cd0 --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/dependency/manifest.yaml @@ -0,0 +1,2 @@ +name: upstream +schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/integer_enum_limits/dependency/model.yaml b/templates/code/python/tests/integer_enum_limits/dependency/model.yaml new file mode 100644 index 0000000..b7dd16c --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/dependency/model.yaml @@ -0,0 +1,11 @@ +file_format: definition/2 +attributes: + - key: upstream.limit + stability: stable + brief: Synthetic imported integer limits. + type: + members: + - id: maximum + value: 9223372036854775807 + stability: stable + brief: Maximum signed 64-bit value. diff --git a/templates/code/python/tests/integer_enum_limits/expected/__init__.py b/templates/code/python/tests/integer_enum_limits/expected/__init__.py new file mode 100644 index 0000000..04aa3b8 --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/limits/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/integer_enum_limits/expected/attributes.py b/templates/code/python/tests/integer_enum_limits/expected/attributes.py new file mode 100644 index 0000000..3b46b02 --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/expected/attributes.py @@ -0,0 +1,25 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class DemoLimit(enum.IntEnum): + """Values of the `demo.limit` attribute.""" + + MINIMUM = -9223372036854775808 + """Minimum signed 64-bit value.""" + NEAR_MINIMUM = -9223372036854775807 + """An integer adjacent to the minimum.""" + BELOW_FLOAT_PRECISION = -9007199254740993 + """A negative integer outside exact double precision.""" + ABOVE_FLOAT_PRECISION = 9007199254740993 + """An integer above exact double precision.""" + NEAR_MAXIMUM = 9223372036854775806 + """An integer adjacent to the maximum.""" + MAXIMUM = 9223372036854775807 + """Maximum signed 64-bit value.""" + + +DEMO_LIMIT_KEY: Final = "demo.limit" +"""Synthetic integer limits.""" diff --git a/templates/code/python/tests/integer_enum_limits/expected/metrics.py b/templates/code/python/tests/integer_enum_limits/expected/metrics.py new file mode 100644 index 0000000..6b079ff --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/expected/metrics.py @@ -0,0 +1,85 @@ +# Code generated by weaver. DO NOT EDIT. + +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class DemoRecorded: + """Synthetic records by integer enum.""" + + NAME: Final = "demo.recorded" + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit="{record}", + description="Synthetic records by integer enum.", + ) + + def add( + self, + amount: int, + /, + *, + limit: attributes.DemoLimit, + upstream_limit: int, + context: Context | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.DEMO_LIMIT_KEY: limit.value, + "upstream.limit": upstream_limit, + } + self._instrument.add(amount, _attributes, context=context) + + +class DemoRecordedObservable: + """Observable form of `DemoRecorded`, reported by callbacks.""" + + NAME: Final = DemoRecorded.NAME + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit="{record}", + description="Synthetic records by integer enum.", + ) + + @staticmethod + def observation( + amount: int, + /, + *, + limit: attributes.DemoLimit, + upstream_limit: int, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.DEMO_LIMIT_KEY: limit.value, + "upstream.limit": upstream_limit, + } + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/integer_enum_limits/registry/manifest.yaml b/templates/code/python/tests/integer_enum_limits/registry/manifest.yaml new file mode 100644 index 0000000..6c15daf --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/registry/manifest.yaml @@ -0,0 +1,6 @@ +name: limits +schema_url: https://example.com/schemas/limits/1.0.0 +dependencies: + - name: upstream + registry_path: dependency + schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/integer_enum_limits/registry/model.yaml b/templates/code/python/tests/integer_enum_limits/registry/model.yaml new file mode 100644 index 0000000..5e7b05c --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/registry/model.yaml @@ -0,0 +1,46 @@ +file_format: definition/2 +attributes: + - key: demo.limit + stability: stable + brief: Synthetic integer limits. + type: + members: + - id: minimum + value: -9223372036854775808 + stability: stable + brief: Minimum signed 64-bit value. + - id: near_minimum + value: -9223372036854775807 + stability: stable + brief: An integer adjacent to the minimum. + - id: below_float_precision + value: -9007199254740993 + stability: stable + brief: A negative integer outside exact double precision. + - id: above_float_precision + value: 9007199254740993 + stability: stable + brief: An integer above exact double precision. + - id: near_maximum + value: 9223372036854775806 + stability: stable + brief: An integer adjacent to the maximum. + - id: maximum + value: 9223372036854775807 + stability: stable + brief: Maximum signed 64-bit value. +metrics: + - name: demo.recorded + requirement_level: recommended + instrument: counter + unit: "{record}" + stability: stable + brief: Synthetic records by integer enum. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: demo.limit + requirement_level: required + - ref: upstream.limit + requirement_level: required diff --git a/templates/code/python/tests/integer_enum_limits/test_consumer.py b/templates/code/python/tests/integer_enum_limits/test_consumer.py new file mode 100644 index 0000000..57c2123 --- /dev/null +++ b/templates/code/python/tests/integer_enum_limits/test_consumer.py @@ -0,0 +1,36 @@ +import enum + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from semconv import attributes, metrics + + +def test_integer_enum_values_are_exact() -> None: + assert issubclass(attributes.DemoLimit, enum.IntEnum) + assert [member.value for member in attributes.DemoLimit] == [ + -9223372036854775808, + -9223372036854775807, + -9007199254740993, + 9007199254740993, + 9223372036854775806, + 9223372036854775807, + ] + + +def test_integer_enum_values_reach_the_sdk_unchanged() -> None: + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader]) + try: + recorded = metrics.DemoRecorded(metrics.get_meter(provider)) + for member in attributes.DemoLimit: + recorded.add(1, limit=member, upstream_limit=9223372036854775807) + data = reader.get_metrics_data() + assert data is not None + metric = data.resource_metrics[0].scope_metrics[0].metrics[0] + assert [dict(point.attributes or {}) for point in metric.data.data_points] == [ + {"demo.limit": member.value, "upstream.limit": 9223372036854775807} + for member in attributes.DemoLimit + ] + finally: + provider.shutdown() diff --git a/templates/code/python/tests/integral_double_enum/dependency/manifest.yaml b/templates/code/python/tests/integral_double_enum/dependency/manifest.yaml new file mode 100644 index 0000000..6fe2cd0 --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/dependency/manifest.yaml @@ -0,0 +1,2 @@ +name: upstream +schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/integral_double_enum/dependency/model.yaml b/templates/code/python/tests/integral_double_enum/dependency/model.yaml new file mode 100644 index 0000000..2efa41d --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/dependency/model.yaml @@ -0,0 +1,15 @@ +file_format: definition/2 +attributes: + - key: upstream.sample + stability: stable + brief: Synthetic imported floating-point values. + type: + members: + - id: single + value: 1.0 + stability: stable + brief: A floating-point value with no fractional part. + - id: large + value: 1.0e+3 + stability: stable + brief: A floating-point value in scientific notation. diff --git a/templates/code/python/tests/integral_double_enum/expected/__init__.py b/templates/code/python/tests/integral_double_enum/expected/__init__.py new file mode 100644 index 0000000..6ff2fd9 --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/expected/__init__.py @@ -0,0 +1,12 @@ +# Code generated by weaver. DO NOT EDIT. + +from typing import Final + +SCHEMA_URL: Final = "https://example.com/schemas/integral-double/1.0.0" +"""Schema URL of the registry; every generated meter reports it.""" + +INSTRUMENTATION_NAME: Final = "generated-semconv" +"""Name of the instrumentation scope that `get_meter` creates.""" + +INSTRUMENTATION_VERSION: Final = "" +"""Version of the instrumentation scope that `get_meter` creates.""" diff --git a/templates/code/python/tests/integral_double_enum/expected/attributes.py b/templates/code/python/tests/integral_double_enum/expected/attributes.py new file mode 100644 index 0000000..a592547 --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/expected/attributes.py @@ -0,0 +1,17 @@ +# Code generated by weaver. DO NOT EDIT. + +import enum +from typing import Final + + +class DemoSample(float, enum.Enum): + """Values of the `demo.sample` attribute.""" + + SINGLE = 1.0 + """A floating-point value with no fractional part.""" + LARGE = 1000.0 + """A floating-point value in scientific notation.""" + + +DEMO_SAMPLE_KEY: Final = "demo.sample" +"""Synthetic integral floating-point values.""" diff --git a/templates/code/python/tests/integral_double_enum/expected/metrics.py b/templates/code/python/tests/integral_double_enum/expected/metrics.py new file mode 100644 index 0000000..2c9b7ba --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/expected/metrics.py @@ -0,0 +1,85 @@ +# Code generated by weaver. DO NOT EDIT. + +from collections.abc import Sequence +from typing import Final + +from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context +from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation +from opentelemetry.util.types import AttributeValue + +from . import INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION, SCHEMA_URL, attributes + + +def get_meter(meter_provider: MeterProvider | None = None) -> Meter: + """Return the meter that is tied to the schema URL of the registry.""" + return otel_metrics.get_meter( + INSTRUMENTATION_NAME, + INSTRUMENTATION_VERSION, + meter_provider, + schema_url=SCHEMA_URL, + ) + + +class DemoRecorded: + """Synthetic records by floating-point enum.""" + + NAME: Final = "demo.recorded" + + def __init__(self, meter: Meter | None = None) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_counter( + self.NAME, + unit="{record}", + description="Synthetic records by floating-point enum.", + ) + + def add( + self, + amount: int, + /, + *, + sample: attributes.DemoSample, + upstream_sample: float, + context: Context | None = None, + ) -> None: + _attributes: dict[str, AttributeValue] = { + attributes.DEMO_SAMPLE_KEY: sample.value, + "upstream.sample": upstream_sample, + } + self._instrument.add(amount, _attributes, context=context) + + +class DemoRecordedObservable: + """Observable form of `DemoRecorded`, reported by callbacks.""" + + NAME: Final = DemoRecorded.NAME + + def __init__( + self, + callbacks: Sequence[CallbackT], + meter: Meter | None = None, + ) -> None: + if meter is None: + meter = get_meter() + self._instrument = meter.create_observable_counter( + self.NAME, + callbacks=callbacks, + unit="{record}", + description="Synthetic records by floating-point enum.", + ) + + @staticmethod + def observation( + amount: int, + /, + *, + sample: attributes.DemoSample, + upstream_sample: float, + ) -> Observation: + _attributes: dict[str, AttributeValue] = { + attributes.DEMO_SAMPLE_KEY: sample.value, + "upstream.sample": upstream_sample, + } + return Observation(amount, _attributes) diff --git a/templates/code/python/tests/integral_double_enum/registry/manifest.yaml b/templates/code/python/tests/integral_double_enum/registry/manifest.yaml new file mode 100644 index 0000000..2016330 --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/registry/manifest.yaml @@ -0,0 +1,6 @@ +name: integral-double +schema_url: https://example.com/schemas/integral-double/1.0.0 +dependencies: + - name: upstream + registry_path: dependency + schema_url: https://example.com/schemas/upstream/1.0.0 diff --git a/templates/code/python/tests/integral_double_enum/registry/model.yaml b/templates/code/python/tests/integral_double_enum/registry/model.yaml new file mode 100644 index 0000000..c94abdf --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/registry/model.yaml @@ -0,0 +1,30 @@ +file_format: definition/2 +attributes: + - key: demo.sample + stability: stable + brief: Synthetic integral floating-point values. + type: + members: + - id: single + value: 1.0 + stability: stable + brief: A floating-point value with no fractional part. + - id: large + value: 1.0e+3 + stability: stable + brief: A floating-point value in scientific notation. +metrics: + - name: demo.recorded + requirement_level: recommended + instrument: counter + unit: "{record}" + stability: stable + brief: Synthetic records by floating-point enum. + annotations: + code_generation: + metric_value_type: int + attributes: + - ref: demo.sample + requirement_level: required + - ref: upstream.sample + requirement_level: required diff --git a/templates/code/python/tests/integral_double_enum/test_consumer.py b/templates/code/python/tests/integral_double_enum/test_consumer.py new file mode 100644 index 0000000..d81420b --- /dev/null +++ b/templates/code/python/tests/integral_double_enum/test_consumer.py @@ -0,0 +1,31 @@ +import enum + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from semconv import attributes, metrics + + +def test_integral_double_enum_values_remain_floats() -> None: + assert issubclass(attributes.DemoSample, float) + assert not issubclass(attributes.DemoSample, enum.IntEnum) + assert [member.value for member in attributes.DemoSample] == [1.0, 1.0e3] + assert all(isinstance(member.value, float) for member in attributes.DemoSample) + + +def test_integral_double_enum_values_reach_the_sdk() -> None: + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader]) + try: + recorded = metrics.DemoRecorded(metrics.get_meter(provider)) + recorded.add(1, sample=attributes.DemoSample.SINGLE, upstream_sample=1.0) + recorded.add(1, sample=attributes.DemoSample.LARGE, upstream_sample=1.0e3) + data = reader.get_metrics_data() + assert data is not None + metric = data.resource_metrics[0].scope_metrics[0].metrics[0] + assert [dict(point.attributes or {}) for point in metric.data.data_points] == [ + {"demo.sample": 1.0, "upstream.sample": 1.0}, + {"demo.sample": 1.0e3, "upstream.sample": 1.0e3}, + ] + finally: + provider.shutdown() diff --git a/templates/code/python/tests/literal_escaping/expected/metrics.py b/templates/code/python/tests/literal_escaping/expected/metrics.py index dea7fe2..36af47d 100644 --- a/templates/code/python/tests/literal_escaping/expected/metrics.py +++ b/templates/code/python/tests/literal_escaping/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -43,6 +44,7 @@ def add( *, mode: attributes.DemoMode | None = None, text: str | None = None, + context: Context | None = None, ) -> None: """Add to the counter. @@ -55,7 +57,7 @@ def add( _attributes[attributes.DEMO_MODE_KEY] = mode.value if text is not None: _attributes[attributes.DEMO_TEXT_KEY] = text - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class DemoWorkObservable: diff --git a/templates/code/python/tests/long_names/expected/metrics.py b/templates/code/python/tests/long_names/expected/metrics.py index 1a7c991..c42d23f 100644 --- a/templates/code/python/tests/long_names/expected/metrics.py +++ b/templates/code/python/tests/long_names/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -44,6 +45,7 @@ def add( task_environment: attributes.MyappTaskEnvironment, task_tags: Sequence[str], task_config_profile_revision: Sequence[str] | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_CONFIG_ENV_KIND_KEY: task_config_env_kind.value, @@ -57,7 +59,7 @@ def add( _attributes[attributes.MYAPP_TASK_CONFIG_PROFILE_REVISION_KEY] = [ *task_config_profile_revision ] - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskLongObservable: @@ -128,6 +130,7 @@ def add( task_config_profile_identifier: Sequence[str] | None = None, task_environment: attributes.MyappTaskEnvironment | None = None, task_tags: Sequence[str] | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = {} if task_config_env_kind is not None: @@ -142,7 +145,7 @@ def add( _attributes[attributes.MYAPP_TASK_ENVIRONMENT_KEY] = task_environment.value if task_tags is not None: _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskLongOptionalObservable: diff --git a/templates/code/python/tests/metric_attributes/expected/metrics.py b/templates/code/python/tests/metric_attributes/expected/metrics.py index aa5bcf9..21d4f9c 100644 --- a/templates/code/python/tests/metric_attributes/expected/metrics.py +++ b/templates/code/python/tests/metric_attributes/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -43,6 +44,7 @@ def set( task_shard_ids: Sequence[int] | None = None, task_tags: Sequence[str] | None = None, task_weights: Sequence[float] | None = None, + context: Context | None = None, ) -> None: """Set the gauge. @@ -62,7 +64,7 @@ def set( _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] if task_weights is not None: _attributes[attributes.MYAPP_TASK_WEIGHTS_KEY] = [*task_weights] - self._instrument.set(amount, _attributes) + self._instrument.set(amount, _attributes, context=context) class MyappTaskArraysOptionalObservable: @@ -138,6 +140,7 @@ def add( task_shard_ids: Sequence[int], task_tags: Sequence[str], task_weights: Sequence[float], + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_FLAGS_KEY: [*task_flags], @@ -145,7 +148,7 @@ def add( attributes.MYAPP_TASK_TAGS_KEY: [*task_tags], attributes.MYAPP_TASK_WEIGHTS_KEY: [*task_weights], } - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskArraysRequiredObservable: @@ -210,6 +213,7 @@ def add( task_preemptible: attributes.MyappTaskPreemptible | None = None, task_sample_rate: attributes.MyappTaskSampleRate | None = None, task_state: attributes.MyappTaskState | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = {} if auth_level is not None: @@ -222,7 +226,7 @@ def add( _attributes[attributes.MYAPP_TASK_SAMPLE_RATE_KEY] = task_sample_rate.value if task_state is not None: _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskEnumsOptionalObservable: @@ -293,6 +297,7 @@ def add( task_preemptible: attributes.MyappTaskPreemptible, task_sample_rate: attributes.MyappTaskSampleRate, task_state: attributes.MyappTaskState, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.AUTH_LEVEL_KEY: auth_level.value, @@ -301,7 +306,7 @@ def add( attributes.MYAPP_TASK_SAMPLE_RATE_KEY: task_sample_rate.value, attributes.MYAPP_TASK_STATE_KEY: task_state.value, } - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskEnumsRequiredObservable: @@ -384,6 +389,7 @@ def record( auth_level: attributes.AuthLevel | None = None, task_retries: int | None = None, task_tags: Sequence[str] | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.AUTH_METHOD_KEY: auth_method.value, @@ -395,7 +401,7 @@ def record( _attributes[attributes.MYAPP_TASK_RETRIES_KEY] = task_retries if task_tags is not None: _attributes[attributes.MYAPP_TASK_TAGS_KEY] = [*task_tags] - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskScalarsOptional: @@ -421,6 +427,7 @@ def add( task_id: str | None = None, task_progress: float | None = None, task_retries: int | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = {} if task_cancelled is not None: @@ -431,7 +438,7 @@ def add( _attributes[attributes.MYAPP_TASK_PROGRESS_KEY] = task_progress if task_retries is not None: _attributes[attributes.MYAPP_TASK_RETRIES_KEY] = task_retries - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskScalarsOptionalObservable: @@ -498,6 +505,7 @@ def add( task_id: str, task_progress: float, task_retries: int, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_CANCELLED_KEY: task_cancelled, @@ -505,7 +513,7 @@ def add( attributes.MYAPP_TASK_PROGRESS_KEY: task_progress, attributes.MYAPP_TASK_RETRIES_KEY: task_retries, } - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskScalarsRequiredObservable: diff --git a/templates/code/python/tests/metric_attributes/registry/model.yaml b/templates/code/python/tests/metric_attributes/registry/model.yaml index 426d561..e1b2253 100644 --- a/templates/code/python/tests/metric_attributes/registry/model.yaml +++ b/templates/code/python/tests/metric_attributes/registry/model.yaml @@ -109,7 +109,7 @@ attributes: stability: stable brief: One task in ten is sampled. - id: all - value: 1 + value: 1.0 stability: stable brief: Every task is sampled. stability: stable diff --git a/templates/code/python/tests/metrics/expected/metrics.py b/templates/code/python/tests/metrics/expected/metrics.py index 0b506ab..8181b08 100644 --- a/templates/code/python/tests/metrics/expected/metrics.py +++ b/templates/code/python/tests/metrics/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -41,12 +42,13 @@ def add( *, method: attributes.AuthMethod, success: bool, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.AUTH_METHOD_KEY: method.value, attributes.AUTH_SUCCESS_KEY: success, } - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class AuthAttemptsObservable: @@ -99,8 +101,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Number of tasks waiting in the queue.", ) - def set(self, amount: int, /) -> None: - self._instrument.set(amount) + def set(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.set(amount, context=context) class MyappQueueDepthObservable: @@ -147,11 +149,12 @@ def add( /, *, task_state: attributes.MyappTaskState, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_STATE_KEY: task_state.value, } - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskActiveObservable: @@ -218,6 +221,7 @@ def record( task_id: str, auth_method: attributes.AuthMethod | None = None, task_state: attributes.MyappTaskState | None = None, + context: Context | None = None, ) -> None: """Record a measurement. @@ -233,7 +237,7 @@ def record( _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value if task_state is not None: _attributes[attributes.MYAPP_TASK_STATE_KEY] = task_state.value - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskFloatActive: @@ -250,8 +254,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Fractional active task adjustment.", ) - def add(self, amount: float, /) -> None: - self._instrument.add(amount) + def add(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskFloatActiveObservable: @@ -292,8 +296,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Fractional queue depth.", ) - def set(self, amount: float, /) -> None: - self._instrument.set(amount) + def set(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.set(amount, context=context) class MyappTaskFloatDepthObservable: @@ -334,8 +338,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Fractional tasks started.", ) - def add(self, amount: float, /) -> None: - self._instrument.add(amount) + def add(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class MyappTaskFloatStartedObservable: @@ -381,8 +385,8 @@ def __init__(self, meter: Meter | None = None) -> None: ), ) - def record(self, amount: int, /) -> None: - self._instrument.record(amount) + def record(self, amount: int, /, *, context: Context | None = None) -> None: + self._instrument.record(amount, context=context) class MyappTaskRetryDuration: @@ -417,6 +421,7 @@ def record( task_id: str, task_state: attributes.MyappTaskState, auth_method: attributes.AuthMethod | None = None, + context: Context | None = None, ) -> None: """Record a measurement. @@ -430,7 +435,7 @@ def record( } if auth_method is not None: _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) class MyappTaskStarted: @@ -455,6 +460,7 @@ def add( /, *, task_id: str | None = None, + context: Context | None = None, ) -> None: """Add to the counter. @@ -465,7 +471,7 @@ def add( _attributes: dict[str, AttributeValue] = {} if task_id is not None: _attributes[attributes.MYAPP_TASK_ID_KEY] = task_id - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskStartedObservable: @@ -538,6 +544,7 @@ def record( myapp_task_id: str, auth_method: attributes.AuthMethod | None = None, myapp_task_state: attributes.MyappTaskState | None = None, + context: Context | None = None, ) -> None: """Record a measurement. @@ -553,4 +560,4 @@ def record( _attributes[attributes.AUTH_METHOD_KEY] = auth_method.value if myapp_task_state is not None: _attributes[attributes.MYAPP_TASK_STATE_KEY] = myapp_task_state.value - self._instrument.record(amount, _attributes) + self._instrument.record(amount, _attributes, context=context) diff --git a/templates/code/python/tests/metrics/test_consumer.py b/templates/code/python/tests/metrics/test_consumer.py index b74e399..23feb7f 100644 --- a/templates/code/python/tests/metrics/test_consumer.py +++ b/templates/code/python/tests/metrics/test_consumer.py @@ -1,15 +1,16 @@ -from collections.abc import Iterable +from collections.abc import Callable, Iterable import pytest -from opentelemetry.metrics import CallbackOptions, Observation -from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.context import Context, attach, detach, get_current +from opentelemetry.metrics import CallbackOptions, Meter, Observation +from opentelemetry.sdk.metrics import ExemplarFilter, MeterProvider from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, InMemoryMetricReader, Metric, NumberDataPoint, ) -from opentelemetry.util.types import AttributeValue +from opentelemetry.util.types import Attributes, AttributeValue from semconv import ( INSTRUMENTATION_NAME, @@ -55,6 +56,122 @@ def histogram_points(metric: Metric) -> list[HistogramDataPoint]: return [point for point in points if isinstance(point, HistogramDataPoint)] +class ContextFilter(ExemplarFilter): + def __init__(self) -> None: + self.contexts: list[Context | None] = [] + + def should_sample( + self, + value: float, + time_unix_nano: int, + attributes: Attributes, + context: Context, + ) -> bool: + self.contexts.append(context) + return False + + +def record_required_counter(meter: Meter, context: Context | None) -> None: + metrics.AuthAttempts(meter).add( + 1, method=attributes.AuthMethod.TOKEN, success=True, context=context + ) + + +def record_optional_counter(meter: Meter, context: Context | None) -> None: + metrics.MyappTaskStarted(meter).add(1, context=context) + + +def record_no_attribute_counter(meter: Meter, context: Context | None) -> None: + metrics.MyappTaskFloatStarted(meter).add(1.0, context=context) + + +def record_required_up_down_counter(meter: Meter, context: Context | None) -> None: + metrics.MyappTaskActive(meter).add( + 1, task_state=attributes.MyappTaskState.QUEUED, context=context + ) + + +def record_no_attribute_up_down_counter(meter: Meter, context: Context | None) -> None: + metrics.MyappTaskFloatActive(meter).add(1.0, context=context) + + +def record_gauge(meter: Meter, context: Context | None) -> None: + metrics.MyappTaskFloatDepth(meter).set(1.0, context=context) + + +def record_required_histogram(meter: Meter, context: Context | None) -> None: + metrics.MyappTaskDuration(meter).record(1.0, task_id="task-1", context=context) + + +def record_no_attribute_histogram(meter: Meter, context: Context | None) -> None: + metrics.MyappTaskPayload(meter).record(1, context=context) + + +@pytest.mark.parametrize( + "record", + [ + record_required_counter, + record_optional_counter, + record_no_attribute_counter, + record_required_up_down_counter, + record_no_attribute_up_down_counter, + record_gauge, + record_required_histogram, + record_no_attribute_histogram, + ], +) +def test_synchronous_instruments_forward_context( + record: Callable[[Meter, Context | None], None], +) -> None: + exemplar_filter = ContextFilter() + provider = MeterProvider( + metric_readers=[InMemoryMetricReader()], exemplar_filter=exemplar_filter + ) + active_context = Context({"request": "active"}) + explicit_context = Context({"request": "explicit"}) + token = attach(active_context) + try: + meter = metrics.get_meter(provider) + meter.create_counter("native.context").add(1, context=explicit_context) + assert exemplar_filter.contexts[-1] is explicit_context + record(meter, explicit_context) + assert exemplar_filter.contexts[-1] is explicit_context + meter.create_counter("native.default").add(1) + native_default_context = exemplar_filter.contexts[-1] + record(meter, None) + assert exemplar_filter.contexts[-1] is native_default_context + assert get_current() is active_context + finally: + detach(token) + provider.shutdown() + + +def invalid_context_consumers(meter: Meter) -> None: + metrics.AuthAttempts(meter).add(1, context=Context()) # type: ignore[call-arg] + metrics.AuthAttempts(meter).add( + 1, + method="token", # type: ignore[arg-type] + success=True, + context=Context(), + ) + metrics.AuthAttempts(meter).add( + 1, + method=attributes.AuthMethod.TOKEN, + success=True, + context={}, # type: ignore[arg-type] + ) + metrics.MyappTaskFloatDepth(meter).set(1.0, context="bad") # type: ignore[arg-type] + metrics.MyappTaskPayload(meter).record(1, context=1) # type: ignore[arg-type] + metrics.MyappTaskStarted(meter).add( + 1, + task_id=1, # type: ignore[arg-type] + context=Context(), + ) + metrics.MyappQueueDepthObservable.observation( # type: ignore[call-arg] + 1, context=Context() + ) + + def test_scope_carries_the_schema_url() -> None: reader, provider = new_provider() meter = metrics.get_meter(provider) diff --git a/templates/code/python/tests/naming/expected/metrics.py b/templates/code/python/tests/naming/expected/metrics.py index 398202e..a229d98 100644 --- a/templates/code/python/tests/naming/expected/metrics.py +++ b/templates/code/python/tests/naming/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -42,6 +43,7 @@ def add( request_method_original: str, response_status_code: int, k8s_pod_uid: str | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.HTTP_REQUEST_METHOD_ORIGINAL_KEY: request_method_original, @@ -49,7 +51,7 @@ def add( } if k8s_pod_uid is not None: _attributes[attributes.K8S_POD_UID_KEY] = k8s_pod_uid - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class HttpServerRequestCountObservable: @@ -103,8 +105,8 @@ def __init__(self, meter: Meter | None = None) -> None: description="Metric name with a digit.", ) - def add(self, amount: float, /) -> None: - self._instrument.add(amount) + def add(self, amount: float, /, *, context: Context | None = None) -> None: + self._instrument.add(amount, context=context) class K8sPodCpuTimeObservable: @@ -152,13 +154,14 @@ def add( *, host: str, http_route: attributes.HttpRoute | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.HOST_KEY: host, } if http_route is not None: _attributes[attributes.HTTP_ROUTE_KEY] = http_route.value - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class RequestsObservable: diff --git a/templates/code/python/tests/params/expected/metrics.py b/templates/code/python/tests/params/expected/metrics.py index 482574d..9cb835d 100644 --- a/templates/code/python/tests/params/expected/metrics.py +++ b/templates/code/python/tests/params/expected/metrics.py @@ -5,6 +5,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -43,6 +44,7 @@ def add( invoice_id: str, invoice_currency: attributes.BillingInvoiceCurrency | None = None, invoice_paid: bool | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.BILLING_INVOICE_ID_KEY: invoice_id, @@ -53,7 +55,7 @@ def add( ) if invoice_paid is not None: _attributes[attributes.BILLING_INVOICE_PAID_KEY] = invoice_paid - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class BillingInvoiceCreatedObservable: diff --git a/templates/code/python/tests/params/test_consumer.py b/templates/code/python/tests/params/test_consumer.py index cbd2b42..6db70c7 100644 --- a/templates/code/python/tests/params/test_consumer.py +++ b/templates/code/python/tests/params/test_consumer.py @@ -105,6 +105,7 @@ def test_parameters_drop_the_vendor_prefix_and_the_namespace() -> None: "invoice_id", "invoice_currency", "invoice_paid", + "context", ] diff --git a/templates/code/python/tests/unsupported_signals/expected/metrics.py b/templates/code/python/tests/unsupported_signals/expected/metrics.py index 4d7520b..50c82c9 100644 --- a/templates/code/python/tests/unsupported_signals/expected/metrics.py +++ b/templates/code/python/tests/unsupported_signals/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -40,11 +41,12 @@ def add( /, *, task_id: str, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.MYAPP_TASK_ID_KEY: task_id, } - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class MyappTaskStartedObservable: diff --git a/templates/code/python/tests/vendor_prefix_off/expected/metrics.py b/templates/code/python/tests/vendor_prefix_off/expected/metrics.py index 5ecf0b7..7e642c1 100644 --- a/templates/code/python/tests/vendor_prefix_off/expected/metrics.py +++ b/templates/code/python/tests/vendor_prefix_off/expected/metrics.py @@ -4,6 +4,7 @@ from typing import Final from opentelemetry import metrics as otel_metrics +from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Meter, MeterProvider, Observation from opentelemetry.util.types import AttributeValue @@ -42,6 +43,7 @@ def add( billing_invoice_id: str, billing_invoice_currency: attributes.AcmeBillingInvoiceCurrency | None = None, billing_invoice_paid: bool | None = None, + context: Context | None = None, ) -> None: _attributes: dict[str, AttributeValue] = { attributes.ACME_BILLING_INVOICE_ID_KEY: billing_invoice_id, @@ -52,7 +54,7 @@ def add( ) if billing_invoice_paid is not None: _attributes[attributes.BILLING_INVOICE_PAID_KEY] = billing_invoice_paid - self._instrument.add(amount, _attributes) + self._instrument.add(amount, _attributes, context=context) class AcmeBillingInvoiceCreatedObservable: diff --git a/templates/code/python/tests/vendor_prefix_off/test_consumer.py b/templates/code/python/tests/vendor_prefix_off/test_consumer.py index 1d6b9c1..8d87001 100644 --- a/templates/code/python/tests/vendor_prefix_off/test_consumer.py +++ b/templates/code/python/tests/vendor_prefix_off/test_consumer.py @@ -78,6 +78,7 @@ def test_parameters_drop_only_the_metric_namespace() -> None: "billing_invoice_id", "billing_invoice_currency", "billing_invoice_paid", + "context", ] diff --git a/templates/code/python/weaver.yaml b/templates/code/python/weaver.yaml index 0934631..4f9ea16 100644 --- a/templates/code/python/weaver.yaml +++ b/templates/code/python/weaver.yaml @@ -76,8 +76,8 @@ templates: elif (.members | length) == 0 then error("attribute \($key) has no enum members left after exclude_deprecated or stable_only, so no value of it could be built") elif (.members | all(.value | type == "string")) then "string" elif (.members | all(.value | type == "boolean")) then "boolean" - elif (.members | all(.value | type == "number" and . == floor)) then "int" - elif (.members | all(.value | type == "number")) then "double" + elif (.members | all(.value | type == "number" and (tostring | test("^-?[0-9]+$")))) then "int" + elif (.members | all(.value | type == "number" and (tostring | test("^-?[0-9]+$") | not))) then "double" else error("attribute \($key) has enum members of more than one value type, which no single Python type can hold") end; def member_literal($type): if $type == "string" then .value | literal elif $type == "boolean" then (if .value then "True" else "False" end) elif $type == "int" then .value | tostring else .value | float_literal end; {v2: true, exclude_deprecated: $exclude_deprecated, stable_only: $stable_only} as $opts @@ -89,8 +89,9 @@ templates: | map(. + {value_type: (.key as $key | .type | value_type($key)), key_literal: (.key | literal), deprecation: (.deprecated | deprecation_text)}) | map(if (.value_type as $t | ["string", "int", "double", "boolean", "string[]", "int[]", "double[]", "boolean[]"] | any(. == $t) | not) then error("attribute \(.key) has an unsupported Python value type") else . end) - | map(if .value_type == "int" and (.type | type) == "object" - and (.type.members | any(.value >= 9223372036854775808 or .value < -9223372036854775808 or (.value | tostring | test("^-?[0-9]+$") | not))) + | map(if (.value_type == "int" or .value_type == "double") and (.type | type) == "object" + and (.type.members | all(.value == (.value | floor))) + and (.type.members | any(.value >= 9223372036854775808 or .value < -9223372036854775808)) then error("attribute \(.key) has an enum value that cannot be represented by a 64-bit integer") else . end) | map(. + {doc: doc(.deprecation)}) | map(if (.type | type) == "object" then @@ -133,8 +134,8 @@ templates: then error("attribute \($key) has no enum members left after exclude_deprecated or stable_only, so no value of it could be built") elif ($members | all(.value | type == "string")) then "string" elif ($members | all(.value | type == "boolean")) then "boolean" - elif ($members | all(.value | type == "number" and . == floor)) then "int" - elif ($members | all(.value | type == "number")) then "double" + elif ($members | all(.value | type == "number" and (tostring | test("^-?[0-9]+$")))) then "int" + elif ($members | all(.value | type == "number" and (tostring | test("^-?[0-9]+$") | not))) then "double" else error("attribute \($key) has enum members of more than one value type, which no single Python type can hold") end; def python_type: {"string": "str", "int": "int", "double": "float", "boolean": "bool", "string[]": "Sequence[str]", "int[]": "Sequence[int]", "double[]": "Sequence[float]", "boolean[]": "Sequence[bool]"}[.]; @@ -231,7 +232,7 @@ templates: assign_wrap: ((24 + (.key_expression | length) + 4 + (.value_expression | length)) > 88)} | if (.field | test("^[a-z][a-z0-9_]*$") | not) then error("metric \($metric) has attribute \(.key), which cannot become a Python parameter") else . end - | if (.field as $field | ["False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield", "self", "amount", "attributes"] | any(. == $field)) + | if (.field as $field | ["False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield", "self", "amount", "attributes", "context"] | any(. == $field)) then error("metric \($metric) has attribute \(.key), which becomes the reserved Python parameter \(.field)") else . end))) | map(.name as $metric | (.attributes | group_by(.field) | map(select(length > 1) | map(.key) | join(" and "))) as $collisions @@ -244,7 +245,7 @@ templates: ([.[] | .name as $metric | .class_name as $class | .instrument as $kind | ([$class] + (if $kind == "histogram" then [] else [$class + "Observable"] end))[] | {symbol: ., name: $metric}] - + (["Final", "Sequence", "Meter", "MeterProvider", "CallbackT", "Observation", "AttributeValue", "SCHEMA_URL", "INSTRUMENTATION_NAME", "INSTRUMENTATION_VERSION", "None", "True", "False", "attributes", "get_meter", "otel_metrics"] + + (["Final", "Sequence", "Meter", "MeterProvider", "Context", "CallbackT", "Observation", "AttributeValue", "SCHEMA_URL", "INSTRUMENTATION_NAME", "INSTRUMENTATION_VERSION", "None", "True", "False", "attributes", "get_meter", "otel_metrics"] | map({symbol: ., name: "a reserved Python symbol"})) | group_by(.symbol) | map(select(length > 1) | map(.name) | unique | join(" and "))