carros-ai-utils 0.2.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 (47) hide show
  1. carros_ai_utils-0.2.0/.github/workflows/pr-agent.yml +22 -0
  2. carros_ai_utils-0.2.0/.github/workflows/publish.yml +24 -0
  3. carros_ai_utils-0.2.0/.gitignore +12 -0
  4. carros_ai_utils-0.2.0/PKG-INFO +32 -0
  5. carros_ai_utils-0.2.0/README.md +109 -0
  6. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/design.md +65 -0
  7. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/proposal.md +24 -0
  8. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/specs/config/spec.md +55 -0
  9. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/specs/fastapi-middleware/spec.md +58 -0
  10. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/specs/init/spec.md +51 -0
  11. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/specs/logger/spec.md +50 -0
  12. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/specs/meter/spec.md +38 -0
  13. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/specs/otlp-exporter/spec.md +45 -0
  14. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/specs/tracer/spec.md +34 -0
  15. carros_ai_utils-0.2.0/openspec/changes/archive/2026-04-03-init-telemetry-python/tasks.md +49 -0
  16. carros_ai_utils-0.2.0/openspec/config.yaml +74 -0
  17. carros_ai_utils-0.2.0/openspec/specs/config/spec.md +55 -0
  18. carros_ai_utils-0.2.0/openspec/specs/fastapi-middleware/spec.md +58 -0
  19. carros_ai_utils-0.2.0/openspec/specs/init/spec.md +51 -0
  20. carros_ai_utils-0.2.0/openspec/specs/logger/spec.md +50 -0
  21. carros_ai_utils-0.2.0/openspec/specs/meter/spec.md +38 -0
  22. carros_ai_utils-0.2.0/openspec/specs/otlp-exporter/spec.md +45 -0
  23. carros_ai_utils-0.2.0/openspec/specs/tracer/spec.md +34 -0
  24. carros_ai_utils-0.2.0/pyproject.toml +56 -0
  25. carros_ai_utils-0.2.0/renovate.json +26 -0
  26. carros_ai_utils-0.2.0/ruff.toml +12 -0
  27. carros_ai_utils-0.2.0/src/carros_ai_utils/__init__.py +134 -0
  28. carros_ai_utils-0.2.0/src/carros_ai_utils/_config.py +41 -0
  29. carros_ai_utils-0.2.0/src/carros_ai_utils/_context.py +79 -0
  30. carros_ai_utils-0.2.0/src/carros_ai_utils/_exporters.py +34 -0
  31. carros_ai_utils-0.2.0/src/carros_ai_utils/_prometheus.py +30 -0
  32. carros_ai_utils-0.2.0/src/carros_ai_utils/_providers.py +51 -0
  33. carros_ai_utils-0.2.0/src/carros_ai_utils/_settings.py +64 -0
  34. carros_ai_utils-0.2.0/src/carros_ai_utils/_state.py +10 -0
  35. carros_ai_utils-0.2.0/src/carros_ai_utils/_structlog.py +103 -0
  36. carros_ai_utils-0.2.0/src/carros_ai_utils/fastapi.py +48 -0
  37. carros_ai_utils-0.2.0/src/carros_ai_utils/settings.py +21 -0
  38. carros_ai_utils-0.2.0/tests/conftest.py +23 -0
  39. carros_ai_utils-0.2.0/tests/test_config.py +60 -0
  40. carros_ai_utils-0.2.0/tests/test_context.py +47 -0
  41. carros_ai_utils-0.2.0/tests/test_exporters.py +32 -0
  42. carros_ai_utils-0.2.0/tests/test_fastapi.py +78 -0
  43. carros_ai_utils-0.2.0/tests/test_init.py +72 -0
  44. carros_ai_utils-0.2.0/tests/test_providers.py +58 -0
  45. carros_ai_utils-0.2.0/tests/test_settings.py +81 -0
  46. carros_ai_utils-0.2.0/tests/test_structlog.py +56 -0
  47. carros_ai_utils-0.2.0/uv.lock +1283 -0
