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.
- tgedr_observability-0.0.1/PKG-INFO +236 -0
- tgedr_observability-0.0.1/README.md +222 -0
- tgedr_observability-0.0.1/pyproject.toml +145 -0
- tgedr_observability-0.0.1/pyproject.toml.orig +136 -0
- tgedr_observability-0.0.1/src/tgedr_observability/__init__.py +0 -0
- tgedr_observability-0.0.1/src/tgedr_observability/commons.py +15 -0
- tgedr_observability-0.0.1/src/tgedr_observability/logs.py +86 -0
- tgedr_observability-0.0.1/src/tgedr_observability/metrics.py +140 -0
|
@@ -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
|
+

|
|
18
|
+
[](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
|
+

|
|
4
|
+
[](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"
|
|
File without changes
|
|
@@ -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()
|