Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions src/common/core/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from typing import Literal

DEFAULT_PROMETHEUS_MULTIPROC_DIR_NAME = "flagsmith-prometheus"

LOGGING_DEFAULT_ROOT_LOG_LEVEL = "WARNING"

OtlpProtocol = Literal["grpc", "http/protobuf"]
Comment thread
khvn26 marked this conversation as resolved.
Outdated
DEFAULT_OTLP_PROTOCOL: OtlpProtocol = "http/protobuf"
22 changes: 19 additions & 3 deletions src/common/core/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
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, OtlpProtocol
from common.core.logging import setup_logging
from common.gunicorn.processors import make_gunicorn_access_processor

Expand Down Expand Up @@ -63,6 +64,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 +74,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
62 changes: 52 additions & 10 deletions src/common/core/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,30 @@
from opentelemetry._logs import SeverityNumber
from opentelemetry.baggage.propagation import W3CBaggagePropagator
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.constants import OtlpProtocol

_SEVERITY_MAP: dict[str, SeverityNumber] = {
"debug": SeverityNumber.DEBUG,
"info": SeverityNumber.INFO,
Expand All @@ -51,6 +56,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 +169,46 @@ 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":
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter as GrpcOTLPLogExporter,
)
Comment thread
khvn26 marked this conversation as resolved.
Outdated

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":
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter as GrpcOTLPSpanExporter,
)

span_exporter = GrpcOTLPSpanExporter(endpoint=endpoint)
else:
span_exporter = HttpOTLPSpanExporter(endpoint=endpoint)
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
return tracer_provider

Expand Down
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
83 changes: 83 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,107 @@ 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(
"opentelemetry.exporter.otlp.proto.grpc._log_exporter.OTLPLogExporter"
)

# 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(
"opentelemetry.exporter.otlp.proto.grpc.trace_exporter.OTLPSpanExporter"
)

# 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