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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ OTel instrumentation is opt-in, controlled by environment variables:

| Variable | Description | Default |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint (e.g. `http://collector:4318`). If unset, no OTel setup occurs. | _(disabled)_ |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP endpoint (e.g. `http://collector:4318` for HTTP, `http://collector:4317` for gRPC). If unset, no OTel setup occurs. | _(disabled)_ |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP transport: `grpc` or `http/protobuf` (default). | `http/protobuf` |
| `OTEL_SERVICE_NAME` | The `service.name` resource attribute. Defaults to `flagsmith-task-processor` when running the task processor. | `flagsmith-api` |
| `OTEL_TRACING_EXCLUDED_URL_PATHS` | Comma-separated URL paths to exclude from tracing (e.g. `health/liveness,health/readiness`). | _(none)_ |

Expand All @@ -143,7 +144,7 @@ Standard `OTEL_*` env vars (e.g. `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_EXPORTER_OTLP

When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, `ensure_cli_env()` sets up:

- **Tracing**: `TracerProvider` with OTLP/HTTP span export, W3C `TraceContext` + `Baggage` propagation, and auto-instrumentation for:
- **Tracing**: `TracerProvider` with OTLP span export (HTTP or gRPC, per `OTEL_EXPORTER_OTLP_PROTOCOL`), W3C `TraceContext` + `Baggage` propagation, and auto-instrumentation for:
- **Django** (`DjangoInstrumentor`): creates a root span per HTTP request with span names formatted as `{METHOD} {route_template}` (e.g. `GET /api/v1/projects/{pk}/`).
- **psycopg2** (`Psycopg2Instrumentor`): creates child spans for each SQL query with `db.system`, `db.statement`, and `db.name` attributes. SQL commenter is enabled, adding trace context as SQL comments for database-side correlation.
- **Redis** (`RedisInstrumentor`): creates child spans for each Redis command with `db.system` and `db.statement` attributes.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ optional-dependencies = { test-tools = [
"inflection",
"opentelemetry-api (>=1.25,<2)",
"opentelemetry-sdk (>=1.25,<2)",
"opentelemetry-exporter-otlp-proto-grpc (>=1.25,<2)",
"opentelemetry-exporter-otlp-proto-http (>=1.25,<2)",
"sentry-sdk (>=2.0.0,<3.0.0)",
"structlog (>=24.4,<26)",
Expand Down
4 changes: 4 additions & 0 deletions src/common/core/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from common.core.types import OtlpProtocol

DEFAULT_PROMETHEUS_MULTIPROC_DIR_NAME = "flagsmith-prometheus"

LOGGING_DEFAULT_ROOT_LOG_LEVEL = "WARNING"

DEFAULT_OTLP_PROTOCOL: OtlpProtocol = "http/protobuf"
23 changes: 20 additions & 3 deletions src/common/core/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from django.core.management import (
execute_from_command_line as django_execute_from_command_line,
)
from environs import Env
from environs import Env, validate

from common.core.cli import healthcheck, run
from common.core.constants import DEFAULT_OTLP_PROTOCOL
from common.core.logging import setup_logging
from common.core.types import OtlpProtocol
from common.gunicorn.processors import make_gunicorn_access_processor

env = Env()
Expand Down Expand Up @@ -63,6 +65,7 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
build_otel_log_provider,
build_tracer_provider,
make_structlog_otel_processor,
resolve_otlp_export_endpoints,
setup_tracing,
)

Expand All @@ -72,17 +75,31 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
else "flagsmith-api"
)
service_name = env.str("OTEL_SERVICE_NAME", default_service_name)
otel_protocol = typing.cast(
OtlpProtocol,
env.str(
"OTEL_EXPORTER_OTLP_PROTOCOL",
DEFAULT_OTLP_PROTOCOL,
validate=validate.OneOf(typing.get_args(OtlpProtocol)),
),
)
traces_endpoint, logs_endpoint = resolve_otlp_export_endpoints(
otel_endpoint,
otel_protocol,
)
log_provider = build_otel_log_provider(
endpoint=f"{otel_endpoint}/v1/logs",
endpoint=logs_endpoint,
service_name=service_name,
protocol=otel_protocol,
)
otel_processors = [
add_otel_trace_context,
make_structlog_otel_processor(log_provider),
]
tracer_provider = build_tracer_provider(
endpoint=f"{otel_endpoint}/v1/traces",
endpoint=traces_endpoint,
service_name=service_name,
protocol=otel_protocol,
)
excluded_urls = env.str("OTEL_TRACING_EXCLUDED_URL_PATHS", None)
ctx.enter_context(setup_tracing(tracer_provider, excluded_urls=excluded_urls))
Expand Down
60 changes: 50 additions & 10 deletions src/common/core/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,37 @@
from opentelemetry import context as otel_context
from opentelemetry._logs import SeverityNumber
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter as GrpcOTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter as GrpcOTLPSpanExporter,
)
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter,
OTLPLogExporter as HttpOTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter,
OTLPSpanExporter as HttpOTLPSpanExporter,
)
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.propagators.textmap import TextMapPropagator
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk._logs.export import (
BatchLogRecordProcessor,
LogRecordExporter,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
from opentelemetry.trace.propagation.tracecontext import (
TraceContextTextMapPropagator,
)
from opentelemetry.util.types import AnyValue, Attributes
from structlog.typing import EventDict, Processor

from common.core.types import OtlpProtocol

_SEVERITY_MAP: dict[str, SeverityNumber] = {
"debug": SeverityNumber.DEBUG,
"info": SeverityNumber.INFO,
Expand All @@ -51,6 +62,17 @@
)


def resolve_otlp_export_endpoints(
base_endpoint: str,
protocol: OtlpProtocol,
) -> tuple[str, str]:
"""Resolve trace and log exporter endpoints from a base OTLP endpoint."""
normalised_base = base_endpoint.rstrip("/")
if protocol == "grpc":
return normalised_base, normalised_base
return f"{normalised_base}/v1/traces", f"{normalised_base}/v1/logs"


def get_otel_event_name(*, logger_name: str | None, body: str) -> str:
"""Build the event name that reaches OTel from a structlog `(logger, event)`.

Expand Down Expand Up @@ -153,20 +175,38 @@ def map_value_to_otel_value(value: object) -> str | int | float | bool:
return json.dumps(value, default=str)


def build_otel_log_provider(*, endpoint: str, service_name: str) -> LoggerProvider:
"""Create and configure an OTel LoggerProvider with OTLP/HTTP export."""
def build_otel_log_provider(
*,
endpoint: str,
service_name: str,
protocol: OtlpProtocol,
) -> LoggerProvider:
"""Create and configure an OTel LoggerProvider with OTLP export."""
resource = Resource.create({"service.name": service_name})
provider = LoggerProvider(resource=resource)
exporter = OTLPLogExporter(endpoint=endpoint)
exporter: LogRecordExporter
if protocol == "grpc":
exporter = GrpcOTLPLogExporter(endpoint=endpoint)
else:
exporter = HttpOTLPLogExporter(endpoint=endpoint)
provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
return provider


def build_tracer_provider(*, endpoint: str, service_name: str) -> TracerProvider:
"""Create a TracerProvider with OTLP/HTTP export."""
def build_tracer_provider(
*,
endpoint: str,
service_name: str,
protocol: OtlpProtocol,
) -> TracerProvider:
"""Create a TracerProvider with OTLP export."""
resource = Resource.create({"service.name": service_name})
tracer_provider = TracerProvider(resource=resource)
span_exporter = OTLPSpanExporter(endpoint=endpoint)
span_exporter: SpanExporter
if protocol == "grpc":
span_exporter = GrpcOTLPSpanExporter(endpoint=endpoint)
else:
span_exporter = HttpOTLPSpanExporter(endpoint=endpoint)
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
return tracer_provider

Expand Down
3 changes: 3 additions & 0 deletions src/common/core/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from typing import Literal

OtlpProtocol = Literal["grpc", "http/protobuf"]
43 changes: 43 additions & 0 deletions tests/unit/common/core/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ def test_ensure_cli_env__otel_endpoint_set__configures_otel_processors(
mock_build_log.assert_called_once_with(
endpoint="http://collector:4318/v1/logs",
service_name="flagsmith-api",
protocol="http/protobuf",
)
mock_make_processor.assert_called_once_with(mock_log_provider)
mock_build_tracer.assert_called_once_with(
endpoint="http://collector:4318/v1/traces",
service_name="flagsmith-api",
protocol="http/protobuf",
)
mock_setup_tracing.assert_called_once_with(mock_tracer_provider, excluded_urls=None)

Expand Down Expand Up @@ -137,10 +139,12 @@ def test_ensure_cli_env__otel_custom_service_name_and_excluded_urls__passes_to_p
mock_build_log.assert_called_once_with(
endpoint="http://collector:4318/v1/logs",
service_name="my-service",
protocol="http/protobuf",
)
mock_build_tracer.assert_called_once_with(
endpoint="http://collector:4318/v1/traces",
service_name="my-service",
protocol="http/protobuf",
)
mock_setup_tracing.assert_called_once_with(
mock_build_tracer.return_value,
Expand Down Expand Up @@ -213,10 +217,12 @@ def test_ensure_cli_env__argv__expected_otel_service_name(
mock_build_log.assert_called_once_with(
endpoint="http://collector:4318/v1/logs",
service_name=expected_otel_service_name,
protocol="http/protobuf",
)
mock_build_tracer.assert_called_once_with(
endpoint="http://collector:4318/v1/traces",
service_name=expected_otel_service_name,
protocol="http/protobuf",
)


Expand Down Expand Up @@ -247,10 +253,47 @@ def test_ensure_cli_env__env_service_name__expected_otel_service_name(
mock_build_log.assert_called_once_with(
endpoint="http://collector:4318/v1/logs",
service_name="my-custom",
protocol="http/protobuf",
)
mock_build_tracer.assert_called_once_with(
endpoint="http://collector:4318/v1/traces",
service_name="my-custom",
protocol="http/protobuf",
)


def test_ensure_cli_env__otel_grpc_protocol__uses_grpc_endpoints(
monkeypatch: pytest.MonkeyPatch,
mocker: MockerFixture,
) -> None:
# Given
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")

mock_build_log = mocker.patch(
"common.core.otel.build_otel_log_provider",
return_value=mocker.MagicMock(spec=LoggerProvider),
)
mock_build_tracer = mocker.patch(
"common.core.otel.build_tracer_provider",
return_value=mocker.MagicMock(spec=TracerProvider),
)
mocker.patch("common.core.otel.setup_tracing")

# When
with ensure_cli_env():
pass

# Then
mock_build_log.assert_called_once_with(
endpoint="http://collector:4317",
service_name="flagsmith-api",
protocol="grpc",
)
mock_build_tracer.assert_called_once_with(
endpoint="http://collector:4317",
service_name="flagsmith-api",
protocol="grpc",
)


Expand Down
79 changes: 79 additions & 0 deletions tests/unit/common/core/test_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
build_otel_log_provider,
build_tracer_provider,
make_structlog_otel_processor,
resolve_otlp_export_endpoints,
setup_tracing,
)

Expand Down Expand Up @@ -187,25 +188,103 @@ def test_build_otel_log_provider__valid_args__returns_configured_provider() -> N
provider = build_otel_log_provider(
endpoint="http://localhost:4318/v1/logs",
service_name="test-service",
protocol="http/protobuf",
)

# Then
assert isinstance(provider, LoggerProvider)
assert provider.resource.attributes["service.name"] == "test-service"


def test_build_otel_log_provider__grpc_protocol__uses_grpc_exporter(
mocker: pytest_mock.MockerFixture,
) -> None:
# Given
mock_grpc_exporter = mocker.patch("common.core.otel.GrpcOTLPLogExporter")

# When
provider = build_otel_log_provider(
endpoint="http://localhost:4317",
service_name="test-service",
protocol="grpc",
)

# Then
assert isinstance(provider, LoggerProvider)
mock_grpc_exporter.assert_called_once_with(endpoint="http://localhost:4317")


def test_build_tracer_provider__valid_args__returns_configured_provider() -> None:
# Given / When
provider = build_tracer_provider(
endpoint="http://localhost:4318/v1/traces",
service_name="test-service",
protocol="http/protobuf",
)

# Then
assert isinstance(provider, TracerProvider)
assert provider.resource.attributes["service.name"] == "test-service"


def test_build_tracer_provider__grpc_protocol__uses_grpc_exporter(
mocker: pytest_mock.MockerFixture,
) -> None:
# Given
mock_grpc_exporter = mocker.patch("common.core.otel.GrpcOTLPSpanExporter")

# When
provider = build_tracer_provider(
endpoint="http://localhost:4317",
service_name="test-service",
protocol="grpc",
)

# Then
assert isinstance(provider, TracerProvider)
mock_grpc_exporter.assert_called_once_with(endpoint="http://localhost:4317")


@pytest.mark.parametrize(
"base_endpoint, protocol, expected_traces, expected_logs",
[
(
"http://collector:4318",
"http/protobuf",
"http://collector:4318/v1/traces",
"http://collector:4318/v1/logs",
),
(
"http://collector:4318/",
"http/protobuf",
"http://collector:4318/v1/traces",
"http://collector:4318/v1/logs",
),
(
"http://collector:4317",
"grpc",
"http://collector:4317",
"http://collector:4317",
),
],
)
def test_resolve_otlp_export_endpoints__protocol__returns_expected_urls(
base_endpoint: str,
protocol: str,
expected_traces: str,
expected_logs: str,
) -> None:
# Given / When
traces_endpoint, logs_endpoint = resolve_otlp_export_endpoints(
base_endpoint,
protocol, # type: ignore[arg-type]
)

# Then
assert traces_endpoint == expected_traces
assert logs_endpoint == expected_logs


def test_setup_tracing__called__instruments_and_uninstruments_all_libraries(
mocker: pytest_mock.MockerFixture,
) -> None:
Expand Down
Loading