diff --git a/codecarbon/core/api_client.py b/codecarbon/core/api_client.py index bc2e0974e..edaed2403 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 @@ -33,6 +33,22 @@ def get_datetime_with_timezone(): return str(arrow.now().isoformat()) +def _measurement_timestamp(carbon_emission: dict) -> str: + """ + Offset-aware ISO timestamp of *when the measurement was taken*, taken from + EmissionsData.timestamp. Falls back to now for hand-built payloads that + carry no usable timestamp. + """ + try: + return ( + datetime.fromisoformat(carbon_emission["timestamp"]) + .astimezone() + .isoformat() + ) + except (KeyError, TypeError, ValueError): + return get_datetime_with_timezone() + + class ApiClient: # (AsyncClient) """ This class call the Code Carbon API @@ -195,7 +211,7 @@ def add_emission(self, carbon_emission: dict): ) return False emission = EmissionCreate( - timestamp=get_datetime_with_timezone(), + timestamp=_measurement_timestamp(carbon_emission), run_id=self.run_id, duration=int(carbon_emission["duration"]), emissions_sum=carbon_emission["emissions"], @@ -237,6 +253,8 @@ def _create_run(self, experiment_id: str): return None try: run = RunCreate( + # "now" is correct here: a run's timestamp is its creation time, + # unlike an emission's, which is its measurement time. timestamp=get_datetime_with_timezone(), experiment_id=experiment_id, os=self.conf.get("os"), diff --git a/tests/test_api_call.py b/tests/test_api_call.py index 31e25c039..481c76111 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 @@ -261,6 +262,45 @@ def test_add_emission_skips_short_duration(self): ) ) + def test_add_emission_keeps_measurement_timestamp(self): + """The row must carry when it was measured, not when it was sent.""" + payload = { + "duration": 10, + "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", 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" + + # naive timestamp, as produced by EmissionsData + assert api.add_emission({**payload, "timestamp": "2020-01-01T00:00:00"}) + sent = datetime.fromisoformat(m.last_request.json()["timestamp"]) + self.assertEqual( + sent.replace(tzinfo=None).isoformat(), "2020-01-01T00:00:00" + ) + self.assertIsNotNone(sent.tzinfo) + + # missing / unparseable timestamps fall back to now + for bad in ({}, {"timestamp": None}, {"timestamp": "222"}): + assert api.add_emission({**payload, **bad}) + sent = datetime.fromisoformat(m.last_request.json()["timestamp"]) + self.assertIsNotNone(sent.tzinfo) + self.assertGreater(sent.year, 2020) + 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)