hostess-python 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hostess_python-0.1.0/.github/workflows/ci.yml +24 -0
- hostess_python-0.1.0/.github/workflows/publish.yml +25 -0
- hostess_python-0.1.0/.gitignore +9 -0
- hostess_python-0.1.0/.python-version +1 -0
- hostess_python-0.1.0/CHANGELOG.md +36 -0
- hostess_python-0.1.0/LICENSE +21 -0
- hostess_python-0.1.0/PKG-INFO +108 -0
- hostess_python-0.1.0/README.md +66 -0
- hostess_python-0.1.0/examples/fastapi-basic/main.py +23 -0
- hostess_python-0.1.0/pyproject.toml +45 -0
- hostess_python-0.1.0/src/hostess_sdk/__init__.py +19 -0
- hostess_python-0.1.0/src/hostess_sdk/_otel/__init__.py +108 -0
- hostess_python-0.1.0/src/hostess_sdk/_otel/marker.py +86 -0
- hostess_python-0.1.0/src/hostess_sdk/fastapi.py +123 -0
- hostess_python-0.1.0/tests/test_fastapi_instrumentation.py +138 -0
- hostess_python-0.1.0/tests/test_fastapi_marker.py +58 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- name: Install (with the fastapi extra)
|
|
22
|
+
run: uv sync --extra fastapi
|
|
23
|
+
- name: Test
|
|
24
|
+
run: uv run pytest -q
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Cuts a PyPI release when a GitHub Release is published. Uses PyPI Trusted
|
|
4
|
+
# Publishing (OIDC) — no API token stored as a secret. Configure a Trusted
|
|
5
|
+
# Publisher (or a pending publisher for the first claim) at
|
|
6
|
+
# https://pypi.org/manage/account/publishing/ with:
|
|
7
|
+
# project: hostess-python workflow: publish.yml environment: pypi
|
|
8
|
+
on:
|
|
9
|
+
release:
|
|
10
|
+
types: [published]
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment: pypi
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write # required for trusted publishing
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- name: Install uv
|
|
21
|
+
uses: astral-sh/setup-uv@v5
|
|
22
|
+
- name: Build
|
|
23
|
+
run: uv build
|
|
24
|
+
- name: Publish to PyPI (trusted publishing)
|
|
25
|
+
run: uv publish
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-06-13
|
|
9
|
+
|
|
10
|
+
Initial release. FastAPI integration for Hostess **API Insights**.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `hostess_sdk.fastapi.instrument(app)` — one-line FastAPI instrumentation:
|
|
15
|
+
- Applies OpenTelemetry FastAPI instrumentation so requests produce server
|
|
16
|
+
spans carrying the route template, method, and status (never raw paths or
|
|
17
|
+
query strings).
|
|
18
|
+
- Exports spans over OTLP/HTTP to the Hostess collector. The endpoint is
|
|
19
|
+
resolved from `HOSTESS_OTEL_ENDPOINT` → `OTEL_EXPORTER_OTLP_ENDPOINT` →
|
|
20
|
+
the `otlp_endpoint` argument.
|
|
21
|
+
- Attaches to an existing global `TracerProvider` when present; installs its
|
|
22
|
+
own otherwise.
|
|
23
|
+
- Options: `enabled`, `service_name`, `exclude_paths`, `otlp_endpoint`.
|
|
24
|
+
- Instrumentation marker heartbeat — periodically exports
|
|
25
|
+
`hostess_instrumentation_info{language,framework,sdk_version,framework_version} 1`
|
|
26
|
+
on a dedicated (non-global) `MeterProvider`, so the platform can distinguish
|
|
27
|
+
"installed, waiting for traffic" from "not installed".
|
|
28
|
+
- `hostess-python[fastapi]` install extra.
|
|
29
|
+
|
|
30
|
+
### Behavior
|
|
31
|
+
|
|
32
|
+
- Idempotent; a clean no-op when disabled (`enabled=False` /
|
|
33
|
+
`HOSTESS_INSTRUMENTATION=false`) or when no collector endpoint is available.
|
|
34
|
+
- Fails silent and cheap — never adds request latency or crashes the host app.
|
|
35
|
+
|
|
36
|
+
[0.1.0]: https://github.com/howl-cloud/hostess-python/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Horizon Web Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hostess-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hostess SDK for Python — native API Insights for FastAPI and beyond.
|
|
5
|
+
Project-URL: Homepage, https://hostess.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/howl-cloud/hostess-python
|
|
7
|
+
Author: Horizon Web Labs
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Horizon Web Labs
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: fastapi,hostess,insights,observability,opentelemetry
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Topic :: System :: Monitoring
|
|
35
|
+
Requires-Python: >=3.9
|
|
36
|
+
Requires-Dist: opentelemetry-api>=1.27.0
|
|
37
|
+
Requires-Dist: opentelemetry-sdk>=1.27.0
|
|
38
|
+
Provides-Extra: fastapi
|
|
39
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0; extra == 'fastapi'
|
|
40
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0; extra == 'fastapi'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# hostess-python
|
|
44
|
+
|
|
45
|
+
The Hostess SDK for Python. Native **API Insights** for FastAPI (and, later,
|
|
46
|
+
other Python frameworks) with one line of code.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install "hostess-python[fastapi]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from fastapi import FastAPI
|
|
54
|
+
from hostess_sdk.fastapi import instrument
|
|
55
|
+
|
|
56
|
+
app = FastAPI()
|
|
57
|
+
instrument(app)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That's it. When deployed on Hostess, route-level traffic, latency, status
|
|
61
|
+
codes, and error rates appear in Studio — no OTLP, collector, or Prometheus
|
|
62
|
+
configuration required.
|
|
63
|
+
|
|
64
|
+
## What `instrument(app)` does
|
|
65
|
+
|
|
66
|
+
- Applies OpenTelemetry FastAPI instrumentation, so requests produce server
|
|
67
|
+
spans carrying the **route template** (`/items/{item_id}`), method, and
|
|
68
|
+
status — never raw paths or query strings.
|
|
69
|
+
- Exports spans over OTLP/HTTP to the Hostess collector. The endpoint is
|
|
70
|
+
injected by the platform (`HOSTESS_OTEL_ENDPOINT`); override with
|
|
71
|
+
`otlp_endpoint=` for local use.
|
|
72
|
+
- Emits a periodic **marker heartbeat** so Studio can tell "installed, waiting
|
|
73
|
+
for traffic" from "not installed".
|
|
74
|
+
|
|
75
|
+
It is idempotent, fails silent and cheap (bounded queue, never adds request
|
|
76
|
+
latency or crashes the app), and is a clean no-op when disabled
|
|
77
|
+
(`enabled=False` / `HOSTESS_INSTRUMENTATION=false`) or when no collector
|
|
78
|
+
endpoint is available.
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
instrument(
|
|
84
|
+
app,
|
|
85
|
+
enabled=None, # None → env (HOSTESS_INSTRUMENTATION), default on
|
|
86
|
+
service_name=None, # defaults to the platform-injected name
|
|
87
|
+
exclude_paths=["/health"],
|
|
88
|
+
otlp_endpoint=None, # local/advanced override
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Privacy
|
|
93
|
+
|
|
94
|
+
The helper collects HTTP method, route template, status code, and duration. It
|
|
95
|
+
never collects request/response bodies, query strings, raw URLs, headers,
|
|
96
|
+
cookies, tokens, user IDs, or client IPs.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv sync
|
|
102
|
+
uv run pytest
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Status
|
|
106
|
+
|
|
107
|
+
v0.1 — `instrument(app)` for FastAPI. Roadmap: a `HostessFastAPI` drop-in,
|
|
108
|
+
opt-in exception metrics, and additional framework helpers (Django, Flask).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# hostess-python
|
|
2
|
+
|
|
3
|
+
The Hostess SDK for Python. Native **API Insights** for FastAPI (and, later,
|
|
4
|
+
other Python frameworks) with one line of code.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install "hostess-python[fastapi]"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
from hostess_sdk.fastapi import instrument
|
|
13
|
+
|
|
14
|
+
app = FastAPI()
|
|
15
|
+
instrument(app)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That's it. When deployed on Hostess, route-level traffic, latency, status
|
|
19
|
+
codes, and error rates appear in Studio — no OTLP, collector, or Prometheus
|
|
20
|
+
configuration required.
|
|
21
|
+
|
|
22
|
+
## What `instrument(app)` does
|
|
23
|
+
|
|
24
|
+
- Applies OpenTelemetry FastAPI instrumentation, so requests produce server
|
|
25
|
+
spans carrying the **route template** (`/items/{item_id}`), method, and
|
|
26
|
+
status — never raw paths or query strings.
|
|
27
|
+
- Exports spans over OTLP/HTTP to the Hostess collector. The endpoint is
|
|
28
|
+
injected by the platform (`HOSTESS_OTEL_ENDPOINT`); override with
|
|
29
|
+
`otlp_endpoint=` for local use.
|
|
30
|
+
- Emits a periodic **marker heartbeat** so Studio can tell "installed, waiting
|
|
31
|
+
for traffic" from "not installed".
|
|
32
|
+
|
|
33
|
+
It is idempotent, fails silent and cheap (bounded queue, never adds request
|
|
34
|
+
latency or crashes the app), and is a clean no-op when disabled
|
|
35
|
+
(`enabled=False` / `HOSTESS_INSTRUMENTATION=false`) or when no collector
|
|
36
|
+
endpoint is available.
|
|
37
|
+
|
|
38
|
+
## Options
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
instrument(
|
|
42
|
+
app,
|
|
43
|
+
enabled=None, # None → env (HOSTESS_INSTRUMENTATION), default on
|
|
44
|
+
service_name=None, # defaults to the platform-injected name
|
|
45
|
+
exclude_paths=["/health"],
|
|
46
|
+
otlp_endpoint=None, # local/advanced override
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Privacy
|
|
51
|
+
|
|
52
|
+
The helper collects HTTP method, route template, status code, and duration. It
|
|
53
|
+
never collects request/response bodies, query strings, raw URLs, headers,
|
|
54
|
+
cookies, tokens, user IDs, or client IPs.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uv sync
|
|
60
|
+
uv run pytest
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Status
|
|
64
|
+
|
|
65
|
+
v0.1 — `instrument(app)` for FastAPI. Roadmap: a `HostessFastAPI` drop-in,
|
|
66
|
+
opt-in exception metrics, and additional framework helpers (Django, Flask).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Minimal instrumented FastAPI app.
|
|
2
|
+
|
|
3
|
+
Run inside Hostess (the platform injects the OTLP endpoint), or locally with::
|
|
4
|
+
|
|
5
|
+
HOSTESS_OTEL_ENDPOINT=http://localhost:4318 uvicorn main:app
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from hostess_sdk.fastapi import instrument
|
|
11
|
+
|
|
12
|
+
app = FastAPI(title="hostess-python example")
|
|
13
|
+
instrument(app)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.get("/health")
|
|
17
|
+
async def health() -> dict[str, str]:
|
|
18
|
+
return {"status": "ok"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.get("/items/{item_id}")
|
|
22
|
+
async def get_item(item_id: int) -> dict[str, int]:
|
|
23
|
+
return {"item_id": item_id}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hostess-python"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Hostess SDK for Python — native API Insights for FastAPI and beyond."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Horizon Web Labs" }]
|
|
13
|
+
keywords = ["hostess", "observability", "opentelemetry", "fastapi", "insights"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: System :: Monitoring",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"opentelemetry-api>=1.27.0",
|
|
22
|
+
"opentelemetry-sdk>=1.27.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# `pip install "hostess-python[fastapi]"` adds the FastAPI instrumentation and
|
|
26
|
+
# the OTLP/HTTP exporter. The base package stays dependency-light.
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
fastapi = [
|
|
29
|
+
"opentelemetry-instrumentation-fastapi>=0.48b0",
|
|
30
|
+
"opentelemetry-exporter-otlp-proto-http>=1.27.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://hostess.sh"
|
|
35
|
+
Repository = "https://github.com/howl-cloud/hostess-python"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/hostess_sdk"]
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8.0",
|
|
43
|
+
"fastapi>=0.110",
|
|
44
|
+
"httpx>=0.27",
|
|
45
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Hostess SDK for Python.
|
|
2
|
+
|
|
3
|
+
The public integration surface lives in framework submodules, e.g.::
|
|
4
|
+
|
|
5
|
+
from hostess_sdk.fastapi import instrument
|
|
6
|
+
|
|
7
|
+
The base package is intentionally dependency-light; framework helpers pull in
|
|
8
|
+
their instrumentation via extras (``hostess-python[fastapi]``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError
|
|
12
|
+
from importlib.metadata import version as _pkg_version
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = _pkg_version("hostess-python")
|
|
16
|
+
except PackageNotFoundError: # editable/source checkout without installed metadata
|
|
17
|
+
__version__ = "0.0.0+local"
|
|
18
|
+
|
|
19
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Shared OTel setup: endpoint/enabled resolution, resource, tracer provider.
|
|
2
|
+
|
|
3
|
+
Tracing attaches to the app's existing global ``TracerProvider`` when one is
|
|
4
|
+
present (so the helper's spans share context with the app), and only installs
|
|
5
|
+
its own when none exists. The marker heartbeat (see ``marker.py``) uses a
|
|
6
|
+
*dedicated, non-global* ``MeterProvider`` instead, because metric readers cannot
|
|
7
|
+
be added to an existing provider and we must never clobber the app's metrics.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from opentelemetry import trace
|
|
16
|
+
from opentelemetry.sdk.resources import Resource
|
|
17
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
18
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("hostess_sdk")
|
|
21
|
+
|
|
22
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
23
|
+
_FALSE = {"0", "false", "no", "off"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_enabled(enabled: bool | None) -> bool:
|
|
27
|
+
"""Resolve the enabled flag: explicit arg, then env, then default-on."""
|
|
28
|
+
if enabled is not None:
|
|
29
|
+
return enabled
|
|
30
|
+
raw = os.getenv("HOSTESS_INSTRUMENTATION")
|
|
31
|
+
if raw is not None and raw.strip().lower() in _FALSE:
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_endpoint(explicit: str | None) -> str | None:
|
|
37
|
+
"""Resolve the OTLP base endpoint.
|
|
38
|
+
|
|
39
|
+
Precedence: platform-injected ``HOSTESS_OTEL_ENDPOINT`` →
|
|
40
|
+
``OTEL_EXPORTER_OTLP_ENDPOINT`` → the explicit ``otlp_endpoint`` argument.
|
|
41
|
+
"""
|
|
42
|
+
for candidate in (
|
|
43
|
+
os.getenv("HOSTESS_OTEL_ENDPOINT"),
|
|
44
|
+
os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
|
45
|
+
explicit,
|
|
46
|
+
):
|
|
47
|
+
if candidate and candidate.strip():
|
|
48
|
+
return candidate.strip()
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def signal_endpoint(base: str, signal: str) -> str:
|
|
53
|
+
"""Append the OTLP/HTTP signal path (``v1/traces``, ``v1/metrics``)."""
|
|
54
|
+
base = base.rstrip("/")
|
|
55
|
+
suffix = f"/v1/{signal}"
|
|
56
|
+
if base.endswith(suffix):
|
|
57
|
+
return base
|
|
58
|
+
return base + suffix
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_resource(
|
|
62
|
+
*,
|
|
63
|
+
service_name: str | None,
|
|
64
|
+
sdk_version: str,
|
|
65
|
+
framework: str,
|
|
66
|
+
framework_version: str,
|
|
67
|
+
) -> Resource:
|
|
68
|
+
"""Build the OTel resource.
|
|
69
|
+
|
|
70
|
+
``Resource.create`` already folds in env-detected attributes
|
|
71
|
+
(``OTEL_SERVICE_NAME``, ``OTEL_RESOURCE_ATTRIBUTES`` injected by the
|
|
72
|
+
platform). These are advisory: authoritative tenant attribution comes from
|
|
73
|
+
the collector's k8sattributes processor.
|
|
74
|
+
"""
|
|
75
|
+
attrs: dict[str, str] = {
|
|
76
|
+
"hostess.sdk.language": "python",
|
|
77
|
+
"hostess.sdk.version": sdk_version,
|
|
78
|
+
"hostess.framework": framework,
|
|
79
|
+
"hostess.framework.version": framework_version,
|
|
80
|
+
}
|
|
81
|
+
resolved_name = service_name or os.getenv("HOSTESS_SERVICE_NAME")
|
|
82
|
+
if resolved_name:
|
|
83
|
+
attrs["service.name"] = resolved_name
|
|
84
|
+
return Resource.create(attrs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_tracer_provider(resource: Resource, base_endpoint: str) -> TracerProvider:
|
|
88
|
+
"""Return the global SDK ``TracerProvider``, attaching our OTLP exporter.
|
|
89
|
+
|
|
90
|
+
Attaches a span processor to an existing SDK provider, or installs a new
|
|
91
|
+
one (with our resource) if only the API default proxy is present.
|
|
92
|
+
"""
|
|
93
|
+
# Imported lazily so the base package doesn't hard-require the http exporter
|
|
94
|
+
# (it ships with the framework extra).
|
|
95
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
96
|
+
|
|
97
|
+
exporter = OTLPSpanExporter(endpoint=signal_endpoint(base_endpoint, "traces"))
|
|
98
|
+
processor = BatchSpanProcessor(exporter)
|
|
99
|
+
|
|
100
|
+
current = trace.get_tracer_provider()
|
|
101
|
+
if isinstance(current, TracerProvider):
|
|
102
|
+
current.add_span_processor(processor)
|
|
103
|
+
return current
|
|
104
|
+
|
|
105
|
+
provider = TracerProvider(resource=resource)
|
|
106
|
+
provider.add_span_processor(processor)
|
|
107
|
+
trace.set_tracer_provider(provider)
|
|
108
|
+
return provider
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The instrumentation-info marker heartbeat.
|
|
2
|
+
|
|
3
|
+
Periodically exports the gauge
|
|
4
|
+
``hostess_instrumentation_info{language,framework,sdk_version,framework_version} 1``.
|
|
5
|
+
This is what lets the platform tell "installed, no traffic yet" from "not
|
|
6
|
+
installed" — spans only appear once requests arrive.
|
|
7
|
+
|
|
8
|
+
Uses its own ``MeterProvider`` (not the global one) so it exports the marker to
|
|
9
|
+
the Hostess collector regardless of, and without disturbing, any MeterProvider
|
|
10
|
+
the application configures for its own metrics.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Iterable
|
|
17
|
+
|
|
18
|
+
from opentelemetry.metrics import CallbackOptions, Observation
|
|
19
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
20
|
+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
|
21
|
+
from opentelemetry.sdk.resources import Resource
|
|
22
|
+
|
|
23
|
+
from . import signal_endpoint
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("hostess_sdk")
|
|
26
|
+
|
|
27
|
+
# Holds the dedicated provider so its reader thread isn't garbage-collected and
|
|
28
|
+
# so a re-instrument call can detect it (idempotency).
|
|
29
|
+
_marker_provider: MeterProvider | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def start_marker_heartbeat(
|
|
33
|
+
*,
|
|
34
|
+
resource: Resource,
|
|
35
|
+
base_endpoint: str,
|
|
36
|
+
language: str,
|
|
37
|
+
framework: str,
|
|
38
|
+
sdk_version: str,
|
|
39
|
+
framework_version: str,
|
|
40
|
+
interval_seconds: int = 60,
|
|
41
|
+
) -> MeterProvider | None:
|
|
42
|
+
"""Start the marker heartbeat. Idempotent: a no-op if already running."""
|
|
43
|
+
global _marker_provider
|
|
44
|
+
if _marker_provider is not None:
|
|
45
|
+
return _marker_provider
|
|
46
|
+
|
|
47
|
+
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
|
48
|
+
OTLPMetricExporter,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
exporter = OTLPMetricExporter(endpoint=signal_endpoint(base_endpoint, "metrics"))
|
|
52
|
+
reader = PeriodicExportingMetricReader(
|
|
53
|
+
exporter, export_interval_millis=interval_seconds * 1000
|
|
54
|
+
)
|
|
55
|
+
provider = MeterProvider(resource=resource, metric_readers=[reader])
|
|
56
|
+
meter = provider.get_meter("hostess_sdk")
|
|
57
|
+
|
|
58
|
+
attributes = {
|
|
59
|
+
"language": language,
|
|
60
|
+
"framework": framework,
|
|
61
|
+
"sdk_version": sdk_version,
|
|
62
|
+
"framework_version": framework_version,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def _observe(_options: CallbackOptions) -> Iterable[Observation]:
|
|
66
|
+
yield Observation(1, attributes)
|
|
67
|
+
|
|
68
|
+
meter.create_observable_gauge(
|
|
69
|
+
name="hostess_instrumentation_info",
|
|
70
|
+
callbacks=[_observe],
|
|
71
|
+
description="Hostess instrumentation marker (1 = installed).",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_marker_provider = provider
|
|
75
|
+
return provider
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _reset_for_tests() -> None:
|
|
79
|
+
"""Tear down the heartbeat so tests can re-run instrument() cleanly."""
|
|
80
|
+
global _marker_provider
|
|
81
|
+
if _marker_provider is not None:
|
|
82
|
+
try:
|
|
83
|
+
_marker_provider.shutdown()
|
|
84
|
+
except Exception: # pragma: no cover - best effort
|
|
85
|
+
pass
|
|
86
|
+
_marker_provider = None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""FastAPI integration for Hostess.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from hostess_sdk.fastapi import instrument
|
|
7
|
+
|
|
8
|
+
app = FastAPI()
|
|
9
|
+
instrument(app)
|
|
10
|
+
|
|
11
|
+
The helper is deliberately thin: it preconfigures battle-tested OpenTelemetry
|
|
12
|
+
FastAPI instrumentation to push to the Hostess collector, and emits the marker
|
|
13
|
+
heartbeat. It adds no routes to the application.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Iterable, Optional
|
|
20
|
+
|
|
21
|
+
from ._otel import (
|
|
22
|
+
build_resource,
|
|
23
|
+
ensure_tracer_provider,
|
|
24
|
+
is_enabled,
|
|
25
|
+
resolve_endpoint,
|
|
26
|
+
)
|
|
27
|
+
from ._otel.marker import start_marker_heartbeat
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from fastapi import FastAPI
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("hostess_sdk")
|
|
33
|
+
|
|
34
|
+
_LANGUAGE = "python"
|
|
35
|
+
_FRAMEWORK = "fastapi"
|
|
36
|
+
|
|
37
|
+
# Apps already instrumented in this process, for idempotency.
|
|
38
|
+
_instrumented_apps: "set[int]" = set()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def instrument(
|
|
42
|
+
app: "FastAPI",
|
|
43
|
+
*,
|
|
44
|
+
enabled: Optional[bool] = None,
|
|
45
|
+
service_name: Optional[str] = None,
|
|
46
|
+
exclude_paths: Optional[Iterable[str]] = None,
|
|
47
|
+
otlp_endpoint: Optional[str] = None,
|
|
48
|
+
) -> "FastAPI":
|
|
49
|
+
"""Instrument a FastAPI app for Hostess API Insights.
|
|
50
|
+
|
|
51
|
+
- Applies ``opentelemetry-instrumentation-fastapi`` so requests produce
|
|
52
|
+
server spans carrying the route template, method, and status.
|
|
53
|
+
- Exports spans over OTLP/HTTP to the Hostess collector (endpoint injected
|
|
54
|
+
by the platform; overridable via ``otlp_endpoint`` for local use).
|
|
55
|
+
- Starts the marker heartbeat.
|
|
56
|
+
|
|
57
|
+
Idempotent, fail-silent, and a no-op when disabled or when no collector
|
|
58
|
+
endpoint can be resolved. Returns the original ``app`` for fluent use.
|
|
59
|
+
"""
|
|
60
|
+
if not is_enabled(enabled):
|
|
61
|
+
logger.debug("hostess: instrumentation disabled; skipping")
|
|
62
|
+
return app
|
|
63
|
+
|
|
64
|
+
if id(app) in _instrumented_apps:
|
|
65
|
+
logger.debug("hostess: app already instrumented; skipping")
|
|
66
|
+
return app
|
|
67
|
+
|
|
68
|
+
endpoint = resolve_endpoint(otlp_endpoint)
|
|
69
|
+
if endpoint is None:
|
|
70
|
+
# Common when running locally outside Hostess: stay a clean no-op.
|
|
71
|
+
logger.debug("hostess: no OTLP endpoint resolved; instrumentation is a no-op")
|
|
72
|
+
return app
|
|
73
|
+
|
|
74
|
+
sdk_version, framework_version = _versions()
|
|
75
|
+
resource = build_resource(
|
|
76
|
+
service_name=service_name,
|
|
77
|
+
sdk_version=sdk_version,
|
|
78
|
+
framework=_FRAMEWORK,
|
|
79
|
+
framework_version=framework_version,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
ensure_tracer_provider(resource, endpoint)
|
|
84
|
+
_instrument_fastapi(app, exclude_paths)
|
|
85
|
+
start_marker_heartbeat(
|
|
86
|
+
resource=resource,
|
|
87
|
+
base_endpoint=endpoint,
|
|
88
|
+
language=_LANGUAGE,
|
|
89
|
+
framework=_FRAMEWORK,
|
|
90
|
+
sdk_version=sdk_version,
|
|
91
|
+
framework_version=framework_version,
|
|
92
|
+
)
|
|
93
|
+
except Exception: # never break the host app over telemetry setup
|
|
94
|
+
logger.debug("hostess: instrumentation setup failed; continuing", exc_info=True)
|
|
95
|
+
return app
|
|
96
|
+
|
|
97
|
+
_instrumented_apps.add(id(app))
|
|
98
|
+
return app
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _instrument_fastapi(app: "FastAPI", exclude_paths: Optional[Iterable[str]]) -> None:
|
|
102
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
103
|
+
|
|
104
|
+
excluded_urls = ",".join(exclude_paths) if exclude_paths else None
|
|
105
|
+
FastAPIInstrumentor.instrument_app(app, excluded_urls=excluded_urls)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _versions() -> "tuple[str, str]":
|
|
109
|
+
from importlib.metadata import PackageNotFoundError
|
|
110
|
+
from importlib.metadata import version as pkg_version
|
|
111
|
+
|
|
112
|
+
def safe(name: str) -> str:
|
|
113
|
+
try:
|
|
114
|
+
return pkg_version(name)
|
|
115
|
+
except PackageNotFoundError:
|
|
116
|
+
return "unknown"
|
|
117
|
+
|
|
118
|
+
return safe("hostess-python"), safe("fastapi")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reset_for_tests() -> None:
|
|
122
|
+
"""Clear per-process idempotency state (test helper)."""
|
|
123
|
+
_instrumented_apps.clear()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
|
|
6
|
+
from hostess_sdk import _otel
|
|
7
|
+
from hostess_sdk import fastapi as hostess_fastapi
|
|
8
|
+
from hostess_sdk._otel import marker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def _reset(monkeypatch):
|
|
13
|
+
# Clear per-process idempotency + marker state between tests, and start from
|
|
14
|
+
# a clean env (no platform-injected vars).
|
|
15
|
+
hostess_fastapi._reset_for_tests()
|
|
16
|
+
marker._reset_for_tests()
|
|
17
|
+
for var in (
|
|
18
|
+
"HOSTESS_OTEL_ENDPOINT",
|
|
19
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
20
|
+
"HOSTESS_INSTRUMENTATION",
|
|
21
|
+
"HOSTESS_SERVICE_NAME",
|
|
22
|
+
):
|
|
23
|
+
monkeypatch.delenv(var, raising=False)
|
|
24
|
+
yield
|
|
25
|
+
hostess_fastapi._reset_for_tests()
|
|
26
|
+
marker._reset_for_tests()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_is_enabled_precedence(monkeypatch):
|
|
30
|
+
assert _otel.is_enabled(True) is True
|
|
31
|
+
assert _otel.is_enabled(False) is False
|
|
32
|
+
monkeypatch.setenv("HOSTESS_INSTRUMENTATION", "false")
|
|
33
|
+
assert _otel.is_enabled(None) is False
|
|
34
|
+
monkeypatch.setenv("HOSTESS_INSTRUMENTATION", "true")
|
|
35
|
+
assert _otel.is_enabled(None) is True
|
|
36
|
+
monkeypatch.delenv("HOSTESS_INSTRUMENTATION", raising=False)
|
|
37
|
+
assert _otel.is_enabled(None) is True # default on
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_resolve_endpoint_precedence(monkeypatch):
|
|
41
|
+
assert _otel.resolve_endpoint(None) is None
|
|
42
|
+
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://otel:4318")
|
|
43
|
+
assert _otel.resolve_endpoint(None) == "http://otel:4318"
|
|
44
|
+
monkeypatch.setenv("HOSTESS_OTEL_ENDPOINT", "http://hostess-otel:4318")
|
|
45
|
+
assert _otel.resolve_endpoint(None) == "http://hostess-otel:4318" # wins
|
|
46
|
+
assert _otel.resolve_endpoint("http://arg:4318") == "http://hostess-otel:4318"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_signal_endpoint():
|
|
50
|
+
assert _otel.signal_endpoint("http://h:4318", "traces") == "http://h:4318/v1/traces"
|
|
51
|
+
assert _otel.signal_endpoint("http://h:4318/", "metrics") == "http://h:4318/v1/metrics"
|
|
52
|
+
# Idempotent if the path is already present.
|
|
53
|
+
assert (
|
|
54
|
+
_otel.signal_endpoint("http://h:4318/v1/traces", "traces")
|
|
55
|
+
== "http://h:4318/v1/traces"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_noop_without_endpoint():
|
|
60
|
+
app = FastAPI()
|
|
61
|
+
returned = hostess_fastapi.instrument(app)
|
|
62
|
+
assert returned is app
|
|
63
|
+
assert id(app) not in hostess_fastapi._instrumented_apps
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_disabled_is_noop(monkeypatch):
|
|
67
|
+
monkeypatch.setenv("HOSTESS_OTEL_ENDPOINT", "http://hostess-otel:4318")
|
|
68
|
+
app = FastAPI()
|
|
69
|
+
hostess_fastapi.instrument(app, enabled=False)
|
|
70
|
+
assert id(app) not in hostess_fastapi._instrumented_apps
|
|
71
|
+
|
|
72
|
+
monkeypatch.setenv("HOSTESS_INSTRUMENTATION", "false")
|
|
73
|
+
app2 = FastAPI()
|
|
74
|
+
hostess_fastapi.instrument(app2)
|
|
75
|
+
assert id(app2) not in hostess_fastapi._instrumented_apps
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_idempotent(monkeypatch):
|
|
79
|
+
monkeypatch.setenv("HOSTESS_OTEL_ENDPOINT", "http://hostess-otel:4318")
|
|
80
|
+
|
|
81
|
+
calls = {"tracer": 0, "fastapi": 0, "marker": 0}
|
|
82
|
+
monkeypatch.setattr(
|
|
83
|
+
hostess_fastapi, "ensure_tracer_provider",
|
|
84
|
+
lambda *a, **k: calls.__setitem__("tracer", calls["tracer"] + 1),
|
|
85
|
+
)
|
|
86
|
+
monkeypatch.setattr(
|
|
87
|
+
hostess_fastapi, "_instrument_fastapi",
|
|
88
|
+
lambda *a, **k: calls.__setitem__("fastapi", calls["fastapi"] + 1),
|
|
89
|
+
)
|
|
90
|
+
monkeypatch.setattr(
|
|
91
|
+
hostess_fastapi, "start_marker_heartbeat",
|
|
92
|
+
lambda *a, **k: calls.__setitem__("marker", calls["marker"] + 1),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
app = FastAPI()
|
|
96
|
+
hostess_fastapi.instrument(app)
|
|
97
|
+
hostess_fastapi.instrument(app) # second call must be a no-op
|
|
98
|
+
|
|
99
|
+
assert calls == {"tracer": 1, "fastapi": 1, "marker": 1}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_route_template_span_no_raw_path(monkeypatch):
|
|
103
|
+
"""Requests produce server spans with the route template, never raw paths."""
|
|
104
|
+
from opentelemetry import trace
|
|
105
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
106
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
107
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
|
108
|
+
InMemorySpanExporter,
|
|
109
|
+
)
|
|
110
|
+
from fastapi.testclient import TestClient
|
|
111
|
+
|
|
112
|
+
provider = trace.get_tracer_provider()
|
|
113
|
+
if not isinstance(provider, TracerProvider):
|
|
114
|
+
provider = TracerProvider()
|
|
115
|
+
trace.set_tracer_provider(provider)
|
|
116
|
+
memory = InMemorySpanExporter()
|
|
117
|
+
provider.add_span_processor(SimpleSpanProcessor(memory))
|
|
118
|
+
|
|
119
|
+
# Avoid any real network export; only the in-memory exporter matters here.
|
|
120
|
+
monkeypatch.setattr(hostess_fastapi, "ensure_tracer_provider", lambda *a, **k: provider)
|
|
121
|
+
monkeypatch.setattr(hostess_fastapi, "start_marker_heartbeat", lambda *a, **k: None)
|
|
122
|
+
monkeypatch.setenv("HOSTESS_OTEL_ENDPOINT", "http://hostess-otel:4318")
|
|
123
|
+
|
|
124
|
+
app = FastAPI()
|
|
125
|
+
|
|
126
|
+
@app.get("/items/{item_id}")
|
|
127
|
+
async def get_item(item_id: int):
|
|
128
|
+
return {"item_id": item_id}
|
|
129
|
+
|
|
130
|
+
hostess_fastapi.instrument(app)
|
|
131
|
+
client = TestClient(app)
|
|
132
|
+
assert client.get("/items/42").status_code == 200
|
|
133
|
+
|
|
134
|
+
spans = memory.get_finished_spans()
|
|
135
|
+
routes = [s.attributes.get("http.route") for s in spans if s.attributes]
|
|
136
|
+
assert "/items/{item_id}" in routes
|
|
137
|
+
# The raw path with the concrete id must never be a span route.
|
|
138
|
+
assert "/items/42" not in routes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from opentelemetry.sdk.metrics.export import MetricExporter, MetricExportResult
|
|
3
|
+
|
|
4
|
+
from hostess_sdk._otel import marker
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture(autouse=True)
|
|
8
|
+
def _reset():
|
|
9
|
+
marker._reset_for_tests()
|
|
10
|
+
yield
|
|
11
|
+
marker._reset_for_tests()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _FakeExporter(MetricExporter):
|
|
15
|
+
"""A real MetricExporter that exports nowhere (no network in tests)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args, **kwargs):
|
|
18
|
+
super().__init__()
|
|
19
|
+
|
|
20
|
+
def export(self, metrics_data, timeout_millis=10_000, **kwargs):
|
|
21
|
+
return MetricExportResult.SUCCESS
|
|
22
|
+
|
|
23
|
+
def force_flush(self, timeout_millis=10_000):
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
def shutdown(self, timeout_millis=10_000, **kwargs):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_marker_registers_gauge(monkeypatch):
|
|
31
|
+
from opentelemetry.sdk.resources import Resource
|
|
32
|
+
|
|
33
|
+
# Avoid real network: swap the OTLP metric exporter for a no-op.
|
|
34
|
+
monkeypatch.setattr(
|
|
35
|
+
"opentelemetry.exporter.otlp.proto.http.metric_exporter.OTLPMetricExporter",
|
|
36
|
+
_FakeExporter,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
provider = marker.start_marker_heartbeat(
|
|
40
|
+
resource=Resource.create({"service.name": "svc"}),
|
|
41
|
+
base_endpoint="http://hostess-otel:4318",
|
|
42
|
+
language="python",
|
|
43
|
+
framework="fastapi",
|
|
44
|
+
sdk_version="0.1.0",
|
|
45
|
+
framework_version="0.115.0",
|
|
46
|
+
)
|
|
47
|
+
assert provider is not None
|
|
48
|
+
|
|
49
|
+
# A second call is idempotent — same provider, no new heartbeat.
|
|
50
|
+
again = marker.start_marker_heartbeat(
|
|
51
|
+
resource=Resource.create({}),
|
|
52
|
+
base_endpoint="http://hostess-otel:4318",
|
|
53
|
+
language="python",
|
|
54
|
+
framework="fastapi",
|
|
55
|
+
sdk_version="0.1.0",
|
|
56
|
+
framework_version="0.115.0",
|
|
57
|
+
)
|
|
58
|
+
assert again is provider
|