Skip to content

feat(ai-observability): add time-bucketed breakdown to personal spend endpoint#69891

Open
webjunkie wants to merge 8 commits into
masterfrom
posthog-code/personal-spend-hourly-breakdown
Open

feat(ai-observability): add time-bucketed breakdown to personal spend endpoint#69891
webjunkie wants to merge 8 commits into
masterfrom
posthog-code/personal-spend-hourly-breakdown

Conversation

@webjunkie

@webjunkie webjunkie commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Problem

The personal spend endpoint (GET /api/llm_analytics/@me/spend/) only exposes a daily total-cost series (by_day), so clients like the PostHog Code desktop app can't render a fine-grained usage view. In particular, prompt-cache cold starts — where a stale session is revived and its whole context is re-written to the cache at the cache-write rate instead of being read back cheaply — are invisible: they need sub-hour resolution and a per-component cost split to stand out. Since the prompt-cache TTL is 5 minutes, a 5-minute bucket is roughly one turn, which isolates a revival spike that hourly buckets would dilute.

Why: we want the PostHog Code app to show a 24h usage chart granular enough that a sudden burst of full-price tokens (a revived cold session) is visually obvious. A companion PR in the PostHog Code repo (PostHog/code#3329) consumes this.

Changes

  • New opt-in bucket_minutes query param (5, 15, 30, or 60) on /api/llm_analytics/@me/spend/.
  • When set, the response additionally carries by_bucket: a time-ascending UTC series (grouped via toStartOfInterval) with per-bucket cost_usd plus the four cost components forwarded from the LLM gateway's cost breakdown (input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd) and the matching token sums. The bucket size is echoed on the breakdown.
  • The window may span at most 600 buckets of the chosen size (400 otherwise) — 48h at 5-minute buckets, 8+ days hourly; by_bucket ignores limit like by_day does.
  • Cache key, EU redirect param allowlist, and the EU→US proxy all carry the new param.

Note: CI codegen keeps the generated OpenAPI types current (no DB available for hogli build:openapi in the authoring environment).

How did you test this code?

Added tests to test_personal_spend.py, each catching a distinct regression:

  • test_by_bucket_groups_cost_components_per_utc_hour — wrong column order/mapping or grouping in the new HogQL query, and product-scope leakage.
  • test_by_bucket_five_minute_buckets_split_within_the_hour — the bucket size not actually being applied (two calls 15 minutes apart must split at 5-minute resolution).
  • test_by_bucket_defaults_components_to_zero_when_breakdown_missing — fallback-priced events (total cost only) must produce zero components, not errors.
  • test_by_bucket_absent_unless_requestedby_bucket leaking into default responses.
  • test_cache_key_includes_bucket_minutes — a cached bucketless payload being served to a bucketed request (would silently drop by_bucket).
  • test_bucket_window_cap (parameterized) + test_unsupported_bucket_size_rejected — the 600-bucket cap or the size allowlist being dropped.

Could not run ClickHouse-backed tests in this sandbox (no database); verified locally that both files compile, the new HogQL parses via parse_select (toStartOfInterval/toIntervalMinute are whitelisted), and the response serializer omits by_bucket when absent. CI runs the full suite.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

Endpoint is documented via its OpenAPI schema (@extend_schema description and serializer help_text updated in this PR).

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Authored with PostHog Code (Claude). Skills invoked: /improving-drf-endpoints, /writing-tests.
  • Investigated where PostHog Code usage/cost data originates: the LLM gateway captures one $ai_generation event per LLM call with LiteLLM's per-side cost breakdown, and this endpoint is the sanctioned user-scoped surface over those events — so the view was added here rather than as client-side HogQL (the events live in an internal analytics team, not the user's project).
  • Started as an hourly=true boolean; generalized to bucket_minutes after deciding hourly resolution dilutes single-turn revival spikes (cache TTL is 5 minutes, so 5-minute buckets ≈ one turn).
  • Chose an opt-in param + optional response field over a new endpoint to reuse the existing auth/throttle/cache/EU-proxy machinery unchanged.

