From fb18876d11ba505ac62616ed880feb48ba7e5795 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 16:54:23 +0200 Subject: [PATCH 1/2] fix: send measurement time to the API ApiClient.add_emission discarded the timestamp carried by EmissionsData and stamped the moment the payload was built instead, so stored rows were dated by send time rather than by the measurement window they summarise. Use the payload's timestamp when present, localised to an offset-aware ISO string, and fall back to now only for hand-built dicts. Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/core/api_client.py | 22 ++++++++++++++++++++-- tests/test_api_call.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index eaef94a53..a435e007e 100644 --- a/codecarbon/core/api_client.py +++ b/codecarbon/core/api_client.py @@ -8,7 +8,7 @@ # from httpx import AsyncClient import dataclasses import json -from datetime import timedelta, tzinfo +from datetime import datetime, timedelta, tzinfo import requests @@ -28,6 +28,24 @@ def get_datetime_with_timezone(): return str(arrow.now().isoformat()) +def get_measurement_timestamp(timestamp=None): + """ + Return the time the measurement was taken, as an offset-aware ISO string. + + EmissionsData.timestamp is naive local time, so it is localised here. + Falls back to now for hand-built payloads without a timestamp. + """ + if timestamp: + try: + return datetime.fromisoformat(timestamp).astimezone().isoformat() + except (TypeError, ValueError): + logger.warning( + f"ApiClient : could not parse emission timestamp {timestamp!r}, " + + "using current time." + ) + return get_datetime_with_timezone() + + class ApiClient: # (AsyncClient) """ This class call the Code Carbon API @@ -190,7 +208,7 @@ def add_emission(self, carbon_emission: dict): ) return False emission = EmissionCreate( - timestamp=get_datetime_with_timezone(), + timestamp=get_measurement_timestamp(carbon_emission.get("timestamp")), run_id=self.run_id, duration=int(carbon_emission["duration"]), emissions_sum=carbon_emission["emissions"], diff --git a/tests/test_api_call.py b/tests/test_api_call.py index d3b5bd96f..fa813d322 100644 --- a/tests/test_api_call.py +++ b/tests/test_api_call.py @@ -1,5 +1,6 @@ import dataclasses import unittest +from datetime import datetime from uuid import uuid4 import requests @@ -228,6 +229,40 @@ def test_add_emission_skips_short_duration(self): ) ) + def test_add_emission_keeps_measurement_timestamp(self): + payload = { + "duration": 2, + "emissions": 1.0, + "emissions_rate": 1.0, + "cpu_power": 1.0, + "gpu_power": 0.0, + "ram_power": 0.5, + "cpu_energy": 0.1, + "gpu_energy": 0.0, + "ram_energy": 0.1, + "energy_consumed": 0.2, + } + with requests_mock.Mocker() as m: + m.post("http://test.com/emissions", text="ok", status_code=201) + api = ApiClient( + endpoint_url="http://test.com", + experiment_id="exp-1", + conf=conf, + create_run_automatically=False, + ) + api.run_id = "run-1" + + # The measurement timestamp is kept, not the time of the POST. + api.add_emission({**payload, "timestamp": "2020-01-01T00:00:00"}) + sent = m.last_request.json()["timestamp"] + self.assertTrue(sent.startswith("2020-01-01T00:00:00")) + self.assertIsNotNone(datetime.fromisoformat(sent).tzinfo) + + # No timestamp in the payload : fall back to now. + api.add_emission(payload) + sent = m.last_request.json()["timestamp"] + self.assertIsNotNone(datetime.fromisoformat(sent).tzinfo) + def test_add_emission_raises_on_unsuccessful_post(self): with requests_mock.Mocker() as m: m.post("http://test.com/emissions", text="bad", status_code=500) From 0d79a249b4c41dd3eed3ab7599a80e4b937e3179 Mon Sep 17 00:00:00 2001 From: David Berenstein Date: Wed, 12 Aug 2026 19:52:09 +0200 Subject: [PATCH 2/2] docs: note why the API client localises the naive emission timestamp Co-Authored-By: Claude Opus 5 (1M context) --- codecarbon/emissions_tracker.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codecarbon/emissions_tracker.py b/codecarbon/emissions_tracker.py index 96ed00c91..5b3eec529 100644 --- a/codecarbon/emissions_tracker.py +++ b/codecarbon/emissions_tracker.py @@ -1044,6 +1044,9 @@ def _prepare_emissions_data(self) -> EmissionsData: ) total_emissions = EmissionsData( + # Naive local time. Consumers that need an offset-aware value + # (the API client) may assume the local zone -- `.astimezone()` -- + # precisely because this is `datetime.now()`. timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), project_name=self._project_name, run_id=str(self.run_id),