Skip to content

Commit 5485cab

Browse files
committed
fix: replace OpenSensus with OpenTelemetry and fix exception handling
1 parent 0caf81e commit 5485cab

File tree

14 files changed

+662
-487
lines changed

14 files changed

+662
-487
lines changed

api/poetry.lock

Lines changed: 528 additions & 349 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ version = "1.4.0" # x-release-please-version
44
description = "API for Template Fastapi React"
55
authors = []
66
license = ""
7+
package-mode = false
78

89
[tool.poetry.dependencies]
910
cachetools = "^5.3.0"
@@ -14,10 +15,11 @@ uvicorn = {extras = ["standard"], version = "^0.21.1"}
1415
pymongo = "4.1.1"
1516
certifi = "^2023.7.22"
1617
httpx = "^0.23.3"
17-
opencensus-ext-azure = "^1.1.9"
1818
pydantic = "^2.1"
1919
pydantic-settings = "^2.0.1"
2020
pydantic-extra-types = "^2.0.0"
21+
azure-monitor-opentelemetry = "^1.6.2"
22+
opentelemetry-instrumentation-fastapi = "^0.48b0"
2123

2224
[tool.poetry.dev-dependencies]
2325
pre-commit = ">=3"

api/src/app.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
from starlette.middleware import Middleware
44

55
from authentication.authentication import auth_with_jwt
6-
from common.exception_handlers import add_exception_handlers
7-
from common.middleware import LocalLoggerMiddleware, OpenCensusRequestLoggingMiddleware
6+
from common.middleware import LocalLoggerMiddleware
87
from common.responses import responses
98
from config import config
109
from features.health_check import health_check_feature
@@ -35,8 +34,6 @@ def create_app() -> FastAPI:
3534
authenticated_routes.include_router(whoami_feature.router)
3635

3736
middleware = [Middleware(LocalLoggerMiddleware)]
38-
if config.APPINSIGHTS_CONSTRING:
39-
middleware.append(Middleware(OpenCensusRequestLoggingMiddleware))
4037

4138
app = FastAPI(
4239
title="Template FastAPI React",
@@ -54,7 +51,11 @@ def create_app() -> FastAPI:
5451
},
5552
)
5653

57-
add_exception_handlers(app)
54+
if config.APPINSIGHTS_CONSTRING:
55+
from azure.monitor.opentelemetry import configure_azure_monitor
56+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
57+
configure_azure_monitor(connection_string=config.APPINSIGHTS_CONSTRING, logger_name="API")
58+
FastAPIInstrumentor.instrument_app(app, excluded_urls="healthcheck")
5859

5960
app.include_router(authenticated_routes, dependencies=[Security(auth_with_jwt)])
6061
app.include_router(public_routes)

api/src/authentication/authentication.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from fastapi.security import OAuth2AuthorizationCodeBearer
66

77
from authentication.models import User
8-
from common.exceptions import credentials_exception
8+
from common.exceptions import UnauthorizedException
99
from common.logger import logger
1010
from config import config
1111

@@ -25,12 +25,12 @@ def get_JWK_client() -> jwt.PyJWKClient:
2525
return jwt.PyJWKClient(oid_conf["jwks_uri"])
2626
except Exception as error:
2727
logger.error(f"Failed to fetch OpenId Connect configuration for '{config.OAUTH_WELL_KNOWN}': {error}")
28-
raise credentials_exception
28+
raise UnauthorizedException
2929

3030

3131
def auth_with_jwt(jwt_token: str = Security(oauth2_scheme)) -> User:
3232
if not jwt_token:
33-
raise credentials_exception
33+
raise UnauthorizedException
3434
key = get_JWK_client().get_signing_key_from_jwt(jwt_token).key
3535
try:
3636
payload = jwt.decode(jwt_token, key, algorithms=["RS256"], audience=config.OAUTH_AUDIENCE)
@@ -41,8 +41,8 @@ def auth_with_jwt(jwt_token: str = Security(oauth2_scheme)) -> User:
4141
user = User(user_id=payload["sub"], **payload)
4242
except jwt.exceptions.InvalidTokenError as error:
4343
logger.warning(f"Failed to decode JWT: {error}")
44-
raise credentials_exception
44+
raise UnauthorizedException
4545