Created with PostHog Code

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
@github-actions

Copy link
Copy Markdown
Contributor

Hey @webjunkie! 👋

It looks like your git author email on this PR isn't your @posthog.com address (julian.bez@gmail.com). Since you're on the PostHog team, it's worth pointing your local git author email at your @posthog.com address. Why it matters:

  • Consistent work identity in git history — internal tooling that attributes commits to team members keys off your @posthog.com address.
  • Keeps team contributions easy to tell apart from external community ones when scanning history.

You can fix it for this repo with:

git config user.email "you@posthog.com"

Or set it globally with git config --global user.email "you@posthog.com". No need to redo this PR — just a nudge for next time. 🙂

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Bundle size — 🔺 +9.0 KiB (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 70.07 MiB · 🔺 +9.0 KiB (+0.0%)

File Size Δ vs base
posthog-app/src/scenes/instance/QueryPerformance/QueryPerformance.js 31.8 KiB 🔺 +2.8 KiB (+9.6%)
exporter/src/exporter/scenes/ExporterNotebookScene.js 2.88 MiB 🔺 +1.3 KiB (+0.0%)
render-query/src/render-query/render-query.js 24.17 MiB 🔺 +1.3 KiB (+0.0%)
exporter/src/queries/schema.js 1.00 MiB 🔺 +1.2 KiB (+0.1%)
posthog-app/src/queries/schema.js 1.00 MiB 🔺 +1.2 KiB (+0.1%)

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.20 MiB · 22 files 🔺 +356 B (+0.0%) ███░░░░░░░ 28.1% of 4.29 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.08 MiB · 2,957 files 🔺 +64 B (+0.0%) █████████░ 87.3% of 9.25 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
668 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
277.3 KiB ../node_modules/.pnpm/posthog-js@1.399.1/node_modules/posthog-js/dist/rrweb.js
266.9 KiB ../node_modules/.pnpm/@posthog+icons@0.37.4_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
224.6 KiB src/taxonomy/core-filter-definitions-by-group.json
221.5 KiB ../node_modules/.pnpm/posthog-js@1.399.1/node_modules/posthog-js/dist/module.js
164.0 KiB src/queries/validators.js
154.0 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
105.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

⚠️ Dist folder size — 🔺 +180.0 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1435.36 MiB · 🔺 +180.0 KiB (+0.0%)

@trunk-io

trunk-io Bot commented Jul 10, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

…inutes

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
@webjunkie webjunkie changed the title feat(ai-observability): add hourly breakdown to personal spend endpoint feat(ai-observability): add time-bucketed breakdown to personal spend endpoint Jul 10, 2026
tests-posthog Bot and others added 2 commits July 10, 2026 08:56
…utes

Generated-By: PostHog Code
Task-Id: d7854448-4d18-44e7-b54a-d66e1ccddd0a
@webjunkie webjunkie marked this pull request as ready for review July 10, 2026 10:29
Copilot AI review requested due to automatic review settings July 10, 2026 10:29
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🦔 Hogbox preview · ✅ ready

▶ Open the preview

🔑 Login test@posthog.com / 12345678 (demo data)
🧩 Running this PR's backend and frontend, on the PostHog :master base
🔗 Link stable across rebuilds — a re-push swaps the box underneath, the URL stays
🔒 Access tailnet only (PostHog VPN)
💤 Idle sleeps after ~30 min idle (snapshot to S3, zero node cost) and wakes on your next visit in ~30s, behind a brief "waking up" screen

commit ffb5039 · box box-fbafc41e489c · ready in 562s (push → usable) · build log · rebuilds on every push, torn down on close

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "chore(mcp): update personal-spend tool s..." | Re-trigger Greptile

Comment on lines +168 to +172
bucket_minutes = serializers.ChoiceField(
choices=BUCKET_MINUTES_CHOICES,
required=False,
default=None,
allow_null=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Nullable Query Param Rejects Null

This field is optional, but marking it nullable makes generated clients expose bucket_minutes: null as a valid value. The frontend URL builder serializes that as bucket_minutes=null on a GET request, while DRF only accepts a real null value, so typed callers can send an allowed client value and receive a 400 instead of the bucketless response.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted in edbb2d9: dropped allow_null so the schema no longer advertises null (default=None stays for the omitted case), and the EU proxy now omits None-valued params from the signed body so the internal receiver accepts it.

Comment on lines +797 to +807
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."
)
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Boundary Window Exceeds Cap

A window exactly equal to bucket_minutes * 600 passes this check, but partial buckets at both edges can still produce 601 bucket starts. _fetch_by_bucket then returns those 601 rows as untruncated, so clients can receive more buckets than the documented maximum.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted in edbb2d9: validation now counts the bucket starts the window touches (unaligned edges add partial buckets) instead of comparing durations, and the fetch/truncate limits match, so by_bucket can never exceed 600 rows. Added a boundary test where a 600-bucket-duration window with a half-hour offset (601 starts) is rejected.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in bucket_minutes query param to the personal spend endpoint (GET /api/llm_analytics/@me/spend/) so clients can request a sub-day, UTC time-bucketed spend series (by_bucket) with per-component cost/token breakdowns, enabling higher-resolution usage visualizations (for example, spotting prompt-cache cold-start “revival” spikes).

Changes:

  • Add bucket_minutes request validation, cache-key partitioning, EU redirect/proxy forwarding, and a new HogQL aggregation for by_bucket in the personal spend API.
  • Add ClickHouse-backed tests covering bucket sizing, window caps, cost-component grouping/defaults, and cache-key behavior.
  • Regenerate OpenAPI-derived schemas/types and MCP tool wiring to include the new query param and response shape.

Reviewed changes

Copilot reviewed 4 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
services/mcp/tests/unit/snapshots/tool-schemas/llma-personal-spend.json Updates MCP tool JSON schema snapshot to include bucket_minutes.
services/mcp/src/tools/generated/ai_observability.ts Forwards bucket_minutes into the generated MCP tool request query.
services/mcp/src/generated/ai_observability/api.ts Updates generated zod query schema/docstring to include bucket_minutes.
services/mcp/src/api/generated.ts Updates generated TS API types to include by_bucket and the bucket_minutes param enum.
products/ai_observability/frontend/generated/api.ts Updates generated frontend API client docs/types to mention bucket_minutes / by_bucket.
products/ai_observability/frontend/generated/api.schemas.ts Updates generated frontend schema types for the new by_bucket breakdown.
products/ai_observability/backend/api/test/test_personal_spend.py Adds ClickHouse tests for by_bucket, window caps, and cache key behavior.
products/ai_observability/backend/api/personal_spend.py Implements request param validation, new bucketed HogQL query, response serialization, caching, and EU forwarding.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 166 to +180
help_text="If true, bypass the result cache and re-run the underlying queries against ClickHouse.",
)
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 "
"buckets)."
),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in edbb2d9: docs and the constants comment now say 50 hours at 5-minute buckets / 25 days hourly. The regenerated OpenAPI/MCP artifacts follow via CI codegen.

Comment thread products/ai_observability/backend/api/personal_spend.py Outdated
chatgpt-codex-connector[bot]

This comment was marked as outdated.

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
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
@github-actions github-actions Bot requested a deployment to preview-pr-69891 July 10, 2026 11:39 In progress
Comment thread products/ai_observability/frontend/generated/api.schemas.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: edbb2d9cfe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

bucket_minutes = serializers.ChoiceField(
choices=BUCKET_MINUTES_CHOICES,
required=False,
default=None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove null from bucket_minutes schema

Although the comment says this should not be nullable, default=None makes the generated OpenAPI type nullable: the committed LlmAnalyticsPersonalSpendListBucketMinutes includes | null, and the generated frontend URL builder serializes null as the literal bucket_minutes=null. A frontend caller that follows the generated type by clearing this optional value to null will get a 400 instead of the parameter being omitted, so this field should avoid a None default or otherwise override the schema while still normalizing missing values server-side.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants