Skip to content

Commit 779e592

Browse files
committed
Switch to manual instrumentation
1 parent 9fbc8d6 commit 779e592

File tree

18 files changed

+263
-218
lines changed

18 files changed

+263
-218
lines changed

.idea/runConfigurations/Otel_Stack.xml

Lines changed: 0 additions & 19 deletions
This file was deleted.

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,19 @@ FROM base_app AS http
8080
COPY --from=http_builder /venv /venv
8181
COPY --chown=nonroot:nonroot src/http_app ./http_app
8282
# Run CMD using array syntax, so it uses `exec` and runs as PID1
83-
CMD ["opentelemetry-instrument", "python", "-m", "http_app"]
83+
CMD ["python", "-m", "http_app"]
8484

8585
# Copy the socketio python package and requirements from relevant builder
8686
FROM base_app AS socketio
8787
COPY --from=socketio_builder /venv /venv
8888
COPY --chown=nonroot:nonroot src/socketio_app ./socketio_app
8989
# Run CMD using array syntax, so it uses `exec` and runs as PID1
90-
CMD ["opentelemetry-instrument", "python", "-m", "socketio_app"]
90+
CMD ["python", "-m", "socketio_app"]
9191

9292
# Copy the dramatiq python package and requirements from relevant builder
9393
FROM base_app AS dramatiq
9494
COPY --from=dramatiq_builder /venv /venv
9595
COPY --chown=nonroot:nonroot src/dramatiq_worker ./dramatiq_worker
9696
# Run CMD using array syntax, so it uses `exec` and runs as PID1
9797
# TODO: Review processes/threads
98-
CMD ["opentelemetry-instrument", "dramatiq", "-p", "1", "-t", "1", "dramatiq_worker"]
98+
CMD ["dramatiq", "-p", "1", "-t", "1", "dramatiq_worker"]

Makefile

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
.PHONY: docs docs-build adr
22

33
containers:
4-
# Use local UID to avoid files permission issues when mounting directories
5-
# We could do this at runtime, by specifying the user, but it's easier doing it
6-
# at build time, so no special commands will be necessary at runtime
7-
docker compose build --build-arg UID=`id -u` dev
8-
# To build shared container layers only once we build a single container before the other ones
94
docker compose build --build-arg UID=`id -u`
105

116
dev-http:
@@ -14,9 +9,6 @@ dev-http:
149
dev-socketio:
1510
uv run ./src/socketio_app/dev_server.py
1611

17-
otel:
18-
OTEL_SERVICE_NAME=bootstrap-fastapi OTEL_TRACES_EXPORTER=none OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none uv run opentelemetry-instrument uvicorn http_app:create_app --host 0.0.0.0 --port 8000 --factory
19-
2012
run:
2113
uv run uvicorn http_app:create_app --host 0.0.0.0 --port 8000 --factory
2214

docker-compose.yaml

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ services:
55
context: .
66
target: dev
77
env_file: local.env
8+
environment:
9+
APP_NAME: "bootstrap-fastapi"
810
ports:
911
- '8000:8000'
1012
working_dir: "/app/src"
@@ -18,40 +20,14 @@ services:
1820
- ./http_app/dev_server.py
1921

2022
dev-socketio:
21-
<<: *dev
22-
ports:
23-
- '8001:8001'
24-
command:
25-
- python
26-
- ./socketio_app/dev_server.py
27-
28-
otel-http:
2923
<<: *dev
3024
environment:
31-
OTEL_SERVICE_NAME: "bootstrap-fastapi-dev"
32-
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=local"
33-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: ".*"
34-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: ".*"
35-
command:
36-
- opentelemetry-instrument
37-
- python
38-
- -m
39-
- http_app
40-
41-
otel-socketio:
42-
<<: *dev
43-
environment:
44-
OTEL_SERVICE_NAME: "bootstrap-socketio-dev"
45-
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=local"
46-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: ".*"
47-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: ".*"
25+
APP_NAME: "bootstrap-socketio"
4826
ports:
4927
- '8001:8001'
5028
command:
51-
- opentelemetry-instrument
5229
- python
53-
- -m
54-
- socketio_app
30+
- ./socketio_app/dev_server.py
5531

5632
#########################
5733
#### Helper services ####
@@ -85,10 +61,9 @@ services:
8561
dramatiq-worker:
8662
<<: *dev
8763
environment:
88-
OTEL_SERVICE_NAME: "bootstrap-fastapi-dramatiq-worker"
64+
APP_NAME: "bootstrap-dramatiq-worker"
8965
ports: []
9066
command:
91-
- opentelemetry-instrument
9267
- dramatiq
9368
- --watch
9469
- .