4646
if user is None:
47-
raise credentials_exception
47+
raise UnauthorizedException
4848
return user

api/src/common/exception_handlers.py

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import traceback
22
import uuid
3+
from collections.abc import Callable, Coroutine
4+
from typing import Any
35

4-
from fastapi import FastAPI
56
from fastapi.encoders import jsonable_encoder
67
from fastapi.exceptions import RequestValidationError
8+
from fastapi.routing import APIRoute
79
from httpx import HTTPStatusError
810
from starlette import status
911
from starlette.requests import Request
10-
from starlette.responses import JSONResponse
12+
from starlette.responses import JSONResponse, Response
1113

1214
from common.exceptions import (
1315
ApplicationException,
@@ -16,29 +18,16 @@
1618
ExceptionSeverity,
1719
MissingPrivilegeException,
1820
NotFoundException,
21+
UnauthorizedException,
1922
ValidationException,
2023
)
2124
from common.logger import logger
2225

2326

24-
def add_exception_handlers(app: FastAPI) -> None:
25-
# Handle custom exceptions
26-
app.add_exception_handler(BadRequestException, generic_exception_handler)
27-
app.add_exception_handler(ValidationException, generic_exception_handler)
28-
app.add_exception_handler(NotFoundException, generic_exception_handler)
29-
app.add_exception_handler(MissingPrivilegeException, generic_exception_handler)
30-
31-
# Override built-in default handler
32-
app.add_exception_handler(RequestValidationError, validation_exception_handler) # type: ignore
33-
app.add_exception_handler(HTTPStatusError, http_exception_handler)
34-
35-
# Fallback exception handler for all unexpected exceptions
36-
app.add_exception_handler(Exception, fall_back_exception_handler)
37-
38-
3927
def fall_back_exception_handler(request: Request, exc: Exception) -> JSONResponse:
4028
error_id = uuid.uuid4()
4129
traceback_string = " ".join(traceback.format_tb(tb=exc.__traceback__))
30+
print(traceback_string)
4231
logger.error(
4332
f"Unexpected unhandled exception ({error_id}): {exc}",
4433
extra={"custom_dimensions": {"Error ID": error_id, "Traceback": traceback_string}},
@@ -98,3 +87,33 @@ def http_exception_handler(request: Request, exc: HTTPStatusError) -> JSONRespon
9887
debug=exc.response,
9988
)
10089
)
90+
91+
92+
class ExceptionHandlingRoute(APIRoute):
93+
"""APIRoute class for handling exceptions."""
94+
95+
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
96+
"""Intercept response and return correct exception response."""
97+
original_route_handler = super().get_route_handler()
98+
99+
async def custom_route_handler(request: Request) -> Response:
100+
try:
101+
return await original_route_handler(request)
102+
except BadRequestException as e:
103+
return generic_exception_handler(request, e)
104+
except ValidationException as e:
105+
return generic_exception_handler(request, e)
106+
except NotFoundException as e:
107+
return generic_exception_handler(request, e)
108+
except MissingPrivilegeException as e:
109+
return generic_exception_handler(request, e)
110+
except RequestValidationError as e:
111+
return validation_exception_handler(request, e)
112+
except HTTPStatusError as e:
113+
return http_exception_handler(request, e)
114+
except UnauthorizedException as e:
115+
return generic_exception_handler(request, e)
116+
except Exception as e:
117+
return fall_back_exception_handler(request, e)
118+
119+
return custom_route_handler

