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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.12.0"
version = "0.12.1"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
61 changes: 48 additions & 13 deletions src/sap_cloud_sdk/core/auditlog_ng/client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Audit Log OTLP Client.

Sends audit log events as OpenTelemetry LogRecords over gRPC.
Supports mTLS (client certificates) and insecure (no-auth) modes.
Sends audit log events as OpenTelemetry LogRecords over gRPC or HTTP.
Supports mTLS (client certificates) and insecure (no-auth) modes for gRPC.
"""

import json
import os
import uuid
from typing import Optional

Expand All @@ -20,7 +21,13 @@
BatchLogRecordProcessor,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter as GRPCLogExporter,
)
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter as HTTPLogExporter,
)
from opentelemetry.exporter.otlp.proto.http import Compression as HTTPCompression
from opentelemetry._logs.severity import SeverityNumber

from sap_cloud_sdk.core.auditlog_ng.config import (
Expand All @@ -29,6 +36,43 @@
)
from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError
from sap_cloud_sdk.core.telemetry import Module
from sap_cloud_sdk.core.telemetry.config import ENV_OTLP_PROTOCOL


def _create_log_exporter(
config: AuditLogNGConfig,
credentials: Optional[grpc.ChannelCredentials],
):
"""Create an OTLP log exporter based on OTEL_EXPORTER_OTLP_PROTOCOL."""
protocol = os.getenv(ENV_OTLP_PROTOCOL, "grpc").lower()
if protocol == "grpc":
return GRPCLogExporter(
endpoint=config.endpoint,
insecure=config.insecure,
credentials=credentials,
compression=(
grpc.Compression.Gzip
if config.compression
else grpc.Compression.NoCompression
),
)
elif protocol == "http/protobuf":
return HTTPLogExporter(
endpoint=config.endpoint,
certificate_file=config.ca_file,
client_key_file=config.key_file,
client_certificate_file=config.cert_file,
compression=(
HTTPCompression.Gzip
if config.compression
else HTTPCompression.NoCompression
),
)
else:
raise ValueError(
f"Unsupported OTEL_EXPORTER_OTLP_PROTOCOL: '{protocol}'. "
"Supported values are 'grpc' and 'http/protobuf'."
)


class AuditClient:
Expand Down Expand Up @@ -74,16 +118,7 @@ def __init__(
credentials = self._build_credentials(config)

# Create OTLP exporter
self._exporter = OTLPLogExporter(
endpoint=config.endpoint,
insecure=config.insecure,
credentials=credentials,
compression=(
grpc.Compression.Gzip
if config.compression
else grpc.Compression.NoCompression
),
)
self._exporter = _create_log_exporter(config, credentials)

# Create logger provider
self._provider = LoggerProvider(
Expand Down
86 changes: 75 additions & 11 deletions tests/core/unit/auditlog_ng/unit/test_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Tests for AuditClient."""

from __future__ import annotations

import json
Expand Down Expand Up @@ -33,13 +34,15 @@
"namespace": "namespace-123",
"insecure": True,
}
defaults.update(overrides) # ty: ignore[invalid-argument-type]

Check warning on line 37 in tests/core/unit/auditlog_ng/unit/test_client.py

View workflow job for this annotation

GitHub Actions / Code Quality Checks

ty (unused-ignore-comment)

tests/core/unit/auditlog_ng/unit/test_client.py:37:33: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment
return AuditLogNGConfig(**defaults)


@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.GRPCLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def _make_mocked_client(mock_provider_cls, mock_exporter_cls, *, validate_side_effect=None):
def _make_mocked_client(
mock_provider_cls, mock_exporter_cls, *, validate_side_effect=None
):
mock_logger = Mock()
mock_provider = Mock()
mock_provider.get_logger.return_value = mock_logger
Expand All @@ -54,7 +57,14 @@
)
mock_validate = validate_patcher.start()

return client, mock_logger, mock_provider, mock_validate, validate_patcher, mock_provider_cls
return (
client,
mock_logger,
mock_provider,
mock_validate,
validate_patcher,
mock_provider_cls,
)


def _make_mock_event(tenant_id="tenant-123", descriptor_name="DataAccess"):
Expand All @@ -66,8 +76,7 @@


class TestAuditClientInit:

@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.GRPCLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_creates_insecure_client(self, mock_provider_cls, mock_exporter_cls):
config = _make_config(insecure=True)
Expand All @@ -78,7 +87,7 @@
_, kwargs = mock_exporter_cls.call_args
assert kwargs["insecure"] is True

@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.GRPCLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_sets_schema_url_on_logger(self, mock_provider_cls, mock_exporter_cls):
mock_provider = Mock()
Expand All @@ -92,7 +101,7 @@
_, kwargs = mock_provider.get_logger.call_args
assert kwargs["schema_url"] == SCHEMA_URL

@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.GRPCLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_sets_resource_attributes(self, mock_provider_cls, mock_exporter_cls):
config = _make_config(service_name="my-svc")
Expand All @@ -107,7 +116,6 @@


class TestAuditClientSend:

