Skip to content

Commit 7a5a5a9

Browse files
authored
Modernize and add OpenTelemetry (#320)
1 parent 8c60951 commit 7a5a5a9

21 files changed

Lines changed: 702 additions & 407 deletions

.github/workflows/deploy.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ jobs:
1515
name: Deploy to AWS
1616
environment: deploy-to-aws
1717
steps:
18-
- uses: actions/checkout@v4
19-
- uses: aws-actions/configure-aws-credentials@v4
18+
- uses: actions/checkout@v6.0.2
19+
- uses: aws-actions/configure-aws-credentials@v6.0.0
2020
with:
2121
aws-region: ${{ vars.AWS_REGION }}
2222
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ vars.AWS_DEPLOYMENT_ROLE }}
23-
- uses: aws-actions/amazon-ecr-login@v2
23+
- uses: aws-actions/amazon-ecr-login@v2.1.1
2424
id: log-into-ecr
2525
- name: Build, tag, and push Docker image to Amazon ECR
2626
id: build-tag-and-push-docker-image
@@ -32,13 +32,13 @@ jobs:
3232
echo "tag=$TAG" >> $GITHUB_OUTPUT
3333
- name: Inline variables in the task definition
3434
run: sed -i -e 's/AWS_ACCOUNT_ID/${{ secrets.AWS_ACCOUNT_ID }}/g' -e 's/AWS_DATABASE_URL_SECRET_NAME/${{ vars.AWS_DATABASE_URL_SECRET_NAME }}/g' -e 's/AWS_EXECUTION_ROLE/${{ vars.AWS_EXECUTION_ROLE }}/g' -e 's/AWS_REGION/${{ vars.AWS_REGION }}/g' task-definition.json
35-
- uses: aws-actions/amazon-ecs-render-task-definition@v1
35+
- uses: aws-actions/amazon-ecs-render-task-definition@v1.8.4
3636
id: render-task-definition
3737
with:
3838
container-name: atoti-session
3939
image: ${{ steps.build-tag-and-push-docker-image.outputs.tag }}
4040
task-definition: task-definition.json
41-
- uses: aws-actions/amazon-ecs-deploy-task-definition@v2
41+
- uses: aws-actions/amazon-ecs-deploy-task-definition@v2.6.1
4242
with:
4343
cluster: atoti-project-template
4444
service: atoti-project-template

.github/workflows/test.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ jobs:
88
name: Test
99
runs-on: ubuntu-latest
1010
steps:
11-
- uses: actions/checkout@v4
12-
- uses: astral-sh/setup-uv@v3
11+
- uses: actions/checkout@v6.0.2
12+
- uses: astral-sh/setup-uv@v8.0.0
1313
with:
1414
enable-cache: true
1515
# Keep in sync with pyproject.toml's `tool.uv.required-version`.
16-
version: "0.9.18"
16+
version: "0.11.2"
1717
# Keep in sync with pyproject.toml's `project.requires-python`.
18-
- run: uv python install 3.11
18+
- run: uv python install 3.12
1919
- run: uv sync --locked
2020
- run: uv run -m skeleton
2121
- run: uv run ruff format --check

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Keep Python version in sync with:
55
# - pyproject.toml's `project.requires-python`.
66
# - the main stage below.
7-
FROM ghcr.io/astral-sh/uv:0.9.18-python3.11-bookworm-slim AS builder
7+
FROM ghcr.io/astral-sh/uv:0.11.2-python3.12-trixie-slim AS builder
88

99
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
1010

@@ -21,7 +21,7 @@ RUN --mount=type=bind,source=skeleton,target=skeleton_tmp \
2121
rm -r app
2222

2323
# Keep this synced with the `builder` stage above.
24-
FROM python:3.11-slim-bookworm
24+
FROM python:3.12-slim-trixie
2525

