Skip to content

Commit 4330b61

Browse files
committed
feat(api): add monitoring feature with track_events use case
1 parent f56fb14 commit 4330b61

15 files changed

Lines changed: 855 additions & 89 deletions

File tree

api/.openapi.json

Lines changed: 358 additions & 0 deletions
Large diffs are not rendered by default.

api/pyproject.toml

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ description = "API for Template Fastapi React"
55
requires-python = ">=3.14"
66
readme = "../README.md"
77
dependencies = [
8-
"azure-monitor-opentelemetry>=1.6.5",
8+
"azure-identity>=1.19.0",
9+
"azure-monitor-opentelemetry>=1.8.7",
10+
"azure-monitor-opentelemetry-exporter>=1.0.0b49",
911
"cachetools>=7.1.1",
1012
"fastapi[standard]>=0.136.1",
1113
"httpx>=0.28",
12-
"opentelemetry-instrumentation-fastapi>=0.62b1",
13-
"pydantic>=2.13.4",
14-
"pydantic-settings>=2.14.1",
14+
"opentelemetry-instrumentation-fastapi>=0.61b0",
15+
"pydantic>=2.13.3",
16+
"pydantic-settings>=2.14.0",
1517
"pyjwt>=2.8.0",
1618
"pymongo>=4.17.0",
1719
]
@@ -24,19 +26,6 @@ dev = [
2426
"types-cachetools>=7.0.0.20260503",
2527
]
2628

27-
[tool.uv]
28-
# opentelemetry-sdk 1.39 removed `LogData` from `opentelemetry.sdk._logs`,
29-
# which breaks azure-monitor-opentelemetry-exporter (still on the old API
30-
# as of 1.0.0b45). Override the transitive resolution to keep us on the
31-
# last working SDK line until the exporter migrates to LogRecord. Pinning
32-
# in `dependencies` doesn't work — the instrumentation packages declare
33-
# matching `semantic-conventions` versions that drag the SDK back up.
34-
override-dependencies = [
35-
"opentelemetry-sdk<1.39",
36-
"opentelemetry-api<1.39",
37-
"opentelemetry-semantic-conventions<0.60b0",
38-
]
39-
4029
[build-system]
4130
requires = ["hatchling"]
4231
build-backend = "hatchling.build"

api/src/app/app.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from app.common import LocalLoggerMiddleware, responses
66
from app.config import config
77
from app.features.health_check import router as health_check_router
8+
from app.features.monitoring import router as monitoring_router
89
from app.features.todo import router as todo_router
910
from app.features.whoami import router as whoami_router
1011

@@ -28,6 +29,7 @@ def create_app() -> FastAPI:
2829
public_routes.include_router(health_check_router)
2930

3031
authenticated_routes = APIRouter()
32+
authenticated_routes.include_router(monitoring_router)
3133
authenticated_routes.include_router(todo_router)
3234
authenticated_routes.include_router(whoami_router)
3335

@@ -53,7 +55,19 @@ def create_app() -> FastAPI:
5355
from azure.monitor.opentelemetry import configure_azure_monitor
5456
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
5557

56-
configure_azure_monitor(connection_string=config.APPINSIGHTS_CONSTRING, logger_name="API")
58+
kwargs: dict[str, object] = {
59+
"connection_string": config.APPINSIGHTS_CONSTRING,
60+
"logger_name": "API",
61+
}
62+
if config.has_azure_service_principal:
63+
from azure.identity import ClientSecretCredential
64+
65+
kwargs["credential"] = ClientSecretCredential(
66+
tenant_id=config.AZURE_TENANT_ID,
67+
client_id=config.OAUTH_CLIENT_ID,
68+
client_secret=config.OAUTH_CLIENT_SECRET.get_secret_value(),
69+
)
70+
configure_azure_monitor(**kwargs)
5771
FastAPIInstrumentor.instrument_app(app, excluded_urls="healthcheck")
5872

5973
app.include_router(authenticated_routes, dependencies=[Security(auth_with_jwt)])

api/src/app/authentication/authentication.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ def get_JWK_client() -> jwt.PyJWKClient:
2929

3030

