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
54 changes: 54 additions & 0 deletions src/fortifyroot/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -64,13 +65,62 @@
# 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(),
FORTIFYROOT_SDK_LANGUAGE_VERSION_HEADER.lower(),
}


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:
Expand Down Expand Up @@ -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__

Expand Down Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions tests/test_metrics_guardrails.py
Original file line number Diff line number Diff line change
@@ -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
Loading