@@ -0,0 +1,22 @@
1
+ name: PR Agent
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, reopened, ready_for_review, synchronize]
6
+ issue_comment:
7
+
8
+ permissions:
9
+ issues: write
10
+ pull-requests: write
11
+ contents: read
12
+
13
+ jobs:
14
+ review:
15
+ uses: carros-ai/platform-ci-workflows/.github/workflows/pr-agent.yml@main
16
+ permissions:
17
+ issues: write
18
+ pull-requests: write
19
+ contents: read
20
+ secrets:
21
+ ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_KEY }}
22
+
@@ -0,0 +1,24 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: '3.13'
19
+
20
+ - name: Build
21
+ run: pip install build && python -m build
22
+
23
+ - name: Publish
24
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ *.egg-info/
6
+ .env
7
+ .env.*
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ .coverage
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: carros-ai-utils
3
+ Version: 0.2.0
4
+ Summary: Opinionated OpenTelemetry wrapper for carros-ai Python services
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.25.0
7
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.46b0
8
+ Requires-Dist: opentelemetry-sdk>=1.25.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: bandit>=1.7; extra == 'dev'
11
+ Requires-Dist: fastapi>=0.111.0; extra == 'dev'
12
+ Requires-Dist: httpx>=0.27; extra == 'dev'
13
+ Requires-Dist: mypy>=1.10; extra == 'dev'
14
+ Requires-Dist: opentelemetry-exporter-prometheus>=0.46b0; extra == 'dev'
15
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.46b0; extra == 'dev'
16
+ Requires-Dist: prometheus-client>=0.20; extra == 'dev'
17
+ Requires-Dist: pydantic-settings>=2.0; extra == 'dev'
18
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
19
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.4; extra == 'dev'
22
+ Requires-Dist: structlog>=23.0; extra == 'dev'
23
+ Provides-Extra: fastapi
24
+ Requires-Dist: fastapi>=0.111.0; extra == 'fastapi'
25
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.46b0; extra == 'fastapi'
26
+ Provides-Extra: prometheus
27
+ Requires-Dist: opentelemetry-exporter-prometheus>=0.46b0; extra == 'prometheus'
28
+ Requires-Dist: prometheus-client>=0.20; extra == 'prometheus'
29
+ Provides-Extra: settings
30
+ Requires-Dist: pydantic-settings>=2.0; extra == 'settings'
31
+ Provides-Extra: structlog
32
+ Requires-Dist: structlog>=23.0; extra == 'structlog'
@@ -0,0 +1,109 @@
1
+ # carros-ai-utils
2
+
3
+ Opinionated OpenTelemetry wrapper for carros-ai Python services. Single call wires traces, metrics, and logs — all exported via OTLP gRPC to the observability collector.
4
+
5
+ ## Installation
6
+
7
+ ```toml
8
+ # pyproject.toml
9
+ [project]
10
+ dependencies = [
11
+ "carros-ai-utils @ git+ssh://git@github.com/carros-ai/python-utils.git",
12
+ ]
13
+ ```
14
+
15
+ With FastAPI support:
16
+
17
+ ```toml
18
+ dependencies = [
19
+ "carros-ai-utils[fastapi] @ git+ssh://git@github.com/carros-ai/python-utils.git",
20
+ ]
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### FastAPI
26
+
27
+ ```python
28
+ # main.py
29
+ from contextlib import asynccontextmanager
30
+
31
+ import carros_ai_utils
32
+ from carros_ai_utils.fastapi import instrument_fastapi
33
+ from fastapi import FastAPI
34
+
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ carros_ai_utils.init("payments-api")
39
+ instrument_fastapi(app, "payments-api")
40
+ yield
41
+ carros_ai_utils.shutdown()
42
+
43
+
44
+ app = FastAPI(lifespan=lifespan)
45
+ ```
46
+
47
+ ### Plain Python / scripts
48
+
49
+ ```python
50
+ import carros_ai_utils
51
+
52
+ carros_ai_utils.init("my-worker")
53
+
54
+ # traces
55
+ with carros_ai_utils.tracer("my-module").start_as_current_span("process-job") as span:
56
+ span.set_attribute("job.id", job_id)
57
+ do_work()
58
+
59
+ # metrics
60
+ counter = carros_ai_utils.meter("my-module").create_counter("jobs.processed")
61
+ counter.add(1, {"queue": "default"})
62
+
63
+ carros_ai_utils.shutdown()
64
+ ```
65
+
66
+ ### Logging
67
+
68
+ After `carros_ai_utils.init()`, Python's standard `logging` module is automatically bridged to OTLP. No extra setup needed:
69
+
70
+ ```python
71
+ import logging
72
+
73
+ logger = logging.getLogger(__name__)
74
+ logger.info("job started", extra={"job_id": "abc123"})
75
+ ```
76
+
77
+ ## Environment variables
78
+
79
+ | Variable | Default | Description |
80
+ |---|---|---|
81
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | `localhost:4317` | Collector gRPC endpoint |
82
+ | `APP_ENVIRONMENT` | `development` | Set to `production` to activate OTLP export |
83
+ | `APP_VERSION` | `0.0.0` | Reported as `service.version` resource attribute |
84
+ | `OTEL_SERVICE_NAME` | _(init arg)_ | Overrides the `service_name` passed to `carros_ai_utils.init()` |
85
+
86
+ In **development** (default), all signals are printed to the console. In **production**, they are exported via gRPC to `OTEL_EXPORTER_OTLP_ENDPOINT`.
87
+
88
+ ## Kubernetes / Coolify deployment
89
+
90
+ Add these env vars to your `deployment.yaml` or Coolify service:
91
+
92
+ ```yaml
93
+ - name: APP_ENVIRONMENT
94
+ value: "production"
95
+ - name: APP_VERSION
96
+ value: "1.0.0"
97
+ - name: OTEL_EXPORTER_OTLP_ENDPOINT
98
+ value: "metrics-collector-api.application.svc.cluster.local:4317"
99
+ ```
100
+
101
+ The `OTEL_SERVICE_NAME` is optional — if omitted, the name passed to `carros_ai_utils.init()` is used.
102
+
103
+ ## FastAPI middleware behavior
104
+
105
+ `instrument_fastapi` automatically:
106
+
107
+ - Creates a span for every HTTP request
108
+ - Propagates trace context from incoming headers (`traceparent`)
109
+ - Copies the `X-User-Id` header (injected by Traefik forward-auth) into the span as `enduser.id`
@@ -0,0 +1,65 @@
1
+ ## Context
2
+ The OpenTelemetry Python SDK requires configuring three independent providers
3
+ (TracerProvider, MeterProvider, LoggerProvider), each with its own exporter,
4
+ resource, and global registration. This library does all of that in one call
5
+ and exposes a minimal, stable surface for application code.
6
+
7
+ ## Goals
8
+ 1. Single init() call configures all three signal types
9
+ 2. No global state leakage between tests — providers can be reset
10
+ 3. Environment-driven config — no config files, no hardcoded values
11
+ 4. Works in production (gRPC to collector) and locally (stdout, no collector needed)
12
+ 5. FastAPI integration requires zero manual span management per route
13
+
14
+ ## Technical Decisions
15
+
16
+ ### Decision: OTLP gRPC only (no HTTP exporter)
17
+ ALTERNATIVES: Support both gRPC and HTTP
18
+ RISKS: Environments that block HTTP/2 cannot use this library
19
+ RATIONALE: Matches the telemetry-collector which only exposes gRPC. All
20
+ carros-ai services run inside private Docker networks where gRPC is fully
21
+ supported. Adding HTTP adds a second code path with no current consumer.
22
+
23
+ ### Decision: APP_ENVIRONMENT drives exporter selection
24
+ ALTERNATIVES: Separate env var (e.g. OTEL_PYTHON_EXPORTER=grpc|stdout)
25
+ RISKS: APP_ENVIRONMENT may be used for other purposes in the app
26
+ RATIONALE: Consistent with the Go telemetry package convention already in use.
27
+ When APP_ENVIRONMENT=production, gRPC to the collector is used. Any other
28
+ value falls back to stdout (ConsoleExporter), allowing local development
29
+ without running the collector.
30
+
31
+ ### Decision: Python logging bridge via LoggingHandler
32
+ ALTERNATIVES: Provide a custom logger API; use structlog
33
+ RISKS: LoggingHandler adds latency to every log call via OTLP export
34
+ RATIONALE: Application code already uses logging.getLogger(). Bridging it
35
+ to OTLP requires zero changes in application code — only telemetry.init()
36
+ needs to be called at startup. Custom logger APIs would require refactoring
37
+ every existing log call.
38
+
39
+ ### Decision: FastAPI middleware via OpenTelemetry FastAPI instrumentation
40
+ ALTERNATIVES: Manual span creation in route handlers; custom ASGI middleware
41
+ RISKS: Upstream instrumentation library may lag behind FastAPI releases
42
+ RATIONALE: opentelemetry-instrumentation-fastapi is the official, maintained
43
+ instrumentation. It handles span creation, HTTP attribute population, and
44
+ error capture automatically. Manual spans would require changes in every route.
45
+
46
+ ### Decision: Frozen dataclass for Config (not Pydantic)
47
+ ALTERNATIVES: Pydantic BaseSettings; plain dict; os.getenv() calls scattered
48
+ RISKS: Less validation than Pydantic; no .env file loading
49
+ RATIONALE: This is a library, not an application. Adding Pydantic as a
50
+ required dependency would force it on every consumer. A frozen dataclass
51
+ with explicit type coercion keeps the dependency footprint minimal.
52
+ If a consumer already uses Pydantic, they can pass config values directly.
53
+
54
+ ## Migration
55
+ Existing services add one dependency to pyproject.toml and one call in main:
56
+ ```python
57
+ import telemetry
58
+ telemetry.init("my-service")
59
+ ```
60
+ No other changes required. Existing logging.getLogger() calls are
61
+ automatically bridged to OTLP after init().
62
+
63
+ ## Open Questions
64
+ - Should meter() expose shorthand helpers (counter, histogram) or only the raw Meter?
65
+ - Should init() accept a shutdown_timeout parameter for graceful flush?
@@ -0,0 +1,24 @@
1
+ ## Why
2
+ Every Python service in the carros-ai platform must configure OpenTelemetry
3
+ providers, exporters, and resource attributes individually. This is repetitive,
4
+ error-prone, and leads to inconsistent observability across services. A shared
5
+ library encapsulates this once — services call telemetry.init("name") and get
6
+ fully configured traces, metrics, and logs with zero boilerplate.
7
+
8
+ ## What Changes
9
+ Initial creation of the telemetry-python library. No existing services are
10
+ modified. All capabilities are ADDED.
11
+
12
+ ## Capabilities
13
+ - **config** — Reads observability settings from environment variables with sensible defaults
14
+ - **otlp-exporter** — Configures OTLP gRPC exporters pointing to the telemetry-collector
15
+ - **init** — Single entry point that wires all providers with one function call
16
+ - **tracer** — Provides a pre-configured Tracer for creating spans
17
+ - **meter** — Provides a pre-configured Meter for recording metrics
18
+ - **logger** — Bridges Python standard logging to OTLP via LoggingHandler
19
+ - **fastapi-middleware** — Auto-instruments FastAPI apps (spans per request, error capture)
20
+
21
+ ## Impact
22
+ - New standalone library — no existing services are modified
23
+ - Services add one dependency and one init() call at startup
24
+ - Requires the telemetry-collector to be reachable at OTEL_EXPORTER_OTLP_ENDPOINT
@@ -0,0 +1,55 @@
1
+ ## Purpose
2
+ Read observability settings from environment variables and expose them as an
3
+ immutable Config object. Provides sensible defaults so services work locally
4
+ without any environment setup.
5
+
6
+ ### Requirement: Config from Environment Variables
7
+ The library SHALL read configuration exclusively from environment variables,
8
+ applying defaults for any variable that is not set.
9
+
10
+ #### Scenario: All defaults applied when no env vars set
11
+ - **GIVEN** no observability-related environment variables are set
12
+ - **WHEN** Config is instantiated
13
+ - **THEN** `endpoint` is `"localhost:4317"`
14
+ - **AND** `environment` is `"development"`
15
+ - **AND** `version` is `"0.0.0"`
16
+ - **AND** `service_name` is `None` (overridden by init() argument)
17
+
18
+ #### Scenario: Production values read from environment
19
+ - **GIVEN** the following env vars are set:
20
+ ```
21
+ OTEL_EXPORTER_OTLP_ENDPOINT=telemetry-collector:4317
22
+ APP_ENVIRONMENT=production
23
+ APP_VERSION=1.2.3
24
+ OTEL_SERVICE_NAME=payments-api
25
+ ```
26
+ - **WHEN** Config is instantiated
27
+ - **THEN** each field reflects the corresponding env var value
28
+
29
+ #### Scenario: OTEL_SERVICE_NAME overrides init() argument
30
+ - **GIVEN** `OTEL_SERVICE_NAME=env-name` is set
31
+ - **WHEN** `telemetry.init("code-name")` is called
32
+ - **THEN** the effective service name used in the resource is `"env-name"`
33
+
34
+ ### Requirement: Immutable Config
35
+ The Config object SHALL be immutable after instantiation — no field may be
36
+ modified by application code.
37
+
38
+ #### Scenario: Mutation attempt raises error
39
+ - **GIVEN** a Config instance
40
+ - **WHEN** application code attempts to set any field (e.g. `cfg.endpoint = "x"`)
41
+ - **THEN** a `FrozenInstanceError` or `AttributeError` is raised
42
+
43
+ ### Requirement: Production Mode Detection
44
+ The library SHALL treat `APP_ENVIRONMENT=production` as the signal to use
45
+ gRPC OTLP export. Any other value (including unset) uses stdout export.
46
+
47
+ #### Scenario: Production mode active
48
+ - **GIVEN** `APP_ENVIRONMENT=production`
49
+ - **WHEN** `config.is_production` is accessed
50
+ - **THEN** it returns `True`
51
+
52
+ #### Scenario: Development mode when env var absent
53
+ - **GIVEN** `APP_ENVIRONMENT` is not set
54
+ - **WHEN** `config.is_production` is accessed
55
+ - **THEN** it returns `False`
@@ -0,0 +1,58 @@
1
+ ## Purpose
2
+ Provide zero-config FastAPI auto-instrumentation that creates a span per
3
+ HTTP request, captures standard HTTP attributes, and records errors —
4
+ without requiring manual span management in route handlers.
5
+
6
+ ### Requirement: Automatic Span per Request
7
+ After instrumentation is applied, the library SHALL create an OTel span for
8
+ every incoming HTTP request handled by FastAPI.
9
+
10
+ #### Scenario: Span created for successful request
11
+ - **GIVEN** `telemetry.init("api")` was called and instrumentation applied
12
+ - **WHEN** a GET request to `/items/42` is handled and returns 200
13
+ - **THEN** a span named `"GET /items/{item_id}"` is created
14
+ - **AND** it has attributes: `http.method=GET`, `http.status_code=200`,
15
+ `http.route=/items/{item_id}`
16
+ - **AND** the span status is OK
17
+
18
+ #### Scenario: Span records 5xx errors
19
+ - **GIVEN** instrumentation is applied
20
+ - **WHEN** a route raises an unhandled exception (500)
21
+ - **THEN** the span status is set to ERROR
22
+ - **AND** the exception is recorded on the span via `span.record_exception()`
23
+
24
+ #### Scenario: Span records 4xx without error status
25
+ - **GIVEN** instrumentation is applied
26
+ - **WHEN** a route returns 404
27
+ - **THEN** the span is created with `http.status_code=404`
28
+ - **AND** the span status is NOT set to ERROR (4xx are not server errors)
29
+
30
+ ### Requirement: Lifespan Integration
31
+ The library SHALL provide a FastAPI lifespan context manager that calls
32
+ `telemetry.init()` on startup and `telemetry.shutdown()` on shutdown.
33
+
34
+ #### Scenario: Init called on app startup
35
+ - **GIVEN** a FastAPI app uses `telemetry.fastapi.lifespan("svc")`
36
+ - **WHEN** the app starts
37
+ - **THEN** `telemetry.init("svc")` is called before the first request
38
+
39
+ #### Scenario: Shutdown called on app shutdown
40
+ - **GIVEN** a FastAPI app uses `telemetry.fastapi.lifespan("svc")`
41
+ - **WHEN** the app receives SIGTERM and begins shutdown
42
+ - **THEN** `telemetry.shutdown()` is called, flushing pending signals
43
+
44
+ ### Requirement: Opt-in Instrumentation
45
+ Instrumentation SHALL NOT be applied automatically on import — the application
46
+ must explicitly call the instrumentation function or use the lifespan helper.
47
+
48
+ #### Scenario: Import does not instrument
49
+ - **GIVEN** `import telemetry.fastapi` was executed
50
+ - **WHEN** no instrumentation call is made
51
+ - **THEN** no spans are created for requests
52
+ - **AND** no global state is modified
53
+
54
+ #### Scenario: Explicit instrumentation applies
55
+ - **GIVEN** an existing FastAPI app instance `app`
56
+ - **WHEN** `telemetry.fastapi.instrument(app)` is called
57
+ - **THEN** the FastAPIInstrumentor is applied to `app`
58
+ - **AND** all subsequent requests generate spans
@@ -0,0 +1,51 @@
1
+ ## Purpose
2
+ Provide a single entry point that configures all OpenTelemetry providers
3
+ (traces, metrics, logs) and registers them globally, so application code
4
+ needs only one call at startup.
5
+
6
+ ### Requirement: Single Init Call
7
+ The library SHALL expose `telemetry.init(service_name)` as the sole required
8
+ setup call. After this call, traces, metrics, and logs are fully configured.
9
+
10
+ #### Scenario: Init configures all three providers
11
+ - **GIVEN** no providers have been configured
12
+ - **WHEN** `telemetry.init("payments-api")` is called
13
+ - **THEN** a TracerProvider is set as the global OTel tracer provider
14
+ - **AND** a MeterProvider is set as the global OTel meter provider
15
+ - **AND** a LoggerProvider is set as the global OTel logger provider
16
+ - **AND** the Python root logger has a LoggingHandler attached
17
+
18
+ #### Scenario: Resource attributes set on all providers
19
+ - **GIVEN** `APP_VERSION=1.2.3` and `APP_ENVIRONMENT=production`
20
+ - **WHEN** `telemetry.init("payments-api")` is called
21
+ - **THEN** all providers share a Resource with:
22
+ - `service.name = "payments-api"`
23
+ - `service.version = "1.2.3"`
24
+ - `deployment.environment = "production"`
25
+
26
+ #### Scenario: OTEL_SERVICE_NAME overrides argument
27
+ - **GIVEN** `OTEL_SERVICE_NAME=env-override` is set
28
+ - **WHEN** `telemetry.init("code-name")` is called
29
+ - **THEN** `service.name` in the Resource is `"env-override"`
30
+
31
+ #### Scenario: Init is idempotent
32
+ - **GIVEN** `telemetry.init("svc")` was already called
33
+ - **WHEN** `telemetry.init("svc")` is called again
34
+ - **THEN** providers are not re-created or re-registered
35
+ - **AND** no error is raised
36
+
37
+ ### Requirement: Graceful Shutdown
38
+ The library SHALL expose `telemetry.shutdown()` that flushes all pending
39
+ signals before the process exits.
40
+
41
+ #### Scenario: Shutdown flushes pending signals
42
+ - **GIVEN** `telemetry.init("svc")` was called and signals are buffered
43
+ - **WHEN** `telemetry.shutdown()` is called
44
+ - **THEN** all three providers are shut down in order (traces, metrics, logs)
45
+ - **AND** pending signals are flushed to the exporter
46
+ - **AND** no error is raised if the exporter is unreachable (logged at WARN)
47
+
48
+ #### Scenario: Shutdown before init is a no-op
49
+ - **GIVEN** `telemetry.init()` was never called
50
+ - **WHEN** `telemetry.shutdown()` is called
51
+ - **THEN** no exception is raised
@@ -0,0 +1,50 @@
1
+ ## Purpose
2
+ Bridge the Python standard logging module to OpenTelemetry so that all
3
+ existing logging.getLogger() calls are automatically exported via OTLP,
4
+ with no changes required in application code.
5
+
6
+ ### Requirement: Logging Bridge via LoggingHandler
7
+ After `telemetry.init()`, the library SHALL attach an OTel LoggingHandler to
8
+ the Python root logger so all log records at WARNING level and above are
9
+ exported via OTLP.
10
+
11
+ #### Scenario: Existing logger sends to OTLP after init
12
+ - **GIVEN** `telemetry.init("svc")` was called
13
+ - **AND** a logger exists: `logger = logging.getLogger("mymodule")`
14
+ - **WHEN** `logger.warning("something failed")`
15
+ - **THEN** a log record is emitted to the OTel LoggerProvider
16
+ - **AND** no changes to the existing logger code are required
17
+
18
+ #### Scenario: Log level threshold is WARNING
19
+ - **GIVEN** `telemetry.init("svc")` was called
20
+ - **WHEN** `logger.debug("debug message")` is called
21
+ - **THEN** the debug record is NOT exported via OTLP
22
+ - **AND** it MAY still appear in stdout depending on the app's log config
23
+
24
+ #### Scenario: Log record carries trace context
25
+ - **GIVEN** a span is active when a log is emitted
26
+ - **WHEN** `logger.error("payment failed")`
27
+ - **THEN** the exported log record includes `trace_id` and `span_id`
28
+ matching the active span
29
+ - **AND** the log is correlated with the trace in HyperDX
30
+
31
+ ### Requirement: No Disruption to Existing Log Handlers
32
+ Attaching the OTel LoggingHandler SHALL NOT remove or replace existing
33
+ handlers on the root logger.
34
+
35
+ #### Scenario: Existing stdout handler preserved
36
+ - **GIVEN** the application has a StreamHandler on the root logger before init()
37
+ - **WHEN** `telemetry.init("svc")` is called
38
+ - **THEN** the StreamHandler is still present and active
39
+ - **AND** logs still appear in stdout as before
40
+ - **AND** logs are also exported via OTLP
41
+
42
+ ### Requirement: Stdout Logging in Development
43
+ When not in production mode, the library SHALL NOT attach the OTel
44
+ LoggingHandler — logs remain in stdout only.
45
+
46
+ #### Scenario: No OTLP logging handler in development
47
+ - **GIVEN** `APP_ENVIRONMENT` is not set
48
+ - **WHEN** `telemetry.init("svc")` is called
49
+ - **THEN** no OTel LoggingHandler is added to the root logger
50
+ - **AND** logging behavior is unchanged from default Python behavior
@@ -0,0 +1,38 @@
1
+ ## Purpose
2
+ Provide convenient access to a pre-configured Meter for recording metrics
3
+ (counters, histograms, gauges), without requiring direct interaction with
4
+ the MeterProvider.
5
+
6
+ ### Requirement: Meter Access
7
+ The library SHALL expose `telemetry.meter(name?)` that returns an OTel Meter
8
+ scoped to the given name, defaulting to the service name from init().
9
+
10
+ #### Scenario: Meter returned after init
11
+ - **GIVEN** `telemetry.init("payments-api")` was called
12
+ - **WHEN** `meter = telemetry.meter()` is called
13
+ - **THEN** a valid `opentelemetry.metrics.Meter` instance is returned
14
+
15
+ #### Scenario: Counter creation and recording
16
+ - **GIVEN** a valid Meter instance
17
+ - **WHEN** `counter = meter.create_counter("requests_total")` and `counter.add(1)`
18
+ - **THEN** the counter value is buffered for export
19
+
20
+ #### Scenario: Histogram creation and recording
21
+ - **GIVEN** a valid Meter instance
22
+ - **WHEN** `hist = meter.create_histogram("request_duration_ms")` and `hist.record(42.5)`
23
+ - **THEN** the observation is buffered for export
24
+
25
+ #### Scenario: Meter before init returns no-op
26
+ - **GIVEN** `telemetry.init()` was never called
27
+ - **WHEN** `telemetry.meter()` is called
28
+ - **THEN** a no-op Meter is returned
29
+ - **AND** no exception is raised
30
+
31
+ ### Requirement: Metric Export Interval
32
+ Metrics SHALL be exported on a periodic basis using the OTLP periodic
33
+ exporting MetricReader with a default interval of 60 seconds.
34
+
35
+ #### Scenario: Default export interval applied
36
+ - **GIVEN** no custom interval is configured
37
+ - **WHEN** the MeterProvider is initialized
38
+ - **THEN** a PeriodicExportingMetricReader with `export_interval_millis=60000` is used
@@ -0,0 +1,45 @@
1
+ ## Purpose
2
+ Configure OTLP gRPC exporters for traces, metrics, and logs that point to
3
+ the telemetry-collector, or fall back to stdout exporters for local development.
4
+
5
+ ### Requirement: gRPC Exporters in Production
6
+ When `config.is_production` is True, the library SHALL create OTLP gRPC
7
+ exporters for all three signal types using the configured endpoint.
8
+
9
+ #### Scenario: Trace exporter created for production
10
+ - **GIVEN** `APP_ENVIRONMENT=production` and `OTEL_EXPORTER_OTLP_ENDPOINT=collector:4317`
11
+ - **WHEN** the trace exporter is built
12
+ - **THEN** it is an `OTLPSpanExporter` configured with `endpoint="collector:4317"`
13
+ - **AND** it uses insecure credentials (no TLS — private network)
14
+
15
+ #### Scenario: Metrics exporter created for production
16
+ - **GIVEN** `APP_ENVIRONMENT=production`
17
+ - **WHEN** the metrics exporter is built
18
+ - **THEN** it is an `OTLPMetricExporter` configured with the same endpoint
19
+
20
+ #### Scenario: Logs exporter created for production
21
+ - **GIVEN** `APP_ENVIRONMENT=production`
22
+ - **WHEN** the logs exporter is built
23
+ - **THEN** it is an `OTLPLogExporter` configured with the same endpoint
24
+
25
+ ### Requirement: Stdout Exporters in Development
26
+ When `config.is_production` is False, the library SHALL use console/stdout
27
+ exporters so developers can inspect signals locally without a collector.
28
+
29
+ #### Scenario: Console exporters used in development
30
+ - **GIVEN** `APP_ENVIRONMENT` is not set (defaults to development)
31
+ - **WHEN** exporters are built
32
+ - **THEN** traces use `ConsoleSpanExporter`
33
+ - **AND** metrics use `ConsoleMetricExporter`
34
+ - **AND** logs use `ConsoleLogExporter`
35
+ - **AND** no network connection is attempted
36
+
37
+ ### Requirement: Insecure gRPC Connection
38
+ The OTLP gRPC exporters SHALL connect without TLS when targeting the
39
+ telemetry-collector on the private Docker network.
40
+
41
+ #### Scenario: Insecure credentials used
42
+ - **GIVEN** `APP_ENVIRONMENT=production`
43
+ - **WHEN** the gRPC exporter is initialized
44
+ - **THEN** it passes `insecure=True` (or equivalent credential config)
45
+ - **AND** no certificate files are required
@@ -0,0 +1,34 @@
1
+ ## Purpose
2
+ Provide convenient access to a pre-configured Tracer for creating spans,
3
+ without requiring application code to interact with the TracerProvider directly.
4
+
5
+ ### Requirement: Tracer Access
6
+ The library SHALL expose `telemetry.tracer(name?)` that returns an OTel Tracer
7
+ scoped to the given name, defaulting to the service name from init().
8
+
9
+ #### Scenario: Tracer returned after init
10
+ - **GIVEN** `telemetry.init("payments-api")` was called
11
+ - **WHEN** `tracer = telemetry.tracer()` is called
12
+ - **THEN** a valid `opentelemetry.trace.Tracer` instance is returned
13
+ - **AND** it can create spans via `tracer.start_as_current_span("op")`
14
+
15
+ #### Scenario: Named tracer for library scoping
16
+ - **GIVEN** `telemetry.init("payments-api")` was called
17
+ - **WHEN** `tracer = telemetry.tracer("payments.checkout")` is called
18
+ - **THEN** the returned tracer is scoped to `"payments.checkout"`
19
+
20
+ #### Scenario: Tracer before init returns no-op
21
+ - **GIVEN** `telemetry.init()` was never called
22
+ - **WHEN** `telemetry.tracer()` is called
23
+ - **THEN** a no-op Tracer is returned (spans are created but not exported)
24
+ - **AND** no exception is raised
25
+
26
+ ### Requirement: Span Context Propagation
27
+ Tracers created via `telemetry.tracer()` SHALL propagate context automatically
28
+ via the W3C TraceContext standard.
29
+
30
+ #### Scenario: Child span linked to parent
31
+ - **GIVEN** a parent span is active
32
+ - **WHEN** a new span is started via `tracer.start_as_current_span("child")`
33
+ - **THEN** the child span carries the same `trace_id` as the parent
34
+ - **AND** its `parent_span_id` equals the parent span's `span_id`