3131
def auth_with_jwt(jwt_token: str = Security(oauth2_scheme)) -> User:
32+
"""Validate the caller's id_token (Authorization header) and return a `User`.
33+
34+
oauth2-proxy injects the id_token as `Authorization: Bearer ...`. The
35+
id_token's `aud` claim is the OIDC client app id (`OAUTH_AUDIENCE`).
36+
Roles are read from the `roles` claim, populated by the API app's
37+
optional claims with `emit_as_roles` (so group GUIDs land in `roles`).
38+
"""
3239
if not config.AUTH_ENABLED:
3340
return default_user
3441
if not jwt_token:
@@ -37,8 +44,12 @@ def auth_with_jwt(jwt_token: str = Security(oauth2_scheme)) -> User:
3744
try:
3845
payload = jwt.decode(jwt_token, key, algorithms=["RS256"], audience=config.OAUTH_AUDIENCE)
3946
if config.MICROSOFT_AUTH_PROVIDER in payload["iss"]:
40-
# Azure AD uses an oid string to uniquely identify users. Each user has a unique oid value.
41-
user = User(user_id=payload["oid"], **payload)
47+
user = User(
48+
user_id=payload["oid"],
49+
full_name=payload.get("name"),
50+
email=payload.get("preferred_username") or payload.get("upn"),
51+
roles=payload.get("roles", []),
52+
)
4253
else:
4354
user = User(user_id=payload["sub"], **payload)
4455
except jwt.exceptions.InvalidTokenError as error:

api/src/app/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import Field
1+
from pydantic import Field, SecretStr
22
from pydantic_settings import BaseSettings
33

44
from app.authentication.models import User
@@ -32,10 +32,16 @@ class Config(BaseSettings):
3232
OAUTH_TOKEN_ENDPOINT: str = ""
3333
OAUTH_AUTH_ENDPOINT: str = ""
3434
OAUTH_CLIENT_ID: str = ""
35+
OAUTH_CLIENT_SECRET: SecretStr = SecretStr("")
3536
OAUTH_AUTH_SCOPE: str = ""
3637
OAUTH_AUDIENCE: str = ""
38+
AZURE_TENANT_ID: str = ""
3739
MICROSOFT_AUTH_PROVIDER: str = "login.microsoftonline.com"
3840

