Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 69 additions & 23 deletions src/sentry/utils/climate_impact.py
Original file line number Diff line number Diff line change
@@ -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")
73 changes: 39 additions & 34 deletions tests/sentry/api/endpoints/test_organization_traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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
97 changes: 97 additions & 0 deletions tests/sentry/utils/test_climate_impact.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 8 additions & 5 deletions tests/snuba/api/endpoints/test_organization_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -1020,20 +1022,22 @@ 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"])

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"])

Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
Loading