containarium-telemetry 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. containarium_telemetry-0.22.0/PKG-INFO +60 -0
  2. containarium_telemetry-0.22.0/pyproject.toml +103 -0
  3. containarium_telemetry-0.22.0/setup.cfg +4 -0
  4. containarium_telemetry-0.22.0/src/containarium_telemetry/__init__.py +16 -0
  5. containarium_telemetry-0.22.0/src/containarium_telemetry/_config.py +54 -0
  6. containarium_telemetry-0.22.0/src/containarium_telemetry/_distro.py +66 -0
  7. containarium_telemetry-0.22.0/src/containarium_telemetry/_dry_run.py +64 -0
  8. containarium_telemetry-0.22.0/src/containarium_telemetry/_exec_shim.py +92 -0
  9. containarium_telemetry-0.22.0/src/containarium_telemetry/_init.py +156 -0
  10. containarium_telemetry-0.22.0/src/containarium_telemetry/_instrumentations.py +93 -0
  11. containarium_telemetry-0.22.0/src/containarium_telemetry/_resource.py +71 -0
  12. containarium_telemetry-0.22.0/src/containarium_telemetry/_version.py +16 -0
  13. containarium_telemetry-0.22.0/src/containarium_telemetry/py.typed +0 -0
  14. containarium_telemetry-0.22.0/src/containarium_telemetry.egg-info/PKG-INFO +60 -0
  15. containarium_telemetry-0.22.0/src/containarium_telemetry.egg-info/SOURCES.txt +25 -0
  16. containarium_telemetry-0.22.0/src/containarium_telemetry.egg-info/dependency_links.txt +1 -0
  17. containarium_telemetry-0.22.0/src/containarium_telemetry.egg-info/entry_points.txt +8 -0
  18. containarium_telemetry-0.22.0/src/containarium_telemetry.egg-info/requires.txt +52 -0
  19. containarium_telemetry-0.22.0/src/containarium_telemetry.egg-info/top_level.txt +1 -0
  20. containarium_telemetry-0.22.0/tests/test_config.py +60 -0
  21. containarium_telemetry-0.22.0/tests/test_distro.py +53 -0
  22. containarium_telemetry-0.22.0/tests/test_dry_run.py +64 -0
  23. containarium_telemetry-0.22.0/tests/test_exec_shim.py +63 -0
  24. containarium_telemetry-0.22.0/tests/test_init.py +78 -0
  25. containarium_telemetry-0.22.0/tests/test_instrumentations.py +79 -0
  26. containarium_telemetry-0.22.0/tests/test_integration_flask.py +61 -0
  27. containarium_telemetry-0.22.0/tests/test_resource.py +102 -0
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: containarium-telemetry
3
+ Version: 0.22.0
4
+ Summary: Containarium telemetry distro for Python — opinionated OpenTelemetry init that pairs with the Containarium platform's monitoring infrastructure.
5
+ Author: FootprintAI
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/footprintai/containarium
8
+ Project-URL: Documentation, https://github.com/footprintai/containarium/blob/main/docs/TELEMETRY-DISTRO-DESIGN.md
9
+ Project-URL: Repository, https://github.com/footprintai/containarium
10
+ Project-URL: Issues, https://github.com/footprintai/containarium/issues
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: System :: Monitoring
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: opentelemetry-api>=1.27.0
22
+ Requires-Dist: opentelemetry-sdk>=1.27.0
23
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0
24
+ Requires-Dist: opentelemetry-instrumentation>=0.48b0
25
+ Provides-Extra: flask
26
+ Requires-Dist: opentelemetry-instrumentation-flask>=0.48b0; extra == "flask"
27
+ Provides-Extra: fastapi
28
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == "fastapi"
29
+ Provides-Extra: django
30
+ Requires-Dist: opentelemetry-instrumentation-django>=0.48b0; extra == "django"
31
+ Provides-Extra: requests
32
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.48b0; extra == "requests"
33
+ Provides-Extra: httpx
34
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0; extra == "httpx"
35
+ Provides-Extra: sqlalchemy
36
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.48b0; extra == "sqlalchemy"
37
+ Provides-Extra: asyncpg
38
+ Requires-Dist: opentelemetry-instrumentation-asyncpg>=0.48b0; extra == "asyncpg"
39
+ Provides-Extra: psycopg
40
+ Requires-Dist: opentelemetry-instrumentation-psycopg>=0.48b0; extra == "psycopg"
41
+ Provides-Extra: redis
42
+ Requires-Dist: opentelemetry-instrumentation-redis>=0.48b0; extra == "redis"
43
+ Provides-Extra: logging
44
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.48b0; extra == "logging"
45
+ Provides-Extra: all
46
+ Requires-Dist: opentelemetry-instrumentation-flask>=0.48b0; extra == "all"
47
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == "all"
48
+ Requires-Dist: opentelemetry-instrumentation-django>=0.48b0; extra == "all"
49
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.48b0; extra == "all"
50
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0; extra == "all"
51
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.48b0; extra == "all"
52
+ Requires-Dist: opentelemetry-instrumentation-asyncpg>=0.48b0; extra == "all"
53
+ Requires-Dist: opentelemetry-instrumentation-psycopg>=0.48b0; extra == "all"
54
+ Requires-Dist: opentelemetry-instrumentation-redis>=0.48b0; extra == "all"
55
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.48b0; extra == "all"
56
+ Provides-Extra: dev
57
+ Requires-Dist: pytest>=7.0; extra == "dev"
58
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
59
+ Requires-Dist: flask>=2.0; extra == "dev"
60
+ Requires-Dist: opentelemetry-instrumentation-flask>=0.48b0; extra == "dev"
@@ -0,0 +1,103 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "containarium-telemetry"
7
+ version = "0.22.0"
8
+ description = "Containarium telemetry distro for Python — opinionated OpenTelemetry init that pairs with the Containarium platform's monitoring infrastructure."
9
+ requires-python = ">=3.9"
10
+ license = { text = "Apache-2.0" }
11
+ authors = [
12
+ { name = "FootprintAI" },
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: System :: Monitoring",
24
+ ]
25
+ dependencies = [
26
+ "opentelemetry-api>=1.27.0",
27
+ "opentelemetry-sdk>=1.27.0",
28
+ "opentelemetry-exporter-otlp-proto-http>=1.27.0",
29
+ # Required for ContainariumDistro / Configurator entry points and
30
+ # the auto-instrumentation runtime used by `containarium-instrument`.
31
+ "opentelemetry-instrumentation>=0.48b0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/footprintai/containarium"
36
+ Documentation = "https://github.com/footprintai/containarium/blob/main/docs/TELEMETRY-DISTRO-DESIGN.md"
37
+ Repository = "https://github.com/footprintai/containarium"
38
+ Issues = "https://github.com/footprintai/containarium/issues"
39
+
40
+ # Per-framework extras pull in the upstream OTel instrumentation package
41
+ # for that framework. The instrumentation packages themselves declare
42
+ # the framework as a transitive dep, so e.g. `pip install
43
+ # containarium-telemetry[flask]` installs flask too. Tenants who already
44
+ # pin their framework get no version conflict because the OTel
45
+ # instrumentation packages declare ranges, not pins.
46
+ [project.optional-dependencies]
47
+ flask = ["opentelemetry-instrumentation-flask>=0.48b0"]
48
+ fastapi = ["opentelemetry-instrumentation-fastapi>=0.48b0"]
49
+ django = ["opentelemetry-instrumentation-django>=0.48b0"]
50
+ requests = ["opentelemetry-instrumentation-requests>=0.48b0"]
51
+ httpx = ["opentelemetry-instrumentation-httpx>=0.48b0"]
52
+ sqlalchemy = ["opentelemetry-instrumentation-sqlalchemy>=0.48b0"]
53
+ asyncpg = ["opentelemetry-instrumentation-asyncpg>=0.48b0"]
54
+ psycopg = ["opentelemetry-instrumentation-psycopg>=0.48b0"]
55
+ redis = ["opentelemetry-instrumentation-redis>=0.48b0"]
56
+ logging = ["opentelemetry-instrumentation-logging>=0.48b0"]
57
+ # `all` is the convenience bundle — recommended default for greenfield
58
+ # apps. Tenants with image-size sensitivity pin individual extras.
59
+ all = [
60
+ "opentelemetry-instrumentation-flask>=0.48b0",
61
+ "opentelemetry-instrumentation-fastapi>=0.48b0",
62
+ "opentelemetry-instrumentation-django>=0.48b0",
63
+ "opentelemetry-instrumentation-requests>=0.48b0",
64
+ "opentelemetry-instrumentation-httpx>=0.48b0",
65
+ "opentelemetry-instrumentation-sqlalchemy>=0.48b0",
66
+ "opentelemetry-instrumentation-asyncpg>=0.48b0",
67
+ "opentelemetry-instrumentation-psycopg>=0.48b0",
68
+ "opentelemetry-instrumentation-redis>=0.48b0",
69
+ "opentelemetry-instrumentation-logging>=0.48b0",
70
+ ]
71
+ dev = [
72
+ "pytest>=7.0",
73
+ "pytest-cov>=4.0",
74
+ # Used by integration tests for the instrumentor auto-discovery path.
75
+ "flask>=2.0",
76
+ "opentelemetry-instrumentation-flask>=0.48b0",
77
+ ]
78
+
79
+ # `containarium-instrument` is always installed (D8) — it's a console
80
+ # script alias over `opentelemetry-instrument` that adds --dry-run +
81
+ # branding.
82
+ [project.scripts]
83
+ containarium-instrument = "containarium_telemetry._exec_shim:main"
84
+
85
+ # Entry points the OTel auto-instrumentation runtime discovers. When
86
+ # `opentelemetry-instrument` (or our `containarium-instrument` alias)
87
+ # runs, it loads our Distro + Configurator so that the init we run via
88
+ # direct API call also runs via the auto-instrument path.
89
+ [project.entry-points."opentelemetry_distro"]
90
+ containarium = "containarium_telemetry._distro:ContainariumDistro"
91
+
92
+ [project.entry-points."opentelemetry_configurator"]
93
+ containarium = "containarium_telemetry._distro:ContainariumConfigurator"
94
+
95
+ [tool.setuptools.packages.find]
96
+ where = ["src"]
97
+
98
+ [tool.setuptools.package-data]
99
+ containarium_telemetry = ["py.typed"]
100
+
101
+ [tool.pytest.ini_options]
102
+ testpaths = ["tests"]
103
+ addopts = "-ra"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """Containarium telemetry distro for Python.
2
+
3
+ Public API:
4
+
5
+ from containarium_telemetry import init, Shutdown
6
+
7
+ handle = init()
8
+ ...
9
+ handle.shutdown(timeout_s=5.0)
10
+
11
+ See docs/TELEMETRY-DISTRO-DESIGN.md for the full contract.
12
+ """
13
+ from ._init import Shutdown, init
14
+ from ._version import __version__
15
+
16
+ __all__ = ["init", "Shutdown", "__version__"]
@@ -0,0 +1,54 @@
1
+ """Env-driven configuration for the distro.
2
+
3
+ The dataclass exists so tests can construct it directly without
4
+ monkey-patching os.environ — every other module in the distro reads
5
+ from a DistroConfig, never from os.getenv().
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from typing import Optional
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DistroConfig:
16
+ # OTel-standard env vars (the SDK also reads these directly; we
17
+ # capture them here for diagnostics and for code paths that need
18
+ # to branch on their presence).
19
+ endpoint: Optional[str]
20
+ service_name: Optional[str]
21
+ resource_attributes: Optional[str]
22
+ headers: Optional[str]
23
+ protocol: Optional[str]
24
+
25
+ # Containarium-stamped env vars (split form per
26
+ # docs/OTEL-AGENT-RELAY-DESIGN.md decision #5).
27
+ container_id: Optional[str]
28
+ backend_id: Optional[str]
29
+ tenant_id: Optional[str]
30
+
31
+ # Tenant-controlled, used for service.version stamping.
32
+ service_version: Optional[str]
33
+
34
+ @classmethod
35
+ def from_env(cls, env: Optional[dict] = None) -> "DistroConfig":
36
+ e = env if env is not None else os.environ
37
+
38
+ def get(key: str) -> Optional[str]:
39
+ v = e.get(key)
40
+ if v is None or v == "":
41
+ return None
42
+ return v
43
+
44
+ return cls(
45
+ endpoint=get("OTEL_EXPORTER_OTLP_ENDPOINT"),
46
+ service_name=get("OTEL_SERVICE_NAME"),
47
+ resource_attributes=get("OTEL_RESOURCE_ATTRIBUTES"),
48
+ headers=get("OTEL_EXPORTER_OTLP_HEADERS"),
49
+ protocol=get("OTEL_EXPORTER_OTLP_PROTOCOL"),
50
+ container_id=get("CONTAINARIUM_CONTAINER_ID"),
51
+ backend_id=get("CONTAINARIUM_BACKEND_ID"),
52
+ tenant_id=get("CONTAINARIUM_TENANT_ID"),
53
+ service_version=get("SERVICE_VERSION"),
54
+ )
@@ -0,0 +1,66 @@
1
+ """OTel Distro + Configurator entry points for the auto-instrument path.
2
+
3
+ When the user runs `containarium-instrument python app.py` (or the
4
+ upstream `opentelemetry-instrument` with this package installed), the
5
+ OTel runtime loads:
6
+
7
+ 1. ContainariumDistro._configure() — sets exporter env defaults
8
+ 2. ContainariumConfigurator._configure() — runs our init()
9
+ 3. Then iterates registered opentelemetry_instrumentor entry points
10
+ and calls .instrument() on each.
11
+
12
+ Our init() detects the auto-instrument context via the
13
+ _CONTAINARIUM_TELEMETRY_AUTO_INSTRUMENT env sentinel and skips its own
14
+ instrumentation-registration step so we don't double-instrument.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+
21
+ from opentelemetry.instrumentation.distro import BaseDistro
22
+ from opentelemetry.sdk._configuration import _BaseConfigurator
23
+
24
+ logger = logging.getLogger("containarium_telemetry")
25
+
26
+ # Sentinel set by ContainariumConfigurator. init() reads it to decide
27
+ # whether to register instrumentors itself or defer to the runtime.
28
+ AUTO_INSTRUMENT_ENV_KEY = "_CONTAINARIUM_TELEMETRY_AUTO_INSTRUMENT"
29
+
30
+
31
+ class ContainariumDistro(BaseDistro):
32
+ """OTel Distro plugin — sets exporter env defaults.
33
+
34
+ setdefault throughout: the user's explicit env always wins. We're a
35
+ distro, not a policy enforcer.
36
+ """
37
+
38
+ def _configure(self, **kwargs) -> None:
39
+ # OTLP metrics by default — matches the central collector.
40
+ os.environ.setdefault("OTEL_METRICS_EXPORTER", "otlp")
41
+ # v1 collector is metrics-only (decision D4); muting trace + log
42
+ # export avoids the SDK fighting an empty endpoint. v2 flips
43
+ # these to "otlp" when Tempo + Loki land.
44
+ os.environ.setdefault("OTEL_TRACES_EXPORTER", "none")
45
+ os.environ.setdefault("OTEL_LOGS_EXPORTER", "none")
46
+ # Pin HTTP/protobuf — the protocol the central collector and
47
+ # otel-sidecar both speak.
48
+ os.environ.setdefault("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
49
+
50
+
51
+ class ContainariumConfigurator(_BaseConfigurator):
52
+ """OTel Configurator plugin — installs MeterProvider via init().
53
+
54
+ Configurators are responsible for actually wiring up the SDK
55
+ providers (TracerProvider, MeterProvider, LoggerProvider). Only
56
+ one configurator runs in the auto-instrument flow — ours wins
57
+ because we register an entry point.
58
+ """
59
+
60
+ def _configure(self, **kwargs) -> None:
61
+ os.environ[AUTO_INSTRUMENT_ENV_KEY] = "1"
62
+ # Lazy import so the OTLP exporter module isn't loaded when
63
+ # only the Distro half of the entry-point pair fires.
64
+ from ._init import init
65
+
66
+ init()
@@ -0,0 +1,64 @@
1
+ """Pretty-printer for the resolved telemetry config.
2
+
3
+ Used by `containarium-instrument --dry-run` (D12). Reports endpoint,
4
+ protocol, resource attrs, headers (bearer redacted by default), and
5
+ whether the pipeline would actually wire up given the env.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from ._config import DistroConfig
12
+ from ._version import __version__
13
+
14
+ _REDACTED_HEADER_KEYS = frozenset({"authorization", "x-api-key"})
15
+
16
+
17
+ def format_config(config: DistroConfig, *, redact_bearer: bool = True) -> str:
18
+ """Return a human-readable rendering of the resolved config."""
19
+ lines = [
20
+ "# containarium-telemetry resolved config",
21
+ f"distro_version : {__version__}",
22
+ f"endpoint : {config.endpoint or '<unset>'}",
23
+ f"protocol : {config.protocol or '<default: http/protobuf>'}",
24
+ f"service_name : {config.service_name or '<unset — SDK will default to unknown_service>'}",
25
+ f"resource_attributes : {config.resource_attributes or '<unset>'}",
26
+ f"headers : {_format_headers(config.headers, redact_bearer)}",
27
+ "",
28
+ "# Containarium identity (CONTAINARIUM_* env)",
29
+ f"container.id : {config.container_id or '<unset>'}",
30
+ f"backend.id : {config.backend_id or '<unset>'}",
31
+ f"tenant.id : {config.tenant_id or '<unset>'}",
32
+ f"service.version : {config.service_version or '<unset>'}",
33
+ "",
34
+ "# Defended distro stamp (always present, never overridable)",
35
+ f"containarium.distro : py/{__version__}",
36
+ "",
37
+ ]
38
+
39
+ if config.endpoint:
40
+ lines.append("Status: telemetry pipeline will be configured.")
41
+ else:
42
+ lines.append("Status: TELEMETRY WILL BE NO-OP. Endpoint env not set; init() will fail-open.")
43
+ lines.append("Enable monitoring on this LXC with:")
44
+ lines.append(" containarium monitoring enable <username>")
45
+
46
+ return "\n".join(lines)
47
+
48
+
49
+ def _format_headers(raw: Optional[str], redact: bool) -> str:
50
+ if not raw:
51
+ return "<unset>"
52
+ if not redact:
53
+ return raw
54
+ parts = []
55
+ for kv in raw.split(","):
56
+ if "=" not in kv:
57
+ parts.append(kv)
58
+ continue
59
+ k, _ = kv.split("=", 1)
60
+ if k.strip().lower() in _REDACTED_HEADER_KEYS:
61
+ parts.append(f"{k.strip()}=<redacted>")
62
+ else:
63
+ parts.append(kv)
64
+ return ",".join(parts)
@@ -0,0 +1,92 @@
1
+ """containarium-instrument console script.
2
+
3
+ Thin alias over `opentelemetry-instrument` (decision D8 — always
4
+ installed). Adds:
5
+
6
+ - `--dry-run`: prints resolved config and exits without launching
7
+ the app (D12).
8
+ - `--version` / `--help`: branding + usage.
9
+
10
+ Anything else execvp()s `opentelemetry-instrument` with the same args
11
+ and lets the upstream runtime drive the auto-instrument flow. Our
12
+ Distro + Configurator are registered as opentelemetry entry points, so
13
+ the upstream `opentelemetry-instrument` picks up our customizations
14
+ without us forking its argv parser.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from typing import List, Optional
21
+
22
+
23
+ def main(argv: Optional[List[str]] = None) -> int:
24
+ if argv is None:
25
+ argv = sys.argv[1:]
26
+
27
+ if not argv or argv[0] in ("-h", "--help"):
28
+ _print_help()
29
+ return 0 if argv else 2
30
+
31
+ if argv[0] in ("-V", "--version"):
32
+ from ._version import __version__
33
+
34
+ print(f"containarium-instrument {__version__}")
35
+ return 0
36
+
37
+ if argv[0] == "--dry-run":
38
+ return _dry_run()
39
+
40
+ # Delegate to upstream. execvp replaces the process so any state
41
+ # we set on the way in (e.g. env defaults from importing this
42
+ # module's deps) carries forward.
43
+ cmd = "opentelemetry-instrument"
44
+ try:
45
+ os.execvp(cmd, [cmd] + argv)
46
+ except FileNotFoundError:
47
+ print(
48
+ f"containarium-instrument: '{cmd}' not found in PATH. "
49
+ "Reinstall containarium-telemetry to pull the "
50
+ "opentelemetry-instrumentation dep, or pip install "
51
+ "opentelemetry-instrumentation directly.",
52
+ file=sys.stderr,
53
+ )
54
+ return 127
55
+ # Unreachable on success — execvp replaces the process.
56
+ return 0
57
+
58
+
59
+ def _dry_run() -> int:
60
+ from ._config import DistroConfig
61
+ from ._dry_run import format_config
62
+
63
+ config = DistroConfig.from_env()
64
+ print(format_config(config))
65
+ return 0
66
+
67
+
68
+ def _print_help() -> None:
69
+ print(
70
+ """containarium-instrument — Run a Python app with the Containarium telemetry distro.
71
+
72
+ Usage:
73
+ containarium-instrument [--dry-run | --version | --help] <command> [args ...]
74
+
75
+ Options:
76
+ --dry-run Print the resolved telemetry config (endpoint, resource
77
+ attrs, redacted headers, distro version) and exit. Does
78
+ NOT launch the wrapped command.
79
+ --version Print distro version and exit.
80
+ --help Show this help and exit.
81
+
82
+ Without a flag, exec()s opentelemetry-instrument with the same args.
83
+ Our Distro + Configurator are registered as OTel entry points, so
84
+ auto-instrumentation picks up the Containarium identity stamping and
85
+ OTLP/HTTP defaults transparently.
86
+
87
+ Examples:
88
+ containarium-instrument python app.py
89
+ containarium-instrument --dry-run
90
+ containarium-instrument python -m flask run
91
+ """
92
+ )
@@ -0,0 +1,156 @@
1
+ """Distro init — the public entry point.
2
+
3
+ Contract (per docs/TELEMETRY-DISTRO-DESIGN.md):
4
+ - Always fail-open. Missing endpoint → WARN + no-op handle (D10).
5
+ - Idempotent. Repeat calls log at DEBUG and return the existing handle.
6
+ - The OTLPMetricExporter reads OTEL_EXPORTER_OTLP_{ENDPOINT,HEADERS,
7
+ PROTOCOL} from env directly, so we do not pass them as constructor
8
+ args — that would shadow user overrides we're meant to honor.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ from typing import Dict, Optional
15
+
16
+ from opentelemetry import metrics, trace
17
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
18
+ OTLPMetricExporter,
19
+ )
20
+ from opentelemetry.sdk.metrics import MeterProvider
21
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
22
+ from opentelemetry.trace import NoOpTracerProvider
23
+
24
+ from ._config import DistroConfig
25
+ from ._distro import AUTO_INSTRUMENT_ENV_KEY
26
+ from ._instrumentations import InstrumentationsArg, register_instrumentations
27
+ from ._resource import build_resource
28
+
29
+ logger = logging.getLogger("containarium_telemetry")
30
+
31
+ _initialized: bool = False
32
+ _shutdown_handle: Optional["Shutdown"] = None
33
+
34
+
35
+ class Shutdown:
36
+ """Idempotent shutdown handle returned by init()."""
37
+
38
+ def __init__(self, provider: Optional[MeterProvider]):
39
+ self._provider = provider
40
+ self._done = False
41
+
42
+ def shutdown(self, timeout_s: float = 5.0) -> None:
43
+ if self._done:
44
+ return
45
+ self._done = True
46
+ if self._provider is None:
47
+ return
48
+ try:
49
+ self._provider.shutdown(timeout_millis=int(timeout_s * 1000))
50
+ except Exception as e: # noqa: BLE001 — never raise from shutdown
51
+ logger.warning("containarium_telemetry shutdown failed: %s", e)
52
+
53
+ def __call__(self, timeout_s: float = 5.0) -> None:
54
+ # Lets callers use the handle directly as `handle()` instead of
55
+ # `handle.shutdown()` — convenient with atexit.register().
56
+ self.shutdown(timeout_s)
57
+
58
+
59
+ def init(
60
+ service_name: Optional[str] = None,
61
+ extra_attrs: Optional[Dict[str, str]] = None,
62
+ instrumentations: InstrumentationsArg = "auto",
63
+ metric_export_interval_ms: int = 5_000,
64
+ metric_export_timeout_ms: int = 10_000,
65
+ ) -> Shutdown:
66
+ """Initialize the distro. Returns an idempotent Shutdown handle.
67
+
68
+ Args:
69
+ service_name: Override OTEL_SERVICE_NAME if not already set.
70
+ extra_attrs: Extra resource attributes — win over env attrs
71
+ (precedence #5 in TELEMETRY-DISTRO-DESIGN.md).
72
+ instrumentations: "auto" (default — every installed
73
+ opentelemetry_instrumentor), "off", or a list of names.
74
+ Skipped when invoked from `containarium-instrument` /
75
+ `opentelemetry-instrument` (the runtime handles it).
76
+ metric_export_interval_ms: Periodic export tick. Default 5s,
77
+ matching the sidecar's batch processor.
78
+ metric_export_timeout_ms: Per-export timeout. Default 10s.
79
+
80
+ Fail-open: missing OTEL_EXPORTER_OTLP_ENDPOINT logs WARN and returns
81
+ a no-op handle. The app never crashes because telemetry isn't wired.
82
+ """
83
+ global _initialized, _shutdown_handle
84
+
85
+ if _initialized:
86
+ logger.debug("init() called twice — returning existing handle")
87
+ return _shutdown_handle # type: ignore[return-value]
88
+
89
+ if service_name:
90
+ # setdefault — explicit user env still wins over the arg.
91
+ os.environ.setdefault("OTEL_SERVICE_NAME", service_name)
92
+
93
+ config = DistroConfig.from_env()
94
+
95
+ if not config.endpoint:
96
+ logger.warning(
97
+ "containarium_telemetry: OTEL_EXPORTER_OTLP_ENDPOINT not set; "
98
+ "telemetry will be a no-op. Enable monitoring on the LXC with "
99
+ "`containarium monitoring enable <username>`."
100
+ )
101
+ _shutdown_handle = Shutdown(None)
102
+ _initialized = True
103
+ return _shutdown_handle
104
+
105
+ try:
106
+ resource = build_resource(config, extra_attrs=extra_attrs)
107
+ exporter = OTLPMetricExporter()
108
+ reader = PeriodicExportingMetricReader(
109
+ exporter,
110
+ export_interval_millis=metric_export_interval_ms,
111
+ export_timeout_millis=metric_export_timeout_ms,
112
+ )
113
+ provider = MeterProvider(resource=resource, metric_readers=[reader])
114
+ metrics.set_meter_provider(provider)
115
+ except Exception as e: # noqa: BLE001 — fail-open per contract
116
+ logger.warning("containarium_telemetry init failed: %s", e)
117
+ _shutdown_handle = Shutdown(None)
118
+ _initialized = True
119
+ return _shutdown_handle
120
+
121
+ # No-op tracer provider so apps that call trace.get_tracer(...)
122
+ # don't crash — v1 collector accepts metrics only (D4). The v2
123
+ # traces pipeline will replace this with a real provider.
124
+ _set_noop_tracer_provider_if_unset()
125
+
126
+ # Skip instrumentation registration when invoked from the
127
+ # auto-instrument runtime (containarium-instrument /
128
+ # opentelemetry-instrument) — the runtime registers them itself
129
+ # after we return.
130
+ if os.environ.get(AUTO_INSTRUMENT_ENV_KEY) != "1":
131
+ register_instrumentations(instrumentations)
132
+
133
+ _shutdown_handle = Shutdown(provider)
134
+ _initialized = True
135
+ return _shutdown_handle
136
+
137
+
138
+ def _set_noop_tracer_provider_if_unset() -> None:
139
+ """Install NoOpTracerProvider only if no real one is configured.
140
+
141
+ OTel's default `ProxyTracerProvider` already returns no-op spans
142
+ when nothing has been set, so this is mostly belt-and-braces — but
143
+ it makes the v1→v2 transition cleaner (one call to flip).
144
+ """
145
+ current = trace.get_tracer_provider()
146
+ # Only stamp if nothing real has been installed yet. Don't clobber
147
+ # an app that already wired its own tracer.
148
+ if type(current).__name__ == "ProxyTracerProvider":
149
+ trace.set_tracer_provider(NoOpTracerProvider())
150
+
151
+
152
+ def _reset_for_tests() -> None:
153
+ """Reset module-level init state. Tests only — not part of the API."""
154
+ global _initialized, _shutdown_handle
155
+ _initialized = False
156
+ _shutdown_handle = None