Skip to content
Open
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
14 changes: 13 additions & 1 deletion carbonserver/carbonserver/api/routers/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
from uuid import UUID

from dependency_injector.wiring import Provide, inject
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Header
from starlette import status

from carbonserver.api.schemas import AccessLevel
from carbonserver.api.schemas_telemetry import TelemetryCreate
from carbonserver.api.services.project_token_service import ProjectTokenService
from carbonserver.api.services.telemetry_service import TelemetryService
from carbonserver.config import settings
from carbonserver.container import ServerContainer

TELEMETRY_ROUTER_TAGS = ["Telemetry"]
Expand All @@ -27,5 +30,14 @@ def add_telemetry(
telemetry_service: TelemetryService = Depends(
Provide[ServerContainer.telemetry_service]
),
project_token_service: ProjectTokenService = Depends(
Provide[ServerContainer.project_token_service]
),
x_api_token: str = Header(None),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this work? Should it not be x_api_token: str = Header(None, alias="x-api-token")?

) -> UUID:
project_token_service.project_token_has_access(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are not doing anything with the response here, it should be if allowed--> do the action n?

AccessLevel.WRITE.value,
experiment_id=settings.telemetry_experiment_id,
project_token=x_api_token,
)
return telemetry_service.add_telemetry(telemetry)
8 changes: 8 additions & 0 deletions carbonserver/carbonserver/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import AliasChoices, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

DEFAULT_TELEMETRY_EXPERIMENT_ID = "d2d69403-1373-42b4-a2c1-09589aed4801"


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="", extra="ignore")
Expand Down Expand Up @@ -46,6 +48,12 @@ class Settings(BaseSettings):
server_host: str = Field(
"0.0.0.0", validation_alias=AliasChoices("SERVER_HOST", "server_host")
)
telemetry_experiment_id: str = Field(
DEFAULT_TELEMETRY_EXPERIMENT_ID,
validation_alias=AliasChoices(
"TELEMETRY_EXPERIMENT_ID", "telemetry_experiment_id"
),
)