api/src/common/exceptions.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,12 @@ def __init__(
9898
self.type = self.__class__.__name__
9999

100100

101-
credentials_exception = HTTPException(
102-
status_code=request_status.HTTP_401_UNAUTHORIZED,
103-
detail="Token validation failed",
104-
headers={"WWW-Authenticate": "Bearer"},
105-
)
101+
class UnauthorizedException(ApplicationException):
102+
def __init__(
103+
self,
104+
message: str = "Token validation failed",
105+
debug: str = "Token was not valid for requested operation.",
106+
extra: dict[str, Any] | None = None,
107+
):
108+
super().__init__(message, debug, extra, request_status.HTTP_401_UNAUTHORIZED)
109+
self.type = self.__class__.__name__

api/src/common/middleware.py

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
import time
22

3-
from azure.core.tracing import SpanKind
4-
from opencensus.ext.azure.trace_exporter import AzureExporter
5-
from opencensus.trace.attributes_helper import COMMON_ATTRIBUTES
6-
from opencensus.trace.samplers import ProbabilitySampler
7-
from opencensus.trace.tracer import Tracer
83
from starlette.datastructures import MutableHeaders
94
from starlette.types import ASGIApp, Message, Receive, Scope, Send
105

116
from common.logger import logger
12-
from config import config
137

148

159
# These middlewares are written as "pure ASGI middleware", see: https://www.starlette.io/middleware/#pure-asgi-middleware
@@ -44,43 +38,3 @@ async def inner_send(message: Message) -> None:
4438

4539
await self.app(scope, receive, inner_send)
4640
logger.info(f"{method} {path} - {process_time}ms - {response['status']}")
47-
48-
49-
class OpenCensusRequestLoggingMiddleware:
50-
exporter = AzureExporter(connection_string=config.APPINSIGHTS_CONSTRING) if config.APPINSIGHTS_CONSTRING else None
51-
sampler = ProbabilitySampler(1.0)
52-
53-
def __init__(self, app: ASGIApp):
54-
self.app = app
55-
56-
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
57-
if scope["type"] != "http":
58-
return await self.app(scope, receive, send)
59-
60-
tracer = Tracer(exporter=self.exporter, sampler=self.sampler)
61-
62-
path = scope["path"]
63-
response: Message = {}
64-
65-
async def inner_send(message: Message) -> None:
66-
nonlocal response
67-
if message["type"] == "http.response.start":
68-
response = message
69-
70-
await send(message)
71-
72-
if path == "/health-check": # Don't send health-check requests to Azure
73-
return await self.app(scope, receive, send)
74-
75-
with tracer.span("main") as span:
76-
span.span_kind = SpanKind.SERVER
77-
78-
await self.app(scope, receive, inner_send)
79-
80-
tracer.add_attribute_to_current_span(
81-
attribute_key=COMMON_ATTRIBUTES["HTTP_STATUS_CODE"], attribute_value=response["status"]
82-
)
83-
host = next((header[1].decode() for header in scope["headers"] if header[0] == b"host"), "")
84-
tracer.add_attribute_to_current_span(
85-
attribute_key=COMMON_ATTRIBUTES["HTTP_URL"], attribute_value=f"{host}{path}"
86-
)

api/src/common/telemetry.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from opentelemetry import trace
2+
# Creates a tracer from the global tracer provider
3+
tracer = trace.get_tracer("tracer.global")

api/src/features/health_check/health_check_feature.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from fastapi import APIRouter, status
22
from fastapi.responses import PlainTextResponse
33

4-
router = APIRouter(tags=["health_check"], prefix="/health-check")
4+
from common.exception_handlers import ExceptionHandlingRoute
5+
6+
router = APIRouter(tags=["health_check"], prefix="/health-check", route_class=ExceptionHandlingRoute)
57

68

79
@router.get(

api/src/features/todo/todo_feature.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
update_todo_use_case,
2525
)
2626

27-
router = APIRouter(tags=["todos"], prefix="/todos")
27+
from common.exception_handlers import ExceptionHandlingRoute
28+
29+
router = APIRouter(tags=["todos"], prefix="/todos", route_class=ExceptionHandlingRoute)
2830

2931

3032
@router.post("", operation_id="create")

0 commit comments

Comments
 (0)