From e16df290bbe0da6e8bcdba95f0ee1862c949dd3d Mon Sep 17 00:00:00 2001 From: Stephen Balogh Date: Wed, 14 Oct 2026 04:06:20 +0000 Subject: [PATCH 1/2] feat: support estimated billable usage metrics Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- s2-specs | 2 +- src/s2_sdk/_ops.py | 4 +++ src/s2_sdk/_types.py | 2 ++ tests/test_metrics_ops.py | 54 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/s2-specs b/s2-specs index 0bd8bba..60dfbe3 160000 --- a/s2-specs +++ b/s2-specs @@ -1 +1 @@ -Subproject commit 0bd8bba1fc113587790b4d62826d36107ac2aee4 +Subproject commit 60dfbe312609572710a5c539a02a393e7ee37a2e diff --git a/src/s2_sdk/_ops.py b/src/s2_sdk/_ops.py index 464cc9f..96c340b 100644 --- a/src/s2_sdk/_ops.py +++ b/src/s2_sdk/_ops.py @@ -571,6 +571,10 @@ async def account_metrics( ) -> list[types.Scalar | types.Accumulation | types.Gauge | types.Label]: """Get account metrics. + ``estimated-billable-usage`` returns estimated USD by billing dimension + and as a ``total`` series. Supports hour (default) and day intervals, + aggregating finalized hours. Daily points may be partial. + Args: set: Metric set to query. start: Start of the time range (epoch seconds). diff --git a/src/s2_sdk/_types.py b/src/s2_sdk/_types.py index 1d1fdc7..f98ae96 100644 --- a/src/s2_sdk/_types.py +++ b/src/s2_sdk/_types.py @@ -442,6 +442,7 @@ class MetricUnit(_DocEnum): BYTES = "bytes" OPERATIONS = "operations" + USD = "usd" class TimeseriesInterval(_DocEnum): @@ -457,6 +458,7 @@ class AccountMetricSet(_DocEnum): ACTIVE_BASINS = "active-basins" ACCOUNT_OPS = "account-ops" + ESTIMATED_BILLABLE_USAGE = "estimated-billable-usage" class BasinMetricSet(_DocEnum): diff --git a/tests/test_metrics_ops.py b/tests/test_metrics_ops.py index 3b5873c..97d02fd 100644 --- a/tests/test_metrics_ops.py +++ b/tests/test_metrics_ops.py @@ -1,4 +1,6 @@ +import json import time +from unittest.mock import AsyncMock, patch import pytest @@ -9,6 +11,7 @@ BasinMetricSet, Gauge, Label, + MetricUnit, S2Basin, S2ServerError, S2Stream, @@ -16,6 +19,57 @@ StreamMetricSet, TimeseriesInterval, ) +from s2_sdk._client import HttpClient, Response + + +@pytest.mark.parametrize( + "interval", [None, TimeseriesInterval.HOUR, TimeseriesInterval.DAY] +) +async def test_account_metrics_estimated_billable_usage( + interval: TimeseriesInterval | None, +): + start, end = 1_700_000_000, 1_700_003_600 + response_interval = interval or TimeseriesInterval.HOUR + response = { + "values": [ + { + "accumulation": { + "name": name, + "unit": "usd", + "interval": response_interval.value, + "values": [[start, 0.012345]], + } + } + for name in ["transfer.writes_standard", "total"] + ] + } + request = AsyncMock(return_value=Response(200, json.dumps(response).encode())) + with patch.object(HttpClient, "unary_request", request): + async with S2("token") as s2: + metrics = await s2.account_metrics( + set=AccountMetricSet.ESTIMATED_BILLABLE_USAGE, + start=start, + end=end, + interval=interval, + ) + + params: dict[str, str | int] = { + "set": "estimated-billable-usage", + "start": start, + "end": end, + } + if interval is not None: + params["interval"] = interval.value + request.assert_awaited_once_with("GET", "/v1/metrics", params=params) + assert metrics == [ + Accumulation( + name=name, + unit=MetricUnit.USD, + interval=response_interval, + values=[(start, 0.012345)], + ) + for name in ["transfer.writes_standard", "total"] + ] def _default_range() -> tuple[int, int]: From 57b5a58e9e27057ba2dc97fcc497ecc87c828fa0 Mon Sep 17 00:00:00 2001 From: Stephen Balogh Date: Wed, 14 Oct 2026 04:11:25 +0000 Subject: [PATCH 2/2] test: keep live account metrics coverage on operational sets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_metrics_ops.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_metrics_ops.py b/tests/test_metrics_ops.py index 97d02fd..3288028 100644 --- a/tests/test_metrics_ops.py +++ b/tests/test_metrics_ops.py @@ -209,9 +209,12 @@ async def test_account_metrics_invalid_time_ranges(self, s2: S2): set=AccountMetricSet.ACTIVE_BASINS, start=now + 3600, end=now ) - async def test_account_metrics_all_sets(self, s2: S2): + async def test_account_metrics_operational_sets(self, s2: S2): start, end = _default_range() - for metric_set in AccountMetricSet: + for metric_set in ( + AccountMetricSet.ACTIVE_BASINS, + AccountMetricSet.ACCOUNT_OPS, + ): metrics = await s2.account_metrics(set=metric_set, start=start, end=end) assert isinstance(metrics, list)