diff --git a/src/sentry/utils/climate_impact.py b/src/sentry/utils/climate_impact.py index 243ac5673002..f7e2be410165 100644 --- a/src/sentry/utils/climate_impact.py +++ b/src/sentry/utils/climate_impact.py @@ -1,42 +1,88 @@ from collections.abc import Mapping from typing import Any +# These fixed values keep the POC deterministic without requiring hardware experiments or +# location-aware grid data. 15 W represents the single CPU assumed by the model. The grid +# intensity rounds EPA's ~394 gCO2e/kWh estimate to avoid false precision in a rough model. +ACTIVE_CPU_WATTS = 15.0 +GRID_INTENSITY_GCO2E_PER_KWH = 400.0 +_GCO2E_PER_MILLISECOND = ACTIVE_CPU_WATTS * GRID_INTENSITY_GCO2E_PER_KWH / 3_600_000_000 -def estimate_span_climate_impact(span: Mapping[str, Any]) -> float: - """Estimate climate impact in grams of CO₂ equivalent.""" - return 1.0 # 1g CO₂e per span as placeholder + +def estimate_gco2e_from_duration_ms(duration_ms: float) -> float: + return duration_ms * _GCO2E_PER_MILLISECOND + + +def _duration_ms_from_attributes(attributes: Any) -> float | None: + if not isinstance(attributes, list): + return None + + for attribute in attributes: + if not isinstance(attribute, dict) or attribute.get("name") != "span.duration": + continue + try: + return float(attribute["value"]) + except (KeyError, TypeError, ValueError): + return None + + return None + + +def _duration_ms_from_span(span: Mapping[str, Any]) -> float | None: + duration_ms = span.get("duration") + if duration_ms is None: + # Trace item details serializes duration inside its attribute list instead of at the + # top level, so both API response shapes need explicit support. + return _duration_ms_from_attributes(span.get("attributes")) + + try: + return float(duration_ms) + except (TypeError, ValueError): + return None + + +def estimate_span_climate_impact(span: Mapping[str, Any]) -> float | None: + # The span schema has no CPU-time field. Wall duration is the closest existing proxy and + # lets this POC ship without SDK or schema changes. + duration_ms = _duration_ms_from_span(span) + if duration_ms is None or duration_ms < 0: + return None + return estimate_gco2e_from_duration_ms(duration_ms) + + +def _annotate_span(span: dict[str, Any], field: str) -> None: + estimate = estimate_span_climate_impact(span) + if estimate is None: + # The frontend uses field presence as the availability signal. Omitting unknown values + # avoids presenting missing measurements as a measured zero-carbon operation. + return + span[field] = estimate def annotate_trace_tree(events: Any) -> None: - """Walk children of event list; stamp only event_type == 'span'.""" if not isinstance(events, list): return + for event in events: - if isinstance(event, dict) and event.get("event_type") == "span": - event["estimated_climate_impact_co2e_grams"] = estimate_span_climate_impact(event) - children = event.get("children") if isinstance(event, dict) else None - if children is not None: - annotate_trace_tree(children) + if not isinstance(event, dict): + continue + if event.get("event_type") == "span": + _annotate_span(event, "estimated_climate_impact_co2e_grams") + annotate_trace_tree(event.get("children")) def annotate_trace_summaries(traces: Any) -> None: - """Annotate /traces/ results; each row gets estimatedClimateImpactCo2eGrams = numSpans * 1.0.""" - if not isinstance(traces, dict): - return - data = traces.get("data") - if not isinstance(data, list): + if not isinstance(traces, dict) or not isinstance(traces.get("data"), list): return - for row in data: - if not isinstance(row, dict): - continue - if "numSpans" not in row: + + for trace in traces["data"]: + if not isinstance(trace, dict): continue - row["estimatedClimateImpactCo2eGrams"] = row.get( - "numSpans", 0 - ) * estimate_span_climate_impact({}) + # The summary endpoint does not return every span, so trace wall duration provides + # a stable estimate without an extra query or a misleading partial sum. + _annotate_span(trace, "estimatedClimateImpactCo2eGrams") def annotate_trace_item(item: Any) -> None: - """Stamp item response for spans; no event_type here.""" if isinstance(item, dict): - item["estimatedClimateImpactCo2eGrams"] = estimate_span_climate_impact({}) + _annotate_span(item, "estimatedClimateImpactCo2eGrams") diff --git a/tests/sentry/api/endpoints/test_organization_traces.py b/tests/sentry/api/endpoints/test_organization_traces.py index e991115597cc..f5a0580514cd 100644 --- a/tests/sentry/api/endpoints/test_organization_traces.py +++ b/tests/sentry/api/endpoints/test_organization_traces.py @@ -17,6 +17,7 @@ from sentry.testutils.helpers import parse_link_header from sentry.testutils.helpers.datetime import before_now from sentry.testutils.helpers.features import with_feature +from sentry.utils.climate_impact import estimate_gco2e_from_duration_ms from sentry.utils.samples import load_data from sentry.utils.snuba import _snuba_query @@ -2550,27 +2551,31 @@ def setUp(self) -> None: @with_feature("organizations:visibility-explore-view") def test_climate_impact_data_feature_enabled(self) -> None: trace_id = uuid4().hex - self.store_span( - { - "trace_id": trace_id, - "span_id": "a" * 16, - "parent_span_id": None, - "is_segment": True, - "transaction": "api/test", - "op": "http.server", - "description": "GET /api/test", - } + start = before_now(minutes=10) + self.store_segment( + project_id=self.project.id, + trace_id=trace_id, + transaction_id=uuid4().hex, + span_id="a" * 16, + transaction="api/test", + op="http.server", + duration=2_000, + exclusive_time=1_000, + timestamp=start, + organization_id=self.organization.id, ) - self.store_span( - { - "trace_id": trace_id, - "span_id": "b" * 16, - "parent_span_id": "a" * 16, - "is_segment": False, - "transaction": "api/test", - "op": "db", - "description": "SELECT * FROM table", - } + self.store_segment( + project_id=self.project.id, + trace_id=trace_id, + transaction_id=uuid4().hex, + span_id="b" * 16, + parent_span_id="a" * 16, + transaction="api/test", + op="db", + duration=1_000, + exclusive_time=1_000, + timestamp=start, + organization_id=self.organization.id, ) response = self.client.get( @@ -2587,25 +2592,26 @@ def test_climate_impact_data_feature_enabled(self) -> None: data = response.data["data"] assert len(data) == 1 - # Check that the trace has climate impact field trace = data[0] assert "estimatedClimateImpactCo2eGrams" in trace - # Should be 2.0 (2 spans * 1g CO₂e each) - assert trace["estimatedClimateImpactCo2eGrams"] == 2.0 + assert trace["estimatedClimateImpactCo2eGrams"] == pytest.approx( + estimate_gco2e_from_duration_ms(trace["duration"]) + ) @with_feature("organizations:visibility-explore-view") def test_climate_impact_data_feature_disabled(self) -> None: trace_id = uuid4().hex - self.store_span( - { - "trace_id": trace_id, - "span_id": "a" * 16, - "parent_span_id": None, - "is_segment": True, - "transaction": "api/test", - "op": "http.server", - "description": "GET /api/test", - } + self.store_segment( + project_id=self.project.id, + trace_id=trace_id, + transaction_id=uuid4().hex, + span_id="a" * 16, + transaction="api/test", + op="http.server", + duration=1_000, + exclusive_time=1_000, + timestamp=before_now(minutes=10), + organization_id=self.organization.id, ) response = self.client.get( @@ -2622,6 +2628,5 @@ def test_climate_impact_data_feature_disabled(self) -> None: data = response.data["data"] assert len(data) == 1 - # Check that the trace does not have climate impact field trace = data[0] assert "estimatedClimateImpactCo2eGrams" not in trace diff --git a/tests/sentry/utils/test_climate_impact.py b/tests/sentry/utils/test_climate_impact.py new file mode 100644 index 000000000000..64397c1f242a --- /dev/null +++ b/tests/sentry/utils/test_climate_impact.py @@ -0,0 +1,97 @@ +from typing import Any + +import pytest + +from sentry.utils.climate_impact import ( + ACTIVE_CPU_WATTS, + GRID_INTENSITY_GCO2E_PER_KWH, + annotate_trace_item, + annotate_trace_summaries, + annotate_trace_tree, + estimate_gco2e_from_duration_ms, + estimate_span_climate_impact, +) + + +def test_estimate_gco2e_from_duration_ms() -> None: + assert estimate_gco2e_from_duration_ms(1000) == pytest.approx( + ACTIVE_CPU_WATTS * GRID_INTENSITY_GCO2E_PER_KWH / 3_600_000 + ) + assert estimate_gco2e_from_duration_ms(0) == 0 + + +@pytest.mark.parametrize( + ("span", "duration_ms"), + [ + ({"duration": 1500}, 1500), + ({"attributes": [{"name": "span.duration", "value": "2500"}]}, 2500), + ], +) +def test_estimate_span_climate_impact(span: dict[str, Any], duration_ms: float) -> None: + assert estimate_span_climate_impact(span) == pytest.approx( + estimate_gco2e_from_duration_ms(duration_ms) + ) + + +@pytest.mark.parametrize( + "span", + [ + {}, + {"duration": "invalid"}, + {"duration": -1}, + {"attributes": []}, + {"attributes": [{"name": "span.op", "value": "db"}]}, + ], +) +def test_estimate_span_climate_impact_omits_unknown_duration(span: dict[str, Any]) -> None: + assert estimate_span_climate_impact(span) is None + + +def test_annotate_trace_tree() -> None: + events: list[dict[str, Any]] = [ + { + "event_type": "span", + "duration": 1000, + "children": [ + {"event_type": "span", "duration": 500}, + {"event_type": "error", "duration": 500}, + {"event_type": "span"}, + ], + } + ] + + annotate_trace_tree(events) + + root = events[0] + assert root["estimated_climate_impact_co2e_grams"] == pytest.approx( + estimate_gco2e_from_duration_ms(1000) + ) + assert root["children"][0]["estimated_climate_impact_co2e_grams"] == pytest.approx( + estimate_gco2e_from_duration_ms(500) + ) + assert "estimated_climate_impact_co2e_grams" not in root["children"][1] + assert "estimated_climate_impact_co2e_grams" not in root["children"][2] + + +def test_annotate_trace_summaries() -> None: + traces: dict[str, list[dict[str, Any]]] = {"data": [{"duration": 60_000}, {}]} + + annotate_trace_summaries(traces) + + assert traces["data"][0]["estimatedClimateImpactCo2eGrams"] == pytest.approx( + estimate_gco2e_from_duration_ms(60_000) + ) + assert "estimatedClimateImpactCo2eGrams" not in traces["data"][1] + + +def test_annotate_trace_item() -> None: + item: dict[str, Any] = {"attributes": [{"name": "span.duration", "value": "1000"}]} + empty: dict[str, Any] = {"attributes": []} + + annotate_trace_item(item) + annotate_trace_item(empty) + + assert item["estimatedClimateImpactCo2eGrams"] == pytest.approx( + estimate_gco2e_from_duration_ms(1000) + ) + assert "estimatedClimateImpactCo2eGrams" not in empty diff --git a/tests/snuba/api/endpoints/test_organization_trace.py b/tests/snuba/api/endpoints/test_organization_trace.py index b602b88b31e8..8c5c1c14551a 100644 --- a/tests/snuba/api/endpoints/test_organization_trace.py +++ b/tests/snuba/api/endpoints/test_organization_trace.py @@ -3,6 +3,7 @@ from unittest import mock from uuid import uuid4 +import pytest from django.conf import settings from django.test import override_settings from django.urls import reverse @@ -25,6 +26,7 @@ from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.options import override_options from sentry.uptime.grouptype import UptimeDomainCheckFailure +from sentry.utils.climate_impact import estimate_gco2e_from_duration_ms from sentry.utils.samples import load_data from tests.snuba.api.endpoints.test_organization_events_trace import ( OrganizationEventsTraceEndpointBase, @@ -1020,12 +1022,12 @@ def test_climate_impact_data_feature_enabled(self) -> None: assert response.status_code == 200, response.content data = response.data - # Check that spans have the climate impact field for event in data: if event.get("event_type") == "span": assert "estimated_climate_impact_co2e_grams" in event - assert event["estimated_climate_impact_co2e_grams"] == 1.0 - # Check children recursively + assert event["estimated_climate_impact_co2e_grams"] == pytest.approx( + estimate_gco2e_from_duration_ms(event["duration"]) + ) if "children" in event: self._check_children_for_climate_impact(event["children"]) @@ -1033,7 +1035,9 @@ def _check_children_for_climate_impact(self, children: list) -> None: for child in children: if child.get("event_type") == "span": assert "estimated_climate_impact_co2e_grams" in child - assert child["estimated_climate_impact_co2e_grams"] == 1.0 + assert child["estimated_climate_impact_co2e_grams"] == pytest.approx( + estimate_gco2e_from_duration_ms(child["duration"]) + ) if "children" in child: self._check_children_for_climate_impact(child["children"]) @@ -1046,7 +1050,6 @@ def test_climate_impact_data_feature_disabled(self) -> None: assert response.status_code == 200, response.content data = response.data - # Check that spans do not have the climate impact field for event in data: assert "estimated_climate_impact_co2e_grams" not in event if "children" in event: diff --git a/tests/snuba/api/endpoints/test_project_trace_item_details.py b/tests/snuba/api/endpoints/test_project_trace_item_details.py index e60b2af1a63a..be403d8b7b4a 100644 --- a/tests/snuba/api/endpoints/test_project_trace_item_details.py +++ b/tests/snuba/api/endpoints/test_project_trace_item_details.py @@ -18,6 +18,7 @@ from sentry.testutils.helpers.features import with_feature from sentry.testutils.helpers.options import override_options from sentry.utils import json +from sentry.utils.climate_impact import estimate_gco2e_from_duration_ms from sentry.utils.snuba_rpc import SnubaRPCRateLimitExceeded @@ -897,7 +898,9 @@ def test_climate_impact_data_feature_enabled_for_spans(self) -> None: response = self.do_request("spans", span["span_id"]) assert response.status_code == 200, response.content assert "estimatedClimateImpactCo2eGrams" in response.data - assert response.data["estimatedClimateImpactCo2eGrams"] == 1.0 + assert response.data["estimatedClimateImpactCo2eGrams"] == pytest.approx( + estimate_gco2e_from_duration_ms(1000) + ) def test_climate_impact_data_feature_disabled_for_spans(self) -> None: span = self.create_span({"description": "foo"}, start_ts=self.one_min_ago)