Skip to content

Commit e9cecd4

Browse files
fix(api): stamp emissions with measurement time, not send time
ApiClient.add_emission discarded carbon_emission["timestamp"] and called get_datetime_with_timezone() instead, so every row stored the moment the payload was built rather than the moment it was measured. Harmless while the two are milliseconds apart; wrong by the full latency as soon as a send is slow, retried or queued. Normalise the measurement timestamp to offset-aware instead, falling back to now when the payload carries none or an unparseable one (the method is public and takes a plain dict). CSV output is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ec517ea commit e9cecd4

3 files changed

Lines changed: 60 additions & 10 deletions

File tree

‎codecarbon/core/api_client.py‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# from httpx import AsyncClient
99
import dataclasses
1010
import json
11-
from datetime import timedelta, tzinfo
11+
from datetime import datetime, timedelta, tzinfo
1212

1313
import requests
1414

@@ -33,12 +33,26 @@ def get_datetime_with_timezone():
3333
return str(arrow.now().isoformat())
3434

3535

36-
# (connect, read) seconds. The old flat 2s timed out on a healthy but
37-
# loaded API; these leave room without letting a hung endpoint block the
38-
# scheduler thread for long.
36+
# (connect, read) seconds, replacing a flat 2s that timed out on a loaded API.
3937
_TIMEOUT = (3.05, 10)
4038

4139

40+
def _measurement_timestamp(carbon_emission: dict) -> str:
41+
"""
42+
Offset-aware ISO timestamp of *when the measurement was taken*, taken from
43+
EmissionsData.timestamp. Falls back to now for hand-built payloads that
44+
carry no usable timestamp.
45+
"""
46+
try:
47+
return (
48+
datetime.fromisoformat(carbon_emission["timestamp"])
49+
.astimezone()
50+
.isoformat()
51+
)
52+
except (KeyError, TypeError, ValueError):
53+
return get_datetime_with_timezone()
54+
55+
4256
class ApiClient: # (AsyncClient)
4357
"""
4458
This class call the Code Carbon API
@@ -64,9 +78,7 @@ def __init__(
6478
:create_run_automatically: If False, do not create a run. To use API in read only mode.
6579
"""
6680
# super().__init__(base_url=endpoint_url) # (AsyncClient)
67-
# A Session so the socket and TLS handshake are reused across calls:
68-
# a tracker sending one measurement per tick otherwise opens a new
69-
# connection every time.
81+
# A Session so the socket and TLS handshake are reused across calls.
7082
self._session = requests.Session()
7183
self.url = endpoint_url
7284
self.experiment_id = experiment_id
@@ -209,7 +221,7 @@ def add_emission(self, carbon_emission: dict):
209221
)
210222
return False
211223
emission = EmissionCreate(
212-
timestamp=get_datetime_with_timezone(),
224+
timestamp=_measurement_timestamp(carbon_emission),
213225
run_id=self.run_id,
214226
duration=int(carbon_emission["duration"]),
215227
emissions_sum=carbon_emission["emissions"],

‎codecarbon/output_methods/http.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ def __init__(
5858
self.run_id = self.api.run_id
5959

6060
def exit(self) -> None:
61-
# A Session holds sockets; long-lived processes creating many trackers
62-
# would otherwise leak file descriptors.
6361
self.api.close()
6462

6563
def _ensure_api_run(self) -> None:

‎tests/test_api_call.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dataclasses
22
import unittest
3+
from datetime import datetime
34
from uuid import uuid4
45

56
import requests
@@ -261,6 +262,45 @@ def test_add_emission_skips_short_duration(self):
261262
)
262263
)
263264

265+
def test_add_emission_keeps_measurement_timestamp(self):
266+
"""The row must carry when it was measured, not when it was sent."""
267+
payload = {
268+
"duration": 10,
269+
"emissions": 1.0,
270+
"emissions_rate": 1.0,
271+
"cpu_power": 1.0,
272+
"gpu_power": 0.0,
273+
"ram_power": 0.5,
274+
"cpu_energy": 0.1,
275+
"gpu_energy": 0.0,
276+
"ram_energy": 0.1,
277+
"energy_consumed": 0.2,
278+
}
279+
with requests_mock.Mocker() as m:
280+
m.post("http://test.com/emissions", status_code=201)
281+
api = ApiClient(
282+
endpoint_url="http://test.com",
283+
experiment_id="exp-1",
284+
conf=conf,
285+
create_run_automatically=False,
286+
)
287+
api.run_id = "run-1"
288+
289+
# naive timestamp, as produced by EmissionsData
290+
assert api.add_emission({**payload, "timestamp": "2020-01-01T00:00:00"})
291+
sent = datetime.fromisoformat(m.last_request.json()["timestamp"])
292+
self.assertEqual(
293+
sent.replace(tzinfo=None).isoformat(), "2020-01-01T00:00:00"
294+
)
295+
self.assertIsNotNone(sent.tzinfo)
296+
297+
# missing / unparseable timestamps fall back to now
298+
for bad in ({}, {"timestamp": None}, {"timestamp": "222"}):
299+
assert api.add_emission({**payload, **bad})
300+
sent = datetime.fromisoformat(m.last_request.json()["timestamp"])
301+
self.assertIsNotNone(sent.tzinfo)
302+
self.assertGreater(sent.year, 2020)
303+
264304
def test_add_emission_raises_on_unsuccessful_post(self):
265305
with requests_mock.Mocker() as m:
266306
m.post("http://test.com/emissions", text="bad", status_code=500)

0 commit comments

Comments
 (0)