bcl32-observability 0.1.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.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: bcl32-observability
3
+ Version: 0.1.0
4
+ Summary: Shared OpenTelemetry + structlog observability wiring for FastAPI services
5
+ Project-URL: Repository, https://github.com/bcl32/python-modules
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: structlog>=24.4.0
8
+ Requires-Dist: opentelemetry-sdk>=1.27.0
9
+ Requires-Dist: opentelemetry-exporter-otlp>=1.27.0
10
+ Provides-Extra: fastapi
11
+ Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
12
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == "fastapi"
13
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.48b0; extra == "fastapi"
14
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0; extra == "fastapi"
15
+ Provides-Extra: dev
16
+ Requires-Dist: build; extra == "dev"
17
+ Requires-Dist: twine; extra == "dev"
@@ -0,0 +1,86 @@
1
+ # bcl32-observability
2
+
3
+ Shared OpenTelemetry + structlog observability wiring for FastAPI services in the monorepo.
4
+ The Python reference implementation of [`OBSERVABILITY_CONTRACT.md`](../../OBSERVABILITY_CONTRACT.md):
5
+ one call wires **metrics, logs, and traces** to the central Grafana Alloy collector (OTLP), which
6
+ fans out to Prometheus / Loki / Tempo.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install bcl32-observability[fastapi]
12
+ ```
13
+
14
+ The core install gives structlog + OTLP exporters + metric helpers. The `[fastapi]` extra adds the
15
+ FastAPI / SQLAlchemy / httpx auto-instrumentation needed by `setup_observability`.
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from bcl32_observability import setup_observability, get_logger
21
+
22
+ app = FastAPI(...)
23
+ setup_observability(
24
+ app,
25
+ service_name="print-tracker-api",
26
+ service_version=APP_VERSION,
27
+ namespace="print-tracker",
28
+ environment="prod",
29
+ db_engine=db.engine, # optional: SQLAlchemy (async) engine → DB spans
30
+ )
31
+ log = get_logger(__name__)
32
+ log.info("startup.complete", category="app")
33
+ ```
34
+
35
+ You get, with no further code: HTTP RED metrics + server spans, SQLAlchemy query spans, httpx client
36
+ spans, and `trace_id`/`span_id` injected into every log line.
37
+
38
+ ### Domain metrics (OTel Meter)
39
+
40
+ ```python
41
+ from bcl32_observability import make_counter
42
+
43
+ PRINT_JOBS = make_counter("print_tracker_print_jobs_total", "Print jobs by status/source")
44
+ PRINT_JOBS.add(1, {"status": "completed", "source": "archive"})
45
+ ```
46
+
47
+ Labels (the `attributes` dict) must be **bounded** — never ids. See the contract §3.
48
+
49
+ ### Workers / cross-thread spans
50
+
51
+ ```python
52
+ from bcl32_observability import capture_context, attached_context, get_tracer, bound_context
53
+
54
+ ctx = capture_context() # on the producing thread (e.g. paho callback)
55
+ ...
56
+ with attached_context(ctx): # on the consuming coroutine/thread
57
+ with get_tracer().start_as_current_span("mqtt.archive.process"):
58
+ with bound_context(archive_id=str(archive_id), category="mqtt"):
59
+ log.info("archive.processed")
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Entirely via standard `OTEL_*` env vars — no code changes to retarget:
65
+
66
+ | Env var | Example |
67
+ |---|---|
68
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://alloy:4317` |
69
+ | `OTEL_RESOURCE_ATTRIBUTES` | `service.namespace=print-tracker,deployment.environment=prod` |
70
+ | `OTEL_TRACES_SAMPLER` / `_ARG` | `parentbased_traceidratio` / `0.1` |
71
+
72
+ ## Publishing
73
+
74
+ Published to PyPI by `.github/workflows/publish-observability.yml` on push to `main` that touches
75
+ `observability/src/**` (or via manual `workflow_dispatch`). Version bump auto-detected from
76
+ conventional commits; release tagged `observability-vX.Y.Z`.
77
+
78
+ ### Manual publish
79
+
80
+ ```bash
81
+ cd python-modules/observability
82
+ python3 -m venv .venv && source .venv/bin/activate
83
+ pip install build twine
84
+ python -m build
85
+ twine upload dist/*
86
+ ```
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bcl32-observability"
7
+ version = "0.1.0"
8
+ description = "Shared OpenTelemetry + structlog observability wiring for FastAPI services"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "structlog>=24.4.0",
12
+ "opentelemetry-sdk>=1.27.0",
13
+ "opentelemetry-exporter-otlp>=1.27.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ # Framework auto-instrumentation. Kept out of the core so a non-FastAPI
18
+ # consumer (or a core-only import) doesn't pull in FastAPI/SQLAlchemy/httpx.
19
+ fastapi = [
20
+ "fastapi>=0.100.0",
21
+ "opentelemetry-instrumentation-fastapi>=0.48b0",
22
+ "opentelemetry-instrumentation-sqlalchemy>=0.48b0",
23
+ "opentelemetry-instrumentation-httpx>=0.48b0",
24
+ ]
25
+ dev = ["build", "twine"]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/bcl32/python-modules"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,41 @@
1
+ """
2
+ bcl32-observability: shared OpenTelemetry + structlog wiring for the monorepo.
3
+
4
+ Reference implementation of OBSERVABILITY_CONTRACT.md for Python/FastAPI services.
5
+ One call wires metrics, logs, and traces:
6
+
7
+ from bcl32_observability import setup_observability, get_logger
8
+
9
+ setup_observability(app, service_name="print-tracker-api", service_version=APP_VERSION)
10
+ log = get_logger(__name__)
11
+
12
+ Domain metrics: make_counter / make_histogram / make_updown_counter / make_observable_gauge
13
+ Worker correlation: capture_context / attached_context / get_tracer (cross-thread spans)
14
+ Scoped log fields: bound_context(**fields)
15
+ """
16
+
17
+ from .context import attached_context, capture_context, get_tracer
18
+ from .fastapi import setup_observability
19
+ from .logging import bound_context, get_logger, setup_logging
20
+ from .metrics import (
21
+ make_counter,
22
+ make_histogram,
23
+ make_observable_gauge,
24
+ make_updown_counter,
25
+ )
26
+
27
+ __all__ = [
28
+ "setup_observability",
29
+ "setup_logging",
30
+ "get_logger",
31
+ "bound_context",
32
+ "make_counter",
33
+ "make_histogram",
34
+ "make_updown_counter",
35
+ "make_observable_gauge",
36
+ "capture_context",
37
+ "attached_context",
38
+ "get_tracer",
39
+ ]
40
+
41
+ __version__ = "0.1.0"
@@ -0,0 +1,47 @@
1
+ """
2
+ Span + cross-thread context helpers (OBSERVABILITY_CONTRACT.md §5a).
3
+
4
+ OpenTelemetry tracks the active span in a per-thread contextvar. It propagates
5
+ automatically within one async flow and across HTTP (via `traceparent`), but NOT
6
+ across a manual hand-off to another thread — a thread pool, a queue consumer, or
7
+ the paho-MQTT → asyncio bridge. For those, capture the context on the producing
8
+ side and re-attach it on the consuming side.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from contextlib import contextmanager
14
+ from typing import Any
15
+
16
+ from opentelemetry import context as _otel_context
17
+ from opentelemetry import trace
18
+
19
+
20
+ def get_tracer(name: str = "bcl32_observability"):
21
+ return trace.get_tracer(name)
22
+
23
+
24
+ def capture_context() -> Any:
25
+ """Snapshot the current OTel context. Call on the *producing* thread (e.g.
26
+ inside a paho callback) before handing work to another thread."""
27
+ return _otel_context.get_current()
28
+
29
+
30
+ @contextmanager
31
+ def attached_context(ctx: Any):
32
+ """Re-install a captured context for the duration of the block, then restore.
33
+
34
+ Use on the *consuming* side (e.g. the coroutine scheduled via
35
+ run_coroutine_threadsafe) so spans started inside parent correctly:
36
+
37
+ ctx = capture_context() # producing thread
38
+ ...
39
+ with attached_context(ctx): # consuming thread
40
+ with get_tracer().start_as_current_span("work"):
41
+ ...
42
+ """
43
+ token = _otel_context.attach(ctx)
44
+ try:
45
+ yield
46
+ finally:
47
+ _otel_context.detach(token)
@@ -0,0 +1,52 @@
1
+ """
2
+ The one-call adoption entry point for a FastAPI service.
3
+
4
+ from bcl32_observability import setup_observability, get_logger
5
+
6
+ setup_observability(app, service_name="print-tracker-api", service_version=APP_VERSION)
7
+ log = get_logger(__name__)
8
+
9
+ It builds the Resource, installs the OTLP tracer/meter providers, configures
10
+ structlog with trace-context injection, and auto-instruments FastAPI / httpx /
11
+ (optionally) SQLAlchemy. Requires the `[fastapi]` extra.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from .instrument import instrument_app
19
+ from .logging import setup_logging
20
+ from .resource import build_resource
21
+ from .setup import init_telemetry
22
+
23
+
24
+ def setup_observability(
25
+ app: Any,
26
+ *,
27
+ service_name: str,
28
+ service_version: str | None = None,
29
+ namespace: str | None = None,
30
+ environment: str | None = None,
31
+ db_engine: Any = None,
32
+ default_category: str = "app",
33
+ ) -> Any:
34
+ """Wire metrics, logs, and traces for a FastAPI app. Returns the app.
35
+
36
+ Args:
37
+ service_name: e.g. "print-tracker-api" (also via OTEL_SERVICE_NAME).
38
+ service_version: image tag / build version.
39
+ namespace: the project, e.g. "print-tracker".
40
+ environment: "dev" | "prod".
41
+ db_engine: optional SQLAlchemy (async) engine to instrument for DB spans.
42
+ default_category: structlog `category` default for this service.
43
+ """
44
+ resource = build_resource(service_name, service_version, namespace, environment)
45
+ init_telemetry(resource)
46
+ setup_logging(
47
+ service_name=service_name,
48
+ environment=environment,
49
+ default_category=default_category,
50
+ )
51
+ instrument_app(app, db_engine=db_engine)
52
+ return app
@@ -0,0 +1,50 @@
1
+ """
2
+ Auto-instrumentation wiring (OBSERVABILITY_CONTRACT.md §3/§5).
3
+
4
+ FastAPI instrumentation is the hard requirement (it's the point of the call), so a
5
+ missing `[fastapi]` extra raises a clear error. httpx and SQLAlchemy are wired
6
+ *best-effort* and independently: a service that makes no outbound httpx calls, or
7
+ that passes no engine, simply isn't instrumented for those — it is never forced to
8
+ install a library it doesn't use, and one instrumentor failing never blocks another.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any
15
+
16
+ _log = logging.getLogger(__name__)
17
+
18
+
19
+ def instrument_app(app: Any, *, db_engine: Any = None) -> None:
20
+ """Auto-instrument FastAPI (server spans + RED metrics) always; httpx (client
21
+ spans) if httpx is installed; SQLAlchemy (DB query spans) if an engine is given.
22
+
23
+ `db_engine` may be a sync Engine or an async engine (its `.sync_engine` is used).
24
+ """
25
+ try:
26
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
27
+ except ImportError as exc:
28
+ raise RuntimeError(
29
+ "setup_observability requires the [fastapi] extra: "
30
+ "pip install bcl32-observability[fastapi]"
31
+ ) from exc
32
+ FastAPIInstrumentor.instrument_app(app)
33
+
34
+ # httpx — only if the app actually has httpx available.
35
+ try:
36
+ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
37
+
38
+ HTTPXClientInstrumentor().instrument()
39
+ except ImportError:
40
+ _log.debug("httpx not installed; skipping httpx instrumentation")
41
+
42
+ # SQLAlchemy — only when an engine is supplied.
43
+ if db_engine is not None:
44
+ try:
45
+ from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
46
+
47
+ sync_engine = getattr(db_engine, "sync_engine", db_engine)
48
+ SQLAlchemyInstrumentor().instrument(engine=sync_engine)
49
+ except ImportError:
50
+ _log.debug("sqlalchemy instrumentation unavailable; skipping")
@@ -0,0 +1,121 @@
1
+ """
2
+ Structured logging via structlog, wired for the observability contract (§4).
3
+
4
+ Production gets JSON to stdout; an interactive TTY (or LOG_FORMAT=console) gets
5
+ the dev-friendly console renderer. Stdlib loggers (uvicorn, asyncpg, and every
6
+ `logging.getLogger(...)` caller) are routed through the same pipeline via
7
+ ProcessorFormatter's foreign_pre_chain, so their lines pick up the same fields.
8
+
9
+ On top of the canonical setup this adds two contract processors:
10
+ * trace-context injection — `trace_id` / `span_id` from the active OTel span,
11
+ the seam that joins logs to traces (§6).
12
+ * context defaults — `service`, `env`, and a default `category` so every line
13
+ is identifiable and filterable even when a call site omits them.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import sys
21
+ from contextlib import contextmanager
22
+ from typing import Any
23
+
24
+ import structlog
25
+ from opentelemetry import trace
26
+
27
+
28
+ def _trace_context(_logger: Any, _name: str, event_dict: dict) -> dict:
29
+ """Inject trace_id/span_id when emitted inside a recording span."""
30
+ span = trace.get_current_span()
31
+ ctx = span.get_span_context()
32
+ if ctx.is_valid:
33
+ event_dict["trace_id"] = format(ctx.trace_id, "032x")
34
+ event_dict["span_id"] = format(ctx.span_id, "016x")
35
+ return event_dict
36
+
37
+
38
+ def _context_defaults(service_name: str, environment: str | None, default_category: str):
39
+ """Processor that fills identity fields without overriding explicit kwargs."""
40
+
41
+ def processor(_logger: Any, _name: str, event_dict: dict) -> dict:
42
+ event_dict.setdefault("service", service_name)
43
+ if environment:
44
+ event_dict.setdefault("env", environment)
45
+ event_dict.setdefault("category", default_category)
46
+ return event_dict
47
+
48
+ return processor
49
+
50
+
51
+ def setup_logging(
52
+ *,
53
+ service_name: str,
54
+ environment: str | None = None,
55
+ default_category: str = "app",
56
+ level: int = logging.INFO,
57
+ ) -> None:
58
+ use_json = os.getenv(
59
+ "LOG_FORMAT",
60
+ "console" if sys.stderr.isatty() else "json",
61
+ ).lower() == "json"
62
+
63
+ shared_processors = [
64
+ structlog.contextvars.merge_contextvars,
65
+ structlog.stdlib.add_log_level,
66
+ structlog.stdlib.add_logger_name,
67
+ # Copy LogRecord `extra={...}` attrs into the event_dict so foreign
68
+ # stdlib loggers surface their structured fields.
69
+ structlog.stdlib.ExtraAdder(),
70
+ _context_defaults(service_name, environment, default_category),
71
+ _trace_context,
72
+ structlog.processors.TimeStamper(fmt="iso"),
73
+ ]
74
+
75
+ structlog.configure(
76
+ processors=[
77
+ *shared_processors,
78
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
79
+ ],
80
+ logger_factory=structlog.stdlib.LoggerFactory(),
81
+ cache_logger_on_first_use=True,
82
+ )
83
+
84
+ renderer = (
85
+ structlog.processors.JSONRenderer()
86
+ if use_json
87
+ else structlog.dev.ConsoleRenderer()
88
+ )
89
+ formatter = structlog.stdlib.ProcessorFormatter(
90
+ foreign_pre_chain=shared_processors,
91
+ processors=[
92
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
93
+ renderer,
94
+ ],
95
+ )
96
+
97
+ handler = logging.StreamHandler()
98
+ handler.setFormatter(formatter)
99
+ root = logging.getLogger()
100
+ root.handlers = [handler]
101
+ root.setLevel(level)
102
+
103
+ # Reduce noise from third-party loggers.
104
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
105
+
106
+
107
+ def get_logger(name: str | None = None):
108
+ """Return a structlog logger. Thin wrapper so apps stop importing structlog
109
+ directly and the processor chain can evolve centrally."""
110
+ return structlog.get_logger(name)
111
+
112
+
113
+ @contextmanager
114
+ def bound_context(**fields: Any):
115
+ """Bind structured fields onto every log line emitted within the block.
116
+
117
+ Use in workers / per-unit-of-work scopes (e.g. an MQTT archive handler) to
118
+ attach `archive_id`, `category`, etc. to all lines and their callees.
119
+ """
120
+ with structlog.contextvars.bound_contextvars(**fields):
121
+ yield
@@ -0,0 +1,64 @@
1
+ """
2
+ Domain metric helpers over the OpenTelemetry Meter API (OBSERVABILITY_CONTRACT.md §3).
3
+
4
+ Use these rather than raw `prometheus_client` so domain metrics ride the same
5
+ OTLP pipeline and translate to Prometheus consistently. Naming convention:
6
+ `<namespace>_<subject>_<unit>`, `_total` on counters, base units (`_seconds`,
7
+ `_bytes`). Labels (passed as `attributes={...}` at record time) must be bounded —
8
+ never inject ids / request-scoped values; those belong on spans or log fields.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Callable, Iterable, Sequence
14
+
15
+ from opentelemetry import metrics as _metrics
16
+ from opentelemetry.metrics import CallbackOptions, Counter, Histogram, Observation, UpDownCounter
17
+
18
+ _METER_NAME = "bcl32_observability"
19
+
20
+
21
+ def get_meter(name: str = _METER_NAME):
22
+ return _metrics.get_meter(name)
23
+
24
+
25
+ def make_counter(name: str, description: str, unit: str = "1") -> Counter:
26
+ """Monotonic total, e.g. `print_tracker_print_jobs_total`."""
27
+ return get_meter().create_counter(name, unit=unit, description=description)
28
+
29
+
30
+ def make_updown_counter(name: str, description: str, unit: str = "1") -> UpDownCounter:
31
+ """Value that rises and falls, e.g. queue depth / in-flight count."""
32
+ return get_meter().create_up_down_counter(name, unit=unit, description=description)
33
+
34
+
35
+ def make_histogram(name: str, description: str, unit: str = "s") -> Histogram:
36
+ """Distribution, e.g. `print_tracker_slice_duration_seconds`."""
37
+ return get_meter().create_histogram(name, unit=unit, description=description)
38
+
39
+
40
+ def make_observable_gauge(
41
+ name: str,
42
+ description: str,
43
+ callback: Callable[[CallbackOptions], Iterable[Observation]],
44
+ unit: str = "1",
45
+ ):
46
+ """Point-in-time value read on each collection via `callback`.
47
+
48
+ The callback returns an iterable of `Observation(value, {label: ...})`,
49
+ e.g. current pool size or aggregated inventory by material.
50
+ """
51
+ return get_meter().create_observable_gauge(
52
+ name, callbacks=[callback], unit=unit, description=description
53
+ )
54
+
55
+
56
+ __all__: Sequence[str] = (
57
+ "get_meter",
58
+ "make_counter",
59
+ "make_updown_counter",
60
+ "make_histogram",
61
+ "make_observable_gauge",
62
+ "Observation",
63
+ "CallbackOptions",
64
+ )
@@ -0,0 +1,35 @@
1
+ """
2
+ Build the OpenTelemetry Resource — the identity attached to every metric, log,
3
+ and trace (see OBSERVABILITY_CONTRACT.md §2).
4
+
5
+ `Resource.create()` already reads the standard `OTEL_SERVICE_NAME` and
6
+ `OTEL_RESOURCE_ATTRIBUTES` env vars. We merge any explicit arguments *over* those
7
+ env-derived defaults so a service can set its identity in code, in env, or both.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from opentelemetry.sdk.resources import Resource
13
+ from opentelemetry.semconv.resource import ResourceAttributes
14
+
15
+
16
+ def build_resource(
17
+ service_name: str | None = None,
18
+ service_version: str | None = None,
19
+ namespace: str | None = None,
20
+ environment: str | None = None,
21
+ ) -> Resource:
22
+ """Resource from env (`OTEL_*`) with explicit args taking precedence."""
23
+ overrides: dict[str, str] = {}
24
+ if service_name:
25
+ overrides[ResourceAttributes.SERVICE_NAME] = service_name
26
+ if service_version:
27
+ overrides[ResourceAttributes.SERVICE_VERSION] = service_version
28
+ if namespace:
29
+ overrides[ResourceAttributes.SERVICE_NAMESPACE] = namespace
30
+ if environment:
31
+ overrides[ResourceAttributes.DEPLOYMENT_ENVIRONMENT] = environment
32
+
33
+ # Resource.create merges OTEL_RESOURCE_ATTRIBUTES / OTEL_SERVICE_NAME; the
34
+ # explicit dict wins on conflicts.
35
+ return Resource.create(overrides)
@@ -0,0 +1,69 @@
1
+ """
2
+ Initialise the OpenTelemetry tracer + meter providers and their OTLP exporters.
3
+
4
+ Endpoint is read from `OTEL_EXPORTER_OTLP_ENDPOINT` by the exporters themselves
5
+ (default `http://alloy:4317`); sampling from `OTEL_TRACES_SAMPLER[_ARG]`. The
6
+ exporters are non-blocking and buffer in the background, so a collector outage
7
+ never stalls a request (graceful degradation — see OBSERVABILITY_CONTRACT.md).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ from opentelemetry import metrics, trace
15
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
16
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
17
+ from opentelemetry.sdk.metrics import MeterProvider
18
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
19
+ from opentelemetry.sdk.resources import Resource
20
+ from opentelemetry.sdk.trace import TracerProvider
21
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
22
+ from opentelemetry.sdk.trace.sampling import (
23
+ ALWAYS_OFF,
24
+ ALWAYS_ON,
25
+ ParentBased,
26
+ Sampler,
27
+ TraceIdRatioBased,
28
+ )
29
+
30
+ _initialised = False
31
+
32
+
33
+ def _sampler_from_env() -> Sampler:
34
+ """Honour OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG.
35
+
36
+ Defaults to parent-based ratio sampling so trace volume is bounded from the
37
+ first deploy and upstream sampling decisions are respected.
38
+ """
39
+ name = os.getenv("OTEL_TRACES_SAMPLER", "parentbased_traceidratio").lower()
40
+ try:
41
+ ratio = float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0"))
42
+ except ValueError:
43
+ ratio = 1.0
44
+
45
+ if name in ("always_on", "parentbased_always_on"):
46
+ return ParentBased(ALWAYS_ON) if name.startswith("parent") else ALWAYS_ON
47
+ if name in ("always_off", "parentbased_always_off"):
48
+ return ParentBased(ALWAYS_OFF) if name.startswith("parent") else ALWAYS_OFF
49
+ if name == "traceidratio":
50
+ return TraceIdRatioBased(ratio)
51
+ # parentbased_traceidratio (default)
52
+ return ParentBased(TraceIdRatioBased(ratio))
53
+
54
+
55
+ def init_telemetry(resource: Resource) -> None:
56
+ """Install global tracer + meter providers wired to OTLP. Idempotent."""
57
+ global _initialised
58
+ if _initialised:
59
+ return
60
+
61
+ tracer_provider = TracerProvider(resource=resource, sampler=_sampler_from_env())
62
+ tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
63
+ trace.set_tracer_provider(tracer_provider)
64
+
65
+ reader = PeriodicExportingMetricReader(OTLPMetricExporter())
66
+ meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
67
+ metrics.set_meter_provider(meter_provider)
68
+
69
+ _initialised = True
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: bcl32-observability
3
+ Version: 0.1.0
4
+ Summary: Shared OpenTelemetry + structlog observability wiring for FastAPI services
5
+ Project-URL: Repository, https://github.com/bcl32/python-modules
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: structlog>=24.4.0
8
+ Requires-Dist: opentelemetry-sdk>=1.27.0
9
+ Requires-Dist: opentelemetry-exporter-otlp>=1.27.0
10
+ Provides-Extra: fastapi
11
+ Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
12
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == "fastapi"
13
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.48b0; extra == "fastapi"
14
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.48b0; extra == "fastapi"
15
+ Provides-Extra: dev
16
+ Requires-Dist: build; extra == "dev"
17
+ Requires-Dist: twine; extra == "dev"
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/bcl32_observability/__init__.py
4
+ src/bcl32_observability/context.py
5
+ src/bcl32_observability/fastapi.py
6
+ src/bcl32_observability/instrument.py
7
+ src/bcl32_observability/logging.py
8
+ src/bcl32_observability/metrics.py
9
+ src/bcl32_observability/resource.py
10
+ src/bcl32_observability/setup.py
11
+ src/bcl32_observability.egg-info/PKG-INFO
12
+ src/bcl32_observability.egg-info/SOURCES.txt
13
+ src/bcl32_observability.egg-info/dependency_links.txt
14
+ src/bcl32_observability.egg-info/requires.txt
15
+ src/bcl32_observability.egg-info/top_level.txt
@@ -0,0 +1,13 @@
1
+ structlog>=24.4.0
2
+ opentelemetry-sdk>=1.27.0
3
+ opentelemetry-exporter-otlp>=1.27.0
4
+
5
+ [dev]
6
+ build
7
+ twine
8
+
9
+ [fastapi]
10
+ fastapi>=0.100.0
11
+ opentelemetry-instrumentation-fastapi>=0.48b0
12
+ opentelemetry-instrumentation-sqlalchemy>=0.48b0
13
+ opentelemetry-instrumentation-httpx>=0.48b0
@@ -0,0 +1 @@
1
+ bcl32_observability