@model_validator(mode="after")
def set_default_redirect_url(self):
Expand Down
164 changes: 164 additions & 0 deletions carbonserver/tests/api/integration/test_telemetry_local_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Integration tests for telemetry against a running local carbonserver API."""

import os
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace

import pytest
import requests

REPO_ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(REPO_ROOT))

from codecarbon.core.telemetry.client import ( # noqa: E402
post_private,
post_public_summary,
)
from codecarbon.core.telemetry.collect import build_payload # noqa: E402
from codecarbon.core.telemetry.schemas import TelemetryLevel # noqa: E402
from codecarbon.core.telemetry.settings import ( # noqa: E402
DEFAULT_TELEMETRY_EXPERIMENT_ID,
TelemetrySettings,
)
from codecarbon.output_methods.emissions_data import EmissionsData # noqa: E402

URL = os.getenv("CODECARBON_API_URL")
if URL is None:
pytest.exit("CODECARBON_API_URL is not defined (e.g. http://localhost:8008)")

API_KEY = os.getenv("CODECARBON_TELEMETRY_API_KEY")
if not API_KEY:
pytest.exit("CODECARBON_TELEMETRY_API_KEY is not defined")


def _api_url(path: str) -> str:
base = URL.rstrip("/")
if not base.endswith("/api"):
base = f"{base}/api"
return f"{base}{path}"


def _local_settings() -> TelemetrySettings:
return TelemetrySettings.resolve(
external_conf={
"telemetry_level": "extensive",
"telemetry_api_url": URL.rstrip("/"),
"telemetry_api_key": API_KEY,
"telemetry_experiment_id": DEFAULT_TELEMETRY_EXPERIMENT_ID,
}
)


def _sample_emissions() -> EmissionsData:
return EmissionsData(
timestamp="2026-01-01T00:00:00",
project_name="telemetry-local",
run_id="local-run",
experiment_id=DEFAULT_TELEMETRY_EXPERIMENT_ID,
duration=10.0,
emissions=0.001,
emissions_rate=0.0001,
cpu_power=0.0,
gpu_power=0.0,
ram_power=0.0,
cpu_energy=0.0,
gpu_energy=0.0,
ram_energy=0.0,
energy_consumed=0.01,
water_consumed=0.0,
country_name="France",
country_iso_code="FRA",
region="idf",
cloud_provider="",
cloud_region="",
os="Linux",
python_version="3.12",
codecarbon_version="3.2.8",
cpu_count=4,
cpu_model="test-cpu",
gpu_count=0,
gpu_model="",
longitude=0.0,
latitude=0.0,
ram_total_size=16.0,
tracking_mode="process",
)


def test_local_api_is_up():
response = requests.get(URL.rstrip("/") + "/", timeout=5)
assert response.status_code == 200
assert response.json()["status"] == "OK"


def test_local_telemetry_post_accepts_sdk_payload():
settings = _local_settings()
tracker = SimpleNamespace(
_conf={
"os": "Linux-5.10.0-x86_64",
"codecarbon_version": "3.2.8",
"cpu_count": 4,
"python_version": "3.12",
"tracking_mode": "process",
},
)
payload = build_payload(tracker, _sample_emissions(), level=TelemetryLevel.minimal)
assert post_private(settings, payload) is True


def test_local_extensive_run_and_emission_flow():
settings = _local_settings()
run_payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"experiment_id": settings.experiment_id,
"os": "Linux",
"python_version": "3.12",
"codecarbon_version": "3.2.8",
"cpu_count": 4,
"tracking_mode": "process",
}
run_response = requests.post(
_api_url("/runs"),
json=run_payload,
headers={"x-api-token": settings.api_key},
timeout=10,
)
assert run_response.status_code == 201, run_response.text
run_id = run_response.json()["id"]

emission_payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"run_id": run_id,
"duration": 10,
"emissions_sum": 0.001,
"emissions_rate": 0.0001,
"cpu_power": 0.0,
"gpu_power": 0.0,
"ram_power": 0.0,
"cpu_energy": 0.0,
"gpu_energy": 0.0,
"ram_energy": 0.0,
"energy_consumed": 0.01,
"wue": 0,
}
emission_response = requests.post(
_api_url("/emissions"),
json=emission_payload,
headers={"x-api-token": settings.api_key},
timeout=10,
)
assert emission_response.status_code == 201, emission_response.text
assert uuid.UUID(emission_response.json())


def test_local_post_public_summary_helper():
settings = _local_settings()
assert (
post_public_summary(
settings, {"os": "Linux", "tracking_mode": "process"}, _sample_emissions()
)
is True
)
89 changes: 82 additions & 7 deletions carbonserver/tests/api/routers/test_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
from fastapi.testclient import TestClient
from starlette import status

from carbonserver.api.infra.repositories.repository_projects_tokens import (
SqlAlchemyRepository as ProjectTokenRepository,
)
from carbonserver.api.infra.repositories.repository_telemetry import (
SqlAlchemyRepository as TelemetryRepository,
)
from carbonserver.api.routers import telemetry
from carbonserver.api.schemas import AccessLevel, ProjectToken
from carbonserver.config import settings
from carbonserver.container import ServerContainer

TELEMETRY_ID = "f52fe339-164d-4c2b-a8c0-f562dfce066d"
Expand Down Expand Up @@ -44,37 +49,107 @@ def client(custom_test_server):
def test_add_telemetry(client, custom_test_server):
repository_mock = mock.Mock(spec=TelemetryRepository)
repository_mock.add_telemetry.return_value = UUID(TELEMETRY_ID)

with custom_test_server.container.telemetry_repository.override(repository_mock):
response = client.post("/telemetry", json=MINIMAL_TELEMETRY_TO_CREATE)
project_tokens_repository_mock = mock.Mock(spec=ProjectTokenRepository)
project_tokens_repository_mock.get_project_token_by_experiment_id_and_token.return_value = ProjectToken(
id=UUID("e60afb92-17b7-4720-91a0-1ae91e409ba7"),
project_id=UUID("f52fe339-164d-4c2b-a8c0-f562dfce066d"),
name="Telemetry",
token="token",
access=AccessLevel.WRITE.value,
)

with custom_test_server.container.telemetry_repository.override(
repository_mock
) and custom_test_server.container.project_token_repository.override(
project_tokens_repository_mock
):
response = client.post(
"/telemetry",
json=MINIMAL_TELEMETRY_TO_CREATE,
headers={"x-api-token": "token"},
)

assert response.status_code == status.HTTP_201_CREATED
assert response.json() == TELEMETRY_ID
project_tokens_repository_mock.get_project_token_by_experiment_id_and_token.assert_called_once_with(
settings.telemetry_experiment_id, "token"
)


def test_add_telemetry_rejects_missing_token(client, custom_test_server):
repository_mock = mock.Mock(spec=TelemetryRepository)
project_tokens_repository_mock = mock.Mock(spec=ProjectTokenRepository)

with custom_test_server.container.telemetry_repository.override(
repository_mock
) and custom_test_server.container.project_token_repository.override(
project_tokens_repository_mock
):
response = client.post("/telemetry", json=MINIMAL_TELEMETRY_TO_CREATE)

assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "Not allowed to perform this action. Missing project token"
}
repository_mock.add_telemetry.assert_not_called()
project_tokens_repository_mock.get_project_token_by_experiment_id_and_token.assert_not_called()


def test_minimal_telemetry_rejects_extensive_fields(client, custom_test_server):
repository_mock = mock.Mock(spec=TelemetryRepository)
project_tokens_repository_mock = mock.Mock(spec=ProjectTokenRepository)
project_tokens_repository_mock.get_project_token_by_experiment_id_and_token.return_value = ProjectToken(
id=UUID("e60afb92-17b7-4720-91a0-1ae91e409ba7"),
project_id=UUID("f52fe339-164d-4c2b-a8c0-f562dfce066d"),
name="Telemetry",
token="token",
access=AccessLevel.WRITE.value,
)
telemetry_with_extensive_field = {
**MINIMAL_TELEMETRY_TO_CREATE,
"total_emissions_kg": 0.42,
}

with custom_test_server.container.telemetry_repository.override(repository_mock):
response = client.post("/telemetry", json=telemetry_with_extensive_field)
with custom_test_server.container.telemetry_repository.override(
repository_mock
) and custom_test_server.container.project_token_repository.override(
project_tokens_repository_mock
):
response = client.post(
"/telemetry",
json=telemetry_with_extensive_field,
headers={"x-api-token": "token"},
)

assert response.status_code == 422
repository_mock.add_telemetry.assert_not_called()


def test_disabled_telemetry_is_rejected(client, custom_test_server):
repository_mock = mock.Mock(spec=TelemetryRepository)
project_tokens_repository_mock = mock.Mock(spec=ProjectTokenRepository)
project_tokens_repository_mock.get_project_token_by_experiment_id_and_token.return_value = ProjectToken(
id=UUID("e60afb92-17b7-4720-91a0-1ae91e409ba7"),
project_id=UUID("f52fe339-164d-4c2b-a8c0-f562dfce066d"),
name="Telemetry",
token="token",
access=AccessLevel.WRITE.value,
)
disabled_telemetry = {
**MINIMAL_TELEMETRY_TO_CREATE,
"telemetry_level": "disabled",
}

with custom_test_server.container.telemetry_repository.override(repository_mock):
response = client.post("/telemetry", json=disabled_telemetry)
with custom_test_server.container.telemetry_repository.override(
repository_mock
) and custom_test_server.container.project_token_repository.override(
project_tokens_repository_mock
):
response = client.post(
"/telemetry",
json=disabled_telemetry,
headers={"x-api-token": "token"},
)

assert response.status_code == 422
repository_mock.add_telemetry.assert_not_called()
4 changes: 3 additions & 1 deletion carbonserver/tests/api/test_telemetry_schema_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from carbonserver.api.schemas_telemetry import TelemetryCreate as ServerTelemetryCreate

REPO_ROOT = Path(__file__).resolve().parents[3]
CORE_TELEMETRY_SCHEMA_PATH = REPO_ROOT / "codecarbon" / "core" / "telemetry_schemas.py"
CORE_TELEMETRY_SCHEMA_PATH = (
REPO_ROOT / "codecarbon" / "core" / "telemetry" / "schemas.py"
)


def _load_core_telemetry_create():
Expand Down
Loading
Loading