Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0eea792
feat: add telemetry module with Tier 1 payload builder
davidberenstein1957 Apr 29, 2026
0347f01
fix: improve code quality for Task 1 (security, docs, types)
davidberenstein1957 Apr 29, 2026
0ae7783
feat: add Tier 1 telemetry send with session dedup and silent fail
davidberenstein1957 Apr 29, 2026
ed2d3d2
feat: wire Tier 1 telemetry into BaseEmissionsTracker (opt-out via se…
davidberenstein1957 Apr 29, 2026
5c568e7
feat: first version of telemetry
inimaz May 3, 2026
39eef0b
tests: add a test so that client and server do not differ
inimaz May 3, 2026
f2ada76
merge: integrate telemetry backend from PR #1171
davidberenstein1957 May 19, 2026
9e97561
refactor: use TelemetryClient for minimal tracker telemetry
davidberenstein1957 May 19, 2026
453860f
feat: enhance telemetry module with minimal payload builder and impro…
davidberenstein1957 May 19, 2026
954910e
feat: enhance telemetry functionality with new configuration and sess…
davidberenstein1957 May 19, 2026
2a865f6
refactor: enhance telemetry level resolution and configuration handling
davidberenstein1957 May 19, 2026
1ffc45d
Delete docs/plans/2026-05-19-telemetry-configuration.md
davidberenstein1957 May 19, 2026
0a319bc
fix: improve error handling and logging for AMD GPU metrics
davidberenstein1957 May 19, 2026
dcec0c4
feat: enhance telemetry schema and collection methods
davidberenstein1957 May 19, 2026
1ecc237
refactor: update telemetry schema and enhance data collection
davidberenstein1957 May 19, 2026
6c873eb
refactor: streamline telemetry schema and enhance privacy measures
davidberenstein1957 May 19, 2026
e3dca3f
refactor: simplify telemetry framework detection and enhance privacy
davidberenstein1957 May 20, 2026
de3fff1
refactor: simplify telemetry client and enhance telemetry handling
davidberenstein1957 May 20, 2026
5a65220
refactor: overhaul telemetry handling and structure
davidberenstein1957 May 20, 2026
aefea4c
refactor: improve telemetry imports and code formatting
davidberenstein1957 May 20, 2026
adb50c3
feat: introduce telemetry module and enhance telemetry context manage…
davidberenstein1957 May 20, 2026
eaa75f1
merge: rebase feat/add-telemetry onto origin/master
davidberenstein1957 Jun 15, 2026
74eed94
Align SDK telemetry with server schema and simplify collection
davidberenstein1957 Jun 15, 2026
29664d7
Apply pre-commit formatting for telemetry files
davidberenstein1957 Jun 17, 2026
a231750
Fix CodeQL logging alert and raise telemetry test coverage
davidberenstein1957 Jun 17, 2026
edb2d03
Apply black formatting to telemetry schema tests
davidberenstein1957 Jun 17, 2026
13cde15
Potential fix for pull request finding 'CodeQL / Clear-text logging o…
davidberenstein1957 Jun 17, 2026
c5e6c4a
Merge branch 'master' into feat/add-telemetry
davidberenstein1957 Jun 17, 2026
2145b61
Raise PR coverage for telemetry CLI and API helpers
davidberenstein1957 Jun 17, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,6 @@ tests/test_data/rapl/*
credentials*
.codecarbon.config*
scripts/agent-vm.personal.config.sh

# Added by ggshield
.cache_ggshield
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
13 changes: 13 additions & 0 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
overwrite_local_config,
)
from codecarbon.cli.monitor import run_and_monitor
from codecarbon.cli.telemetry_cli import normalize_telemetry_level, telemetry_app
from codecarbon.core.api_client import ApiClient, get_datetime_with_timezone
from codecarbon.core.schemas import ExperimentCreate, OrganizationCreate, ProjectCreate
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
Expand All @@ -32,6 +33,7 @@
DEFAULT_ORGANIzATION_ID = "e60afa92-17b7-4720-91a0-1ae91e409ba1"

codecarbon = typer.Typer(no_args_is_help=True)
codecarbon.add_typer(telemetry_app, name="telemetry")


def main():
Expand Down Expand Up @@ -342,6 +344,15 @@ def monitor(
str,
typer.Option(help="Region/province for offline mode"),
] = None,
telemetry_level: Annotated[
Optional[str],
typer.Option(
help=(
"Override telemetry tier for this run only "
"(disabled, minimal, or extensive)."
),
),
] = None,
):
"""Monitor your machine's carbon emissions."""

Expand All @@ -350,6 +361,8 @@ def monitor(
"measure_power_secs": measure_power_secs,
"api_call_interval": api_call_interval,
}
if telemetry_level is not None:
tracker_args["telemetry_level"] = normalize_telemetry_level(telemetry_level)
# Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode
if offline:
if not country_iso_code:
Expand Down
252 changes: 252 additions & 0 deletions codecarbon/cli/telemetry_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
"""CLI commands to configure CodeCarbon product telemetry tiers."""

from pathlib import Path
from typing import Optional

import questionary
import typer
from rich import print
from typing_extensions import Annotated

from codecarbon.cli.cli_utils import (
create_new_config_file,
get_config,
overwrite_local_config,
)
from codecarbon.core.config import get_config_file_settings, get_hierarchical_config
from codecarbon.core.telemetry import (
DEFAULT_TELEMETRY_LEVEL,
TelemetryLevel,
TelemetrySettings,
parse_telemetry_level,
)

telemetry_app = typer.Typer(
help="Configure product telemetry (disabled, minimal, or extensive).",
no_args_is_help=False,
)

TIER_DESCRIPTIONS = {
"disabled": "No product telemetry.",
"minimal": "Environment and hardware only (private POST /telemetry).",
"extensive": "Minimal fields plus run metrics and public run summary.",
}


def normalize_telemetry_level(level: str) -> str:
"""Validate and normalize a telemetry tier string for CLI use.

Args:
level: User-provided tier name.

Returns:
Canonical tier value.

Raises:
typer.BadParameter: If the level is not a valid ``TelemetryLevel``.
"""
try:
return parse_telemetry_level(level).value
except ValueError as error:
raise typer.BadParameter(str(error)) from error


def resolve_config_path(config: Optional[Path], *, create: bool = False) -> Path:
"""Resolve which config file to read or write.

Args:
config: Explicit path from ``--config``, if any.
create: When True and no file exists, create ``./.codecarbon.config``.

Returns:
Resolved config file path.
"""
if config is not None:
path = config.expanduser().resolve()
if create and not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("[codecarbon]\n", encoding="utf-8")
return path
local_path = Path.cwd().resolve() / ".codecarbon.config"
if local_path.exists():
return local_path
global_path = (Path.home() / ".codecarbon.config").expanduser().resolve()
if global_path.exists():
return global_path
if create:
local_path.write_text("[codecarbon]\n", encoding="utf-8")
return local_path
return local_path


def pick_config_path_interactive() -> Path:
"""Prompt for which config file to update.

Returns:
Path chosen by the user.
"""
home = Path.home()
global_path = (home / ".codecarbon.config").expanduser().resolve()
local_path = Path.cwd().resolve() / ".codecarbon.config"
options = []
if global_path.exists():
options.append(str(global_path))
if local_path.exists() and local_path not in options:
options.append(str(local_path))
options.append("Create new config file")
if not options:
options = ["Create new config file"]
choice = questionary.select(
"Which configuration file should store telemetry_level?",
choices=options,
).ask()
if choice == "Create new config file":
return create_new_config_file()
return Path(choice).expanduser().resolve()


def write_telemetry_level(path: Path, level: str) -> None:
"""Persist ``telemetry_level`` to a config file.

Args:
path: Target ``.codecarbon.config`` path.
level: Validated tier value.
"""
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("[codecarbon]\n", encoding="utf-8")
overwrite_local_config("telemetry_level", level, path=path)


def print_telemetry_status(config_path: Optional[Path] = None) -> None:
"""Print resolved telemetry settings.

Without ``config_path``, uses the same merged file settings and env overlay
as ``EmissionsTracker``. With ``config_path``, inspects that file only.

Args:
config_path: Optional single config file to inspect.
"""
if config_path is not None:
path = config_path.expanduser().resolve()
if not path.exists():
print(f"[yellow]Config file not found:[/yellow] {path}")
print(f"Default tier: {DEFAULT_TELEMETRY_LEVEL.value} (not explicit)")
return
file_settings = get_config(path)
external_conf: dict[str, str] = {}
source_label = str(path)
else:
file_settings = get_config_file_settings()
external_conf = get_hierarchical_config()
source_label = "merged ~/.codecarbon.config + ./.codecarbon.config"

settings = TelemetrySettings.resolve(
config_file_conf=file_settings,
external_conf=external_conf or None,
)
level = settings.level
explicit = settings.is_explicit
stored = file_settings.get("telemetry_level")
print(f"Config source: {source_label}")
print(f"telemetry_level in file(s): {stored!r}")
print(f"Resolved tier: {level.value}")
print(f"Explicitly configured: {explicit}")
if not explicit:
print(
"[yellow]Minimal telemetry will be sent on each tracker stop "
"until you set telemetry_level.[/yellow]"
)


def run_telemetry_interactive(config: Optional[Path] = None) -> None:
"""Run the interactive telemetry configuration wizard.

Args:
config: Optional fixed config path; otherwise prompt for file choice.
"""
print("CodeCarbon product telemetry")
print(
"Separate from your dashboard experiment (codecarbon config). "
"Controls optional usage analytics and public leaderboard data.\n"
)
path = resolve_config_path(config) if config else None
if path is None or (config is None and not path.exists()):
path = pick_config_path_interactive()
else:
path = resolve_config_path(config, create=True)

choices = [
questionary.Choice("disabled — " + TIER_DESCRIPTIONS["disabled"], "disabled"),
questionary.Choice("minimal — " + TIER_DESCRIPTIONS["minimal"], "minimal"),
questionary.Choice(
"extensive — " + TIER_DESCRIPTIONS["extensive"], "extensive"
),
]
try:
current = get_config(path).get("telemetry_level")
except FileNotFoundError:
current = None
valid_levels = {member.value for member in TelemetryLevel}
default = current if current in valid_levels else "minimal"
level = questionary.select(
"Select telemetry_level:",
choices=choices,
default=default,
).ask()
if level is None:
raise typer.Exit(0)
level = normalize_telemetry_level(level)
write_telemetry_level(path, level)
print(f"[green]Saved[/green] telemetry_level = {level} in {path}")


@telemetry_app.callback(invoke_without_command=True)
def telemetry_entry(
ctx: typer.Context,
config: Annotated[
Optional[Path],
typer.Option(
"--config",
help="Path to .codecarbon.config (default: local then global).",
),
] = None,
) -> None:
"""Configure telemetry interactively when no subcommand is given."""
if ctx.invoked_subcommand is None:
run_telemetry_interactive(config=config)


@telemetry_app.command("status")
def status(
config: Annotated[
Optional[Path],
typer.Option(
"--config",
help="Inspect one file only; default matches EmissionsTracker merge.",
),
] = None,
) -> None:
"""Print resolved telemetry tier and configuration source."""
print_telemetry_status(config_path=config)


@telemetry_app.command("set")
def set_level(
level: Annotated[
str,
typer.Argument(help="Telemetry tier: disabled, minimal, or extensive."),
],
config: Annotated[
Optional[Path],
typer.Option(
"--config",
help="Path to .codecarbon.config (creates ./.codecarbon.config if missing).",
),
] = None,
) -> None:
"""Write telemetry_level to a config file."""
path = resolve_config_path(config, create=True)
normalized = normalize_telemetry_level(level)
write_telemetry_level(path, normalized)
print(f"[green]Saved[/green] telemetry_level = {normalized} in {path}")
12 changes: 9 additions & 3 deletions codecarbon/core/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ def get_datetime_with_timezone():
return timestamp


def _round_coordinate(value, decimals: int = 1) -> float:
"""Round a geographic coordinate for API payloads, treating None as zero."""
if value is None:
return round(0.0, decimals)
return round(float(value), decimals)


class ApiClient: # (AsyncClient)
"""
This class call the Code Carbon API
Expand Down Expand Up @@ -267,9 +274,8 @@ def _create_run(self, experiment_id: str):
cpu_model=self.conf.get("cpu_model"),
gpu_count=self.conf.get("gpu_count"),
gpu_model=self.conf.get("gpu_model"),
# Reduce precision for Privacy
longitude=round(self.conf.get("longitude", 0), 1),
latitude=round(self.conf.get("latitude", 0), 1),
longitude=_round_coordinate(self.conf.get("longitude")),
latitude=_round_coordinate(self.conf.get("latitude")),
region=self.conf.get("region"),
provider=self.conf.get("provider"),
ram_total_size=self.conf.get("ram_total_size"),
Expand Down
40 changes: 29 additions & 11 deletions codecarbon/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,31 @@ def normalize_gpu_ids(
return None


def _config_file_paths() -> tuple[str, str]:
"""Return resolved paths for global and local CodeCarbon config files."""
cwd = Path.cwd()
home = Path.home()
global_path = str((home / ".codecarbon.config").expanduser().resolve())
local_path = str((cwd / ".codecarbon.config").expanduser().resolve())
return global_path, local_path


def get_config_file_settings() -> dict[str, str]:
"""Return the ``[codecarbon]`` section from config files without environment overlay.

Reads ``~/.codecarbon.config`` then ``./.codecarbon.config`` (local overrides global).

Returns:
Configuration dict from files only. Empty when no file or section exists.
"""
config = configparser.ConfigParser()
global_path, local_path = _config_file_paths()
config.read([global_path, local_path])
if "codecarbon" not in config:
return {}
return dict(config["codecarbon"])


def get_hierarchical_config():
"""
Get the user-defined codecarbon configuration ConfigParser dictionnary
Expand Down Expand Up @@ -137,13 +162,7 @@ def get_hierarchical_config():
dict: The final configuration dict parsed from global,
local and environment configurations. **All values are strings**.
"""

config = configparser.ConfigParser()

cwd = Path.cwd()
home = Path.home()
global_path = str((home / ".codecarbon.config").expanduser().resolve())
local_path = str((cwd / ".codecarbon.config").expanduser().resolve())
global_path, local_path = _config_file_paths()
if Path(global_path).exists():
logger.info(
f"Codecarbon is taking the configuration from global file: {global_path}"
Expand All @@ -155,7 +174,6 @@ def get_hierarchical_config():
f"Codecarbon is taking the configuration from the local file {local_path}"
)

config.read([global_path, local_path])
config.read_dict(parse_env_config())

return dict(config["codecarbon"])
conf = get_config_file_settings()
conf.update(parse_env_config().get("codecarbon", {}))
return conf
Loading
Loading