diff --git a/docs/doctoring/opentelemetry-packaging-extra.md b/docs/doctoring/opentelemetry-packaging-extra.md new file mode 100644 index 00000000..dd9c173b --- /dev/null +++ b/docs/doctoring/opentelemetry-packaging-extra.md @@ -0,0 +1,57 @@ +# OpenTelemetry packaging extra + +## Operator action + +Install the package-owned instrumentation API with the reviewed optional extra: + +```bash +pip install 'pg-llm-batch[observability]' +``` + +This extra installs `opentelemetry-api>=1.44,<2` and does **not** install +`opentelemetry-sdk`. The base package remains OpenTelemetry-independent. +Applications that want exported telemetry must separately select, configure, +and operate a compatible SDK, resource attributes, processors or readers, +exporters, collector endpoints, sampling, credentials, and retention. + +This separation follows the OpenTelemetry library-instrumentation boundary: +libraries depend on the API, while the embedding application owns the SDK and +emission pipeline. It also uses the Python packaging `optional-dependencies` +contract, which maps an extra to conditional `Requires-Dist` metadata rather +than silently widening every installation. + +## Supply-chain and compatibility boundary + +The `observability` extra is represented in `pyproject.toml`, the built +wheel/sdist metadata, and the repository `uv.lock`. The lock is generated and +verified with repository-pinned uv 0.12.3; it must not be hand edited. Clean +installation tests prove both directions: + +- base wheel: `opentelemetry` is absent; +- `pg-llm-batch[observability]`: the OpenTelemetry API is present and + `pg_llm_batch.observability.OpenTelemetryBatchAPIClient` is importable. + +`opentelemetry-sdk` remains a host deployment decision. Installing the package +extra alone is not evidence that telemetry is exported, retained, secured, or +compliant with any certification regime. + +## Rollback + +A host can remove package-level OpenTelemetry support by reinstalling the base +package without the extra. The host must separately remove or reconfigure any +SDK/exporter components it owns. Package rollback does not delete host telemetry +or change backend retention. + +## References + +OpenTelemetry Authors. (n.d.). *Instrumentation*. OpenTelemetry. Retrieved +August 14, 2026, from +https://opentelemetry.io/docs/languages/python/instrumentation/ + +Python Packaging Authority. (n.d.). *pyproject.toml specification*. Python +Packaging User Guide. Retrieved August 14, 2026, from +https://packaging.python.org/en/latest/specifications/pyproject-toml/ + +Python Packaging Authority. (n.d.). *Core metadata specifications*. Python +Packaging User Guide. Retrieved August 14, 2026, from +https://packaging.python.org/en/latest/specifications/core-metadata/ diff --git a/pyproject.toml b/pyproject.toml index d1aba037..701c84e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ ] [project.optional-dependencies] +observability = ["opentelemetry-api>=1.44,<2"] secrets = ["cryptography>=50.0.0"] test = [ "pytest>=7.4", diff --git a/tests/test_opentelemetry_optional_dependency.py b/tests/test_opentelemetry_optional_dependency.py new file mode 100644 index 00000000..c396387f --- /dev/null +++ b/tests/test_opentelemetry_optional_dependency.py @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Contract tests for the first-class OpenTelemetry optional dependency.""" + +from __future__ import annotations + +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 + import tomli as tomllib + + +ROOT = Path(__file__).resolve().parents[1] +OBSERVABILITY_API_REQUIREMENT = "opentelemetry-api>=1.44,<2" + + +def _project_metadata() -> dict[str, object]: + """Load the package metadata used by build and installation tooling.""" + return tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + + +def test_observability_extra_declares_api_only_dependency() -> None: + """Package metadata exposes bounded API-only OpenTelemetry installation.""" + project = _project_metadata() + extras = project["optional-dependencies"] + + assert extras["observability"] == [OBSERVABILITY_API_REQUIREMENT] + assert not any( + requirement.startswith("opentelemetry-sdk") + for requirement in extras["observability"] + ) + + +def test_base_runtime_remains_opentelemetry_independent() -> None: + """The default install must not silently acquire telemetry dependencies.""" + project = _project_metadata() + dependencies = project["dependencies"] + + assert not any( + requirement.startswith("opentelemetry-") for requirement in dependencies + ) + + +def test_observability_extra_has_one_explicit_runtime_requirement() -> None: + """Keep the optional installation surface minimal and reviewable.""" + project = _project_metadata() + extras = project["optional-dependencies"] + + assert set(extras) >= {"observability", "secrets", "test"} + assert len(extras["observability"]) == 1 diff --git a/uv.lock b/uv.lock index e3d21b0b..47b9e2dc 100644 --- a/uv.lock +++ b/uv.lock @@ -653,6 +653,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -672,6 +684,9 @@ dependencies = [ ] [package.optional-dependencies] +observability = [ + { name = "opentelemetry-api" }, +] secrets = [ { name = "cryptography" }, ] @@ -694,11 +709,12 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.14.3" }, { name = "cryptography", marker = "extra == 'secrets'", specifier = ">=50.0.0" }, { name = "cryptography", marker = "extra == 'test'", specifier = ">=50.0.0" }, + { name = "opentelemetry-api", marker = "extra == 'observability'", specifier = ">=1.44,<2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7.4" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23" }, ] -provides-extras = ["secrets", "test"] +provides-extras = ["observability", "secrets", "test"] [package.metadata.requires-dev] dev = [