From c886dde44f0bc3c5f34ca68a49b486d2a618453e Mon Sep 17 00:00:00 2001 From: Manasjyoti Sharma Date: Tue, 23 Jun 2026 15:23:51 +0530 Subject: [PATCH] Clamp managed metrics interval and add service instance identity --- src/fortifyroot/core.py | 54 ++++++++++++++++++++++++++++++++ tests/test_metrics_guardrails.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/test_metrics_guardrails.py diff --git a/src/fortifyroot/core.py b/src/fortifyroot/core.py index b8c4e13..0215b47 100644 --- a/src/fortifyroot/core.py +++ b/src/fortifyroot/core.py @@ -11,6 +11,7 @@ import os import platform import sys +import uuid from urllib.parse import urlsplit from typing import Callable, Dict, List, Optional, Set, TypedDict, cast @@ -64,6 +65,8 @@ # Default API endpoint for FortifyRoot DEFAULT_API_ENDPOINT = "https://api.fortifyroot.com" AUTHORIZATION_HEADER = "Authorization" +MIN_MANAGED_METRICS_EXPORT_INTERVAL_MS = 60000 +_PROCESS_SERVICE_INSTANCE_ID: Optional[str] = None SDK_METADATA_HEADERS = { FORTIFYROOT_SDK_VERSION_HEADER.lower(), FORTIFYROOT_SDK_LANGUAGE_HEADER.lower(), @@ -71,6 +74,53 @@ } +def _get_process_service_instance_id() -> str: + """Return a stable random service.instance.id for this Python process.""" + global _PROCESS_SERVICE_INSTANCE_ID + if _PROCESS_SERVICE_INSTANCE_ID is None: + _PROCESS_SERVICE_INSTANCE_ID = str(uuid.uuid4()) + return _PROCESS_SERVICE_INSTANCE_ID + + +def _otel_resource_attributes_has_key(key: str) -> bool: + raw = os.getenv("OTEL_RESOURCE_ATTRIBUTES", "") + for item in raw.split(","): + name, sep, _ = item.partition("=") + if sep and name.strip() == key: + return True + return False + + +def _ensure_default_service_instance_id(resource_attributes: Dict) -> None: + """Inject service.instance.id unless explicit resource/env attrs provide it.""" + if "service.instance.id" in resource_attributes: + return + if _otel_resource_attributes_has_key("service.instance.id"): + return + resource_attributes["service.instance.id"] = _get_process_service_instance_id() + + +def _clamp_managed_metrics_export_interval() -> None: + """Clamp only an explicit env override; the OTel default is already 60000ms.""" + raw = os.getenv("OTEL_METRIC_EXPORT_INTERVAL", "").strip() + if not raw: + return + try: + interval_ms = int(raw) + except (TypeError, ValueError): + return + if interval_ms >= MIN_MANAGED_METRICS_EXPORT_INTERVAL_MS: + return + os.environ["OTEL_METRIC_EXPORT_INTERVAL"] = str(MIN_MANAGED_METRICS_EXPORT_INTERVAL_MS) + logger.warning( + "OTEL_METRIC_EXPORT_INTERVAL=%dms is below FortifyRoot's managed metrics " + "minimum of %dms; using %dms", + interval_ms, + MIN_MANAGED_METRICS_EXPORT_INTERVAL_MS, + MIN_MANAGED_METRICS_EXPORT_INTERVAL_MS, + ) + + def _resolve_api_endpoint(value: str) -> str: """Resolve the API endpoint from explicit arg or FortifyRoot env.""" if value != DEFAULT_API_ENDPOINT: @@ -791,6 +841,8 @@ def span_callback(span): if shorthand in resource_attributes and canonical not in resource_attributes: resource_attributes[canonical] = resource_attributes.pop(shorthand) + _ensure_default_service_instance_id(resource_attributes) + # Inject FortifyRoot Ocelle SDK version into resource attributes resource_attributes[FORTIFYROOT_SDK_VERSION_ATTRIBUTE] = __version__ @@ -824,6 +876,8 @@ def span_callback(span): final_processors = [AttributeRenamingProcessor(default_processor)] metrics_enabled = _is_enabled_from_env("FORTIFYROOT_METRICS_ENABLED", True) + if metrics_enabled: + _clamp_managed_metrics_export_interval() # FIX: When we create a default processor (final_processors), Traceloop interprets # this as a "custom pipeline" and requires a matching metrics_exporter. diff --git a/tests/test_metrics_guardrails.py b/tests/test_metrics_guardrails.py new file mode 100644 index 0000000..1c9bcc8 --- /dev/null +++ b/tests/test_metrics_guardrails.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import logging +import os +import uuid + +from fortifyroot import core + + +def test_clamp_managed_metrics_export_interval_raises_low_env(monkeypatch, caplog): + monkeypatch.setenv("OTEL_METRIC_EXPORT_INTERVAL", "5000") + + with caplog.at_level(logging.WARNING): + core._clamp_managed_metrics_export_interval() + + assert core.MIN_MANAGED_METRICS_EXPORT_INTERVAL_MS == 60000 + assert os.environ["OTEL_METRIC_EXPORT_INTERVAL"] == str( + core.MIN_MANAGED_METRICS_EXPORT_INTERVAL_MS + ) + assert "below FortifyRoot's managed metrics minimum" in caplog.text + + +def test_default_service_instance_id_injected_once_per_process(monkeypatch): + monkeypatch.delenv("OTEL_RESOURCE_ATTRIBUTES", raising=False) + monkeypatch.setattr(core, "_PROCESS_SERVICE_INSTANCE_ID", None) + + first: dict[str, str] = {} + second: dict[str, str] = {} + core._ensure_default_service_instance_id(first) + core._ensure_default_service_instance_id(second) + + assert uuid.UUID(first["service.instance.id"]).version == 4 + assert second["service.instance.id"] == first["service.instance.id"] + + +def test_default_service_instance_id_preserves_explicit_resource_attr(monkeypatch): + monkeypatch.delenv("OTEL_RESOURCE_ATTRIBUTES", raising=False) + attrs = {"service.instance.id": "customer-instance"} + + core._ensure_default_service_instance_id(attrs) + + assert attrs["service.instance.id"] == "customer-instance" + + +def test_default_service_instance_id_preserves_otel_env(monkeypatch): + monkeypatch.setenv("OTEL_RESOURCE_ATTRIBUTES", "service.instance.id=env-instance,team=ml") + attrs: dict[str, str] = {} + + core._ensure_default_service_instance_id(attrs) + + assert "service.instance.id" not in attrs