Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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`, `http/protobuf` (default), or `http/json`. | `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
15 changes: 13 additions & 2 deletions src/common/core/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
build_otel_log_provider,
build_tracer_provider,
make_structlog_otel_processor,
resolve_otlp_export_endpoints,
resolve_otlp_protocol,
setup_tracing,
)

Expand All @@ -72,17 +74,26 @@ def ensure_cli_env() -> typing.Generator[None, None, None]:
else "flagsmith-api"
)
service_name = env.str("OTEL_SERVICE_NAME", default_service_name)
otel_protocol = resolve_otlp_protocol(
env.str("OTEL_EXPORTER_OTLP_PROTOCOL", None)
)
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
70 changes: 61 additions & 9 deletions src/common/core/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Generator
from datetime import datetime, timezone
from importlib.metadata import version
from typing import cast
from typing import Literal, cast

import inflection
import structlog
Expand All @@ -12,10 +12,10 @@
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
Expand Down Expand Up @@ -50,6 +50,34 @@
]
)

OtlpProtocol = Literal["grpc", "http/protobuf", "http/json"]
Comment thread
khvn26 marked this conversation as resolved.
Outdated
_DEFAULT_OTLP_PROTOCOL: OtlpProtocol = "http/protobuf"


def resolve_otlp_protocol(protocol: str | None) -> OtlpProtocol:
"""Normalise ``OTEL_EXPORTER_OTLP_PROTOCOL`` to a supported OTLP transport."""
if protocol in (None, "", "http/protobuf"):
return "http/protobuf"
if protocol == "grpc":
return "grpc"
if protocol == "http/json":
return "http/json"
raise ValueError(
f"Unsupported OTLP protocol {protocol!r}. "
"Expected one of: grpc, http/protobuf, http/json."
)
Comment thread
khvn26 marked this conversation as resolved.
Outdated


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 +181,44 @@ 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 = _DEFAULT_OTLP_PROTOCOL,
Comment thread
khvn26 marked this conversation as resolved.
Outdated
) -> 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)
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 = _DEFAULT_OTLP_PROTOCOL,
Comment thread
khvn26 marked this conversation as resolved.
Outdated
) -> TracerProvider:
"""Create a TracerProvider with OTLP export."""
resource = Resource.create({"service.name": service_name})
tracer_provider = TracerProvider(resource=resource)
span_exporter = OTLPSpanExporter(endpoint=endpoint)
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
109 changes: 109 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,8 @@
build_otel_log_provider,
build_tracer_provider,
make_structlog_otel_processor,
resolve_otlp_export_endpoints,
resolve_otlp_protocol,
setup_tracing,
)

Expand Down Expand Up @@ -194,6 +196,26 @@ def test_build_otel_log_provider__valid_args__returns_configured_provider() -> N
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(
Expand All @@ -206,6 +228,93 @@ def test_build_tracer_provider__valid_args__returns_configured_provider() -> Non
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(
"protocol, expected",
[
(None, "http/protobuf"),
("http/protobuf", "http/protobuf"),
("grpc", "grpc"),
("http/json", "http/json"),
],
)
def test_resolve_otlp_protocol__supported_values__returns_normalised_protocol(
protocol: str | None,
expected: str,
) -> None:
# Given / When
resolved = resolve_otlp_protocol(protocol)

# Then
assert resolved == expected


def test_resolve_otlp_protocol__unsupported_value__raises() -> None:
# Given / When
with pytest.raises(ValueError, match="Unsupported OTLP protocol"):
# Then
resolve_otlp_protocol("websocket")


@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/json",
"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
Loading