From 627009220b957a2438e2d2fd97b4d5d7ed3e4122 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Fri, 10 Jul 2026 10:15:07 +0200 Subject: [PATCH 01/16] feat(ai-observability): add hourly breakdown to personal spend endpoint Opt-in `hourly=true` query param adds a `by_hour` breakdown to GET /api/llm_analytics/@me/spend/: an hour-ascending UTC series with per-hour cost split into uncached input / output / cache read / cache creation components (plus matching token sums), capped to windows of 8 days or less. Enables clients to render a 24h view where prompt-cache cold starts stand out as cache-creation-dominated bars. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/personal_spend.py | 170 +++++++++++++++++- .../backend/api/test/test_personal_spend.py | 112 ++++++++++++ 2 files changed, 275 insertions(+), 7 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 6d3d934dff11..11154072b4ce 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -7,7 +7,7 @@ query param; see `SUPPORTED_PRODUCTS` for the currently accepted values. Endpoint: -- GET /api/llm_analytics/@me/spend/?product=&date_from=-30d&date_to=&limit=50&refresh=false +- GET /api/llm_analytics/@me/spend/?product=&date_from=-30d&date_to=&limit=50&refresh=false&hourly=false """ from __future__ import annotations @@ -63,6 +63,11 @@ MAX_WINDOW_DAYS = 90 # The most calendar days a MAX_WINDOW_DAYS window can touch: partial days at both edges. BY_DAY_MAX_ROWS = MAX_WINDOW_DAYS + 1 +# Hourly series are only useful (and cheap) over short windows; a "last 24h" or +# "last few days" view is the intended consumer. +BY_HOUR_MAX_WINDOW_DAYS = 8 +# Partial hours at both edges, same reasoning as BY_DAY_MAX_ROWS. +BY_HOUR_MAX_ROWS = BY_HOUR_MAX_WINDOW_DAYS * 24 + 1 _RELATIVE_DATE_RE = re.compile(r"^-?\d+[hdwmqyHDWMQY](Start|End)?$") MIN_LIMIT = 1 @@ -78,9 +83,9 @@ def _internal_team_id() -> int: return settings.LLM_ANALYTICS_INTERNAL_TEAM_ID -def _cache_key(email: str, date_from: str, date_to: str | None, product: str, limit: int) -> str: +def _cache_key(email: str, date_from: str, date_to: str | None, product: str, limit: int, hourly: bool) -> str: to_slot = date_to or "_now" - return f"personal_spend:{email}:{date_from}:{to_slot}:{product}:{limit}" + return f"personal_spend:{email}:{date_from}:{to_slot}:{product}:{limit}:{int(hourly)}" def _parse_date_param(value: str, field: str, now: datetime.datetime) -> datetime.datetime: @@ -158,6 +163,16 @@ class _SpendQueryParamsSerializer(serializers.Serializer): default=False, help_text="If true, bypass the result cache and re-run the underlying queries against ClickHouse.", ) + hourly = serializers.BooleanField( + required=False, + default=False, + help_text=( + "If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the " + "scoped product, with per-hour cost split into uncached input / output / cache read / cache " + "creation components plus the matching token sums. Only allowed for windows of " + f"{BY_HOUR_MAX_WINDOW_DAYS} days or less." + ), + ) def validate_product(self, value: str) -> str: if value not in SUPPORTED_PRODUCTS: @@ -226,6 +241,42 @@ class _DayBreakdownRowSerializer(serializers.Serializer): cost_usd = serializers.FloatField(help_text="Total cost in USD on this day for the scoped product.") +class _HourBreakdownRowSerializer(serializers.Serializer): + hour = serializers.DateTimeField(help_text="UTC hour bucket the events fall in (`toStartOfHour(timestamp)`).") + event_count = serializers.IntegerField( + help_text="Number of $ai_generation + $ai_embedding events in this hour for the scoped product." + ) + cost_usd = serializers.FloatField( + help_text=( + "Total cost in USD in this hour (sum of `$ai_total_cost_usd`). Authoritative: the component " + "columns below can sum to less than this when the cost breakdown was unavailable for some " + "events — render any remainder as uncategorized rather than assuming the components reconcile." + ) + ) + input_cost_usd = serializers.FloatField( + help_text="Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`)." + ) + output_cost_usd = serializers.FloatField(help_text="Cost of output tokens in USD (sum of `$ai_output_cost_usd`).") + cache_read_cost_usd = serializers.FloatField( + help_text="Cost of prompt-cache reads in USD (sum of `$ai_cache_read_cost_usd`)." + ) + cache_creation_cost_usd = serializers.FloatField( + help_text=( + "Cost of prompt-cache writes in USD (sum of `$ai_cache_creation_cost_usd`). A spike here with " + "near-zero cache reads is the signature of a cold session being revived: the full conversation " + "context is re-written to the cache at the cache-write rate instead of being read back cheaply." + ) + ) + input_tokens = serializers.IntegerField(help_text="Sum of uncached `$ai_input_tokens` in this hour.") + output_tokens = serializers.IntegerField(help_text="Sum of `$ai_output_tokens` in this hour.") + cache_read_input_tokens = serializers.IntegerField( + help_text="Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this hour." + ) + cache_creation_input_tokens = serializers.IntegerField( + help_text="Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this hour." + ) + + class _TopTraceRowSerializer(serializers.Serializer): trace_id = serializers.CharField( allow_null=True, @@ -310,6 +361,23 @@ class _DayBreakdownSerializer(serializers.Serializer): ) +class _HourBreakdownSerializer(serializers.Serializer): + items = _HourBreakdownRowSerializer( + many=True, + help_text=( + "One row per UTC hour that has events, ordered by hour ascending. Hours with no events are " + "omitted — zero-fill client-side when rendering a continuous series." + ), + ) + truncated = serializers.BooleanField( + help_text=( + "Effectively always false: `by_hour` ignores `limit` because truncating a time series by " + f"cost would be meaningless, and the {BY_HOUR_MAX_WINDOW_DAYS}-day window cap already bounds " + "the series length." + ) + ) + + class _TopTracesSerializer(serializers.Serializer): items = _TopTraceRowSerializer(many=True, help_text="Rows of top traces by cost, ordered by cost descending.") truncated = serializers.BooleanField( @@ -329,6 +397,13 @@ class PersonalSpendAnalysisResponseSerializer(serializers.Serializer): by_day = _DayBreakdownSerializer( help_text="Spend grouped by UTC day, ordered ascending. Scoped to `product`. Not subject to `limit`." ) + by_hour = _HourBreakdownSerializer( + required=False, + help_text=( + "Spend grouped by UTC hour with per-hour cost/token components, ordered ascending. Scoped to " + "`product`. Only present when the request set `hourly=true`." + ), + ) top_traces = _TopTracesSerializer( help_text=( "Deprecated — always returns `{items: [], truncated: false}`. Trace IDs are opaque strings " @@ -623,6 +698,76 @@ def _fetch_by_day( return _truncate(rows, BY_DAY_MAX_ROWS) +def _fetch_by_hour( + team: Team, + email: str, + from_dt: datetime.datetime, + to_dt: datetime.datetime, + product: str, +) -> dict[str, Any]: + # Cost components come from LiteLLM's cost_breakdown forwarded by the LLM gateway + # ($ai_input_cost_usd is uncached input only — cache read/creation are priced + # separately). Events that went through the token-estimation fallback carry only + # $ai_total_cost_usd, so the components can undershoot cost_usd; the serializer + # help_text tells clients to render the remainder as uncategorized. + query = parse_select( + """ + SELECT + toStartOfHour(timestamp) AS hour, + count() AS event_count, + round(sum(toFloat(properties.$ai_total_cost_usd)), 6) AS cost_usd, + round(sum(toFloat(properties.$ai_input_cost_usd)), 6) AS input_cost_usd, + round(sum(toFloat(properties.$ai_output_cost_usd)), 6) AS output_cost_usd, + round(sum(toFloat(properties.$ai_cache_read_cost_usd)), 6) AS cache_read_cost_usd, + round(sum(toFloat(properties.$ai_cache_creation_cost_usd)), 6) AS cache_creation_cost_usd, + sum(toFloat(properties.$ai_input_tokens)) AS input_tokens, + sum(toFloat(properties.$ai_output_tokens)) AS output_tokens, + sum(toFloat(properties.$ai_cache_read_input_tokens)) AS cache_read_input_tokens, + sum(toFloat(properties.$ai_cache_creation_input_tokens)) AS cache_creation_input_tokens + FROM events + WHERE {event_in} + AND {product_filter} + AND {email_filter} + AND {timestamp_filter} + GROUP BY hour + ORDER BY hour ASC + LIMIT {limit} + """ + ) + result = execute_hogql_query( + query=query, + placeholders={ + "event_in": _event_in(["$ai_generation", "$ai_embedding"]), + "product_filter": _product_filter(product), + "email_filter": _email_filter(email), + "timestamp_filter": _timestamp_filter(from_dt, to_dt), + # Not the request `limit` — same reasoning as by_day. +1 is the truncation probe row. + "limit": ast.Constant(value=BY_HOUR_MAX_ROWS + 1), + }, + team=team, + # Hours are documented as UTC; pin them like by_day does. + modifiers=HogQLQueryModifiers(convertToProjectTimezone=False), + query_type="PersonalSpendByHour", + ) + rows = [ + { + "hour": row[0], + "event_count": int(row[1] or 0), + "cost_usd": float(row[2] or 0.0), + "input_cost_usd": float(row[3] or 0.0), + "output_cost_usd": float(row[4] or 0.0), + "cache_read_cost_usd": float(row[5] or 0.0), + "cache_creation_cost_usd": float(row[6] or 0.0), + "input_tokens": int(row[7] or 0), + "output_tokens": int(row[8] or 0), + "cache_read_input_tokens": int(row[9] or 0), + "cache_creation_input_tokens": int(row[10] or 0), + } + for row in (result.results or []) + ] + return _truncate(rows, BY_HOUR_MAX_ROWS) + + def _compute_spend_analysis( *, email: str, @@ -631,12 +776,17 @@ def _compute_spend_analysis( product: str, limit: int, refresh: bool, + hourly: bool, ) -> dict[str, Any]: """Cached, email-scoped spend analysis shared by the US viewset and the cross-region receiver. Expects already-validated params.""" from_dt, to_dt = _resolve_window(date_from, date_to) + if hourly and (to_dt - from_dt).total_seconds() > BY_HOUR_MAX_WINDOW_DAYS * 86400: + raise exceptions.ValidationError( + {"hourly": f"Hourly breakdown is only available for windows of {BY_HOUR_MAX_WINDOW_DAYS} days or less."} + ) - cache_key = _cache_key(email, date_from, date_to, product, limit) + cache_key = _cache_key(email, date_from, date_to, product, limit, hourly) if not refresh: cached = cache.get(cache_key) @@ -669,6 +819,7 @@ def _compute_spend_analysis( "by_tool": by_tool, "by_model": _fetch_by_model(team, email, from_dt, to_dt, product, limit), "by_day": _fetch_by_day(team, email, from_dt, to_dt, product), + **({"by_hour": _fetch_by_hour(team, email, from_dt, to_dt, product)} if hourly else {}), # Deprecated — trace IDs are opaque and unactionable in the UI. Returned empty so # existing consumers don't crash while they remove the rendering. Drop the field # entirely once no consumer reads it. @@ -754,7 +905,10 @@ class PersonalSpendViewSet(_PersonalSpendUserViewSet): "query param is required and scopes the tool / model / day / trace breakdowns to a single " f"product; supported values: {', '.join(sorted(SUPPORTED_PRODUCTS))}. `by_product` is " "always returned for cross-product visibility. `by_day` returns a day-ascending spend " - "series for the scoped product. Use `refresh=true` to bypass the 5-minute response cache." + "series for the scoped product. Pass `hourly=true` (windows of " + f"{BY_HOUR_MAX_WINDOW_DAYS} days or less) to additionally get `by_hour`, an hour-ascending " + "series with per-hour cost split into uncached input / output / cache read / cache creation " + "components. Use `refresh=true` to bypass the 5-minute response cache." ), tags=["AI observability"], ) @@ -769,7 +923,7 @@ def list(self, request: Request) -> Response: _EU_REDIRECT_TARGET = "https://us.posthog.com/api/llm_analytics/@me/spend/" -_EU_REDIRECT_FORWARDED_PARAMS = frozenset({"date_from", "date_to", "product", "limit", "refresh"}) +_EU_REDIRECT_FORWARDED_PARAMS = frozenset({"date_from", "date_to", "product", "limit", "refresh", "hourly"}) def personal_spend_eu_redirect(request: HttpRequest) -> HttpResponseRedirect: @@ -902,7 +1056,9 @@ def list(self, request: Request) -> HttpResponseBase: data = params.validated_data # Same cache as the US compute path, so repeat loads skip the cross-region hop. - cache_key = _cache_key(email, data["date_from"], data["date_to"], data["product"], data["limit"]) + cache_key = _cache_key( + email, data["date_from"], data["date_to"], data["product"], data["limit"], data["hourly"] + ) if not data["refresh"]: cached = cache.get(cache_key) if cached is not None: diff --git a/products/ai_observability/backend/api/test/test_personal_spend.py b/products/ai_observability/backend/api/test/test_personal_spend.py index f30fbf760858..23f6f1fafdd3 100644 --- a/products/ai_observability/backend/api/test/test_personal_spend.py +++ b/products/ai_observability/backend/api/test/test_personal_spend.py @@ -138,6 +138,16 @@ def test_date_to_before_date_from_rejected(self) -> None: response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=-7d&date_to=-30d") assert response.status_code == status.HTTP_400_BAD_REQUEST + @parameterized.expand( + [ + ("within_cap", "-7d", status.HTTP_200_OK), + ("over_cap", "-30d", status.HTTP_400_BAD_REQUEST), + ] + ) + def test_hourly_window_cap(self, _label: str, date_from: str, expected: int) -> None: + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from={date_from}&hourly=true") + assert response.status_code == expected + def test_product_too_long_rejected(self) -> None: response = self.client.get(f"{ENDPOINT}?product={'x' * 100}") assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -206,6 +216,7 @@ def _create_generation( output_tokens: int = 500, event_name: str = "$ai_generation", timestamp: datetime | None = None, + extra_props: dict | None = None, ) -> None: props: dict = { "$ai_input_tokens": input_tokens, @@ -218,6 +229,8 @@ def _create_generation( props["$ai_total_cost_usd"] = cost if tool is not None: props["$ai_tools_called"] = tool + if extra_props: + props.update(extra_props) kwargs: dict = {} if timestamp is not None: kwargs["timestamp"] = timestamp @@ -460,6 +473,105 @@ def test_by_day_uses_utc_days_regardless_of_team_timezone(self) -> None: response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-10&date_to=2026-06-16") assert [r["day"] for r in response.json()["by_day"]["items"]] == ["2026-06-15"] + def test_by_hour_absent_unless_requested(self) -> None: + response = self.client.get(ENDPOINT_OK) + assert response.status_code == status.HTTP_200_OK + assert "by_hour" not in response.json() + + def test_by_hour_groups_cost_components_per_utc_hour(self) -> None: + warm = datetime(2026, 6, 15, 9, 30, tzinfo=UTC) + cold = datetime(2026, 6, 15, 11, 5, tzinfo=UTC) + # Warm turn: most of the prompt served from cache. + self._create_generation( + cost=1.0, + trace_id="warm", + timestamp=warm, + input_tokens=1000, + output_tokens=500, + extra_props={ + "$ai_input_cost_usd": 0.1, + "$ai_output_cost_usd": 0.2, + "$ai_cache_read_cost_usd": 0.6, + "$ai_cache_creation_cost_usd": 0.1, + "$ai_cache_read_input_tokens": 400000, + "$ai_cache_creation_input_tokens": 20000, + }, + ) + # Cold-revival turn: the whole context re-written to cache, nothing read back. + self._create_generation( + cost=3.0, + trace_id="cold", + timestamp=cold, + input_tokens=2000, + output_tokens=800, + extra_props={ + "$ai_input_cost_usd": 0.2, + "$ai_output_cost_usd": 0.3, + "$ai_cache_read_cost_usd": 0.0, + "$ai_cache_creation_cost_usd": 2.5, + "$ai_cache_read_input_tokens": 0, + "$ai_cache_creation_input_tokens": 500000, + }, + ) + # Same hour, another product: must not leak into the scoped series. + self._create_generation(ai_product="background_agents", cost=99.0, timestamp=cold) + flush_persons_and_events() + + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-15&date_to=2026-06-16&hourly=true") + by_hour = response.json()["by_hour"] + assert by_hour["truncated"] is False + assert by_hour["items"] == [ + { + "hour": "2026-06-15T09:00:00Z", + "event_count": 1, + "cost_usd": 1.0, + "input_cost_usd": 0.1, + "output_cost_usd": 0.2, + "cache_read_cost_usd": 0.6, + "cache_creation_cost_usd": 0.1, + "input_tokens": 1000, + "output_tokens": 500, + "cache_read_input_tokens": 400000, + "cache_creation_input_tokens": 20000, + }, + { + "hour": "2026-06-15T11:00:00Z", + "event_count": 1, + "cost_usd": 3.0, + "input_cost_usd": 0.2, + "output_cost_usd": 0.3, + "cache_read_cost_usd": 0.0, + "cache_creation_cost_usd": 2.5, + "input_tokens": 2000, + "output_tokens": 800, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 500000, + }, + ] + + def test_by_hour_defaults_components_to_zero_when_breakdown_missing(self) -> None: + # Fallback-priced events carry only $ai_total_cost_usd — components must be 0, not an error. + self._create_generation(cost=1.5, timestamp=datetime(2026, 6, 15, 9, 30, tzinfo=UTC)) + flush_persons_and_events() + + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-15&date_to=2026-06-16&hourly=true") + items = response.json()["by_hour"]["items"] + assert len(items) == 1 + assert items[0]["cost_usd"] == 1.5 + assert items[0]["input_cost_usd"] == 0.0 + assert items[0]["cache_creation_cost_usd"] == 0.0 + assert items[0]["cache_read_input_tokens"] == 0 + + def test_cache_key_includes_hourly(self) -> None: + # Without `hourly` in the cache key, this second call would be served the + # cached non-hourly payload and silently drop `by_hour`. + with patch("products.ai_observability.backend.api.personal_spend.execute_hogql_query") as mock_exec: + mock_exec.return_value.results = [] + self.client.get(ENDPOINT_OK) + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&hourly=true") + assert response.status_code == status.HTTP_200_OK + assert "by_hour" in response.json() + def test_by_day_counts_embeddings_and_costless_events(self) -> None: day_one = datetime(2026, 6, 13, 9, 0, tzinfo=UTC) self._create_generation(cost=1.0, timestamp=day_one) From f1aca6cb46cc2da1567a65b3b5d01a5b7caff3ee Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:20:23 +0000 Subject: [PATCH 02/16] chore: update OpenAPI generated types --- .../frontend/generated/api.schemas.ts | 38 +++++++++++++++++++ .../frontend/generated/api.ts | 2 +- services/mcp/src/api/generated.ts | 38 +++++++++++++++++++ .../mcp/src/generated/ai_observability/api.ts | 9 ++++- .../src/tools/generated/ai_observability.ts | 1 + 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/products/ai_observability/frontend/generated/api.schemas.ts b/products/ai_observability/frontend/generated/api.schemas.ts index d6a4d3ed4c98..751f736c62b1 100644 --- a/products/ai_observability/frontend/generated/api.schemas.ts +++ b/products/ai_observability/frontend/generated/api.schemas.ts @@ -105,6 +105,38 @@ export interface _DayBreakdownApi { truncated: boolean } +export interface _HourBreakdownRowApi { + /** UTC hour bucket the events fall in (`toStartOfHour(timestamp)`). */ + hour: string + /** Number of $ai_generation + $ai_embedding events in this hour for the scoped product. */ + event_count: number + /** Total cost in USD in this hour (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ + cost_usd: number + /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ + input_cost_usd: number + /** Cost of output tokens in USD (sum of `$ai_output_cost_usd`). */ + output_cost_usd: number + /** Cost of prompt-cache reads in USD (sum of `$ai_cache_read_cost_usd`). */ + cache_read_cost_usd: number + /** Cost of prompt-cache writes in USD (sum of `$ai_cache_creation_cost_usd`). A spike here with near-zero cache reads is the signature of a cold session being revived: the full conversation context is re-written to the cache at the cache-write rate instead of being read back cheaply. */ + cache_creation_cost_usd: number + /** Sum of uncached `$ai_input_tokens` in this hour. */ + input_tokens: number + /** Sum of `$ai_output_tokens` in this hour. */ + output_tokens: number + /** Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this hour. */ + cache_read_input_tokens: number + /** Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this hour. */ + cache_creation_input_tokens: number +} + +export interface _HourBreakdownApi { + /** One row per UTC hour that has events, ordered by hour ascending. Hours with no events are omitted — zero-fill client-side when rendering a continuous series. */ + items: _HourBreakdownRowApi[] + /** Effectively always false: `by_hour` ignores `limit` because truncating a time series by cost would be meaningless, and the 8-day window cap already bounds the series length. */ + truncated: boolean +} + export interface _TopTraceRowApi { /** * `$ai_trace_id` of the session — opaque string scoped to the originating product. Format is not stable: most are UUIDs but some SDK wrappers emit JSON-shaped strings like `{"device_id":"...","session_id":"..."}`. Callers should treat this as an opaque identifier (URL-encode before linking to a trace view). @@ -143,6 +175,8 @@ export interface PersonalSpendAnalysisResponseApi { by_model: _ModelBreakdownApi /** Spend grouped by UTC day, ordered ascending. Scoped to `product`. Not subject to `limit`. */ by_day: _DayBreakdownApi + /** Spend grouped by UTC hour with per-hour cost/token components, ordered ascending. Scoped to `product`. Only present when the request set `hourly=true`. */ + by_hour?: _HourBreakdownApi /** Deprecated — always returns `{items: [], truncated: false}`. Trace IDs are opaque strings that aren't actionable in the UI. Kept in the response shape so existing consumers don't crash; remove your rendering of this field and we'll drop it from the response entirely in a follow-up. */ top_traces: _TopTracesApi } @@ -2405,6 +2439,10 @@ export type LlmAnalyticsPersonalSpendListParams = { * @nullable */ date_to?: string | null + /** + * If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the scoped product, with per-hour cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Only allowed for windows of 8 days or less. + */ + hourly?: boolean /** * Maximum number of rows to return per breakdown (1-200, defaults to 50). Each breakdown returns up to this many rows ordered by cost descending. Per-breakdown `truncated: true` indicates more rows exist beyond the limit. * @minimum 1 diff --git a/products/ai_observability/frontend/generated/api.ts b/products/ai_observability/frontend/generated/api.ts index 0fb34a81abf7..ff84e62547bc 100644 --- a/products/ai_observability/frontend/generated/api.ts +++ b/products/ai_observability/frontend/generated/api.ts @@ -140,7 +140,7 @@ export const getLlmAnalyticsPersonalSpendListUrl = (params: LlmAnalyticsPersonal } /** - * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Use `refresh=true` to bypass the 5-minute response cache. + * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Pass `hourly=true` (windows of 8 days or less) to additionally get `by_hour`, an hour-ascending series with per-hour cost split into uncached input / output / cache read / cache creation components. Use `refresh=true` to bypass the 5-minute response cache. */ export const llmAnalyticsPersonalSpendList = async ( params: LlmAnalyticsPersonalSpendListParams, diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index b3105b0d6d8a..897d98b3264f 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -45147,6 +45147,38 @@ export namespace Schemas { truncated: boolean; } + export interface _HourBreakdownRow { + /** UTC hour bucket the events fall in (`toStartOfHour(timestamp)`). */ + hour: string; + /** Number of $ai_generation + $ai_embedding events in this hour for the scoped product. */ + event_count: number; + /** Total cost in USD in this hour (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ + cost_usd: number; + /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ + input_cost_usd: number; + /** Cost of output tokens in USD (sum of `$ai_output_cost_usd`). */ + output_cost_usd: number; + /** Cost of prompt-cache reads in USD (sum of `$ai_cache_read_cost_usd`). */ + cache_read_cost_usd: number; + /** Cost of prompt-cache writes in USD (sum of `$ai_cache_creation_cost_usd`). A spike here with near-zero cache reads is the signature of a cold session being revived: the full conversation context is re-written to the cache at the cache-write rate instead of being read back cheaply. */ + cache_creation_cost_usd: number; + /** Sum of uncached `$ai_input_tokens` in this hour. */ + input_tokens: number; + /** Sum of `$ai_output_tokens` in this hour. */ + output_tokens: number; + /** Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this hour. */ + cache_read_input_tokens: number; + /** Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this hour. */ + cache_creation_input_tokens: number; + } + + export interface _HourBreakdown { + /** One row per UTC hour that has events, ordered by hour ascending. Hours with no events are omitted — zero-fill client-side when rendering a continuous series. */ + items: _HourBreakdownRow[]; + /** Effectively always false: `by_hour` ignores `limit` because truncating a time series by cost would be meaningless, and the 8-day window cap already bounds the series length. */ + truncated: boolean; + } + export interface _TopTraceRow { /** * `$ai_trace_id` of the session — opaque string scoped to the originating product. Format is not stable: most are UUIDs but some SDK wrappers emit JSON-shaped strings like `{"device_id":"...","session_id":"..."}`. Callers should treat this as an opaque identifier (URL-encode before linking to a trace view). @@ -45185,6 +45217,8 @@ export namespace Schemas { by_model: _ModelBreakdown; /** Spend grouped by UTC day, ordered ascending. Scoped to `product`. Not subject to `limit`. */ by_day: _DayBreakdown; + /** Spend grouped by UTC hour with per-hour cost/token components, ordered ascending. Scoped to `product`. Only present when the request set `hourly=true`. */ + by_hour?: _HourBreakdown; /** Deprecated — always returns `{items: [], truncated: false}`. Trace IDs are opaque strings that aren't actionable in the UI. Kept in the response shape so existing consumers don't crash; remove your rendering of this field and we'll drop it from the response entirely in a follow-up. */ top_traces: _TopTraces; } @@ -63766,6 +63800,10 @@ export namespace Schemas { * @nullable */ date_to?: string | null; + /** + * If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the scoped product, with per-hour cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Only allowed for windows of 8 days or less. + */ + hourly?: boolean; /** * Maximum number of rows to return per breakdown (1-200, defaults to 50). Each breakdown returns up to this many rows ordered by cost descending. Per-breakdown `truncated: true` indicates more rows exist beyond the limit. * @minimum 1 diff --git a/services/mcp/src/generated/ai_observability/api.ts b/services/mcp/src/generated/ai_observability/api.ts index 54386c70ff4f..8ee327a4a19d 100644 --- a/services/mcp/src/generated/ai_observability/api.ts +++ b/services/mcp/src/generated/ai_observability/api.ts @@ -9,13 +9,14 @@ import * as zod from 'zod' /** - * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Use `refresh=true` to bypass the 5-minute response cache. + * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Pass `hourly=true` (windows of 8 days or less) to additionally get `by_hour`, an hour-ascending series with per-hour cost split into uncached input / output / cache read / cache creation components. Use `refresh=true` to bypass the 5-minute response cache. */ export const llmAnalyticsPersonalSpendListQueryDateFromDefault = `-30d` export const llmAnalyticsPersonalSpendListQueryDateFromMax = 32 export const llmAnalyticsPersonalSpendListQueryDateToMax = 32 +export const llmAnalyticsPersonalSpendListQueryHourlyDefault = false export const llmAnalyticsPersonalSpendListQueryLimitDefault = 50 export const llmAnalyticsPersonalSpendListQueryLimitMax = 200 @@ -37,6 +38,12 @@ export const LlmAnalyticsPersonalSpendListQueryParams = /* @__PURE__ */ zod.obje .max(llmAnalyticsPersonalSpendListQueryDateToMax) .nullish() .describe('End of the spend window. Accepts the same formats as `date_from`. Defaults to `now` when omitted.'), + hourly: zod + .boolean() + .default(llmAnalyticsPersonalSpendListQueryHourlyDefault) + .describe( + 'If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the scoped product, with per-hour cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Only allowed for windows of 8 days or less.' + ), limit: zod .number() .min(1) diff --git a/services/mcp/src/tools/generated/ai_observability.ts b/services/mcp/src/tools/generated/ai_observability.ts index 80dfd99c59a4..6b33232d25eb 100644 --- a/services/mcp/src/tools/generated/ai_observability.ts +++ b/services/mcp/src/tools/generated/ai_observability.ts @@ -723,6 +723,7 @@ const llmaPersonalSpend = (): ToolBase Date: Fri, 10 Jul 2026 10:52:22 +0200 Subject: [PATCH 03/16] feat(ai-observability): generalize hourly spend breakdown to bucket_minutes Replace the hourly=true boolean with a bucket_minutes param (5, 15, 30, or 60) so clients can pick sub-hour resolution: 5-minute buckets match the prompt-cache TTL, isolating a cold-revival spike that hourly buckets would dilute. The by_hour breakdown becomes by_bucket (row field bucket_start, bucket size echoed) grouped via toStartOfInterval, and the window cap is now a flat 600 buckets of the chosen size. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/personal_spend.py | 126 +++++++++++------- .../backend/api/test/test_personal_spend.py | 64 ++++++--- 2 files changed, 118 insertions(+), 72 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 11154072b4ce..9b0bffcc8c51 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -7,7 +7,7 @@ query param; see `SUPPORTED_PRODUCTS` for the currently accepted values. Endpoint: -- GET /api/llm_analytics/@me/spend/?product=&date_from=-30d&date_to=&limit=50&refresh=false&hourly=false +- GET /api/llm_analytics/@me/spend/?product=&date_from=-30d&date_to=&limit=50&refresh=false&bucket_minutes=5 """ from __future__ import annotations @@ -63,11 +63,11 @@ MAX_WINDOW_DAYS = 90 # The most calendar days a MAX_WINDOW_DAYS window can touch: partial days at both edges. BY_DAY_MAX_ROWS = MAX_WINDOW_DAYS + 1 -# Hourly series are only useful (and cheap) over short windows; a "last 24h" or -# "last few days" view is the intended consumer. -BY_HOUR_MAX_WINDOW_DAYS = 8 -# Partial hours at both edges, same reasoning as BY_DAY_MAX_ROWS. -BY_HOUR_MAX_ROWS = BY_HOUR_MAX_WINDOW_DAYS * 24 + 1 +# Sub-day series are only useful (and cheap) over short windows; a "last 24h" +# view is the intended consumer. The cap bounds the series length regardless of +# bucket size: 600 buckets covers 48h at 5-minute buckets and 8+ days hourly. +BUCKET_MINUTES_CHOICES = [5, 15, 30, 60] +MAX_TIME_BUCKETS = 600 _RELATIVE_DATE_RE = re.compile(r"^-?\d+[hdwmqyHDWMQY](Start|End)?$") MIN_LIMIT = 1 @@ -83,9 +83,11 @@ def _internal_team_id() -> int: return settings.LLM_ANALYTICS_INTERNAL_TEAM_ID -def _cache_key(email: str, date_from: str, date_to: str | None, product: str, limit: int, hourly: bool) -> str: +def _cache_key( + email: str, date_from: str, date_to: str | None, product: str, limit: int, bucket_minutes: int | None +) -> str: to_slot = date_to or "_now" - return f"personal_spend:{email}:{date_from}:{to_slot}:{product}:{limit}:{int(hourly)}" + return f"personal_spend:{email}:{date_from}:{to_slot}:{product}:{limit}:{bucket_minutes or 0}" def _parse_date_param(value: str, field: str, now: datetime.datetime) -> datetime.datetime: @@ -163,14 +165,18 @@ class _SpendQueryParamsSerializer(serializers.Serializer): default=False, help_text="If true, bypass the result cache and re-run the underlying queries against ClickHouse.", ) - hourly = serializers.BooleanField( + bucket_minutes = serializers.ChoiceField( + choices=BUCKET_MINUTES_CHOICES, required=False, - default=False, + default=None, + allow_null=True, help_text=( - "If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the " - "scoped product, with per-hour cost split into uncached input / output / cache read / cache " - "creation components plus the matching token sums. Only allowed for windows of " - f"{BY_HOUR_MAX_WINDOW_DAYS} days or less." + "When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for " + "the scoped product at this bucket size in minutes, with per-bucket cost split into uncached " + "input / output / cache read / cache creation components plus the matching token sums. " + f"Supported bucket sizes: {', '.join(str(c) for c in BUCKET_MINUTES_CHOICES)}. The window may " + f"span at most {MAX_TIME_BUCKETS} buckets of the chosen size (e.g. 48 hours at 5-minute " + "buckets)." ), ) @@ -241,14 +247,16 @@ class _DayBreakdownRowSerializer(serializers.Serializer): cost_usd = serializers.FloatField(help_text="Total cost in USD on this day for the scoped product.") -class _HourBreakdownRowSerializer(serializers.Serializer): - hour = serializers.DateTimeField(help_text="UTC hour bucket the events fall in (`toStartOfHour(timestamp)`).") +class _BucketBreakdownRowSerializer(serializers.Serializer): + bucket_start = serializers.DateTimeField( + help_text="UTC start of the time bucket the events fall in (`toStartOfInterval(timestamp, ...)`)." + ) event_count = serializers.IntegerField( - help_text="Number of $ai_generation + $ai_embedding events in this hour for the scoped product." + help_text="Number of $ai_generation + $ai_embedding events in this bucket for the scoped product." ) cost_usd = serializers.FloatField( help_text=( - "Total cost in USD in this hour (sum of `$ai_total_cost_usd`). Authoritative: the component " + "Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component " "columns below can sum to less than this when the cost breakdown was unavailable for some " "events — render any remainder as uncategorized rather than assuming the components reconcile." ) @@ -267,13 +275,13 @@ class _HourBreakdownRowSerializer(serializers.Serializer): "context is re-written to the cache at the cache-write rate instead of being read back cheaply." ) ) - input_tokens = serializers.IntegerField(help_text="Sum of uncached `$ai_input_tokens` in this hour.") - output_tokens = serializers.IntegerField(help_text="Sum of `$ai_output_tokens` in this hour.") + input_tokens = serializers.IntegerField(help_text="Sum of uncached `$ai_input_tokens` in this bucket.") + output_tokens = serializers.IntegerField(help_text="Sum of `$ai_output_tokens` in this bucket.") cache_read_input_tokens = serializers.IntegerField( - help_text="Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this hour." + help_text="Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this bucket." ) cache_creation_input_tokens = serializers.IntegerField( - help_text="Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this hour." + help_text="Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this bucket." ) @@ -361,19 +369,22 @@ class _DayBreakdownSerializer(serializers.Serializer): ) -class _HourBreakdownSerializer(serializers.Serializer): - items = _HourBreakdownRowSerializer( +class _BucketBreakdownSerializer(serializers.Serializer): + items = _BucketBreakdownRowSerializer( many=True, help_text=( - "One row per UTC hour that has events, ordered by hour ascending. Hours with no events are " - "omitted — zero-fill client-side when rendering a continuous series." + "One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with " + "no events are omitted — zero-fill client-side when rendering a continuous series." ), ) + bucket_minutes = serializers.IntegerField( + help_text="Bucket size in minutes the series was computed at — echoes the request `bucket_minutes`." + ) truncated = serializers.BooleanField( help_text=( - "Effectively always false: `by_hour` ignores `limit` because truncating a time series by " - f"cost would be meaningless, and the {BY_HOUR_MAX_WINDOW_DAYS}-day window cap already bounds " - "the series length." + "Effectively always false: `by_bucket` ignores `limit` because truncating a time series by " + f"cost would be meaningless, and the {MAX_TIME_BUCKETS}-bucket window cap already bounds the " + "series length." ) ) @@ -397,11 +408,11 @@ class PersonalSpendAnalysisResponseSerializer(serializers.Serializer): by_day = _DayBreakdownSerializer( help_text="Spend grouped by UTC day, ordered ascending. Scoped to `product`. Not subject to `limit`." ) - by_hour = _HourBreakdownSerializer( + by_bucket = _BucketBreakdownSerializer( required=False, help_text=( - "Spend grouped by UTC hour with per-hour cost/token components, ordered ascending. Scoped to " - "`product`. Only present when the request set `hourly=true`." + "Spend grouped by UTC time bucket with per-bucket cost/token components, ordered ascending. " + "Scoped to `product`. Only present when the request set `bucket_minutes`." ), ) top_traces = _TopTracesSerializer( @@ -698,12 +709,13 @@ def _fetch_by_day( return _truncate(rows, BY_DAY_MAX_ROWS) -def _fetch_by_hour( +def _fetch_by_bucket( team: Team, email: str, from_dt: datetime.datetime, to_dt: datetime.datetime, product: str, + bucket_minutes: int, ) -> dict[str, Any]: # Cost components come from LiteLLM's cost_breakdown forwarded by the LLM gateway # ($ai_input_cost_usd is uncached input only — cache read/creation are priced @@ -713,7 +725,7 @@ def _fetch_by_hour( query = parse_select( """ SELECT - toStartOfHour(timestamp) AS hour, + toStartOfInterval(timestamp, toIntervalMinute({bucket_minutes})) AS bucket_start, count() AS event_count, round(sum(toFloat(properties.$ai_total_cost_usd)), 6) AS cost_usd, round(sum(toFloat(properties.$ai_input_cost_usd)), 6) AS input_cost_usd, @@ -729,29 +741,30 @@ def _fetch_by_hour( AND {product_filter} AND {email_filter} AND {timestamp_filter} - GROUP BY hour - ORDER BY hour ASC + GROUP BY bucket_start + ORDER BY bucket_start ASC LIMIT {limit} """ ) result = execute_hogql_query( query=query, placeholders={ + "bucket_minutes": ast.Constant(value=bucket_minutes), "event_in": _event_in(["$ai_generation", "$ai_embedding"]), "product_filter": _product_filter(product), "email_filter": _email_filter(email), "timestamp_filter": _timestamp_filter(from_dt, to_dt), # Not the request `limit` — same reasoning as by_day. +1 is the truncation probe row. - "limit": ast.Constant(value=BY_HOUR_MAX_ROWS + 1), + "limit": ast.Constant(value=MAX_TIME_BUCKETS + 2), }, team=team, - # Hours are documented as UTC; pin them like by_day does. + # Buckets are documented as UTC; pin them like by_day does. modifiers=HogQLQueryModifiers(convertToProjectTimezone=False), - query_type="PersonalSpendByHour", + query_type="PersonalSpendByBucket", ) rows = [ { - "hour": row[0], + "bucket_start": row[0], "event_count": int(row[1] or 0), "cost_usd": float(row[2] or 0.0), "input_cost_usd": float(row[3] or 0.0), @@ -765,7 +778,8 @@ def _fetch_by_hour( } for row in (result.results or []) ] - return _truncate(rows, BY_HOUR_MAX_ROWS) + # +1: a MAX_TIME_BUCKETS-sized window can touch one extra partial bucket at the edge. + return {**_truncate(rows, MAX_TIME_BUCKETS + 1), "bucket_minutes": bucket_minutes} def _compute_spend_analysis( @@ -776,17 +790,23 @@ def _compute_spend_analysis( product: str, limit: int, refresh: bool, - hourly: bool, + bucket_minutes: int | None, ) -> dict[str, Any]: """Cached, email-scoped spend analysis shared by the US viewset and the cross-region receiver. Expects already-validated params.""" from_dt, to_dt = _resolve_window(date_from, date_to) - if hourly and (to_dt - from_dt).total_seconds() > BY_HOUR_MAX_WINDOW_DAYS * 86400: + if bucket_minutes is not None and (to_dt - from_dt).total_seconds() > bucket_minutes * 60 * MAX_TIME_BUCKETS: + max_hours = bucket_minutes * MAX_TIME_BUCKETS // 60 raise exceptions.ValidationError( - {"hourly": f"Hourly breakdown is only available for windows of {BY_HOUR_MAX_WINDOW_DAYS} days or less."} + { + "bucket_minutes": ( + f"A window this large would exceed {MAX_TIME_BUCKETS} buckets at {bucket_minutes}-minute " + f"resolution — narrow the window to {max_hours} hours or less, or pick a larger bucket size." + ) + } ) - cache_key = _cache_key(email, date_from, date_to, product, limit, hourly) + cache_key = _cache_key(email, date_from, date_to, product, limit, bucket_minutes) if not refresh: cached = cache.get(cache_key) @@ -819,7 +839,11 @@ def _compute_spend_analysis( "by_tool": by_tool, "by_model": _fetch_by_model(team, email, from_dt, to_dt, product, limit), "by_day": _fetch_by_day(team, email, from_dt, to_dt, product), - **({"by_hour": _fetch_by_hour(team, email, from_dt, to_dt, product)} if hourly else {}), + **( + {"by_bucket": _fetch_by_bucket(team, email, from_dt, to_dt, product, bucket_minutes)} + if bucket_minutes is not None + else {} + ), # Deprecated — trace IDs are opaque and unactionable in the UI. Returned empty so # existing consumers don't crash while they remove the rendering. Drop the field # entirely once no consumer reads it. @@ -905,9 +929,9 @@ class PersonalSpendViewSet(_PersonalSpendUserViewSet): "query param is required and scopes the tool / model / day / trace breakdowns to a single " f"product; supported values: {', '.join(sorted(SUPPORTED_PRODUCTS))}. `by_product` is " "always returned for cross-product visibility. `by_day` returns a day-ascending spend " - "series for the scoped product. Pass `hourly=true` (windows of " - f"{BY_HOUR_MAX_WINDOW_DAYS} days or less) to additionally get `by_hour`, an hour-ascending " - "series with per-hour cost split into uncached input / output / cache read / cache creation " + "series for the scoped product. Pass `bucket_minutes` (5, 15, 30, or 60; the window may span " + f"at most {MAX_TIME_BUCKETS} buckets) to additionally get `by_bucket`, a time-ascending " + "series with per-bucket cost split into uncached input / output / cache read / cache creation " "components. Use `refresh=true` to bypass the 5-minute response cache." ), tags=["AI observability"], @@ -923,7 +947,7 @@ def list(self, request: Request) -> Response: _EU_REDIRECT_TARGET = "https://us.posthog.com/api/llm_analytics/@me/spend/" -_EU_REDIRECT_FORWARDED_PARAMS = frozenset({"date_from", "date_to", "product", "limit", "refresh", "hourly"}) +_EU_REDIRECT_FORWARDED_PARAMS = frozenset({"date_from", "date_to", "product", "limit", "refresh", "bucket_minutes"}) def personal_spend_eu_redirect(request: HttpRequest) -> HttpResponseRedirect: @@ -1057,7 +1081,7 @@ def list(self, request: Request) -> HttpResponseBase: # Same cache as the US compute path, so repeat loads skip the cross-region hop. cache_key = _cache_key( - email, data["date_from"], data["date_to"], data["product"], data["limit"], data["hourly"] + email, data["date_from"], data["date_to"], data["product"], data["limit"], data["bucket_minutes"] ) if not data["refresh"]: cached = cache.get(cache_key) diff --git a/products/ai_observability/backend/api/test/test_personal_spend.py b/products/ai_observability/backend/api/test/test_personal_spend.py index 23f6f1fafdd3..cb3faa319fc9 100644 --- a/products/ai_observability/backend/api/test/test_personal_spend.py +++ b/products/ai_observability/backend/api/test/test_personal_spend.py @@ -140,14 +140,20 @@ def test_date_to_before_date_from_rejected(self) -> None: @parameterized.expand( [ - ("within_cap", "-7d", status.HTTP_200_OK), - ("over_cap", "-30d", status.HTTP_400_BAD_REQUEST), + ("hourly_within_cap", 60, "-7d", status.HTTP_200_OK), + ("hourly_over_cap", 60, "-30d", status.HTTP_400_BAD_REQUEST), + ("five_min_within_cap", 5, "-1d", status.HTTP_200_OK), + ("five_min_over_cap", 5, "-7d", status.HTTP_400_BAD_REQUEST), ] ) - def test_hourly_window_cap(self, _label: str, date_from: str, expected: int) -> None: - response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from={date_from}&hourly=true") + def test_bucket_window_cap(self, _label: str, bucket_minutes: int, date_from: str, expected: int) -> None: + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from={date_from}&bucket_minutes={bucket_minutes}") assert response.status_code == expected + def test_unsupported_bucket_size_rejected(self) -> None: + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&bucket_minutes=7") + assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_product_too_long_rejected(self) -> None: response = self.client.get(f"{ENDPOINT}?product={'x' * 100}") assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -473,12 +479,12 @@ def test_by_day_uses_utc_days_regardless_of_team_timezone(self) -> None: response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-10&date_to=2026-06-16") assert [r["day"] for r in response.json()["by_day"]["items"]] == ["2026-06-15"] - def test_by_hour_absent_unless_requested(self) -> None: + def test_by_bucket_absent_unless_requested(self) -> None: response = self.client.get(ENDPOINT_OK) assert response.status_code == status.HTTP_200_OK - assert "by_hour" not in response.json() + assert "by_bucket" not in response.json() - def test_by_hour_groups_cost_components_per_utc_hour(self) -> None: + def test_by_bucket_groups_cost_components_per_utc_hour(self) -> None: warm = datetime(2026, 6, 15, 9, 30, tzinfo=UTC) cold = datetime(2026, 6, 15, 11, 5, tzinfo=UTC) # Warm turn: most of the prompt served from cache. @@ -517,12 +523,13 @@ def test_by_hour_groups_cost_components_per_utc_hour(self) -> None: self._create_generation(ai_product="background_agents", cost=99.0, timestamp=cold) flush_persons_and_events() - response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-15&date_to=2026-06-16&hourly=true") - by_hour = response.json()["by_hour"] - assert by_hour["truncated"] is False - assert by_hour["items"] == [ + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-15&date_to=2026-06-16&bucket_minutes=60") + by_bucket = response.json()["by_bucket"] + assert by_bucket["truncated"] is False + assert by_bucket["bucket_minutes"] == 60 + assert by_bucket["items"] == [ { - "hour": "2026-06-15T09:00:00Z", + "bucket_start": "2026-06-15T09:00:00Z", "event_count": 1, "cost_usd": 1.0, "input_cost_usd": 0.1, @@ -535,7 +542,7 @@ def test_by_hour_groups_cost_components_per_utc_hour(self) -> None: "cache_creation_input_tokens": 20000, }, { - "hour": "2026-06-15T11:00:00Z", + "bucket_start": "2026-06-15T11:00:00Z", "event_count": 1, "cost_usd": 3.0, "input_cost_usd": 0.2, @@ -549,28 +556,43 @@ def test_by_hour_groups_cost_components_per_utc_hour(self) -> None: }, ] - def test_by_hour_defaults_components_to_zero_when_breakdown_missing(self) -> None: + def test_by_bucket_five_minute_buckets_split_within_the_hour(self) -> None: + # Two calls 15 minutes apart share an hourly bucket but must split at 5-minute + # resolution — this is what isolates a cold-revival spike from surrounding traffic. + self._create_generation(cost=1.0, trace_id="a", timestamp=datetime(2026, 6, 15, 9, 2, tzinfo=UTC)) + self._create_generation(cost=3.0, trace_id="b", timestamp=datetime(2026, 6, 15, 9, 17, tzinfo=UTC)) + flush_persons_and_events() + + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-15&date_to=2026-06-16&bucket_minutes=5") + by_bucket = response.json()["by_bucket"] + assert by_bucket["bucket_minutes"] == 5 + assert [(r["bucket_start"], r["cost_usd"]) for r in by_bucket["items"]] == [ + ("2026-06-15T09:00:00Z", 1.0), + ("2026-06-15T09:15:00Z", 3.0), + ] + + def test_by_bucket_defaults_components_to_zero_when_breakdown_missing(self) -> None: # Fallback-priced events carry only $ai_total_cost_usd — components must be 0, not an error. self._create_generation(cost=1.5, timestamp=datetime(2026, 6, 15, 9, 30, tzinfo=UTC)) flush_persons_and_events() - response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-15&date_to=2026-06-16&hourly=true") - items = response.json()["by_hour"]["items"] + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=2026-06-15&date_to=2026-06-16&bucket_minutes=60") + items = response.json()["by_bucket"]["items"] assert len(items) == 1 assert items[0]["cost_usd"] == 1.5 assert items[0]["input_cost_usd"] == 0.0 assert items[0]["cache_creation_cost_usd"] == 0.0 assert items[0]["cache_read_input_tokens"] == 0 - def test_cache_key_includes_hourly(self) -> None: - # Without `hourly` in the cache key, this second call would be served the - # cached non-hourly payload and silently drop `by_hour`. + def test_cache_key_includes_bucket_minutes(self) -> None: + # Without `bucket_minutes` in the cache key, this second call would be served + # the cached bucketless payload and silently drop `by_bucket`. with patch("products.ai_observability.backend.api.personal_spend.execute_hogql_query") as mock_exec: mock_exec.return_value.results = [] self.client.get(ENDPOINT_OK) - response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&hourly=true") + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&bucket_minutes=60") assert response.status_code == status.HTTP_200_OK - assert "by_hour" in response.json() + assert "by_bucket" in response.json() def test_by_day_counts_embeddings_and_costless_events(self) -> None: day_one = datetime(2026, 6, 13, 9, 0, tzinfo=UTC) From 86e23a3325b2f36b873c6a0c34508fb9b4ef9fc5 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:56:49 +0000 Subject: [PATCH 04/16] chore: update OpenAPI generated types --- .../frontend/generated/api.schemas.ts | 57 ++++++++++++------- .../frontend/generated/api.ts | 2 +- services/mcp/src/api/generated.ts | 56 +++++++++++------- .../mcp/src/generated/ai_observability/api.ts | 15 +++-- .../src/tools/generated/ai_observability.ts | 2 +- 5 files changed, 84 insertions(+), 48 deletions(-) diff --git a/products/ai_observability/frontend/generated/api.schemas.ts b/products/ai_observability/frontend/generated/api.schemas.ts index 751f736c62b1..c5430df37652 100644 --- a/products/ai_observability/frontend/generated/api.schemas.ts +++ b/products/ai_observability/frontend/generated/api.schemas.ts @@ -105,12 +105,12 @@ export interface _DayBreakdownApi { truncated: boolean } -export interface _HourBreakdownRowApi { - /** UTC hour bucket the events fall in (`toStartOfHour(timestamp)`). */ - hour: string - /** Number of $ai_generation + $ai_embedding events in this hour for the scoped product. */ +export interface _BucketBreakdownRowApi { + /** UTC start of the time bucket the events fall in (`toStartOfInterval(timestamp, ...)`). */ + bucket_start: string + /** Number of $ai_generation + $ai_embedding events in this bucket for the scoped product. */ event_count: number - /** Total cost in USD in this hour (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ + /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ input_cost_usd: number @@ -120,20 +120,22 @@ export interface _HourBreakdownRowApi { cache_read_cost_usd: number /** Cost of prompt-cache writes in USD (sum of `$ai_cache_creation_cost_usd`). A spike here with near-zero cache reads is the signature of a cold session being revived: the full conversation context is re-written to the cache at the cache-write rate instead of being read back cheaply. */ cache_creation_cost_usd: number - /** Sum of uncached `$ai_input_tokens` in this hour. */ + /** Sum of uncached `$ai_input_tokens` in this bucket. */ input_tokens: number - /** Sum of `$ai_output_tokens` in this hour. */ + /** Sum of `$ai_output_tokens` in this bucket. */ output_tokens: number - /** Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this hour. */ + /** Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this bucket. */ cache_read_input_tokens: number - /** Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this hour. */ + /** Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this bucket. */ cache_creation_input_tokens: number } -export interface _HourBreakdownApi { - /** One row per UTC hour that has events, ordered by hour ascending. Hours with no events are omitted — zero-fill client-side when rendering a continuous series. */ - items: _HourBreakdownRowApi[] - /** Effectively always false: `by_hour` ignores `limit` because truncating a time series by cost would be meaningless, and the 8-day window cap already bounds the series length. */ +export interface _BucketBreakdownApi { + /** One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with no events are omitted — zero-fill client-side when rendering a continuous series. */ + items: _BucketBreakdownRowApi[] + /** Bucket size in minutes the series was computed at — echoes the request `bucket_minutes`. */ + bucket_minutes: number + /** Effectively always false: `by_bucket` ignores `limit` because truncating a time series by cost would be meaningless, and the 600-bucket window cap already bounds the series length. */ truncated: boolean } @@ -175,8 +177,8 @@ export interface PersonalSpendAnalysisResponseApi { by_model: _ModelBreakdownApi /** Spend grouped by UTC day, ordered ascending. Scoped to `product`. Not subject to `limit`. */ by_day: _DayBreakdownApi - /** Spend grouped by UTC hour with per-hour cost/token components, ordered ascending. Scoped to `product`. Only present when the request set `hourly=true`. */ - by_hour?: _HourBreakdownApi + /** Spend grouped by UTC time bucket with per-bucket cost/token components, ordered ascending. Scoped to `product`. Only present when the request set `bucket_minutes`. */ + by_bucket?: _BucketBreakdownApi /** Deprecated — always returns `{items: [], truncated: false}`. Trace IDs are opaque strings that aren't actionable in the UI. Kept in the response shape so existing consumers don't crash; remove your rendering of this field and we'll drop it from the response entirely in a follow-up. */ top_traces: _TopTracesApi } @@ -2427,6 +2429,16 @@ export interface TestHogTaggerResponseApi { } export type LlmAnalyticsPersonalSpendListParams = { + /** + * When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets). + * + * * `5` - 5 + * * `15` - 15 + * * `30` - 30 + * * `60` - 60 + * @nullable + */ + bucket_minutes?: LlmAnalyticsPersonalSpendListBucketMinutes /** * Start of the spend window. Accepts absolute dates (`2026-04-23`) or relative strings (`-7d`, `-1m`, etc.) — same parser used elsewhere in PostHog. Defaults to `-30d`. The window between `date_from` and `date_to` cannot exceed 90 days. * @minLength 1 @@ -2439,10 +2451,6 @@ export type LlmAnalyticsPersonalSpendListParams = { * @nullable */ date_to?: string | null - /** - * If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the scoped product, with per-hour cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Only allowed for windows of 8 days or less. - */ - hourly?: boolean /** * Maximum number of rows to return per breakdown (1-200, defaults to 50). Each breakdown returns up to this many rows ordered by cost descending. Per-breakdown `truncated: true` indicates more rows exist beyond the limit. * @minimum 1 @@ -2461,6 +2469,17 @@ export type LlmAnalyticsPersonalSpendListParams = { refresh?: boolean } +export type LlmAnalyticsPersonalSpendListBucketMinutes = + | (typeof LlmAnalyticsPersonalSpendListBucketMinutes)[keyof typeof LlmAnalyticsPersonalSpendListBucketMinutes] + | null + +export const LlmAnalyticsPersonalSpendListBucketMinutes = { + Number5: 5, + Number15: 15, + Number30: 30, + Number60: 60, +} as const + export type DatasetItemsListParams = { /** * Filter by dataset ID diff --git a/products/ai_observability/frontend/generated/api.ts b/products/ai_observability/frontend/generated/api.ts index ff84e62547bc..c40f7ab54417 100644 --- a/products/ai_observability/frontend/generated/api.ts +++ b/products/ai_observability/frontend/generated/api.ts @@ -140,7 +140,7 @@ export const getLlmAnalyticsPersonalSpendListUrl = (params: LlmAnalyticsPersonal } /** - * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Pass `hourly=true` (windows of 8 days or less) to additionally get `by_hour`, an hour-ascending series with per-hour cost split into uncached input / output / cache read / cache creation components. Use `refresh=true` to bypass the 5-minute response cache. + * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Pass `bucket_minutes` (5, 15, 30, or 60; the window may span at most 600 buckets) to additionally get `by_bucket`, a time-ascending series with per-bucket cost split into uncached input / output / cache read / cache creation components. Use `refresh=true` to bypass the 5-minute response cache. */ export const llmAnalyticsPersonalSpendList = async ( params: LlmAnalyticsPersonalSpendListParams, diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 897d98b3264f..7ff7072ef301 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -45147,12 +45147,12 @@ export namespace Schemas { truncated: boolean; } - export interface _HourBreakdownRow { - /** UTC hour bucket the events fall in (`toStartOfHour(timestamp)`). */ - hour: string; - /** Number of $ai_generation + $ai_embedding events in this hour for the scoped product. */ + export interface _BucketBreakdownRow { + /** UTC start of the time bucket the events fall in (`toStartOfInterval(timestamp, ...)`). */ + bucket_start: string; + /** Number of $ai_generation + $ai_embedding events in this bucket for the scoped product. */ event_count: number; - /** Total cost in USD in this hour (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ + /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number; /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ input_cost_usd: number; @@ -45162,20 +45162,22 @@ export namespace Schemas { cache_read_cost_usd: number; /** Cost of prompt-cache writes in USD (sum of `$ai_cache_creation_cost_usd`). A spike here with near-zero cache reads is the signature of a cold session being revived: the full conversation context is re-written to the cache at the cache-write rate instead of being read back cheaply. */ cache_creation_cost_usd: number; - /** Sum of uncached `$ai_input_tokens` in this hour. */ + /** Sum of uncached `$ai_input_tokens` in this bucket. */ input_tokens: number; - /** Sum of `$ai_output_tokens` in this hour. */ + /** Sum of `$ai_output_tokens` in this bucket. */ output_tokens: number; - /** Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this hour. */ + /** Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this bucket. */ cache_read_input_tokens: number; - /** Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this hour. */ + /** Sum of `$ai_cache_creation_input_tokens` (prompt tokens written to cache) in this bucket. */ cache_creation_input_tokens: number; } - export interface _HourBreakdown { - /** One row per UTC hour that has events, ordered by hour ascending. Hours with no events are omitted — zero-fill client-side when rendering a continuous series. */ - items: _HourBreakdownRow[]; - /** Effectively always false: `by_hour` ignores `limit` because truncating a time series by cost would be meaningless, and the 8-day window cap already bounds the series length. */ + export interface _BucketBreakdown { + /** One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with no events are omitted — zero-fill client-side when rendering a continuous series. */ + items: _BucketBreakdownRow[]; + /** Bucket size in minutes the series was computed at — echoes the request `bucket_minutes`. */ + bucket_minutes: number; + /** Effectively always false: `by_bucket` ignores `limit` because truncating a time series by cost would be meaningless, and the 600-bucket window cap already bounds the series length. */ truncated: boolean; } @@ -45217,8 +45219,8 @@ export namespace Schemas { by_model: _ModelBreakdown; /** Spend grouped by UTC day, ordered ascending. Scoped to `product`. Not subject to `limit`. */ by_day: _DayBreakdown; - /** Spend grouped by UTC hour with per-hour cost/token components, ordered ascending. Scoped to `product`. Only present when the request set `hourly=true`. */ - by_hour?: _HourBreakdown; + /** Spend grouped by UTC time bucket with per-bucket cost/token components, ordered ascending. Scoped to `product`. Only present when the request set `bucket_minutes`. */ + by_bucket?: _BucketBreakdown; /** Deprecated — always returns `{items: [], truncated: false}`. Trace IDs are opaque strings that aren't actionable in the UI. Kept in the response shape so existing consumers don't crash; remove your rendering of this field and we'll drop it from the response entirely in a follow-up. */ top_traces: _TopTraces; } @@ -63788,6 +63790,16 @@ export namespace Schemas { }; export type LlmAnalyticsPersonalSpendListParams = { + /** + * When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets). + * + * * `5` - 5 + * * `15` - 15 + * * `30` - 30 + * * `60` - 60 + * @nullable + */ + bucket_minutes?: LlmAnalyticsPersonalSpendListBucketMinutes; /** * Start of the spend window. Accepts absolute dates (`2026-04-23`) or relative strings (`-7d`, `-1m`, etc.) — same parser used elsewhere in PostHog. Defaults to `-30d`. The window between `date_from` and `date_to` cannot exceed 90 days. * @minLength 1 @@ -63800,10 +63812,6 @@ export namespace Schemas { * @nullable */ date_to?: string | null; - /** - * If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the scoped product, with per-hour cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Only allowed for windows of 8 days or less. - */ - hourly?: boolean; /** * Maximum number of rows to return per breakdown (1-200, defaults to 50). Each breakdown returns up to this many rows ordered by cost descending. Per-breakdown `truncated: true` indicates more rows exist beyond the limit. * @minimum 1 @@ -63822,6 +63830,16 @@ export namespace Schemas { refresh?: boolean; }; + export type LlmAnalyticsPersonalSpendListBucketMinutes = typeof LlmAnalyticsPersonalSpendListBucketMinutes[keyof typeof LlmAnalyticsPersonalSpendListBucketMinutes] | null; + + + export const LlmAnalyticsPersonalSpendListBucketMinutes = { + Number5: 5, + Number15: 15, + Number30: 30, + Number60: 60, + } as const; + export type ListParams = { /** * Number of results to return per page. diff --git a/services/mcp/src/generated/ai_observability/api.ts b/services/mcp/src/generated/ai_observability/api.ts index 8ee327a4a19d..ec2af9ed329c 100644 --- a/services/mcp/src/generated/ai_observability/api.ts +++ b/services/mcp/src/generated/ai_observability/api.ts @@ -9,14 +9,13 @@ import * as zod from 'zod' /** - * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Pass `hourly=true` (windows of 8 days or less) to additionally get `by_hour`, an hour-ascending series with per-hour cost split into uncached input / output / cache read / cache creation components. Use `refresh=true` to bypass the 5-minute response cache. + * Return a structured personal LLM spend analysis for the requesting user. Pass `date_from` / `date_to` (absolute like `2026-04-23` or relative like `-7d`) to bound the window — defaults to the last 30 days, max 90 days. The `product=` query param is required and scopes the tool / model / day / trace breakdowns to a single product; supported values: posthog_code. `by_product` is always returned for cross-product visibility. `by_day` returns a day-ascending spend series for the scoped product. Pass `bucket_minutes` (5, 15, 30, or 60; the window may span at most 600 buckets) to additionally get `by_bucket`, a time-ascending series with per-bucket cost split into uncached input / output / cache read / cache creation components. Use `refresh=true` to bypass the 5-minute response cache. */ export const llmAnalyticsPersonalSpendListQueryDateFromDefault = `-30d` export const llmAnalyticsPersonalSpendListQueryDateFromMax = 32 export const llmAnalyticsPersonalSpendListQueryDateToMax = 32 -export const llmAnalyticsPersonalSpendListQueryHourlyDefault = false export const llmAnalyticsPersonalSpendListQueryLimitDefault = 50 export const llmAnalyticsPersonalSpendListQueryLimitMax = 200 @@ -25,6 +24,12 @@ export const llmAnalyticsPersonalSpendListQueryProductMax = 64 export const llmAnalyticsPersonalSpendListQueryRefreshDefault = false export const LlmAnalyticsPersonalSpendListQueryParams = /* @__PURE__ */ zod.object({ + bucket_minutes: zod + .union([zod.literal(5), zod.literal(15), zod.literal(30), zod.literal(60), zod.literal(null)]) + .nullish() + .describe( + 'When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets).\n\n* `5` - 5\n* `15` - 15\n* `30` - 30\n* `60` - 60' + ), date_from: zod .string() .min(1) @@ -38,12 +43,6 @@ export const LlmAnalyticsPersonalSpendListQueryParams = /* @__PURE__ */ zod.obje .max(llmAnalyticsPersonalSpendListQueryDateToMax) .nullish() .describe('End of the spend window. Accepts the same formats as `date_from`. Defaults to `now` when omitted.'), - hourly: zod - .boolean() - .default(llmAnalyticsPersonalSpendListQueryHourlyDefault) - .describe( - 'If true, additionally return a `by_hour` breakdown: an hour-ascending UTC cost series for the scoped product, with per-hour cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Only allowed for windows of 8 days or less.' - ), limit: zod .number() .min(1) diff --git a/services/mcp/src/tools/generated/ai_observability.ts b/services/mcp/src/tools/generated/ai_observability.ts index 6b33232d25eb..1a73c32b725a 100644 --- a/services/mcp/src/tools/generated/ai_observability.ts +++ b/services/mcp/src/tools/generated/ai_observability.ts @@ -721,9 +721,9 @@ const llmaPersonalSpend = (): ToolBase Date: Fri, 10 Jul 2026 11:30:55 +0200 Subject: [PATCH 05/16] chore(mcp): update personal-spend tool schema snapshot for bucket_minutes Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../tool-schemas/llma-personal-spend.json | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json index 1af547772fbd..bf01cbb6beb4 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json @@ -1,6 +1,38 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "bucket_minutes": { + "anyOf": [ + { + "anyOf": [ + { + "const": 5, + "type": "number" + }, + { + "const": 15, + "type": "number" + }, + { + "const": 30, + "type": "number" + }, + { + "const": 60, + "type": "number" + }, + { + "const": null, + "type": "null" + } + ] + }, + { + "type": "null" + } + ], + "description": "When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets).\n\n* `5` - 5\n* `15` - 15\n* `30` - 30\n* `60` - 60" + }, "date_from": { "default": "-30d", "description": "Start of the spend window. Accepts absolute dates (`2026-04-23`) or relative strings (`-7d`, `-1m`, etc.) — same parser used elsewhere in PostHog. Defaults to `-30d`. The window between `date_from` and `date_to` cannot exceed 90 days.", From a07b005d68b02823e81466fcc21787d87820dfd5 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Fri, 10 Jul 2026 12:53:31 +0200 Subject: [PATCH 06/16] fix(ai-observability): keep cache-key test window under the bucket cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test used the default 30-day window, which at 60-minute buckets is 720 buckets — rejected by the endpoint's own 600-bucket cap with a 400. Pin the window to a day so the test exercises the cache-key behavior it targets instead of the window validation. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/test/test_personal_spend.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/products/ai_observability/backend/api/test/test_personal_spend.py b/products/ai_observability/backend/api/test/test_personal_spend.py index cb3faa319fc9..c93bcefd63e4 100644 --- a/products/ai_observability/backend/api/test/test_personal_spend.py +++ b/products/ai_observability/backend/api/test/test_personal_spend.py @@ -586,11 +586,12 @@ def test_by_bucket_defaults_components_to_zero_when_breakdown_missing(self) -> N def test_cache_key_includes_bucket_minutes(self) -> None: # Without `bucket_minutes` in the cache key, this second call would be served - # the cached bucketless payload and silently drop `by_bucket`. + # the cached bucketless payload and silently drop `by_bucket`. The window must + # stay under the 600-bucket cap, so pin it to a day rather than the 30d default. with patch("products.ai_observability.backend.api.personal_spend.execute_hogql_query") as mock_exec: mock_exec.return_value.results = [] - self.client.get(ENDPOINT_OK) - response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&bucket_minutes=60") + self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=-1d") + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from=-1d&bucket_minutes=60") assert response.status_code == status.HTTP_200_OK assert "by_bucket" in response.json() From edbb2d9cfe3a2ce8b374ab721b39f45dc58a9210 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Fri, 10 Jul 2026 13:38:49 +0200 Subject: [PATCH 07/16] fix(ai-observability): address review on the bucket_minutes param Drop allow_null from bucket_minutes: a nullable query param makes generated clients advertise null, which serializes to the literal string "null" in a GET query and gets rejected. The EU proxy now omits None-valued params from the signed cross-region body accordingly. Cap validation now counts the bucket starts the window touches (unaligned edges add partial buckets) instead of comparing durations, so by_bucket can never return more than 600 rows; fetch and truncate limits match. Also correct the cap arithmetic in docs (600 buckets is 50 hours at 5-minute resolution, not 48) and drop em dashes from the added API text. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/personal_spend.py | 52 +++++++++++-------- .../backend/api/test/test_personal_spend.py | 14 +++++ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 9b0bffcc8c51..56d14b4a7152 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -65,7 +65,7 @@ BY_DAY_MAX_ROWS = MAX_WINDOW_DAYS + 1 # Sub-day series are only useful (and cheap) over short windows; a "last 24h" # view is the intended consumer. The cap bounds the series length regardless of -# bucket size: 600 buckets covers 48h at 5-minute buckets and 8+ days hourly. +# bucket size: 600 buckets is 50 hours at 5-minute buckets and 25 days hourly. BUCKET_MINUTES_CHOICES = [5, 15, 30, 60] MAX_TIME_BUCKETS = 600 _RELATIVE_DATE_RE = re.compile(r"^-?\d+[hdwmqyHDWMQY](Start|End)?$") @@ -165,17 +165,18 @@ class _SpendQueryParamsSerializer(serializers.Serializer): default=False, help_text="If true, bypass the result cache and re-run the underlying queries against ClickHouse.", ) + # No allow_null: nullable would make generated clients advertise `bucket_minutes: null`, + # which serializes to the literal string "null" in a GET query and gets rejected. bucket_minutes = serializers.ChoiceField( choices=BUCKET_MINUTES_CHOICES, required=False, default=None, - allow_null=True, help_text=( "When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for " "the scoped product at this bucket size in minutes, with per-bucket cost split into uncached " "input / output / cache read / cache creation components plus the matching token sums. " f"Supported bucket sizes: {', '.join(str(c) for c in BUCKET_MINUTES_CHOICES)}. The window may " - f"span at most {MAX_TIME_BUCKETS} buckets of the chosen size (e.g. 48 hours at 5-minute " + f"span at most {MAX_TIME_BUCKETS} buckets of the chosen size (e.g. 50 hours at 5-minute " "buckets)." ), ) @@ -258,7 +259,7 @@ class _BucketBreakdownRowSerializer(serializers.Serializer): help_text=( "Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component " "columns below can sum to less than this when the cost breakdown was unavailable for some " - "events — render any remainder as uncategorized rather than assuming the components reconcile." + "events; render any remainder as uncategorized rather than assuming the components reconcile." ) ) input_cost_usd = serializers.FloatField( @@ -374,11 +375,11 @@ class _BucketBreakdownSerializer(serializers.Serializer): many=True, help_text=( "One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with " - "no events are omitted — zero-fill client-side when rendering a continuous series." + "no events are omitted; zero-fill client-side when rendering a continuous series." ), ) bucket_minutes = serializers.IntegerField( - help_text="Bucket size in minutes the series was computed at — echoes the request `bucket_minutes`." + help_text="Bucket size in minutes the series was computed at; echoes the request `bucket_minutes`." ) truncated = serializers.BooleanField( help_text=( @@ -718,7 +719,7 @@ def _fetch_by_bucket( bucket_minutes: int, ) -> dict[str, Any]: # Cost components come from LiteLLM's cost_breakdown forwarded by the LLM gateway - # ($ai_input_cost_usd is uncached input only — cache read/creation are priced + # ($ai_input_cost_usd is uncached input only; cache read/creation are priced # separately). Events that went through the token-estimation fallback carry only # $ai_total_cost_usd, so the components can undershoot cost_usd; the serializer # help_text tells clients to render the remainder as uncategorized. @@ -754,8 +755,8 @@ def _fetch_by_bucket( "product_filter": _product_filter(product), "email_filter": _email_filter(email), "timestamp_filter": _timestamp_filter(from_dt, to_dt), - # Not the request `limit` — same reasoning as by_day. +1 is the truncation probe row. - "limit": ast.Constant(value=MAX_TIME_BUCKETS + 2), + # Not the request `limit`; same reasoning as by_day. +1 is the truncation probe row. + "limit": ast.Constant(value=MAX_TIME_BUCKETS + 1), }, team=team, # Buckets are documented as UTC; pin them like by_day does. @@ -778,8 +779,7 @@ def _fetch_by_bucket( } for row in (result.results or []) ] - # +1: a MAX_TIME_BUCKETS-sized window can touch one extra partial bucket at the edge. - return {**_truncate(rows, MAX_TIME_BUCKETS + 1), "bucket_minutes": bucket_minutes} + return {**_truncate(rows, MAX_TIME_BUCKETS), "bucket_minutes": bucket_minutes} def _compute_spend_analysis( @@ -795,16 +795,22 @@ def _compute_spend_analysis( """Cached, email-scoped spend analysis shared by the US viewset and the cross-region receiver. Expects already-validated params.""" from_dt, to_dt = _resolve_window(date_from, date_to) - if bucket_minutes is not None and (to_dt - from_dt).total_seconds() > bucket_minutes * 60 * MAX_TIME_BUCKETS: - max_hours = bucket_minutes * MAX_TIME_BUCKETS // 60 - raise exceptions.ValidationError( - { - "bucket_minutes": ( - f"A window this large would exceed {MAX_TIME_BUCKETS} buckets at {bucket_minutes}-minute " - f"resolution — narrow the window to {max_hours} hours or less, or pick a larger bucket size." - ) - } - ) + if bucket_minutes is not None: + bucket_seconds = bucket_minutes * 60 + # Count the bucket starts the window touches (unaligned edges add partial + # buckets), so `by_bucket` can never return more than MAX_TIME_BUCKETS rows. + n_buckets = int(to_dt.timestamp() // bucket_seconds) - int(from_dt.timestamp() // bucket_seconds) + 1 + if n_buckets > MAX_TIME_BUCKETS: + max_hours = bucket_minutes * MAX_TIME_BUCKETS // 60 + raise exceptions.ValidationError( + { + "bucket_minutes": ( + f"A window this large would span more than {MAX_TIME_BUCKETS} buckets at " + f"{bucket_minutes}-minute resolution; narrow the window to under {max_hours} hours, " + "or pick a larger bucket size." + ) + } + ) cache_key = _cache_key(email, date_from, date_to, product, limit, bucket_minutes) @@ -1088,7 +1094,9 @@ def list(self, request: Request) -> HttpResponseBase: if cached is not None: return Response(cached, status=status.HTTP_200_OK) - body = json.dumps({**data, "email": email}).encode("utf-8") + # Omit None-valued params (serializer defaults) so the internal receiver's + # non-nullable fields (e.g. bucket_minutes) accept the payload. + body = json.dumps({**{k: v for k, v in data.items() if v is not None}, "email": email}).encode("utf-8") signature, ts = sign_cross_region_spend_request(body, secret) try: upstream = requests.post( diff --git a/products/ai_observability/backend/api/test/test_personal_spend.py b/products/ai_observability/backend/api/test/test_personal_spend.py index c93bcefd63e4..3f6779770c92 100644 --- a/products/ai_observability/backend/api/test/test_personal_spend.py +++ b/products/ai_observability/backend/api/test/test_personal_spend.py @@ -154,6 +154,20 @@ def test_unsupported_bucket_size_rejected(self) -> None: response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&bucket_minutes=7") assert response.status_code == status.HTTP_400_BAD_REQUEST + @parameterized.expand( + [ + # 25 days is exactly 600 hourly buckets of duration, but the half-hour offset + # touches 601 bucket starts; a duration-only check would let 601 rows through. + ("unaligned_at_cap", "2026-06-01T00:30:00", "2026-06-26T00:30:00", status.HTTP_400_BAD_REQUEST), + ("aligned_under_cap", "2026-06-01T00:00:00", "2026-06-25T23:30:00", status.HTTP_200_OK), + ] + ) + def test_bucket_cap_counts_partial_edge_buckets( + self, _label: str, date_from: str, date_to: str, expected: int + ) -> None: + response = self.client.get(f"{ENDPOINT}?{PRODUCT_QS}&date_from={date_from}&date_to={date_to}&bucket_minutes=60") + assert response.status_code == expected + def test_product_too_long_rejected(self) -> None: response = self.client.get(f"{ENDPOINT}?product={'x' * 100}") assert response.status_code == status.HTTP_400_BAD_REQUEST From ffb503951fcd912a2d71bf124f7aa2aaf802b739 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:43:18 +0000 Subject: [PATCH 08/16] chore: update OpenAPI generated types --- .../frontend/generated/api.schemas.ts | 12 +++++------- services/mcp/src/api/generated.ts | 11 +++++------ services/mcp/src/generated/ai_observability/api.ts | 6 +++--- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/products/ai_observability/frontend/generated/api.schemas.ts b/products/ai_observability/frontend/generated/api.schemas.ts index c5430df37652..aed64539a828 100644 --- a/products/ai_observability/frontend/generated/api.schemas.ts +++ b/products/ai_observability/frontend/generated/api.schemas.ts @@ -110,7 +110,7 @@ export interface _BucketBreakdownRowApi { bucket_start: string /** Number of $ai_generation + $ai_embedding events in this bucket for the scoped product. */ event_count: number - /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ + /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events; render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ input_cost_usd: number @@ -131,9 +131,9 @@ export interface _BucketBreakdownRowApi { } export interface _BucketBreakdownApi { - /** One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with no events are omitted — zero-fill client-side when rendering a continuous series. */ + /** One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with no events are omitted; zero-fill client-side when rendering a continuous series. */ items: _BucketBreakdownRowApi[] - /** Bucket size in minutes the series was computed at — echoes the request `bucket_minutes`. */ + /** Bucket size in minutes the series was computed at; echoes the request `bucket_minutes`. */ bucket_minutes: number /** Effectively always false: `by_bucket` ignores `limit` because truncating a time series by cost would be meaningless, and the 600-bucket window cap already bounds the series length. */ truncated: boolean @@ -2430,13 +2430,12 @@ export interface TestHogTaggerResponseApi { export type LlmAnalyticsPersonalSpendListParams = { /** - * When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets). + * When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 50 hours at 5-minute buckets). * * * `5` - 5 * * `15` - 15 * * `30` - 30 * * `60` - 60 - * @nullable */ bucket_minutes?: LlmAnalyticsPersonalSpendListBucketMinutes /** @@ -2470,8 +2469,7 @@ export type LlmAnalyticsPersonalSpendListParams = { } export type LlmAnalyticsPersonalSpendListBucketMinutes = - | (typeof LlmAnalyticsPersonalSpendListBucketMinutes)[keyof typeof LlmAnalyticsPersonalSpendListBucketMinutes] - | null + (typeof LlmAnalyticsPersonalSpendListBucketMinutes)[keyof typeof LlmAnalyticsPersonalSpendListBucketMinutes] export const LlmAnalyticsPersonalSpendListBucketMinutes = { Number5: 5, diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 7ff7072ef301..aa11ac986b2b 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -45152,7 +45152,7 @@ export namespace Schemas { bucket_start: string; /** Number of $ai_generation + $ai_embedding events in this bucket for the scoped product. */ event_count: number; - /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events — render any remainder as uncategorized rather than assuming the components reconcile. */ + /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events; render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number; /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ input_cost_usd: number; @@ -45173,9 +45173,9 @@ export namespace Schemas { } export interface _BucketBreakdown { - /** One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with no events are omitted — zero-fill client-side when rendering a continuous series. */ + /** One row per UTC time bucket that has events, ordered by bucket start ascending. Buckets with no events are omitted; zero-fill client-side when rendering a continuous series. */ items: _BucketBreakdownRow[]; - /** Bucket size in minutes the series was computed at — echoes the request `bucket_minutes`. */ + /** Bucket size in minutes the series was computed at; echoes the request `bucket_minutes`. */ bucket_minutes: number; /** Effectively always false: `by_bucket` ignores `limit` because truncating a time series by cost would be meaningless, and the 600-bucket window cap already bounds the series length. */ truncated: boolean; @@ -63791,13 +63791,12 @@ export namespace Schemas { export type LlmAnalyticsPersonalSpendListParams = { /** - * When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets). + * When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 50 hours at 5-minute buckets). * * * `5` - 5 * * `15` - 15 * * `30` - 30 * * `60` - 60 - * @nullable */ bucket_minutes?: LlmAnalyticsPersonalSpendListBucketMinutes; /** @@ -63830,7 +63829,7 @@ export namespace Schemas { refresh?: boolean; }; - export type LlmAnalyticsPersonalSpendListBucketMinutes = typeof LlmAnalyticsPersonalSpendListBucketMinutes[keyof typeof LlmAnalyticsPersonalSpendListBucketMinutes] | null; + export type LlmAnalyticsPersonalSpendListBucketMinutes = typeof LlmAnalyticsPersonalSpendListBucketMinutes[keyof typeof LlmAnalyticsPersonalSpendListBucketMinutes]; export const LlmAnalyticsPersonalSpendListBucketMinutes = { diff --git a/services/mcp/src/generated/ai_observability/api.ts b/services/mcp/src/generated/ai_observability/api.ts index ec2af9ed329c..295f821869f8 100644 --- a/services/mcp/src/generated/ai_observability/api.ts +++ b/services/mcp/src/generated/ai_observability/api.ts @@ -25,10 +25,10 @@ export const llmAnalyticsPersonalSpendListQueryRefreshDefault = false export const LlmAnalyticsPersonalSpendListQueryParams = /* @__PURE__ */ zod.object({ bucket_minutes: zod - .union([zod.literal(5), zod.literal(15), zod.literal(30), zod.literal(60), zod.literal(null)]) - .nullish() + .union([zod.literal(5), zod.literal(15), zod.literal(30), zod.literal(60)]) + .optional() .describe( - 'When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets).\n\n* `5` - 5\n* `15` - 15\n* `30` - 30\n* `60` - 60' + 'When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 50 hours at 5-minute buckets).\n\n* `5` - 5\n* `15` - 15\n* `30` - 30\n* `60` - 60' ), date_from: zod .string() From af974952979d3a8e1e5ffcab02921557b7ea03e7 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 15 Jul 2026 09:00:46 +0200 Subject: [PATCH 09/16] fix(ai-observability): address second review round on bucket_minutes Drop the serializer default (None) so drf-spectacular emits no default for the param and generated clients cannot read it as nullable; omitted now means the key is absent from validated_data, with the compute function defaulting it. Append the cache-key bucket slot only when the param is set, so pre-existing bucketless cache keys remain valid. Also refresh the MCP tool-schema snapshot against the regenerated types. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/personal_spend.py | 16 +++++--- .../tool-schemas/llma-personal-spend.json | 37 +++++++------------ 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 56d14b4a7152..8ccee33cc5b8 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -87,7 +87,9 @@ def _cache_key( email: str, date_from: str, date_to: str | None, product: str, limit: int, bucket_minutes: int | None ) -> str: to_slot = date_to or "_now" - return f"personal_spend:{email}:{date_from}:{to_slot}:{product}:{limit}:{bucket_minutes or 0}" + # Suffix only when set, so bucketless requests keep their pre-bucket_minutes cache keys. + bucket_slot = f":{bucket_minutes}" if bucket_minutes else "" + return f"personal_spend:{email}:{date_from}:{to_slot}:{product}:{limit}{bucket_slot}" def _parse_date_param(value: str, field: str, now: datetime.datetime) -> datetime.datetime: @@ -165,12 +167,12 @@ class _SpendQueryParamsSerializer(serializers.Serializer): default=False, help_text="If true, bypass the result cache and re-run the underlying queries against ClickHouse.", ) - # No allow_null: nullable would make generated clients advertise `bucket_minutes: null`, - # which serializes to the literal string "null" in a GET query and gets rejected. + # No allow_null and no default: either would mark the generated schema nullable, and + # typed clients would then advertise `bucket_minutes: null`, which serializes to the + # literal string "null" in a GET query and gets rejected. Omitted means "no by_bucket". bucket_minutes = serializers.ChoiceField( choices=BUCKET_MINUTES_CHOICES, required=False, - default=None, help_text=( "When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for " "the scoped product at this bucket size in minutes, with per-bucket cost split into uncached " @@ -790,7 +792,9 @@ def _compute_spend_analysis( product: str, limit: int, refresh: bool, - bucket_minutes: int | None, + # Defaulted: callers splat validated_data, which omits the key entirely when + # the request didn't set it (the field has no serializer-level default). + bucket_minutes: int | None = None, ) -> dict[str, Any]: """Cached, email-scoped spend analysis shared by the US viewset and the cross-region receiver. Expects already-validated params.""" @@ -1087,7 +1091,7 @@ def list(self, request: Request) -> HttpResponseBase: # Same cache as the US compute path, so repeat loads skip the cross-region hop. cache_key = _cache_key( - email, data["date_from"], data["date_to"], data["product"], data["limit"], data["bucket_minutes"] + email, data["date_from"], data["date_to"], data["product"], data["limit"], data.get("bucket_minutes") ) if not data["refresh"]: cached = cache.get(cache_key) diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json index bf01cbb6beb4..e97c5a6d6fbd 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/llma-personal-spend.json @@ -4,34 +4,23 @@ "bucket_minutes": { "anyOf": [ { - "anyOf": [ - { - "const": 5, - "type": "number" - }, - { - "const": 15, - "type": "number" - }, - { - "const": 30, - "type": "number" - }, - { - "const": 60, - "type": "number" - }, - { - "const": null, - "type": "null" - } - ] + "const": 5, + "type": "number" }, { - "type": "null" + "const": 15, + "type": "number" + }, + { + "const": 30, + "type": "number" + }, + { + "const": 60, + "type": "number" } ], - "description": "When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 48 hours at 5-minute buckets).\n\n* `5` - 5\n* `15` - 15\n* `30` - 30\n* `60` - 60" + "description": "When set, additionally return a `by_bucket` breakdown: a time-ascending UTC cost series for the scoped product at this bucket size in minutes, with per-bucket cost split into uncached input / output / cache read / cache creation components plus the matching token sums. Supported bucket sizes: 5, 15, 30, 60. The window may span at most 600 buckets of the chosen size (e.g. 50 hours at 5-minute buckets).\n\n* `5` - 5\n* `15` - 15\n* `30` - 30\n* `60` - 60" }, "date_from": { "default": "-30d", From af4fa150894d58d46ac378e104f2549146dc46fa Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 15 Jul 2026 09:25:27 +0200 Subject: [PATCH 10/16] fix(ai-observability): allow cap-aligned bucket windows, document fallback cost folding date_to is exclusive (timestamp < date_to), so a window whose end lands exactly on a bucket boundary can never produce a row in that final bucket; the cap check no longer counts it, letting users request the full advertised 600-bucket window. Added a boundary test. Also document on input_cost_usd that the uncached split only holds for gateway-provided cost breakdowns: events priced by PostHog's ingestion pipeline fold cache costs into input cost and leave the cache columns at 0. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/personal_spend.py | 19 +++++++++++++++---- .../backend/api/test/test_personal_spend.py | 3 +++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 8ccee33cc5b8..701de97d48e6 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -265,7 +265,11 @@ class _BucketBreakdownRowSerializer(serializers.Serializer): ) ) input_cost_usd = serializers.FloatField( - help_text="Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`)." + help_text=( + "Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). Only a true " + "uncached split when the event carried a gateway-provided cost breakdown; events priced by " + "PostHog's ingestion pipeline fold cache costs into this figure and leave the cache columns at 0." + ) ) output_cost_usd = serializers.FloatField(help_text="Cost of output tokens in USD (sum of `$ai_output_cost_usd`).") cache_read_cost_usd = serializers.FloatField( @@ -801,9 +805,16 @@ def _compute_spend_analysis( from_dt, to_dt = _resolve_window(date_from, date_to) if bucket_minutes is not None: bucket_seconds = bucket_minutes * 60 - # Count the bucket starts the window touches (unaligned edges add partial - # buckets), so `by_bucket` can never return more than MAX_TIME_BUCKETS rows. - n_buckets = int(to_dt.timestamp() // bucket_seconds) - int(from_dt.timestamp() // bucket_seconds) + 1 + # Count the bucket starts the window can actually produce rows for (unaligned + # edges add partial buckets), so `by_bucket` never exceeds MAX_TIME_BUCKETS rows. + from_bucket = int(from_dt.timestamp() // bucket_seconds) + to_ts = to_dt.timestamp() + last_bucket = int(to_ts // bucket_seconds) + if to_ts % bucket_seconds == 0: + # `date_to` is exclusive (`timestamp < date_to`), so an end aligned exactly + # on a bucket boundary can never produce a row in its own bucket. + last_bucket -= 1 + n_buckets = last_bucket - from_bucket + 1 if n_buckets > MAX_TIME_BUCKETS: max_hours = bucket_minutes * MAX_TIME_BUCKETS // 60 raise exceptions.ValidationError( diff --git a/products/ai_observability/backend/api/test/test_personal_spend.py b/products/ai_observability/backend/api/test/test_personal_spend.py index 3f6779770c92..b265bf24c74f 100644 --- a/products/ai_observability/backend/api/test/test_personal_spend.py +++ b/products/ai_observability/backend/api/test/test_personal_spend.py @@ -160,6 +160,9 @@ def test_unsupported_bucket_size_rejected(self) -> None: # touches 601 bucket starts; a duration-only check would let 601 rows through. ("unaligned_at_cap", "2026-06-01T00:30:00", "2026-06-26T00:30:00", status.HTTP_400_BAD_REQUEST), ("aligned_under_cap", "2026-06-01T00:00:00", "2026-06-25T23:30:00", status.HTTP_200_OK), + # date_to is exclusive, so an end aligned exactly on a bucket boundary never + # reaches its own bucket: the full advertised 600-bucket window must pass. + ("aligned_at_cap_exclusive_end", "2026-06-01T00:00:00", "2026-06-26T00:00:00", status.HTTP_200_OK), ] ) def test_bucket_cap_counts_partial_edge_buckets( From f77575a37325e8b118b3b3b050e026d61f137d68 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:29:48 +0000 Subject: [PATCH 11/16] chore: update OpenAPI generated types --- products/ai_observability/frontend/generated/api.schemas.ts | 2 +- services/mcp/src/api/generated.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/products/ai_observability/frontend/generated/api.schemas.ts b/products/ai_observability/frontend/generated/api.schemas.ts index aed64539a828..b15981d757c3 100644 --- a/products/ai_observability/frontend/generated/api.schemas.ts +++ b/products/ai_observability/frontend/generated/api.schemas.ts @@ -112,7 +112,7 @@ export interface _BucketBreakdownRowApi { event_count: number /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events; render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number - /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ + /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). Only a true uncached split when the event carried a gateway-provided cost breakdown; events priced by PostHog's ingestion pipeline fold cache costs into this figure and leave the cache columns at 0. */ input_cost_usd: number /** Cost of output tokens in USD (sum of `$ai_output_cost_usd`). */ output_cost_usd: number diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index aa11ac986b2b..c87082f480f8 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -45154,7 +45154,7 @@ export namespace Schemas { event_count: number; /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events; render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number; - /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). */ + /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). Only a true uncached split when the event carried a gateway-provided cost breakdown; events priced by PostHog's ingestion pipeline fold cache costs into this figure and leave the cache columns at 0. */ input_cost_usd: number; /** Cost of output tokens in USD (sum of `$ai_output_cost_usd`). */ output_cost_usd: number; From a21d166ee7cee5b0f49c731bf179914c9891d396 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 15 Jul 2026 09:52:40 +0200 Subject: [PATCH 12/16] fix(ai-observability): derive uncached input cost in by_bucket The stored $ai_input_cost_usd includes prompt-cache read/write costs: on events carrying the cache cost columns, input + output reconciles to total and the cache costs sit inside input. Summing all four component columns therefore double counted cache spend, inflating stacked charts. by_bucket now derives uncached input per event as input minus cache read/write, clamped at zero so a future switch to exclusive reporting degrades to undercounting instead of double-subtracting. The four components are now disjoint and sum to cost_usd when the breakdown is present. The test fixture encodes the real inclusive semantics, so the assertions fail without the derivation. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/personal_spend.py | 25 +++++++++++++------ .../backend/api/test/test_personal_spend.py | 11 +++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 701de97d48e6..69b128569568 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -266,9 +266,10 @@ class _BucketBreakdownRowSerializer(serializers.Serializer): ) input_cost_usd = serializers.FloatField( help_text=( - "Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). Only a true " - "uncached split when the event carried a gateway-provided cost breakdown; events priced by " - "PostHog's ingestion pipeline fold cache costs into this figure and leave the cache columns at 0." + "Cost of uncached (full-price) input tokens in USD, derived per event as `$ai_input_cost_usd` " + "minus the cache read/write costs (the stored input cost includes them), clamped at zero. " + "The four component columns are disjoint: they sum to `cost_usd` when the full breakdown is " + "present, so they can be stacked without double counting cache costs." ) ) output_cost_usd = serializers.FloatField(help_text="Cost of output tokens in USD (sum of `$ai_output_cost_usd`).") @@ -724,9 +725,14 @@ def _fetch_by_bucket( product: str, bucket_minutes: int, ) -> dict[str, Any]: - # Cost components come from LiteLLM's cost_breakdown forwarded by the LLM gateway - # ($ai_input_cost_usd is uncached input only; cache read/creation are priced - # separately). Events that went through the token-estimation fallback carry only + # The stored $ai_input_cost_usd INCLUDES prompt-cache read/write costs: on events + # carrying the cache cost columns, input + output reconciles to total and the cache + # costs sit inside input (verified against production events; the ingestion cost + # pipeline prices cache tokens inside input cost the same way). Uncached input is + # therefore derived per event as input minus cache read/write, clamped at zero so a + # future switch to exclusive reporting degrades to undercounting rather than + # double-subtracting. Events without the cache columns carry no cached tokens, so + # the subtraction is a no-op there. Fallback-priced events carry only # $ai_total_cost_usd, so the components can undershoot cost_usd; the serializer # help_text tells clients to render the remainder as uncategorized. query = parse_select( @@ -735,7 +741,12 @@ def _fetch_by_bucket( toStartOfInterval(timestamp, toIntervalMinute({bucket_minutes})) AS bucket_start, count() AS event_count, round(sum(toFloat(properties.$ai_total_cost_usd)), 6) AS cost_usd, - round(sum(toFloat(properties.$ai_input_cost_usd)), 6) AS input_cost_usd, + round(sum(greatest( + toFloat(properties.$ai_input_cost_usd) + - coalesce(toFloat(properties.$ai_cache_read_cost_usd), 0) + - coalesce(toFloat(properties.$ai_cache_creation_cost_usd), 0), + 0 + )), 6) AS input_cost_usd, round(sum(toFloat(properties.$ai_output_cost_usd)), 6) AS output_cost_usd, round(sum(toFloat(properties.$ai_cache_read_cost_usd)), 6) AS cache_read_cost_usd, round(sum(toFloat(properties.$ai_cache_creation_cost_usd)), 6) AS cache_creation_cost_usd, diff --git a/products/ai_observability/backend/api/test/test_personal_spend.py b/products/ai_observability/backend/api/test/test_personal_spend.py index b265bf24c74f..109a4c9f30aa 100644 --- a/products/ai_observability/backend/api/test/test_personal_spend.py +++ b/products/ai_observability/backend/api/test/test_personal_spend.py @@ -504,7 +504,9 @@ def test_by_bucket_absent_unless_requested(self) -> None: def test_by_bucket_groups_cost_components_per_utc_hour(self) -> None: warm = datetime(2026, 6, 15, 9, 30, tzinfo=UTC) cold = datetime(2026, 6, 15, 11, 5, tzinfo=UTC) - # Warm turn: most of the prompt served from cache. + # As stored on real events, $ai_input_cost_usd INCLUDES the cache read/write + # costs (input + output = total); the endpoint must derive the uncached split. + # Warm turn: most of the prompt served from cache (0.1 uncached inside 0.8). self._create_generation( cost=1.0, trace_id="warm", @@ -512,7 +514,7 @@ def test_by_bucket_groups_cost_components_per_utc_hour(self) -> None: input_tokens=1000, output_tokens=500, extra_props={ - "$ai_input_cost_usd": 0.1, + "$ai_input_cost_usd": 0.8, "$ai_output_cost_usd": 0.2, "$ai_cache_read_cost_usd": 0.6, "$ai_cache_creation_cost_usd": 0.1, @@ -520,7 +522,8 @@ def test_by_bucket_groups_cost_components_per_utc_hour(self) -> None: "$ai_cache_creation_input_tokens": 20000, }, ) - # Cold-revival turn: the whole context re-written to cache, nothing read back. + # Cold-revival turn: the whole context re-written to cache, nothing read back + # (0.2 uncached inside 2.7). self._create_generation( cost=3.0, trace_id="cold", @@ -528,7 +531,7 @@ def test_by_bucket_groups_cost_components_per_utc_hour(self) -> None: input_tokens=2000, output_tokens=800, extra_props={ - "$ai_input_cost_usd": 0.2, + "$ai_input_cost_usd": 2.7, "$ai_output_cost_usd": 0.3, "$ai_cache_read_cost_usd": 0.0, "$ai_cache_creation_cost_usd": 2.5, From 71c9c01990c57150c76b38938159f80ab9403524 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:09:23 +0000 Subject: [PATCH 13/16] chore: update OpenAPI generated types --- products/ai_observability/frontend/generated/api.schemas.ts | 2 +- services/mcp/src/api/generated.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/products/ai_observability/frontend/generated/api.schemas.ts b/products/ai_observability/frontend/generated/api.schemas.ts index b15981d757c3..5d96754d7160 100644 --- a/products/ai_observability/frontend/generated/api.schemas.ts +++ b/products/ai_observability/frontend/generated/api.schemas.ts @@ -112,7 +112,7 @@ export interface _BucketBreakdownRowApi { event_count: number /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events; render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number - /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). Only a true uncached split when the event carried a gateway-provided cost breakdown; events priced by PostHog's ingestion pipeline fold cache costs into this figure and leave the cache columns at 0. */ + /** Cost of uncached (full-price) input tokens in USD, derived per event as `$ai_input_cost_usd` minus the cache read/write costs (the stored input cost includes them), clamped at zero. The four component columns are disjoint: they sum to `cost_usd` when the full breakdown is present, so they can be stacked without double counting cache costs. */ input_cost_usd: number /** Cost of output tokens in USD (sum of `$ai_output_cost_usd`). */ output_cost_usd: number diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index c87082f480f8..7abd383210f8 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -45154,7 +45154,7 @@ export namespace Schemas { event_count: number; /** Total cost in USD in this bucket (sum of `$ai_total_cost_usd`). Authoritative: the component columns below can sum to less than this when the cost breakdown was unavailable for some events; render any remainder as uncategorized rather than assuming the components reconcile. */ cost_usd: number; - /** Cost of uncached (full-price) input tokens in USD (sum of `$ai_input_cost_usd`). Only a true uncached split when the event carried a gateway-provided cost breakdown; events priced by PostHog's ingestion pipeline fold cache costs into this figure and leave the cache columns at 0. */ + /** Cost of uncached (full-price) input tokens in USD, derived per event as `$ai_input_cost_usd` minus the cache read/write costs (the stored input cost includes them), clamped at zero. The four component columns are disjoint: they sum to `cost_usd` when the full breakdown is present, so they can be stacked without double counting cache costs. */ input_cost_usd: number; /** Cost of output tokens in USD (sum of `$ai_output_cost_usd`). */ output_cost_usd: number; From 2cbd7c434fe93518be4090d37563064d0b6230f6 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 15 Jul 2026 11:00:08 +0200 Subject: [PATCH 14/16] fix(ai-observability): document provider-dependent token semantics on by_bucket input_tokens claimed to be uncached, but whether cached tokens are included in $ai_input_tokens follows the provider's reporting ($ai_cache_reporting_exclusive): Anthropic-style events exclude them, OpenAI-style events include them. Describe the raw semantics and warn against stacking with the cache token sums instead of normalizing on a flag that is not reliably present. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- products/ai_observability/backend/api/personal_spend.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 69b128569568..d5c871fd6a24 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -283,7 +283,13 @@ class _BucketBreakdownRowSerializer(serializers.Serializer): "context is re-written to the cache at the cache-write rate instead of being read back cheaply." ) ) - input_tokens = serializers.IntegerField(help_text="Sum of uncached `$ai_input_tokens` in this bucket.") + input_tokens = serializers.IntegerField( + help_text=( + "Sum of `$ai_input_tokens` in this bucket. Whether cached tokens are included follows the " + "provider's reporting (`$ai_cache_reporting_exclusive`): Anthropic-style events exclude them, " + "OpenAI-style events include them, so don't stack this with the cache token sums." + ) + ) output_tokens = serializers.IntegerField(help_text="Sum of `$ai_output_tokens` in this bucket.") cache_read_input_tokens = serializers.IntegerField( help_text="Sum of `$ai_cache_read_input_tokens` (prompt tokens served from cache) in this bucket." From f76895389baeba1d358fab92b159926a58a7ec01 Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:04:38 +0000 Subject: [PATCH 15/16] chore: update OpenAPI generated types --- products/ai_observability/frontend/generated/api.schemas.ts | 2 +- services/mcp/src/api/generated.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/products/ai_observability/frontend/generated/api.schemas.ts b/products/ai_observability/frontend/generated/api.schemas.ts index 5d96754d7160..0ff0411109bf 100644 --- a/products/ai_observability/frontend/generated/api.schemas.ts +++ b/products/ai_observability/frontend/generated/api.schemas.ts @@ -120,7 +120,7 @@ export interface _BucketBreakdownRowApi { cache_read_cost_usd: number /** Cost of prompt-cache writes in USD (sum of `$ai_cache_creation_cost_usd`). A spike here with near-zero cache reads is the signature of a cold session being revived: the full conversation context is re-written to the cache at the cache-write rate instead of being read back cheaply. */ cache_creation_cost_usd: number - /** Sum of uncached `$ai_input_tokens` in this bucket. */ + /** Sum of `$ai_input_tokens` in this bucket. Whether cached tokens are included follows the provider's reporting (`$ai_cache_reporting_exclusive`): Anthropic-style events exclude them, OpenAI-style events include them, so don't stack this with the cache token sums. */ input_tokens: number /** Sum of `$ai_output_tokens` in this bucket. */ output_tokens: number diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 7abd383210f8..7667190438ff 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -45162,7 +45162,7 @@ export namespace Schemas { cache_read_cost_usd: number; /** Cost of prompt-cache writes in USD (sum of `$ai_cache_creation_cost_usd`). A spike here with near-zero cache reads is the signature of a cold session being revived: the full conversation context is re-written to the cache at the cache-write rate instead of being read back cheaply. */ cache_creation_cost_usd: number; - /** Sum of uncached `$ai_input_tokens` in this bucket. */ + /** Sum of `$ai_input_tokens` in this bucket. Whether cached tokens are included follows the provider's reporting (`$ai_cache_reporting_exclusive`): Anthropic-style events exclude them, OpenAI-style events include them, so don't stack this with the cache token sums. */ input_tokens: number; /** Sum of `$ai_output_tokens` in this bucket. */ output_tokens: number; From 232ebd388b68dda20cc53f8e50bcb756fb01d41d Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 15 Jul 2026 11:22:15 +0200 Subject: [PATCH 16/16] fix(ai-observability): enforce the bucket window cap EU-side before the cache lookup The EU proxy caches on the raw date strings, so a relative window cached while under the bucket cap could keep serving after it resolved over the cap. The resolve-and-validate step is now a shared helper the proxy runs before its cache lookup, which also gives EU callers fast in-region 400s for invalid windows instead of paying the cross-region hop. Generated-By: PostHog Code Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a --- .../backend/api/personal_spend.py | 65 ++++++++++++------- .../backend/api/test/test_personal_spend.py | 11 ++++ 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index d5c871fd6a24..49b6417210ec 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -123,6 +123,40 @@ def _resolve_window(date_from: str, date_to: str | None) -> tuple[datetime.datet return from_dt, to_dt +def _resolve_and_validate_window( + date_from: str, date_to: str | None, bucket_minutes: int | None +) -> tuple[datetime.datetime, datetime.datetime]: + """Resolve the window and enforce the bucket cap on the resolved bounds. Shared by + the US compute path and the EU proxy, which must run this before its cache lookup: + with relative dates the same raw cache key can be cached while the resolved window + is under the cap and resolve over it moments later.""" + from_dt, to_dt = _resolve_window(date_from, date_to) + if bucket_minutes is not None: + bucket_seconds = bucket_minutes * 60 + # Count the bucket starts the window can actually produce rows for (unaligned + # edges add partial buckets), so `by_bucket` never exceeds MAX_TIME_BUCKETS rows. + from_bucket = int(from_dt.timestamp() // bucket_seconds) + to_ts = to_dt.timestamp() + last_bucket = int(to_ts // bucket_seconds) + if to_ts % bucket_seconds == 0: + # `date_to` is exclusive (`timestamp < date_to`), so an end aligned exactly + # on a bucket boundary can never produce a row in its own bucket. + last_bucket -= 1 + n_buckets = last_bucket - from_bucket + 1 + if n_buckets > MAX_TIME_BUCKETS: + max_hours = bucket_minutes * MAX_TIME_BUCKETS // 60 + raise exceptions.ValidationError( + { + "bucket_minutes": ( + f"A window this large would span more than {MAX_TIME_BUCKETS} buckets at " + f"{bucket_minutes}-minute resolution; narrow the window to under {max_hours} hours, " + "or pick a larger bucket size." + ) + } + ) + return from_dt, to_dt + + class _SpendQueryParamsSerializer(serializers.Serializer): date_from = serializers.CharField( required=False, @@ -819,30 +853,7 @@ def _compute_spend_analysis( ) -> dict[str, Any]: """Cached, email-scoped spend analysis shared by the US viewset and the cross-region receiver. Expects already-validated params.""" - from_dt, to_dt = _resolve_window(date_from, date_to) - if bucket_minutes is not None: - bucket_seconds = bucket_minutes * 60 - # Count the bucket starts the window can actually produce rows for (unaligned - # edges add partial buckets), so `by_bucket` never exceeds MAX_TIME_BUCKETS rows. - from_bucket = int(from_dt.timestamp() // bucket_seconds) - to_ts = to_dt.timestamp() - last_bucket = int(to_ts // bucket_seconds) - if to_ts % bucket_seconds == 0: - # `date_to` is exclusive (`timestamp < date_to`), so an end aligned exactly - # on a bucket boundary can never produce a row in its own bucket. - last_bucket -= 1 - n_buckets = last_bucket - from_bucket + 1 - if n_buckets > MAX_TIME_BUCKETS: - max_hours = bucket_minutes * MAX_TIME_BUCKETS // 60 - raise exceptions.ValidationError( - { - "bucket_minutes": ( - f"A window this large would span more than {MAX_TIME_BUCKETS} buckets at " - f"{bucket_minutes}-minute resolution; narrow the window to under {max_hours} hours, " - "or pick a larger bucket size." - ) - } - ) + from_dt, to_dt = _resolve_and_validate_window(date_from, date_to, bucket_minutes) cache_key = _cache_key(email, date_from, date_to, product, limit, bucket_minutes) @@ -1112,10 +1123,14 @@ def list(self, request: Request) -> HttpResponseBase: email = self._require_email(request) - # Validate in-region so bad requests 400 without paying for the hop. + # Validate in-region so bad requests 400 without paying for the hop. The window + # check must precede the cache lookup: the cache is keyed on the raw date + # strings, so a relative window cached while under the bucket cap could + # otherwise keep serving after it resolves over the cap. params = _SpendQueryParamsSerializer(data=request.query_params) params.is_valid(raise_exception=True) data = params.validated_data + _resolve_and_validate_window(data["date_from"], data["date_to"], data.get("bucket_minutes")) # Same cache as the US compute path, so repeat loads skip the cross-region hop. cache_key = _cache_key( diff --git a/products/ai_observability/backend/api/test/test_personal_spend.py b/products/ai_observability/backend/api/test/test_personal_spend.py index 109a4c9f30aa..6431c915f89a 100644 --- a/products/ai_observability/backend/api/test/test_personal_spend.py +++ b/products/ai_observability/backend/api/test/test_personal_spend.py @@ -856,6 +856,17 @@ def test_invalid_params_rejected_without_upstream_call(self) -> None: assert response.status_code == status.HTTP_400_BAD_REQUEST post.assert_not_called() + def test_over_cap_bucket_window_rejected_without_upstream_call(self) -> None: + # The window cap must be enforced EU-side before the cache lookup, not + # delegated to the US receiver. + with override_settings(PERSONAL_SPEND_CROSS_REGION_SECRET=CROSS_REGION_SECRET): + with patch("products.ai_observability.backend.api.personal_spend.requests.post") as post: + response = self._get( + {"product": "posthog_code", "date_from": "-30d", "bucket_minutes": "60"}, user=self.user + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + post.assert_not_called() + def test_relays_upstream_success_and_signs_asserted_email(self) -> None: upstream_payload = {"summary": {"scoped_cost_usd": 1.25}} with override_settings(PERSONAL_SPEND_CROSS_REGION_SECRET=CROSS_REGION_SECRET):