41+
@property
42+
def has_azure_service_principal(self) -> bool:
43+
return bool(self.AZURE_TENANT_ID and self.OAUTH_CLIENT_ID and self.OAUTH_CLIENT_SECRET.get_secret_value())
44+
3945
@property
4046
def log_level(self) -> str:
4147
"""Returns LOGGER_LEVEL as a (lower case) string."""
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from app.features.monitoring.monitoring_feature import router
2+
3+
__all__ = ["router"]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from fastapi import APIRouter, Depends
2+
3+
from app.authentication.authentication import auth_with_jwt
4+
from app.authentication.models import User
5+
from app.common.exception_handlers import ExceptionHandlingRoute
6+
from app.features.monitoring.use_cases.track_events import Event, TelemetryResult, track_events_use_case
7+
8+
router = APIRouter(tags=["monitoring"], prefix="/monitoring", route_class=ExceptionHandlingRoute)
9+
10+
11+
@router.post("/v2/track", operation_id="track")
12+
async def track(events: list[Event], user: User = Depends(auth_with_jwt)) -> TelemetryResult | None:
13+
return track_events_use_case(events=events, user=user)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import datetime
2+
import functools
3+
from http import HTTPStatus
4+
from typing import Any, NotRequired, TypedDict
5+
6+
from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter
7+
from azure.monitor.opentelemetry.exporter._connection_string_parser import ConnectionStringParser
8+
from azure.monitor.opentelemetry.exporter._generated.exporter.models import MonitorBase, TelemetryItem
9+
10+
from app.authentication.models import User
11+
from app.common.logger import logger
12+
from app.config import config
13+
14+
15+
@functools.lru_cache(maxsize=1)
16+
def get_trace_exporter() -> AzureMonitorTraceExporter:
17+
kwargs: dict[str, Any] = {
18+
"connection_string": config.APPINSIGHTS_CONSTRING,
19+
"logger_name": "API",
20+
}
21+
if config.has_azure_service_principal:
22+
from azure.identity import ClientSecretCredential
23+
24+
kwargs["credential"] = ClientSecretCredential(
25+
tenant_id=config.AZURE_TENANT_ID,
26+
client_id=config.OAUTH_CLIENT_ID,
27+
client_secret=config.OAUTH_CLIENT_SECRET.get_secret_value(),
28+
)
29+
return AzureMonitorTraceExporter(**kwargs)
30+
31+
32+
class EventData(TypedDict):
33+
ver: int
34+
properties: dict[str, Any]
35+
name: str
36+
url: NotRequired[str]
37+
measurements: NotRequired[dict[str, float]]
38+
39+
40+
class MetricsData(TypedDict):
41+
ver: int
42+
metrics: list[dict[str, Any]]
43+
properties: dict[str, Any]
44+
45+
46+
class ExceptionData(TypedDict):
47+
ver: int
48+
exceptions: list[dict[str, Any]]
49+
properties: dict[str, Any]
50+
51+
52+
class EventBase(TypedDict):
53+
baseType: str
54+
baseData: EventData | MetricsData | ExceptionData
55+
56+
57+
class Event(TypedDict):
58+
name: str
59+
tags: dict[str, str]
60+
time: datetime.datetime
61+
data: EventBase
62+
63+
64+
class TelemetryErrorDetails(TypedDict):
65+
index: int
66+
statusCode: int
67+
message: str
68+
69+
70+
class TelemetryResult(TypedDict):
71+
items_received: int
72+
items_accepted: int
73+
errors: list[TelemetryErrorDetails]
74+
75+
76+
def track_events_use_case(user: User, events: list[Event]) -> TelemetryResult | None:
77+
if connection_string := config.APPINSIGHTS_CONSTRING:
78+
instrumentation_key = ConnectionStringParser(connection_string=connection_string).instrumentation_key
79+
else:
80+
logger.warning("Environment variable 'APPINSIGHTS_CONSTRING' is unset; Unable to send to Azure Monitor")
81+
return TelemetryResult(
82+
items_accepted=0,
83+
items_received=len(events),
84+
errors=[
85+
TelemetryErrorDetails(
86+
index=idx,
87+
statusCode=HTTPStatus.SERVICE_UNAVAILABLE,
88+
message="No Applications Insight connection string provided",
89+
)
90+
for idx in range(len(events))
91+
],
92+
)
93+
telemetry_items = [
94+
TelemetryItem(
95+
name=event["name"],
96+
instrumentation_key=instrumentation_key,
97+
tags=event["tags"],
98+
time=event["time"],
99+
data=MonitorBase(event["data"]),
100+
)
101+
for event in events
102+
]
103+
trace_exporter = get_trace_exporter()
104+
try:
105+
result = trace_exporter.client.track(telemetry_items)
106+
return TelemetryResult(
107+
items_received=result.items_received or len(events),
108+
items_accepted=result.items_accepted or 0,
109+
errors=[
110+
TelemetryErrorDetails(
111+
index=error.index or 0,
112+
statusCode=error.status_code or 0,
113+
message=error.message or "",
114+
)
115+
for error in (result.errors or [])
116+
],
117+
)
118+
except Exception as e:
119+
logger.error(f"Failed to track events: {e}")
120+
return None
121+
122+
123+
__all__ = ["track_events_use_case", "Event", "TelemetryResult"]

api/src/app/features/todo/use_cases/add_todo.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class AddTodoResponse(BaseModel):
2424
"examples": ["vytxeTZskVKR7C7WgdSP3d"],
2525
}
2626
)
27+
user_id: str
2728
title: str = Field(
2829
json_schema_extra={
2930
"examples": ["Read about clean architecture"],
@@ -33,7 +34,12 @@ class AddTodoResponse(BaseModel):
3334

3435
@classmethod
3536
def from_entity(cls, todo_item: TodoItem) -> Self:
36-
return cls(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed)
37+
return cls(
38+
id=todo_item.id,
39+
user_id=todo_item.user_id,
40+
title=todo_item.title,
41+
is_completed=todo_item.is_completed,
42+
)
3743

3844

3945
def add_todo_use_case(

api/src/app/features/todo/use_cases/get_todo_all.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@
1010

1111
class GetTodoAllResponse(BaseModel):
1212
id: str
13+
user_id: str
1314
title: str
1415
is_completed: bool
1516

1617
@classmethod
1718
def from_entity(cls, todo_item: TodoItem) -> Self:
18-
return cls(id=todo_item.id, title=todo_item.title, is_completed=todo_item.is_completed)
19+
return cls(
20+
id=todo_item.id,
21+
user_id=todo_item.user_id,
22+
title=todo_item.title,
23+
is_completed=todo_item.is_completed,
24+
)
1925

2026

2127
# Telemetry example: Initialize a span that will be used to log telemetry data

0 commit comments

Comments
 (0)