From b23f79314337f9c7dbf3fa79c7e978e82c1aa4c5 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 16:54:36 +0200 Subject: [PATCH 1/3] fix: propagate API run id to tracker When OutputMethod.API was enabled, the tracker read run_id from the API output handler before the run existed (it is created lazily), so run_id stayed None and every record was written with the string "None". Make run_id a property backed by a locally generated uuid that prefers the API run id, and create the API run in start() so all outputs of a run share the same id. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/emissions_tracker.py | 25 ++++++++++++++--- tests/test_emissions_tracker.py | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 96ed00c91..bb6afa4c7 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -266,7 +266,18 @@ def _resolve_output_methods( if self._save_to_logfire: self._output_methods.append(OutputMethod.LOGFIRE) + @property + def run_id(self): + """ + Id of the current run. It is the API run id as soon as the API output + method has created one, and a locally generated uuid otherwise. + """ + api_run_id = getattr(self._api_output, "run_id", None) + return api_run_id if api_run_id is not None else self._run_id + def _initialize_runtime_state(self) -> None: + self._api_output = None + self._run_id = uuid.uuid4() self._start_time: Optional[float] = None self._last_measured_time: float = time.perf_counter() self._total_energy: Energy = Energy.from_energy(kWh=0) @@ -612,7 +623,6 @@ def _init_output_methods(self, *, api_key: str = None): methods = set(self._output_methods) if self._output_methods else set() if not methods and not self._emissions_endpoint: - self.run_id = uuid.uuid4() return from codecarbon.output_methods.boamps import BoAmpsOutput @@ -645,10 +655,8 @@ def _init_output_methods(self, *, api_key: str = None): api_key=api_key, conf=self._conf, ) - self.run_id = cc_api__out.run_id + self._api_output = cc_api__out self._output_handlers.append(cc_api__out) - else: - self.run_id = uuid.uuid4() if OutputMethod.PROMETHEUS in methods: self._output_handlers.append( @@ -710,6 +718,15 @@ def start(self) -> None: return self._ensure_hardware_ready() + + if self._api_output is not None: + # Create the run now, so that every record of this run, whatever the + # output method, carries the API run id. + try: + self._api_output._ensure_api_run() + except Exception as e: + logger.error(e, exc_info=True) + self._last_measured_time = self._start_time = time.perf_counter() # Clear utilization history for fresh measurements diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 8ab12e5d8..6ea6271ad 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -356,6 +356,54 @@ def test_output_methods_boamps_adds_boamps_output_handler( ) ) + def test_run_id_with_api_output_is_never_none( + self, + mock_cli_setup, + mock_log_values, + mocked_get_gpu_details, + mocked_env_cloud_details, + mocked_get_gpu_utilization_list, + mocked_is_gpu_details_available, + mocked_is_nvidia_system, + ): + with ( + mock.patch( + "codecarbon.output_methods.http.ApiClient._create_run" + ) as mock_create_run, + mock.patch("codecarbon.output_methods.http.ApiClient.add_emission"), + ): + tracker = EmissionsTracker( + output_dir=self.temp_path, + output_handlers=[], + output_methods=[OutputMethod.CSV, OutputMethod.API], + experiment_id="test-experiment-id", + api_key="test-api-key", + ) + api_output = next( + handler + for handler in tracker._output_handlers + if isinstance(handler, CodeCarbonAPIOutput) + ) + + def create_run(experiment_id): + api_output.api.run_id = "run-created" + return "run-created" + + mock_create_run.side_effect = create_run + + # Before the run is created, the tracker falls back on a local uuid. + self.assertIsNotNone(tracker.run_id) + + tracker.start() + heavy_computation(1) + tracker.stop() + + # Once the API created the run, the tracker exposes the API run id... + self.assertEqual(tracker.run_id, "run-created") + # ...and it is what got persisted, instead of the string "None". + emissions_df = pd.read_csv(self.emissions_file_path) + self.assertEqual(emissions_df["run_id"].iloc[0], "run-created") + def test_default_output_methods_is_csv( self, mock_cli_setup, From 312856c2cc68c2025c0ad82c5bf4e6c566c89adc Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 17:39:23 +0200 Subject: [PATCH 2/3] test: cover the API run creation failure path Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_emissions_tracker.py | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index 6ea6271ad..b878b12b1 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -404,6 +404,47 @@ def create_run(experiment_id): emissions_df = pd.read_csv(self.emissions_file_path) self.assertEqual(emissions_df["run_id"].iloc[0], "run-created") + def test_run_id_falls_back_to_uuid_when_api_run_creation_fails( + self, + mock_cli_setup, + mock_log_values, + mocked_get_gpu_details, + mocked_env_cloud_details, + mocked_get_gpu_utilization_list, + mocked_is_gpu_details_available, + mocked_is_nvidia_system, + ): + with ( + mock.patch( + "codecarbon.output_methods.http.ApiClient._create_run", + side_effect=Exception("API is down"), + ), + mock.patch("codecarbon.output_methods.http.ApiClient.add_emission"), + ): + tracker = EmissionsTracker( + output_dir=self.temp_path, + output_handlers=[], + output_methods=[OutputMethod.CSV, OutputMethod.API], + experiment_id="test-experiment-id", + api_key="test-api-key", + ) + local_run_id = tracker.run_id + + with self.assertLogs("codecarbon", level="ERROR") as logs: + tracker.start() + # The API failure is reported but does not abort the tracking. + self.assertTrue(any("API is down" in line for line in logs.output)) + self.assertIsNotNone(tracker._start_time) + + heavy_computation(1) + tracker.stop() + + # No API run id available: the tracker keeps its local uuid, and it is + # what gets persisted. + self.assertEqual(tracker.run_id, local_run_id) + emissions_df = pd.read_csv(self.emissions_file_path) + self.assertEqual(emissions_df["run_id"].iloc[0], str(local_run_id)) + def test_default_output_methods_is_csv( self, mock_cli_setup, From 922ebd2181f2c2bd7cd4147a94b72768f45bef94 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 19:54:45 +0200 Subject: [PATCH 3/3] fix(tracker): keep run_id writable and make the API lookup explicit Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/emissions_tracker.py | 13 +++++++++++-- tests/test_emissions_tracker.py | 5 ++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index bb6afa4c7..4a569f787 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -272,8 +272,17 @@ def run_id(self): Id of the current run. It is the API run id as soon as the API output method has created one, and a locally generated uuid otherwise. """ - api_run_id = getattr(self._api_output, "run_id", None) - return api_run_id if api_run_id is not None else self._run_id + if self._api_output is not None and self._api_output.run_id is not None: + return self._api_output.run_id + return self._run_id + + @run_id.setter + def run_id(self, value) -> None: + """ + `run_id` used to be a plain attribute ; keep it writable for callers + that set their own id. An API run id, once created, still wins. + """ + self._run_id = value def _initialize_runtime_state(self) -> None: self._api_output = None diff --git a/tests/test_emissions_tracker.py b/tests/test_emissions_tracker.py index b878b12b1..f8ef35bea 100644 --- a/tests/test_emissions_tracker.py +++ b/tests/test_emissions_tracker.py @@ -391,8 +391,11 @@ def create_run(experiment_id): mock_create_run.side_effect = create_run - # Before the run is created, the tracker falls back on a local uuid. + # Before the run is created, the tracker falls back on a local uuid, + # which stays writable for callers that set their own id. self.assertIsNotNone(tracker.run_id) + tracker.run_id = "caller-provided" + self.assertEqual(tracker.run_id, "caller-provided") tracker.start() heavy_computation(1)