local.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ ENVIRONMENT: "local"
22
AUTH__JWKS_URL: "http://oathkeeper:4456/.well-known/jwks.json"
33
#DRAMATIQ__REDIS_URL: "redis://redis:6379/0"
44
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317"
5-
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true"
5+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: ".*"
6+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: ".*"

pyproject.toml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ dependencies = [
1616
"dramatiq[redis,watch]<2.0.0,>=1.17.1",
1717
"hiredis<4.0.0,>=3.1.0", # Recommended by dramatiq
1818
"httpx>=0.23.0",
19-
"opentelemetry-distro[otlp]",
20-
"opentelemetry-instrumentation",
2119
"opentelemetry-instrumentation-httpx",
2220
"opentelemetry-instrumentation-sqlalchemy",
2321
"opentelemetry-instrumentor-dramatiq",
22+
"opentelemetry-exporter-otlp",
23+
"opentelemetry-sdk",
2424
"orjson<4.0.0,>=3.10.12",
2525
"pydantic<3.0.0,>=2.2.1",
2626
"pydantic-asyncapi>=0.2.1",
@@ -36,9 +36,10 @@ http = [
3636
"cryptography>=44.0.0",
3737
"fastapi>=0.99.0",
3838
"jinja2<4.0.0,>=3.1.2",
39-
# We use the generic ASGI instrumentation, so that if we decide to change
40-
# framework it will still name metrics with a generic naming.
41-
"opentelemetry-instrumentation-asgi",
39+
# FastAPI instrumentation is based on the generic ASGI instrumentation,
40+
# but automatically creates span when routes are invoked.
41+
# If we decide to change framework, the generic ASGI instrumentation
42+
# will still name metrics with a generic naming.
4243
"opentelemetry-instrumentation-fastapi",
4344
"pyjwt>=2.10.1",
4445
"strawberry-graphql[debug-server]>=0.204.0",

src/common/bootstrap.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .dramatiq import init_dramatiq
1111
from .logs import init_logger
1212
from .storage import init_storage
13+
from .telemetry import instrument_opentelemetry
1314

1415

1516
class InitReference(BaseModel):
@@ -29,6 +30,7 @@ def application_init(app_config: AppConfig) -> InitReference:
2930
init_storage()
3031
init_dramatiq(app_config)
3132
init_asyncapi_info(app_config.APP_NAME)
33+
instrument_opentelemetry(app_config)
3234

3335
return InitReference(
3436
di_container=container,

src/common/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,7 @@ class AppConfig(BaseSettings):
3838
async_engine=True,
3939
),
4040
)
41+
OTEL_EXPORTER_OTLP_ENDPOINT: Optional[str] = None
42+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: Optional[str] = None
43+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: Optional[str] = None
44+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: Optional[str] = None

src/common/dramatiq.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from dramatiq.brokers.stub import StubBroker
88
from dramatiq.encoder import DecodeError, Encoder, MessageData
99
from dramatiq.middleware import AsyncIO
10-
from opentelemetry_instrumentor_dramatiq import DramatiqInstrumentor
1110

1211
from .config import AppConfig
1312

@@ -28,10 +27,6 @@ def decode(self, data: bytes) -> MessageData:
2827
def init_dramatiq(config: AppConfig):
2928
broker: Broker
3029

31-
dramatiq_instrumentor = DramatiqInstrumentor()
32-
if not dramatiq_instrumentor.is_instrumented_by_opentelemetry:
33-
dramatiq_instrumentor.instrument()
34-
3530
if config.DRAMATIQ.REDIS_URL is not None:
3631
broker = RedisBroker(url=config.DRAMATIQ.REDIS_URL)
3732
else:

src/common/telemetry.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import asyncio
2+
from functools import wraps
3+
4+
from opentelemetry import metrics, trace
5+
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
6+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
7+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
8+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
9+
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
10+
from opentelemetry.sdk._configuration import _init_logging as init_otel_logging
11+
from opentelemetry.sdk.metrics import MeterProvider
12+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
13+
from opentelemetry.sdk.resources import Resource
14+
from opentelemetry.sdk.trace import TracerProvider
15+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
16+
from opentelemetry_instrumentor_dramatiq import DramatiqInstrumentor
17+
18+
from .config import AppConfig
19+
20+
# Get the _tracer instance (You can set your own _tracer name)
21+
tracer = trace.get_tracer(__name__)
22+
23+
24+
def trace_function(trace_attributes: bool = True, trace_result: bool = True):
25+
"""
26+
Decorator to trace callables using OpenTelemetry spans.
27+
28+
Parameters:
29+
- trace_attributes (bool): If False, disables adding function arguments to the span.
30+
- trace_result (bool): If False, disables adding the function's result to the span.
31+
"""
32+
33+
def decorator(func):
34+
@wraps(func)
35+
async def async_wrapper(*args, **kwargs):
36+
with tracer.start_as_current_span(func.__name__) as span:
37+
try:
38+
# Set function arguments as attributes
39+
if trace_attributes:
40+
span.set_attribute("function.args", str(args))
41+
span.set_attribute("function.kwargs", str(kwargs))
42+
43+
result = await func(*args, **kwargs)
44+
# Add result to span
45+
if trace_result:
46+
span.set_attribute("function.result", str(result))
47+
return result
48+
except Exception as e:
49+
# Record the exception in the span
50+
span.record_exception(e)
51+
span.set_status(trace.status.Status(trace.status.StatusCode.ERROR))
52+
raise
53+
54+
@wraps(func)
55+
def sync_wrapper(*args, **kwargs):
56+
with tracer.start_as_current_span(func.__name__) as span:
57+
try:
58+
# Set function arguments as attributes
59+
if trace_attributes:
60+
span.set_attribute("function.args", str(args))
61+
span.set_attribute("function.kwargs", str(kwargs))
62+
63+
result = func(*args, **kwargs)
64+
# Add result to span
65+
if trace_result:
66+
span.set_attribute("function.result", str(result))
67+
return result
68+
69+
except Exception as e:
70+
# Record the exception in the span
71+
span.record_exception(e)
72+
span.set_status(trace.status.Status(trace.status.StatusCode.ERROR))
73+
raise
74+
75+
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
76+
77+
return decorator
78+
79+
80+
"""
81+
Manual instrumentation bring several benefits:
82+
- We don't need to use `opentelemetry-instrument` command, which
83+
gives us more control over the application running process.
84+
- It is more performant
85+
- It works with uvicorn reloader
86+
- Avoids duplicating environment variables (i.e. OTEL_SERVICE_NAME is already defined in the config)
87+
"""
88+
89+
90+
def instrument_opentelemetry(config: AppConfig): # pragma: no cover
91+
"""
92+
Configures OpenTelemetry instrumentation for tracing, metrics, and logging.
93+
94+
This function sets up OpenTelemetry components, including span processors, metric
95+
exporters, and log exporters, based on the provided application configuration.
96+
97+
Parameters:
98+
config (AppConfig): Configuration object containing application-specific settings
99+
required for initializing OpenTelemetry instrumentation.
100+
"""
101+
102+
resource = Resource.create(
103+
{
104+
"service.name": config.APP_NAME,
105+
"deployment.environment": config.ENVIRONMENT,
106+
}
107+
)
108+
109+
"""
110+
The exporters can be still configured using OTEL_* environment variables,
111+
we capture and check the variables so we can avoid instrumenting if we don't have
112+
any endpoints configured. This will avoid instrumenting the application when
113+
running locally or during unit tests.
114+
"""
115+
traces_endpoint = config.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT or config.OTEL_EXPORTER_OTLP_ENDPOINT
116+
metrics_endpoint = config.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT or config.OTEL_EXPORTER_OTLP_ENDPOINT
117+
logs_endpoint = config.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT or config.OTEL_EXPORTER_OTLP_ENDPOINT
118+
119+
# Traces
120+
if traces_endpoint:
121+
span_exporter = OTLPSpanExporter(endpoint=traces_endpoint)
122+
tracer_provider = TracerProvider(resource=resource)
123+
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
124+
trace.set_tracer_provider(tracer_provider)
125+
126+
# Metrics
127+
if metrics_endpoint:
128+
metrics_exporter = OTLPMetricExporter(endpoint=metrics_endpoint)
129+
metrics_provider = MeterProvider(
130+
resource=resource, metric_readers=[PeriodicExportingMetricReader(metrics_exporter)]
131+
)
132+
metrics.set_meter_provider(metrics_provider)
133+
134+
# Logs
135+
"""
136+
Log instrumentation is still experimental, so we borrow a private instrumentation
137+
function, which should allow us to keep it working as expected with upcoming changes.
138+
When logs instrumentation will be stable this should be revisited.
139+
We still don't support passing the custom endpoint as parameter but it will
140+
be configured using OTEL_* environment variables.
141+
"""
142+
if logs_endpoint:
143+
init_otel_logging(resource=resource, exporters={"otel": OTLPLogExporter})
144+
145+
146+
def instrument_third_party():
147+
"""
148+
Instrument third-party libraries for monitoring and tracing.
149+
150+
This function initializes and instruments various third-party libraries
151+
that are commonly used in applications. It configures them to work with
152+
monitoring and tracing systems to collect performance metrics and
153+
distributed trace data.
154+
155+
Raises:
156+
This function does not explicitly raise exceptions, but exceptions
157+
may propagate from the individual instrumentor methods if the
158+
instrumentation process fails.
159+
"""
160+
DramatiqInstrumentor().instrument()
161+
HTTPXClientInstrumentor().instrument()
162+
SQLAlchemyInstrumentor().instrument()

0 commit comments

Comments
 (0)