def test_send_binary_success(self):
client, mock_logger, _, mock_validate, patcher, _ = _make_mocked_client()
try:
Expand All @@ -121,7 +129,10 @@
_, kwargs = mock_logger.emit.call_args
assert kwargs["event_name"] == "sap.als.AuditEvent.DataAccess.v2"
assert kwargs["body"] == b"\x00\x01\x02"
assert kwargs["attributes"]["sap.auditlogging.mime_type"] == "application/protobuf"
assert (
kwargs["attributes"]["sap.auditlogging.mime_type"]
== "application/protobuf"
)
assert kwargs["attributes"]["sap.tenancy.tenant_id"] == "tenant-123"
assert "cloudevents.event_id" in kwargs["attributes"]
finally:
Expand All @@ -137,7 +148,9 @@
mock_logger.emit.assert_called_once()

_, kwargs = mock_logger.emit.call_args
assert kwargs["attributes"]["sap.auditlogging.mime_type"] == "application/json"
assert (
kwargs["attributes"]["sap.auditlogging.mime_type"] == "application/json"
)
assert isinstance(kwargs["body"], str)
finally:
patcher.stop()
Expand Down Expand Up @@ -197,7 +210,6 @@


class TestAuditClientLifecycle:

def test_flush(self):
client, _, mock_provider, _, patcher, _ = _make_mocked_client()
patcher.stop()
Expand Down Expand Up @@ -231,3 +243,55 @@

assert client._closed is True
mock_provider.shutdown.assert_called_once()


class TestAuditClientProtocol:
@patch("sap_cloud_sdk.core.auditlog_ng.client.GRPCLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_grpc_is_default(
self, mock_provider_cls, mock_grpc_exporter_cls, monkeypatch
):
monkeypatch.delenv("OTEL_EXPORTER_OTLP_PROTOCOL", raising=False)
AuditClient(_make_config())
mock_grpc_exporter_cls.assert_called_once()

@patch("sap_cloud_sdk.core.auditlog_ng.client.HTTPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_http_protobuf_protocol(
self, mock_provider_cls, mock_http_exporter_cls, monkeypatch
):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
AuditClient(
_make_config(
cert_file="client.pem", key_file="client.key", ca_file="ca.pem"
)
)
mock_http_exporter_cls.assert_called_once()
_, kwargs = mock_http_exporter_cls.call_args
assert kwargs["endpoint"] == "localhost:4317"
assert kwargs["client_certificate_file"] == "client.pem"
assert kwargs["client_key_file"] == "client.key"
assert kwargs["certificate_file"] == "ca.pem"

@patch("sap_cloud_sdk.core.auditlog_ng.client.HTTPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_http_compression(
self, mock_provider_cls, mock_http_exporter_cls, monkeypatch
):
from opentelemetry.exporter.otlp.proto.http import (
Compression as HTTPCompression,
)

monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf")
AuditClient(_make_config(compression=True))
_, kwargs = mock_http_exporter_cls.call_args
assert kwargs["compression"] == HTTPCompression.Gzip

@patch("sap_cloud_sdk.core.auditlog_ng.client.GRPCLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_unsupported_protocol_raises(
self, mock_provider_cls, mock_exporter_cls, monkeypatch
):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json")
with pytest.raises(ValueError, match="Unsupported OTEL_EXPORTER_OTLP_PROTOCOL"):
AuditClient(_make_config())
16 changes: 8 additions & 8 deletions tests/core/unit/auditlog_ng/unit/test_create_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@

class TestCreateClient:

@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_create_client_with_config(self, mock_provider_cls, mock_exporter_cls):
def test_create_client_with_config(self, mock_provider_cls, mock_exporter_fn):
mock_provider = Mock()
mock_provider.get_logger.return_value = Mock()
mock_provider_cls.return_value = mock_provider
Expand All @@ -28,9 +28,9 @@ def test_create_client_with_config(self, mock_provider_cls, mock_exporter_cls):

assert isinstance(client, AuditClient)

@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_create_client_with_keyword_args(self, mock_provider_cls, mock_exporter_cls):
def test_create_client_with_keyword_args(self, mock_provider_cls, mock_exporter_fn):
mock_provider = Mock()
mock_provider.get_logger.return_value = Mock()
mock_provider_cls.return_value = mock_provider
Expand Down Expand Up @@ -68,10 +68,10 @@ def test_create_client_invalid_deployment_id_raises(self):
namespace="ns-1",
)

@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_create_client_unexpected_exception_wraps_in_client_creation_error(
self, mock_provider_cls, mock_exporter_cls
self, mock_provider_cls, mock_exporter_fn
):
mock_provider_cls.side_effect = RuntimeError("Unexpected failure")

Expand All @@ -83,9 +83,9 @@ def test_create_client_unexpected_exception_wraps_in_client_creation_error(
insecure=True,
)

@patch("sap_cloud_sdk.core.auditlog_ng.client.OTLPLogExporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter")
@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider")
def test_config_keyword_args_are_forwarded(self, mock_provider_cls, mock_exporter_cls):
def test_config_keyword_args_are_forwarded(self, mock_provider_cls, mock_exporter_fn):
mock_provider = Mock()
mock_provider.get_logger.return_value = Mock()
mock_provider_cls.return_value = mock_provider
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading