Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions fastapi_template/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ def parse_args():
default=None,
dest="sentry_enabled",
)
parser.add_argument(
"--opentelemetry",
help="Add opentelemetry integration",
action="store_true",
default=None,
dest="otlp_enabled",
)
parser.add_argument(
"--force",
help="Owerrite directory if it exists",
Expand Down Expand Up @@ -192,6 +199,10 @@ def ask_features(current_context: BuilderContext) -> BuilderContext:
"name": "sentry_enabled",
"value": current_context.sentry_enabled,
},
"Opentelemetry integration": {
"name": "otlp_enabled",
"value": current_context.otlp_enabled,
},
}
if current_context.db != DatabaseType.none:
features["Migrations support"] = {
Expand Down
1 change: 1 addition & 0 deletions fastapi_template/input_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class BuilderContext(BaseModel):
self_hosted_swagger: Optional[bool]
prometheus_enabled: Optional[bool]
sentry_enabled: Optional[bool]
otlp_enabled: Optional[bool]
enable_rmq: Optional[bool]
force: bool = False
quite: bool = False
Expand Down
3 changes: 3 additions & 0 deletions fastapi_template/template/cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
"sentry_enabled": {
"type": "bool"
},
"otlp_enabled": {
"type": "bool"
},
"_extensions": [
"cookiecutter.extensions.RandomStringExtension"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM python:3.9.6-slim-buster

{% if cookiecutter.db_info.name == "mysql" -%}
{%- if cookiecutter.db_info.name == "mysql" %}
RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \
gcc \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,24 @@ services:
- .:/app/src/
environment:
{{cookiecutter.project_name | upper}}_RELOAD: "True"
{%- if cookiecutter.otlp_enabled == "True" %}
{{cookiecutter.project_name | upper}}_OPENTELEMETRY_ENDPOINT: "http://otel-collector:4317"
{% endif %}

{%- if cookiecutter.otlp_enabled == "True" %}
otel-collector:
image: otel/opentelemetry-collector-contrib:0.53.0
volumes:
- ./deploy/otel-collector-config.yml:/config.yml
command: --config config.yml
ports:
# Collector's endpoint
- "4317:4317"

jaeger:
image: jaegertracing/all-in-one:1.35
hostname: jaeger
ports:
# Jaeger UI
- 16686:16686
{%- endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
receivers:
otlp:
protocols:
grpc:
http:

processors:
batch:

exporters:
logging:
logLevel: info

jaeger:
endpoint: "jaeger:14250"
tls:
insecure: true

extensions:
health_check:
pprof:

service:
extensions: [health_check, pprof]
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [logging, jaeger]
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@ prometheus-fastapi-instrumentator = "5.8.1"
{%- if cookiecutter.sentry_enabled == "True" %}
sentry-sdk = "^1.5.12"
{%- endif %}
{%- if cookiecutter.otlp_enabled == "True" %}
protobuf = "~3.20.0"
opentelemetry-api = {version = "^1.12.0rc1", allow-prereleases = true}
opentelemetry-sdk = {version = "^1.12.0rc1", allow-prereleases = true}
opentelemetry-exporter-otlp = {version = "^1.12.0rc1", allow-prereleases = true}
opentelemetry-instrumentation = "^0.31b0"
opentelemetry-instrumentation-logging = "^0.31b0"
opentelemetry-instrumentation-fastapi = "^0.31b0"
{%- if cookiecutter.enable_redis == "True" %}
opentelemetry-instrumentation-redis = "^0.31b0"
{%- endif %}
{%- if cookiecutter.db_info.name == "postgresql" and cookiecutter.orm in ["ormar", "tortoise"] %}
opentelemetry-instrumentation-asyncpg = "^0.31b0"
{%- endif %}
{%- if cookiecutter.orm == "sqlalchemy" %}
opentelemetry-instrumentation-sqlalchemy = "^0.31b0"
{%- endif %}
{%- endif %}

[tool.poetry.dev-dependencies]
pytest = "^7.0"
Expand Down Expand Up @@ -135,6 +153,7 @@ pretty = true
show_error_codes = true
implicit_reexport = true
allow_untyped_decorators = true
warn_unused_ignores = false
warn_return_any = false
{%- if cookiecutter.orm == "sqlalchemy" %}
plugins = ["sqlalchemy.ext.mypy.plugin"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ class Settings(BaseSettings):
sentry_sample_rate: float = 1.0
{%- endif %}

{%- if cookiecutter.otlp_enabled == "True" %}
opentelemetry_endpoint: Optional[str] = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder, if we set otlp_enabled to True wouldn't we want a default endpoint instead of None ?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works like this.
If you don't provide any endpoint through environment variables, it won't enable the integration by default, but if you do it will enable tracing and start sending data to the specified endpoint.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also you don't always want to trace requests while running local copy of a project.

{%- endif %}

{%- if cookiecutter.db_info.name != "none" %}
@property
def db_url(self) -> URL:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
router = APIRouter()

@router.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html(request: Request) -> HTMLResponse:
async def swagger_ui_html(request: Request) -> HTMLResponse:
"""
Swagger UI.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from {{cookiecutter.project_name}}.settings import settings

{%- if cookiecutter.prometheus_enabled == "True" %}
from prometheus_fastapi_instrumentator import Instrumentator
from prometheus_fastapi_instrumentator.instrumentation import PrometheusFastApiInstrumentator
{%- endif %}

{%- if cookiecutter.enable_redis == "True" %}
Expand All @@ -26,6 +26,31 @@
{%- endif %}
{%- endif %}

{%- if cookiecutter.otlp_enabled == "True" %}
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore
OTLPSpanExporter,
)
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor # type: ignore
from opentelemetry.sdk.resources import ( # type: ignore
SERVICE_NAME,
TELEMETRY_SDK_LANGUAGE,
DEPLOYMENT_ENVIRONMENT,
Resource,
)
from opentelemetry.sdk.trace import TracerProvider # type: ignore
from opentelemetry.sdk.trace.export import BatchSpanProcessor # type: ignore
from opentelemetry.trace import set_tracer_provider # type: ignore
{%- if cookiecutter.enable_redis == "True" %}
from opentelemetry.instrumentation.redis import RedisInstrumentor # type: ignore
{%- endif %}
{%- if cookiecutter.db_info.name == "postgresql" and cookiecutter.orm in ["ormar", "tortoise"] %}
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor # type: ignore
{%- endif %}
{%- if cookiecutter.orm == "sqlalchemy" %}
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor # type: ignore
{%- endif %}

{%- endif %}

{%- if cookiecutter.orm == "psycopg" %}
import psycopg_pool
Expand Down Expand Up @@ -97,17 +122,103 @@ async def _create_tables() -> None:
{%- endif %}
{%- endif %}

{%- if cookiecutter.otlp_enabled == "True" %}
def setup_opentelemetry(app: FastAPI) -> None:
"""
Enables opentelemetry instrumetnation.

:param app: current application.
"""
if not settings.opentelemetry_endpoint:
return

tracer_provider = TracerProvider(
resource=Resource(
attributes={
SERVICE_NAME: "{{cookiecutter.project_name}}",
TELEMETRY_SDK_LANGUAGE: "python",
DEPLOYMENT_ENVIRONMENT: settings.environment,
}
)
)

tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint=settings.opentelemetry_endpoint,
insecure=True,
)
)
)

excluded_endpoints = [
app.url_path_for('health_check'),
app.url_path_for('openapi'),
app.url_path_for('swagger_ui_html'),
app.url_path_for('swagger_ui_redirect'),
app.url_path_for('redoc_html'),
{%- if cookiecutter.prometheus_enabled == "True" %}
"/metrics",
{%- endif %}
]

FastAPIInstrumentor().instrument_app(
app,
tracer_provider=tracer_provider,
excluded_urls=",".join(excluded_endpoints),
)
{%- if cookiecutter.enable_redis == "True" %}
RedisInstrumentor().instrument(
tracer_provider=tracer_provider,
)
{%- endif %}
{%- if cookiecutter.db_info.name == "postgresql" and cookiecutter.orm in ["ormar", "tortoise"] %}
AsyncPGInstrumentor().instrument(
tracer_provider=tracer_provider,
)
{%- endif %}
{%- if cookiecutter.orm == "sqlalchemy" %}
SQLAlchemyInstrumentor().instrument(
tracer_provider=tracer_provider,
engine=app.state.db_engine.sync_engine,
)
{%- endif %}

set_tracer_provider(tracer_provider=tracer_provider)


def stop_opentelemetry(app: FastAPI) -> None:
"""
Disables opentelemetry instrumentation.

:param app: current application.
"""
if not settings.opentelemetry_endpoint:
return

FastAPIInstrumentor().uninstrument_app(app)
{%- if cookiecutter.enable_redis == "True" %}
RedisInstrumentor().uninstrument()
{%- endif %}
{%- if cookiecutter.db_info.name == "postgresql" and cookiecutter.orm in ["ormar", "tortoise"] %}
AsyncPGInstrumentor().uninstrument()
{%- endif %}
{%- if cookiecutter.orm == "sqlalchemy" %}
SQLAlchemyInstrumentor().uninstrument()
{%- endif %}

{%- endif %}

{%- if cookiecutter.prometheus_enabled == "True" %}
def setup_prometheus(app: FastAPI) -> None:
"""
Enables prometheus integration.

:param app: current application.
"""
Instrumentator(should_group_status_codes=False).instrument(app).expose(
PrometheusFastApiInstrumentator(should_group_status_codes=False).instrument(
app,
should_gzip=True,
)
).expose(app, should_gzip=True, name="prometheus_metrics")
{%- endif %}


Expand Down Expand Up @@ -136,6 +247,9 @@ async def _startup() -> None: # noqa: WPS430
await _create_tables()
{%- endif %}
{%- endif %}
{%- if cookiecutter.otlp_enabled == "True" %}
setup_opentelemetry(app)
{%- endif %}
{%- if cookiecutter.enable_redis == "True" %}
init_redis(app)
{%- endif %}
Expand Down Expand Up @@ -173,6 +287,9 @@ async def _shutdown() -> None: # noqa: WPS430
{%- if cookiecutter.enable_rmq == "True" %}
await shutdown_rabbit(app)
{%- endif %}
{%- if cookiecutter.otlp_enabled == "True" %}
stop_opentelemetry(app)
{%- endif %}
pass # noqa: WPS420

return _shutdown
4 changes: 4 additions & 0 deletions fastapi_template/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def default_context(project_name: str) -> None:
enable_routers=True,
add_dummy=False,
self_hosted_swagger=False,
enable_rmq=False,
prometheus_enabled=False,
otlp_enabled=False,
sentry_enabled=False,
force=True,
)

Expand Down
9 changes: 9 additions & 0 deletions fastapi_template/tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,12 @@ def test_rmq(default_context: BuilderContext, api: APIType):
default_context.enable_rmq = True
default_context.api_type = api
run_default_check(default_context)


def test_telemetry_pre_commit(default_context: BuilderContext):
default_context.enable_rmq = True
default_context.enable_redis = True
default_context.prometheus_enabled = True
default_context.otlp_enabled = True
default_context.sentry_enabled = True
run_default_check(default_context, without_pytest=True)
6 changes: 5 additions & 1 deletion fastapi_template/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ def run_docker_compose_command(command: Optional[str] = None) -> subprocess.Comp
return subprocess.run(docker_command)


def run_default_check(context: BuilderContext):
def run_default_check(context: BuilderContext, without_pytest=False):
generate_project_and_chdir(context)
assert run_pre_commit() == 0

if without_pytest:
return

build = run_docker_compose_command("build")
assert build.returncode == 0
tests = run_docker_compose_command("run --rm api pytest -vv .")
Expand Down