2626
COPY --from=builder /venv app
2727

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ On top of the `atoti` package, it comes with:
77
- Dependency management with [uv](https://docs.astral.sh/uv)
88
- Config management with [Pydantic](https://docs.pydantic.dev/2.6/concepts/pydantic_settings)
99
- Testing with [pytest](https://docs.pytest.org)
10+
- Observability with [OpenTelemetry](https://opentelemetry.io/docs/languages/python)
1011
- Type checking with [ty](https://docs.astral.sh/ty)
1112
- Formatting and linting with [Ruff](https://docs.astral.sh/ruff)
1213
- Continuous testing with [GitHub Actions](https://github.com/features/actions)
@@ -30,6 +31,12 @@ To start the app:
3031
uv run python -m app
3132
```
3233

34+
To start the app in Docker with [Jaeger](https://www.jaegertracing.io) to observe traces:
35+
36+
```bash
37+
docker compose up --build
38+
```
39+
3340
Other useful commands can be found in [`test.yml`](.github/workflows/test.yml).
3441

3542
## Deployment

app/__main__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
from asyncio import run, to_thread
22
from urllib.parse import urlparse
33

4+
from opentelemetry import trace
5+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
6+
from opentelemetry.sdk.resources import Resource
7+
from opentelemetry.sdk.trace import TracerProvider
8+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
9+
410
from . import Config, start_app
511

612

13+
def _setup_opentelemetry() -> None:
14+
resource = Resource.create({"service.name": "app"})
15+
provider = TracerProvider(resource=resource)
16+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
17+
trace.set_tracer_provider(provider)
18+
19+
720
async def main() -> None:
21+
_setup_opentelemetry()
822
async with start_app(config=Config()) as session:
923
port = urlparse(session.url).port or 80
1024
print(f"Session listening on port {port}") # noqa: T201

app/create_and_join_tables.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import atoti as tt
22

3+
from .opentelemetry import traced
34
from .skeleton import Skeleton
45
from .util import column
56

67

8+
@traced
79
def create_station_status_table(session: tt.Session, /) -> None:
810
skeleton = Skeleton.tables.STATION_STATUS
911
session.create_table(
@@ -20,6 +22,7 @@ def create_station_status_table(session: tt.Session, /) -> None:
2022
)
2123

2224

25+
@traced
2326
def create_station_information_table(session: tt.Session, /) -> None:
2427
skeleton = Skeleton.tables.STATION_INFORMATION
2528
session.create_table(
@@ -43,6 +46,7 @@ def create_station_information_table(session: tt.Session, /) -> None:
4346
)
4447

4548

49+
@traced
4650
def join_tables(session: tt.Session, /) -> None:
4751
skeleton = Skeleton.tables
4852
session.tables[skeleton.STATION_STATUS.name].join(
@@ -52,6 +56,7 @@ def join_tables(session: tt.Session, /) -> None:
5256
)
5357

5458

59+
@traced
5560
def create_and_join_tables(session: tt.Session, /) -> None:
5661
create_station_status_table(session)
5762
create_station_information_table(session)

app/create_cubes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import atoti as tt
22

3+
from .opentelemetry import traced
34
from .skeleton import Skeleton
45
from .util import column, fact_based_hierarchy
56

67

8+
@traced
79
def create_station_cube(session: tt.Session, /) -> None:
810
skeleton = Skeleton.cubes.STATION
911

@@ -59,5 +61,6 @@ def create_station_cube(session: tt.Session, /) -> None:
5961
)
6062

6163

64+
@traced
6265
def create_cubes(session: tt.Session, /) -> None:
6366
create_station_cube(session)

app/load_tables.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from asyncio import gather
22
from collections.abc import Iterable, Mapping
3-
from logging import getLogger
43
from pathlib import Path
54
from typing import Any, cast
65

@@ -10,13 +9,13 @@
109
from pydantic import DirectoryPath, FilePath, HttpUrl
1110

1211
from .config import Config
12+
from .opentelemetry import traced
1313
from .path import RESOURCES_DIRECTORY
1414
from .skeleton import Skeleton
1515
from .util import read_json, reverse_geocode
1616

17-
_LOGGER = getLogger(__name__)
18-
1917

18+
@traced
2019
async def read_station_information(
2120
*,
2221
http_client: httpx.AsyncClient,
@@ -73,6 +72,7 @@ async def read_station_information(
7372
).drop(columns=coordinates_column_names)
7473

7574

75+
@traced
7676
async def read_station_status(
7777
velib_data_base_path: HttpUrl | Path,
7878
/,
@@ -106,14 +106,14 @@ async def read_station_status(
106106
return pd.DataFrame(station_statuses)
107107

108108

109+
@traced
109110
async def load_tables(
110111
session: tt.Session,
111112
/,
112113
*,
113114
config: Config,
114115
http_client: httpx.AsyncClient,
115116
) -> None:
116-
_LOGGER.info("Loading tables.")
117117
if config.data_refresh_period is None:
118118
reverse_geocoding_path: HttpUrl | FilePath = (
119119
RESOURCES_DIRECTORY / "station_location.csv"
@@ -151,4 +151,3 @@ async def load_tables(
151151
station_status_df
152152
),
153153
)
154-
_LOGGER.info("Tables loaded.")

app/opentelemetry.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from collections.abc import Callable, Coroutine
2+
from functools import wraps
3+
from inspect import iscoroutinefunction, isfunction
4+
from typing import Final
5+
6+
from opentelemetry import trace
7+
8+
TRACER: Final = trace.get_tracer(__name__)
9+
10+
11+
def traced[**P, R](func: Callable[P, R], /) -> Callable[P, R]:
12+
"""Thin wrapper around :meth:`opentelemetry.trace.Tracer.start_as_current_span`'s automatically using the decorated's function qualified name as the span name."""
13+
assert isfunction(func)
14+
15+
if iscoroutinefunction(func):
16+
17+
@wraps(func)
18+
async def wrapper(
19+
*args: P.args, **kwargs: P.kwargs
20+
) -> Coroutine[object, object, R]:
21+
with TRACER.start_as_current_span(func.__qualname__):
22+
return await func(*args, **kwargs)
23+
24+
return wrapper # ty: ignore[invalid-return-type]
25+
26+
@wraps(func)
27+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
28+
with TRACER.start_as_current_span(func.__qualname__):
29+
return func(*args, **kwargs)
30+
31+
return wrapper

app/start_app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import atoti as tt
55
import httpx
6+
from opentelemetry.instrumentation.httpx import AsyncOpenTelemetryTransport
67

78
from .config import Config
89
from .load_tables import load_tables
@@ -12,8 +13,9 @@
1213

1314
@asynccontextmanager
1415
async def start_app(*, config: Config) -> AsyncGenerator[tt.Session]:
16+
httpx_transport = AsyncOpenTelemetryTransport(httpx.AsyncHTTPTransport())
1517
async with (
16-
httpx.AsyncClient() as http_client,
18+
httpx.AsyncClient(transport=httpx_transport) as http_client,
1719
start_session(config=config, http_client=http_client) as session,
1820
run_periodically(
1921
lambda: load_tables(session, config=config, http_client=http_client),

0 commit comments

Comments
 (0)