tgedr-observability 0.0.1__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,236 @@
1
+ Metadata-Version: 2.3
2
+ Name: tgedr-observability
3
+ Version: 0.0.1
4
+ Summary: simple observability solution
5
+ Author: developer
6
+ Author-email: developer <developer@email.com>
7
+ Requires-Dist: opentelemetry-api>=1.44.0
8
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.44.0
9
+ Requires-Dist: opentelemetry-sdk>=1.44.0
10
+ Requires-Dist: pandas>=2.3.0,<3.0.0
11
+ Requires-Dist: tgedr-pycommons>=1.2.1
12
+ Requires-Python: >=3.12, <3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # tgedr-observability
16
+
17
+ ![Coverage](./coverage.svg)
18
+ [![PyPI](https://img.shields.io/pypi/v/tgedr-observability)](https://pypi.org/project/tgedr-observability/)
19
+
20
+ ## overview
21
+
22
+ This repository is an early-stage observability toolkit built around a small Python package and a companion local monitoring stack. The Docker side is the main operational surface: an OpenTelemetry collector receives telemetry and routes metrics, logs, and traces into dedicated backends for storage and analysis. In practice, the project acts as a foundation for collecting, storing, and exploring observability data rather than as a finished application.
23
+
24
+ ## development
25
+ - main requirements:
26
+ - _uv_
27
+ - _bash_
28
+ - Clone the repository like this:
29
+
30
+ ``` bash
31
+ git clone git@github.com:jtviegas/observability
32
+ ```
33
+ - cd into the folder: `cd observability`
34
+ - install requirements: `./helper.sh reqs`
35
+
36
+ ## source code guide
37
+
38
+ The Python package lives under `src/tgedr_observability` and currently exposes three modules:
39
+
40
+ - `commons.py`: shared constants and common types.
41
+ - `metrics.py`: singleton wrapper for OpenTelemetry metrics setup and recording.
42
+ - `logs.py`: singleton wrapper for OpenTelemetry logs setup and recording.
43
+
44
+ ### commons.py
45
+
46
+ `commons.py` contains:
47
+
48
+ - `LOCAL_METRICS_URL`: default local OTLP HTTP endpoint for metrics (`http://localhost:4318/v1/metrics`).
49
+ - `OtlpConfig`: dataclass with exporter endpoint and optional headers.
50
+ - `ObservabilityError`: package-specific exception used for observability validation errors.
51
+
52
+ ### metrics.py
53
+
54
+ `Metrics` is a singleton manager that lazily initializes a global OpenTelemetry `MeterProvider`.
55
+
56
+ Initialization behavior:
57
+
58
+ 1. `Metrics.instance()` returns the singleton object.
59
+ 2. `init(service, otlp_config=None)` configures:
60
+ - `Resource` with `service.name`.
61
+ - `ConsoleMetricExporter` (always enabled).
62
+ - Optional `OTLPMetricExporter` over HTTP when `otlp_config` is passed.
63
+ 3. The configured provider is set globally with `metrics.set_meter_provider(provider)`.
64
+
65
+ Metric naming convention:
66
+
67
+ - Metric names must have at least two dot-separated parts, for example `orders.api.requests`.
68
+ - Everything before the last segment is used as meter scope name.
69
+ - Last segment is used as instrument name.
70
+ - Invalid names raise `ObservabilityError`.
71
+
72
+ Instruments managed by the class:
73
+
74
+ - Counter path: internally uses `create_up_down_counter`.
75
+ - Gauge path: uses `create_gauge` with unit inferred from suffix (`_s` -> `s`, otherwise `1`).
76
+ - Histogram path: uses `create_histogram` with the same unit inference.
77
+
78
+ Public methods:
79
+
80
+ - `add_to_counter(name, value, attributes=None)`
81
+ - `add_to_gauge(name, value, attributes=None)`
82
+ - `add_to_histogram(name, value, attributes=None)`
83
+ - `force_flush(timeout_millis=10000)`
84
+ - `shutdown()`
85
+ - `app_shutdown()` (flush + shutdown convenience hook)
86
+
87
+ ### logs.py
88
+
89
+ `Logs` is a singleton manager that lazily initializes a global OpenTelemetry `LoggerProvider`.
90
+
91
+ Initialization behavior:
92
+
93
+ 1. `Logs.instance()` returns the singleton object.
94
+ 2. `init(service, otlp_config=None)` configures:
95
+ - `Resource` with `service.name`.
96
+ - `BatchLogRecordProcessor(ConsoleLogRecordExporter())` (always enabled).
97
+ - Optional `BatchLogRecordProcessor(OTLPLogExporter(...))` when `otlp_config` is passed.
98
+ 3. The provider is set globally with `set_logger_provider(provider)`.
99
+ 4. A `LoggingHandler` is attached through `logging.basicConfig(..., force=True)`.
100
+
101
+ Public methods:
102
+
103
+ - `force_flush(timeout_millis=10000)`
104
+ - `shutdown()` (also detaches and closes the installed handler)
105
+ - `app_shutdown()` (flush + shutdown convenience hook)
106
+
107
+ ## usage examples
108
+
109
+ ### metrics
110
+
111
+ ```python
112
+ from tgedr_observability.commons import LOCAL_METRICS_URL, OtlpConfig
113
+ from tgedr_observability.metrics import Metrics
114
+
115
+ metrics_manager = Metrics.instance()
116
+ metrics_manager.init(
117
+ service="orders-service",
118
+ otlp_config=OtlpConfig(endpoint=LOCAL_METRICS_URL),
119
+ )
120
+
121
+ metrics_manager.add_to_counter("orders.api.requests", 1, {"route": "/orders"})
122
+ metrics_manager.add_to_histogram("orders.api.latency_s", 0.123, {"route": "/orders"})
123
+ metrics_manager.force_flush()
124
+ ```
125
+
126
+ ### logs
127
+
128
+ ```python
129
+ import logging
130
+ from tgedr_observability.commons import OtlpConfig
131
+ from tgedr_observability.logs import Logs
132
+
133
+ logs_manager = Logs.instance()
134
+ logs_manager.init(
135
+ service="orders-service",
136
+ otlp_config=OtlpConfig(endpoint="http://localhost:4318/v1/logs"),
137
+ )
138
+
139
+ logger = logging.getLogger(__name__)
140
+ logger.info("orders api started")
141
+ logs_manager.force_flush()
142
+ ```
143
+
144
+ ### graceful shutdown
145
+
146
+ Register app-level shutdown hooks in your entrypoint:
147
+
148
+ ```python
149
+ import atexit
150
+ from tgedr_observability.logs import Logs
151
+ from tgedr_observability.metrics import Metrics
152
+
153
+ atexit.register(Logs.app_shutdown)
154
+ atexit.register(Metrics.app_shutdown)
155
+ ```
156
+
157
+ ## tests and coverage
158
+
159
+ Run tests:
160
+
161
+ ```bash
162
+ ./helper.sh test
163
+ ```
164
+
165
+ Run and print coverage:
166
+
167
+ ```bash
168
+ ./helper.sh test_coverage
169
+ ```
170
+
171
+ Current unit tests cover all Python code under `src/`.
172
+
173
+
174
+ ## observability stack
175
+
176
+ ### VictoriaMetrics
177
+
178
+ `victoriametrics/victoria-metrics` is the metrics database in this stack. It stores time-series data such as request counts, latency measurements, resource usage, and any custom application metrics sent through OpenTelemetry. In this project, the collector forwards metrics to VictoriaMetrics through its OpenTelemetry ingestion endpoint, and Grafana can then query that stored data for dashboards and operational analysis.
179
+
180
+ ### Loki
181
+
182
+ `grafana/loki` is the log storage backend. Loki is designed to ingest and organize logs efficiently, making it a good fit for centralizing application and infrastructure logs without the overhead of a heavier full-text indexing platform. Here, the OpenTelemetry collector sends log data to Loki so logs can be explored alongside metrics and traces, which makes it easier to correlate failures, warnings, and service behavior across the same time window.
183
+
184
+ ### Jaeger
185
+
186
+ `jaegertracing/all-in-one` is the distributed tracing backend. It collects and visualizes traces, which show how a request flows across services and how long each span takes, making it useful for debugging latency and request-path failures. The `all-in-one` image bundles Jaeger's main components into a single container, which keeps this setup simple for local or small-scale environments while still providing a usable trace exploration surface.
187
+
188
+ ## securing the collector
189
+
190
+ **⚠️ Important: The collector requires a valid Bearer token on every request.** The OTLP HTTP receiver is protected with Bearer token authentication using the OpenTelemetry Collector's `bearertokenauth` extension. Any request to the collector endpoint (`localhost:4318` or the remote endpoint) without an `Authorization: Bearer <token>` header will be rejected with HTTP 401 Unauthorized. This applies to all telemetry data—traces, metrics, and logs.
191
+
192
+ ### configuration
193
+
194
+ 1. **Set the token.** Add a strong random secret to your `.secrets` file (which is git-ignored and loaded by `helper.sh`):
195
+
196
+ ```bash
197
+ export COLLECTOR_TOKEN="$(openssl rand -hex 32)"
198
+ ```
199
+
200
+ 2. **Rebuild and push the collector image** so the updated config is baked in:
201
+
202
+ ```bash
203
+ ./helper.sh build_push_collector
204
+ ```
205
+
206
+ 3. **Start the stack.** The `docker-compose.yml` passes `COLLECTOR_TOKEN` through as an environment variable so the collector reads it at runtime:
207
+
208
+ ```bash
209
+ cd docker/observability && docker compose up -d
210
+ ```
211
+
212
+ ### sending telemetry
213
+
214
+ **All telemetry ingestion requires the token header.** Any client sending OTLP over HTTP must include the `Authorization: Bearer <token>` header on every call, or the request will be rejected:
215
+
216
+ ```
217
+ Authorization: Bearer <your-token>
218
+ ```
219
+
220
+ **Examples:**
221
+
222
+ With `telemetrygen` (as used in `helper.sh`):
223
+
224
+ ```bash
225
+ telemetrygen traces --otlp-http \
226
+ --otlp-header "Authorization=\"Bearer $COLLECTOR_TOKEN\"" \
227
+ --traces 1
228
+ ```
229
+
230
+ With the OpenTelemetry SDK, configure the exporter headers via the `OTEL_EXPORTER_OTLP_HEADERS` environment variable (applied to all outbound requests):
231
+
232
+ ```bash
233
+ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $COLLECTOR_TOKEN"
234
+ ```
235
+
236
+ If the token is missing or invalid, the collector will return `401 Unauthorized`. Verify the token is set correctly in your environment before troubleshooting other issues.
@@ -0,0 +1,222 @@
1
+ # tgedr-observability
2
+
3
+ ![Coverage](./coverage.svg)
4
+ [![PyPI](https://img.shields.io/pypi/v/tgedr-observability)](https://pypi.org/project/tgedr-observability/)
5
+
6
+ ## overview
7
+
8
+ This repository is an early-stage observability toolkit built around a small Python package and a companion local monitoring stack. The Docker side is the main operational surface: an OpenTelemetry collector receives telemetry and routes metrics, logs, and traces into dedicated backends for storage and analysis. In practice, the project acts as a foundation for collecting, storing, and exploring observability data rather than as a finished application.
9
+
10
+ ## development
11
+ - main requirements:
12
+ - _uv_
13
+ - _bash_
14
+ - Clone the repository like this:
15
+
16
+ ``` bash
17
+ git clone git@github.com:jtviegas/observability
18
+ ```
19
+ - cd into the folder: `cd observability`
20
+ - install requirements: `./helper.sh reqs`
21
+
22
+ ## source code guide
23
+
24
+ The Python package lives under `src/tgedr_observability` and currently exposes three modules:
25
+
26
+ - `commons.py`: shared constants and common types.
27
+ - `metrics.py`: singleton wrapper for OpenTelemetry metrics setup and recording.
28
+ - `logs.py`: singleton wrapper for OpenTelemetry logs setup and recording.
29
+
30
+ ### commons.py
31
+
32
+ `commons.py` contains:
33
+
34
+ - `LOCAL_METRICS_URL`: default local OTLP HTTP endpoint for metrics (`http://localhost:4318/v1/metrics`).
35
+ - `OtlpConfig`: dataclass with exporter endpoint and optional headers.
36
+ - `ObservabilityError`: package-specific exception used for observability validation errors.
37
+
38
+ ### metrics.py
39
+
40
+ `Metrics` is a singleton manager that lazily initializes a global OpenTelemetry `MeterProvider`.
41
+
42
+ Initialization behavior:
43
+
44
+ 1. `Metrics.instance()` returns the singleton object.
45
+ 2. `init(service, otlp_config=None)` configures:
46
+ - `Resource` with `service.name`.
47
+ - `ConsoleMetricExporter` (always enabled).
48
+ - Optional `OTLPMetricExporter` over HTTP when `otlp_config` is passed.
49
+ 3. The configured provider is set globally with `metrics.set_meter_provider(provider)`.
50
+
51
+ Metric naming convention:
52
+
53
+ - Metric names must have at least two dot-separated parts, for example `orders.api.requests`.
54
+ - Everything before the last segment is used as meter scope name.
55
+ - Last segment is used as instrument name.
56
+ - Invalid names raise `ObservabilityError`.
57
+
58
+ Instruments managed by the class:
59
+
60
+ - Counter path: internally uses `create_up_down_counter`.
61
+ - Gauge path: uses `create_gauge` with unit inferred from suffix (`_s` -> `s`, otherwise `1`).
62
+ - Histogram path: uses `create_histogram` with the same unit inference.
63
+
64
+ Public methods:
65
+
66
+ - `add_to_counter(name, value, attributes=None)`
67
+ - `add_to_gauge(name, value, attributes=None)`
68
+ - `add_to_histogram(name, value, attributes=None)`
69
+ - `force_flush(timeout_millis=10000)`
70
+ - `shutdown()`
71
+ - `app_shutdown()` (flush + shutdown convenience hook)
72
+
73
+ ### logs.py
74
+
75
+ `Logs` is a singleton manager that lazily initializes a global OpenTelemetry `LoggerProvider`.
76
+
77
+ Initialization behavior:
78
+
79
+ 1. `Logs.instance()` returns the singleton object.
80
+ 2. `init(service, otlp_config=None)` configures:
81
+ - `Resource` with `service.name`.
82
+ - `BatchLogRecordProcessor(ConsoleLogRecordExporter())` (always enabled).
83
+ - Optional `BatchLogRecordProcessor(OTLPLogExporter(...))` when `otlp_config` is passed.
84
+ 3. The provider is set globally with `set_logger_provider(provider)`.
85
+ 4. A `LoggingHandler` is attached through `logging.basicConfig(..., force=True)`.
86
+
87
+ Public methods:
88
+
89
+ - `force_flush(timeout_millis=10000)`
90
+ - `shutdown()` (also detaches and closes the installed handler)
91
+ - `app_shutdown()` (flush + shutdown convenience hook)
92
+
93
+ ## usage examples
94
+
95
+ ### metrics
96
+
97
+ ```python
98
+ from tgedr_observability.commons import LOCAL_METRICS_URL, OtlpConfig
99
+ from tgedr_observability.metrics import Metrics
100
+
101
+ metrics_manager = Metrics.instance()
102
+ metrics_manager.init(
103
+ service="orders-service",
104
+ otlp_config=OtlpConfig(endpoint=LOCAL_METRICS_URL),
105
+ )
106
+
107
+ metrics_manager.add_to_counter("orders.api.requests", 1, {"route": "/orders"})
108
+ metrics_manager.add_to_histogram("orders.api.latency_s", 0.123, {"route": "/orders"})
109
+ metrics_manager.force_flush()
110
+ ```
111
+
112
+ ### logs
113
+
114
+ ```python
115
+ import logging
116
+ from tgedr_observability.commons import OtlpConfig
117
+ from tgedr_observability.logs import Logs
118
+
119
+ logs_manager = Logs.instance()
120
+ logs_manager.init(
121
+ service="orders-service",
122
+ otlp_config=OtlpConfig(endpoint="http://localhost:4318/v1/logs"),
123
+ )
124
+
125
+ logger = logging.getLogger(__name__)
126
+ logger.info("orders api started")
127
+ logs_manager.force_flush()
128
+ ```
129
+
130
+ ### graceful shutdown
131
+
132
+ Register app-level shutdown hooks in your entrypoint:
133
+
134
+ ```python
135
+ import atexit
136
+ from tgedr_observability.logs import Logs
137
+ from tgedr_observability.metrics import Metrics
138
+
139
+ atexit.register(Logs.app_shutdown)
140
+ atexit.register(Metrics.app_shutdown)
141
+ ```
142
+
143
+ ## tests and coverage
144
+
145
+ Run tests:
146
+
147
+ ```bash
148
+ ./helper.sh test
149
+ ```
150
+
151
+ Run and print coverage:
152
+
153
+ ```bash
154
+ ./helper.sh test_coverage
155
+ ```
156
+
157
+ Current unit tests cover all Python code under `src/`.
158
+
159
+
160
+ ## observability stack
161
+
162
+ ### VictoriaMetrics
163
+
164
+ `victoriametrics/victoria-metrics` is the metrics database in this stack. It stores time-series data such as request counts, latency measurements, resource usage, and any custom application metrics sent through OpenTelemetry. In this project, the collector forwards metrics to VictoriaMetrics through its OpenTelemetry ingestion endpoint, and Grafana can then query that stored data for dashboards and operational analysis.
165
+
166
+ ### Loki
167
+
168
+ `grafana/loki` is the log storage backend. Loki is designed to ingest and organize logs efficiently, making it a good fit for centralizing application and infrastructure logs without the overhead of a heavier full-text indexing platform. Here, the OpenTelemetry collector sends log data to Loki so logs can be explored alongside metrics and traces, which makes it easier to correlate failures, warnings, and service behavior across the same time window.
169
+
170
+ ### Jaeger
171
+
172
+ `jaegertracing/all-in-one` is the distributed tracing backend. It collects and visualizes traces, which show how a request flows across services and how long each span takes, making it useful for debugging latency and request-path failures. The `all-in-one` image bundles Jaeger's main components into a single container, which keeps this setup simple for local or small-scale environments while still providing a usable trace exploration surface.
173
+
174
+ ## securing the collector
175
+
176
+ **⚠️ Important: The collector requires a valid Bearer token on every request.** The OTLP HTTP receiver is protected with Bearer token authentication using the OpenTelemetry Collector's `bearertokenauth` extension. Any request to the collector endpoint (`localhost:4318` or the remote endpoint) without an `Authorization: Bearer <token>` header will be rejected with HTTP 401 Unauthorized. This applies to all telemetry data—traces, metrics, and logs.
177
+
178
+ ### configuration
179
+
180
+ 1. **Set the token.** Add a strong random secret to your `.secrets` file (which is git-ignored and loaded by `helper.sh`):
181
+
182
+ ```bash
183
+ export COLLECTOR_TOKEN="$(openssl rand -hex 32)"
184
+ ```
185
+
186
+ 2. **Rebuild and push the collector image** so the updated config is baked in:
187
+
188
+ ```bash
189
+ ./helper.sh build_push_collector
190
+ ```
191
+
192
+ 3. **Start the stack.** The `docker-compose.yml` passes `COLLECTOR_TOKEN` through as an environment variable so the collector reads it at runtime:
193
+
194
+ ```bash
195
+ cd docker/observability && docker compose up -d
196
+ ```
197
+
198
+ ### sending telemetry
199
+
200
+ **All telemetry ingestion requires the token header.** Any client sending OTLP over HTTP must include the `Authorization: Bearer <token>` header on every call, or the request will be rejected:
201
+
202
+ ```
203
+ Authorization: Bearer <your-token>
204
+ ```
205
+
206
+ **Examples:**
207
+
208
+ With `telemetrygen` (as used in `helper.sh`):
209
+
210
+ ```bash
211
+ telemetrygen traces --otlp-http \
212
+ --otlp-header "Authorization=\"Bearer $COLLECTOR_TOKEN\"" \
213
+ --traces 1
214
+ ```
215
+
216
+ With the OpenTelemetry SDK, configure the exporter headers via the `OTEL_EXPORTER_OTLP_HEADERS` environment variable (applied to all outbound requests):
217
+
218
+ ```bash
219
+ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $COLLECTOR_TOKEN"
220
+ ```
221
+
222
+ If the token is missing or invalid, the collector will return `401 Unauthorized`. Verify the token is set correctly in your environment before troubleshooting other issues.
@@ -0,0 +1,145 @@
1
+ [project]
2
+ name = "tgedr-observability"
3
+ version = "0.0.1"
4
+ description = "simple observability solution"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12,<3.13"
7
+ dependencies = [
8
+ "opentelemetry-api>=1.44.0",
9
+ "opentelemetry-exporter-otlp-proto-http>=1.44.0",
10
+ "opentelemetry-sdk>=1.44.0",
11
+ "pandas>=2.3.0,<3.0.0",
12
+ "tgedr-pycommons>=1.2.1",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "developer"
17
+ email = "developer@email.com"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pre-commit~=4.2.0",
22
+ "pytest~=8.3.5",
23
+ "pytest-bdd~=8.1.0",
24
+ "pytest-cov~=4.1.0",
25
+ "pytest-mock~=3.15.0",
26
+ "ruff==0.9.10",
27
+ "bandit==1.8.3",
28
+ "safety==3.5.1",
29
+ "typer<0.17.0",
30
+ "genbadge[coverage]>=1.1.3",
31
+ "ipykernel>=7.2.0",
32
+ "jupyter>=1.1.1",
33
+ "ipywidgets>=8.1.8",
34
+ "python-dotenv>=1.2.2",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["uv-build>=0.6,<1.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.uv.build-backend]
42
+ module-name = "tgedr_observability"
43
+
44
+ [tool.coverage.paths]
45
+ source = ["src/"]
46
+
47
+ [tool.coverage.run]
48
+ source = ["src/"]
49
+ include = ["src/*"]
50
+ omit = [
51
+ "*/tests/*",
52
+ "*/test/*",
53
+ "*/test_*",
54
+ "*/__pycache__/*",
55
+ "*/migrations/*",
56
+ "*/venv/*",
57
+ "*/.venv/*",
58
+ ]
59
+
60
+ [tool.coverage.report]
61
+ exclude_lines = [
62
+ "pragma: no cover",
63
+ "def __repr__",
64
+ "raise AssertionError",
65
+ "raise NotImplementedError",
66
+ "if __name__ == .__main__.:",
67
+ "if TYPE_CHECKING:",
68
+ ]
69
+ show_missing = true
70
+ skip_covered = false
71
+ skip_empty = false
72
+
73
+ [tool.pytest.ini_options]
74
+ pythonpath = "."
75
+
76
+ [tool.ruff]
77
+ exclude = [
78
+ ".bzr",
79
+ ".direnv",
80
+ ".eggs",
81
+ ".git",
82
+ ".git-rewrite",
83
+ ".hg",
84
+ ".ipynb_checkpoints",
85
+ ".mypy_cache",
86
+ ".nox",
87
+ ".pants.d",
88
+ ".pyenv",
89
+ ".pytest_cache",
90
+ ".pytype",
91
+ ".ruff_cache",
92
+ ".svn",
93
+ ".tox",
94
+ ".venv",
95
+ ".vscode",
96
+ "__pypackages__",
97
+ "_build",
98
+ "buck-out",
99
+ "build",
100
+ "dist",
101
+ "node_modules",
102
+ "site-packages",
103
+ "venv",
104
+ "tests/",
105
+ "test/",
106
+ "typings/",
107
+ "server/",
108
+ ]
109
+ line-length = 120
110
+ indent-width = 4
111
+
112
+ [tool.ruff.lint]
113
+ select = ["ALL"]
114
+ ignore = [
115
+ "D203",
116
+ "S101",
117
+ "D104",
118
+ "INP001",
119
+ "D213",
120
+ "COM812",
121
+ "I001",
122
+ "D401",
123
+ "D407",
124
+ "RET504",
125
+ "PLR2004",
126
+ "FA102",
127
+ "E501",
128
+ "EXE002",
129
+ "PLR0913",
130
+ "PLR0912",
131
+ "C901",
132
+ "PLR0911",
133
+ "G004",
134
+ "D413",
135
+ "CPY001",
136
+ ]
137
+ fixable = ["ALL"]
138
+ unfixable = []
139
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
140
+
141
+ [tool.ruff.format]
142
+ quote-style = "double"
143
+ indent-style = "space"
144
+ skip-magic-trailing-comma = false
145
+ line-ending = "auto"
@@ -0,0 +1,136 @@
1
+ [project]
2
+ name = "tgedr-observability"
3
+ version = "0.0.1"
4
+ description = "simple observability solution"
5
+ authors = [
6
+ {name = "developer",email = "developer@email.com"}
7
+ ]
8
+ readme = "README.md"
9
+
10
+ requires-python = ">=3.12,<3.13"
11
+
12
+ dependencies = [
13
+ "opentelemetry-api>=1.44.0",
14
+ "opentelemetry-exporter-otlp-proto-http>=1.44.0",
15
+ "opentelemetry-sdk>=1.44.0",
16
+ "pandas>=2.3.0,<3.0.0",
17
+ "tgedr-pycommons>=1.2.1",
18
+ ]
19
+ [dependency-groups]
20
+ dev = [
21
+ "pre-commit~=4.2.0",
22
+ "pytest~=8.3.5",
23
+ "pytest-bdd~=8.1.0",
24
+ "pytest-cov~=4.1.0",
25
+ "pytest-mock~=3.15.0",
26
+ "ruff==0.9.10",
27
+ "bandit==1.8.3",
28
+ "safety==3.5.1",
29
+ "typer<0.17.0",
30
+ "genbadge[coverage]>=1.1.3",
31
+ "ipykernel>=7.2.0",
32
+ "jupyter>=1.1.1",
33
+ "ipywidgets>=8.1.8",
34
+ "python-dotenv>=1.2.2",
35
+ ]
36
+
37
+ # [project.scripts]
38
+ # run = "tgedr_pycommons.cicd.entrypoint:entrypoint"
39
+
40
+ [build-system]
41
+ requires = ["uv-build>=0.6,<1.0"]
42
+ build-backend = "uv_build"
43
+
44
+ [tool.uv.build-backend]
45
+ module-name = "tgedr_observability"
46
+
47
+ [tool.coverage.paths]
48
+ source = ["src/"]
49
+
50
+ [tool.coverage.run]
51
+ source = ["src/"]
52
+ include = ["src/*"]
53
+ omit = [
54
+ "*/tests/*",
55
+ "*/test/*",
56
+ "*/test_*",
57
+ "*/__pycache__/*",
58
+ "*/migrations/*",
59
+ "*/venv/*",
60
+ "*/.venv/*"
61
+ ]
62
+
63
+ [tool.coverage.report]
64
+ exclude_lines = [
65
+ "pragma: no cover",
66
+ "def __repr__",
67
+ "raise AssertionError",
68
+ "raise NotImplementedError",
69
+ "if __name__ == .__main__.:",
70
+ "if TYPE_CHECKING:",
71
+ ]
72
+ show_missing = true
73
+ skip_covered = false
74
+ skip_empty = false
75
+
76
+ [tool.pytest.ini_options]
77
+ # bdd_features_base_dir = "documentation/features"
78
+ pythonpath = "."
79
+
80
+
81
+ [tool.ruff]
82
+ exclude = [
83
+ ".bzr",
84
+ ".direnv",
85
+ ".eggs",
86
+ ".git",
87
+ ".git-rewrite",
88
+ ".hg",
89
+ ".ipynb_checkpoints",
90
+ ".mypy_cache",
91
+ ".nox",
92
+ ".pants.d",
93
+ ".pyenv",
94
+ ".pytest_cache",
95
+ ".pytype",
96
+ ".ruff_cache",
97
+ ".svn",
98
+ ".tox",
99
+ ".venv",
100
+ ".vscode",
101
+ "__pypackages__",
102
+ "_build",
103
+ "buck-out",
104
+ "build",
105
+ "dist",
106
+ "node_modules",
107
+ "site-packages",
108
+ "venv",
109
+ "tests/",
110
+ "test/",
111
+ "typings/",
112
+ "server/"
113
+ ]
114
+
115
+ line-length = 120
116
+ indent-width = 4
117
+
118
+ [tool.ruff.lint]
119
+ select = ["ALL"]
120
+ ignore = ["D203", "S101", "D104", "INP001", "D213", "COM812", "I001",
121
+ "D401", "D407", "RET504", "PLR2004", "FA102", "E501", "EXE002", "PLR0913",
122
+ "PLR0912", "C901", "PLR0911", "G004", "D413", "CPY001"]
123
+ fixable = ["ALL"]
124
+ unfixable = []
125
+ # Allow unused variables when underscore-prefixed.
126
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
127
+
128
+ [tool.ruff.format]
129
+ # Like Black, use double quotes for strings.
130
+ quote-style = "double"
131
+ # Like Black, indent with spaces, rather than tabs.
132
+ indent-style = "space"
133
+ # Like Black, respect magic trailing commas.
134
+ skip-magic-trailing-comma = false
135
+ # Like Black, automatically detect the appropriate line ending.
136
+ line-ending = "auto"
@@ -0,0 +1,15 @@
1
+ """Common configuration models for observability integrations."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ LOCAL_METRICS_URL: str = "http://localhost:4318/v1/metrics"
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class OtlpConfig: # noqa: D101
10
+ endpoint: str
11
+ headers: dict[str, str] | None = None
12
+
13
+
14
+ class ObservabilityError(Exception):
15
+ """Exception raised for observability-related errors."""
@@ -0,0 +1,86 @@
1
+ """OpenTelemetry logging setup and singleton manager for application logs."""
2
+
3
+ import logging
4
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
5
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
6
+ from opentelemetry.sdk._logs.export import (
7
+ BatchLogRecordProcessor,
8
+ ConsoleLogRecordExporter,
9
+ ) # ConsoleLogExporter on versions earlier than 1.39.0
10
+ from opentelemetry._logs import set_logger_provider
11
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
12
+ from tgedr_pycommons.utils.singleton import SingletonMeta
13
+ from tgedr_observability.commons import OtlpConfig
14
+
15
+
16
+ class Logs(metaclass=SingletonMeta):
17
+ """Singleton manager for configuring OpenTelemetry logging."""
18
+
19
+ @staticmethod
20
+ def instance() -> "Logs":
21
+ """Get the singleton instance of the Logs manager."""
22
+ return Logs() # pyright: ignore[reportReturnType]
23
+
24
+ def __init__(self) -> None:
25
+ """Initialize the singleton logs manager state."""
26
+ self._logger_provider: LoggerProvider | None = None
27
+ self._handler: LoggingHandler | None = None
28
+
29
+ def init(self, service: str, otlp_config: OtlpConfig | None = None) -> None:
30
+ """Initialize the logging provider and configure log exporting."""
31
+ if self._logger_provider is None:
32
+ resource = Resource.create(attributes={SERVICE_NAME: service})
33
+ provider = LoggerProvider(resource=resource)
34
+ provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogRecordExporter()))
35
+
36
+ if otlp_config is not None:
37
+ otlp_exporter = OTLPLogExporter(
38
+ endpoint=otlp_config.endpoint,
39
+ headers=otlp_config.headers,
40
+ )
41
+ provider.add_log_record_processor(BatchLogRecordProcessor(otlp_exporter))
42
+
43
+ set_logger_provider(provider)
44
+
45
+ handler = LoggingHandler(level=logging.INFO, logger_provider=provider)
46
+ logging.basicConfig(handlers=[handler], level=logging.INFO, force=True)
47
+ self._handler = handler
48
+ self._logger_provider = provider
49
+
50
+ def force_flush(self, timeout_millis: int = 10_000) -> bool:
51
+ """Force export of pending logs from all configured processors."""
52
+ if self._logger_provider is None:
53
+ return False
54
+ return self._logger_provider.force_flush(timeout_millis=timeout_millis)
55
+
56
+ def shutdown(self) -> None:
57
+ """Shut down log processing and detach the OpenTelemetry handler."""
58
+ if self._logger_provider is None:
59
+ return
60
+
61
+ self._logger_provider.shutdown()
62
+ self._logger_provider = None
63
+
64
+ if self._handler is not None:
65
+ root_logger = logging.getLogger()
66
+ root_logger.handlers = [
67
+ existing_handler for existing_handler in root_logger.handlers if existing_handler is not self._handler
68
+ ]
69
+ self._handler.close()
70
+ self._handler = None
71
+
72
+ @staticmethod
73
+ def app_shutdown() -> None:
74
+ """Application shutdown hook for logs.
75
+
76
+ add shutdownhook to flush and shutdown the logs provider on application exit.
77
+ ````
78
+ import atexit
79
+ atexit.register(Logs.app_shutdown)
80
+ ````
81
+ """
82
+ instance = Logs.instance()
83
+ if instance._logger_provider is None: # noqa: SLF001
84
+ return
85
+ instance.force_flush()
86
+ instance.shutdown()
@@ -0,0 +1,140 @@
1
+ """Metrics manager and helpers for OpenTelemetry instrumentation."""
2
+
3
+ from opentelemetry import metrics
4
+ from opentelemetry.sdk.metrics import MeterProvider
5
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
6
+ from opentelemetry.metrics import Instrument
7
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader, ConsoleMetricExporter
8
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
9
+ from tgedr_pycommons.utils.singleton import SingletonMeta
10
+ from tgedr_observability.commons import ObservabilityError, OtlpConfig
11
+
12
+
13
+ class Metrics(metaclass=SingletonMeta):
14
+ """Singleton manager for meter creation and metric recording."""
15
+
16
+ @staticmethod
17
+ def instance() -> "Metrics":
18
+ """Get the singleton instance of the Metrics manager."""
19
+ return Metrics() # pyright: ignore[reportReturnType]
20
+
21
+ def __init__(self) -> None:
22
+ """Initialize the singleton metrics manager state."""
23
+ self._meter_provider: MeterProvider | None = None
24
+ self._meters: dict[str, metrics.Meter] = {}
25
+ self._counters: dict[str, metrics.UpDownCounter] = {}
26
+ self._gauges: dict[str, Instrument] = {}
27
+ self._histograms: dict[str, metrics.Histogram] = {}
28
+
29
+ def init(self, service: str, otlp_config: OtlpConfig | None = None) -> None:
30
+ """Initialize the metrics provider and configure metric exporting."""
31
+ if self._meter_provider is None:
32
+ resource = Resource.create(attributes={SERVICE_NAME: service})
33
+ readers = []
34
+ console_exporter = ConsoleMetricExporter()
35
+ reader_console = PeriodicExportingMetricReader(console_exporter)
36
+ readers.append(reader_console)
37
+
38
+ if otlp_config is not None:
39
+ http_exporter = OTLPMetricExporter(
40
+ endpoint=otlp_config.endpoint,
41
+ headers=otlp_config.headers,
42
+ )
43
+ reader_http = PeriodicExportingMetricReader(http_exporter, export_interval_millis=5000)
44
+ readers.append(reader_http)
45
+
46
+ provider = MeterProvider(metric_readers=readers, resource=resource)
47
+ metrics.set_meter_provider(provider) # Set the global meter provider
48
+ self._meter_provider = provider
49
+
50
+ def _get_meter(self, name: str) -> metrics.Meter:
51
+ toponomy: list[str] = name.split(".")
52
+ if len(toponomy) < 2:
53
+ error_message = "Metric name must have at least two parts separated by '.'"
54
+ raise ObservabilityError(error_message)
55
+ meter_name = ".".join(toponomy[:-1])
56
+
57
+ if meter_name not in self._meters:
58
+ self._meters[meter_name] = metrics.get_meter(meter_name)
59
+ meter = self._meters[meter_name]
60
+ return meter
61
+
62
+ def _get_counter(self, name: str) -> metrics.UpDownCounter:
63
+ if name not in self._counters:
64
+ meter = self._get_meter(name)
65
+ toponomy: list[str] = name.split(".")
66
+ counter_name = toponomy[-1]
67
+ self._counters[name] = meter.create_up_down_counter(
68
+ counter_name, unit="1", description=f"Counter metric for {name}"
69
+ )
70
+ return self._counters[name]
71
+
72
+ def _get_gauge(self, name: str) -> Instrument:
73
+ if name not in self._gauges:
74
+ meter = self._get_meter(name)
75
+ toponomy: list[str] = name.split(".")
76
+ gauge_name = toponomy[-1]
77
+ gauge_type = "s" if gauge_name[-2:] == "_s" else "1"
78
+ self._gauges[name] = meter.create_gauge(gauge_name, unit=gauge_type, description=f"Gauge metric for {name}")
79
+ return self._gauges[name]
80
+
81
+ def _get_histogram(self, name: str) -> metrics.Histogram:
82
+ if name not in self._histograms:
83
+ meter = self._get_meter(name)
84
+ toponomy: list[str] = name.split(".")
85
+ histogram_name = toponomy[-1]
86
+ histogram_type = "s" if histogram_name[-2:] == "_s" else "1"
87
+ self._histograms[name] = meter.create_histogram(
88
+ histogram_name, unit=histogram_type, description=f"Histogram metric for {name}"
89
+ )
90
+ return self._histograms[name]
91
+
92
+ def add_to_counter(self, name: str, value: int, attributes: dict[str, str] | None = None) -> None:
93
+ """Add a value to the named counter metric."""
94
+ counter = self._get_counter(name)
95
+ counter.add(value, attributes=attributes)
96
+
97
+ def add_to_gauge(self, name: str, value: float, attributes: dict[str, str] | None = None) -> None:
98
+ """Add a value to the named gauge metric."""
99
+ gauge = self._get_gauge(name)
100
+ gauge.set(value, attributes=attributes) # pyright: ignore[reportAttributeAccessIssue]
101
+
102
+ def add_to_histogram(self, name: str, value: float, attributes: dict[str, str] | None = None) -> None:
103
+ """Add a value to the named histogram metric."""
104
+ histogram = self._get_histogram(name)
105
+ histogram.record(value, attributes=attributes)
106
+
107
+ def force_flush(self, timeout_millis: int = 10_000) -> bool:
108
+ """Force export of pending metrics from all configured readers."""
109
+ if self._meter_provider is None:
110
+ error_message = "Metrics provider is not initialized. Call init() first."
111
+ raise ObservabilityError(error_message)
112
+ return self._meter_provider.force_flush(timeout_millis=timeout_millis)
113
+
114
+ def shutdown(self) -> None:
115
+ """Shut down the metrics pipeline and clear cached instruments."""
116
+ if self._meter_provider is None:
117
+ return
118
+
119
+ self._meter_provider.shutdown()
120
+ self._meter_provider = None
121
+ self._meters.clear()
122
+ self._counters.clear()
123
+ self._gauges.clear()
124
+ self._histograms.clear()
125
+
126
+ @staticmethod
127
+ def app_shutdown() -> None:
128
+ """Application shutdown hook for metrics.
129
+
130
+ add shutdownhook to flush and shutdown the metrics provider on application exit.
131
+ ````
132
+ import atexit
133
+ atexit.register(Metrics.app_shutdown)
134
+ ````
135
+ """
136
+ instance = Metrics.instance()
137
+ if instance._meter_provider is None: # noqa: SLF001
138
+ return
139
+ instance.force_flush()
140
+ instance.shutdown()