Skip to content

feat(aio): explain empty generation output when tokens were billed - #90159

Open
bernatixer wants to merge 15 commits into
masterfrom
posthog/aio-explain-empty-generation-output
Open

feat(aio): explain empty generation output when tokens were billed#90159
bernatixer wants to merge 15 commits into
masterfrom
posthog/aio-explain-empty-generation-output

Conversation

@bernatixer

@bernatixer bernatixer commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Problem

The AI observability trace view can show a generation whose output panel is empty while the provider still reports tokens for it. That rendered as a bare "No output" box.

The box made several different situations look identical: a provider that genuinely returned nothing, a response that ran out of room, one the provider blocked, and content that was billed but never reached the event. Someone hitting any of the last three had nothing to go on, so the page read as broken rather than as a known outcome.

The shape is not rare and is not specific to one provider or model. It appears on generations that carry no error flag, which is exactly when an unexplained empty box is most confusing.

Changes

  • The empty output box now names the tokens the provider reported, and says no content was captured.
  • It gives the cause when the event carries one. $ai_stop_reason is the provider's own account of why it stopped, so a response that ran out of room says so, and one the provider blocked says that. A blocked response can arrive before anything is billed, so a known cause shows on its own.
  • Reasoning takes the blame only when it plausibly accounts for the whole billed output. A reasoning count that matches the output count reads as one amount counted twice, so the notice names it once and says all of it may have been reasoning. A reasoning count above a nonzero output count proves the provider bills them separately, so that keeps the general wording. An explicit $ai_text_output_tokens of zero states the fact directly: none of the output was text.
  • A generation billed only for reasoning tokens, with no output count at all, is now explained. It previously showed a bare box.
  • A generation that really was billed nothing still shows the plain "No output" box.
  • An empty $ai_output_choices no longer hides a populated $ai_output. A nullish check kept the empty container, so the panel rendered nothing and would have gone on to state that no content was captured. Fixed in the trace view, the replay inspector, and the lazy heavy-property load behind both.
  • The notice appears in the trace view and in the session replay inspector.
  • Every explained notice ends with a "Learn more" link to the troubleshooting section documenting each cause and its fix (PostHog/posthog.com#19793). That docs PR should merge first so the anchor resolves; until then the link lands at the top of the existing troubleshooting page.
  • Mechanical: $ai_output_tokens, $ai_reasoning_tokens, and $ai_stop_reason thread through to the shared message display, read leniently so a provider that sends a count as a string or as { total, … } still gets the notice, and so a hyphenated stop reason matches its underscored spelling.

Providers disagree on whether reasoning tokens sit inside the output count or beside it, so the notice names each count that is present instead of deriving one from the other.

Billed output tokens Billed reasoning tokens
after_output after_reasoning
Stop reason says the response ran out of room Stop reason says the provider blocked it
after_hit-token-limit after_blocked-by-provider

Before, all four rendered as just the italic "No output" line, with the metadata header above still showing a token count:

before

How did you test this code?

Automated, all extending existing parameterized tests rather than adding suites:

  • Fifteen rows in ConversationMessagesDisplay.test.tsx cover output only, reasoning only, reasoning accounting for the whole output, reasoning as a fraction of it, an explicit text-token split in both directions, matching counts folded into one claim, a reasoning count above the output count, an upper-case stop reason, a hyphenated one, a stop reason that outranks the reasoning guess, a normal stop reason that must not match, a string-typed count, an object-shaped count, an explicit zero, and no counts. Every explained row also pins the "Learn more" href to the documented anchor, so a dropped link or typo'd anchor fails 13 rows. Each catches a distinct regression: the notice going silent for a shape that was billed, firing on a generation billed nothing, naming the wrong cause, or missing a provider's spelling. A case-sensitive stop-reason check would miss most real truncation values, and a naive count check drops the pre-7.3.0 object shape.
  • One row in ConversationDisplay.test.tsx covers an empty $ai_output_choices beside a populated $ai_output, asserting the populated one reaches the renderer. No existing row exercised that fallthrough.

By the agent: added four Storybook stories for the explained shapes and rendered them headless; the screenshots above are those stories. Ran the touched files, then the whole products/ai_observability/frontend/ and session-replay inspector suites (2,033 tests) to check the shared output-selection change, plus the repo typecheck.

From CI on an earlier commit: the visual review run reported zero changed snapshots against 2,238 baselines, so this does not alter how any existing story renders. The new stories show as new baselines pending approval, which is why that check is red.

Not done: this was not exercised against real data in a running app, and the full frontend suite was not run locally.

Automatic notifications

  • Publish to changelog?

Docs update

PostHog/posthog.com#19793 documents every sentence this notice can show, with the fix for each. The "Learn more" link in the notice points at it.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

A person directed this work in a PostHog Desktop session, but their GitHub handle was never established there. The agent set no assignee and mentioned nobody; any assignee or reviewer shown here came from repo automation. The requester should claim it.

Several drafts were discarded against data rather than shipped. An early theory that the UI was masking real data via the $ai_output_choices nullish check turned out not to explain the originating trace, where both properties were genuinely empty — so it was cut, then reinstated once the notice began asserting a cause, because a vague box tolerates that ambiguity and a sentence does not. The truncation copy went through the same cycle: an early draft asserted it with nothing behind it, the shipped code asserts it only when $ai_stop_reason says so, and that property proved populated on a third of generations. Copy blaming reasoning tokens was gated twice after review: first on a count comparison, then on the explicit $ai_text_output_tokens split, since the comparison is only exact for providers that count reasoning inside the output.

hogli review needs interactive Greptile auth, unavailable in a cloud task, so the /reviewing-before-pr harness fallback ran: independent reviewers over the diff, findings verified against the source before applying. Applied findings covered string- and object-typed token counts, the shared number formatter, the missing session replay call site, provider spellings of the stop reason, the reasoning-proportion gate, the empty-container fallthrough in three places, and a test assertion pinned to one hard-coded value. Skills invoked: /exploring-llm-traces, /query-clickhouse-via-metabase, /writing-user-facing-copy, /writing-tests, /code-review, /reviewing-before-pr, /writing-pr-descriptions.

Public artifact: this started from a customer trace. None of it reaches this PR. The Storybook and test fixtures are invented, and the description carries no identifiers, counts, or figures.


Created with PostHog Desktop

A generation can reach the trace view with no captured output while the provider still reports output tokens. The output panel showed only an unexplained "No output" box, so a provider that genuinely returned nothing looked identical to content that was generated and never reached the event.

The panel now names the second case when output tokens were billed, and stays as it was when none were.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
Covers a generation whose output normalizes to nothing while the provider reports output tokens, so the state stays reviewable in Storybook.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
@trunk-io

trunk-io Bot commented Aug 27, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

The notice read the token count as a strict number, so a provider that reports it as a string fell through and the box stayed unexplained. Read it leniently instead, and format it with the shared number helper so it matches the token count in the metadata header above it.

The session replay inspector renders generations through the same panel, so pass the token count there too.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

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

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

Total: 68.14 MiB · 🔺 +16.1 KiB (+0.0%)

File Size Δ vs base
posthog-app/_parent/products/engineering_analytics/frontend/scenes/EngineeringAnalyticsScene.js 71.7 KiB 🔺 +9.9 KiB (+16.1%)
render-query/src/render-query/render-query.js 21.77 MiB 🔺 +6.7 KiB (+0.0%)
posthog-app/src/scenes/models/NodeDetailScene.js 15.6 KiB 🟢 -1.5 KiB (-8.5%)
exporter/src/exporter/scenes/ExporterNotebookScene.js 3.40 MiB 🟢 -1.3 KiB (-0.0%)

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.37 MiB · 22 files 🔺 +41 B (+0.0%) ███░░░░░░░ 30.3% of 4.51 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.82 MiB · 3,247 files 🔺 +3.6 KiB (+0.0%) █████████░ 90.8% of 9.71 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
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
307.0 KiB ../node_modules/.pnpm/posthog-js@1.422.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
263.4 KiB ../node_modules/.pnpm/posthog-js@1.422.0_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.js
252.5 KiB src/taxonomy/core-filter-definitions-by-group.json
154.2 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
104.7 KiB src/lib/api.ts
95.2 KiB ../packages/quill/packages/quill/dist/index.js
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

Toolbar bundle — eager 2.26 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.26 MiB · 18 files 🔺 +159 B (+0.0%) ████░░░░░░ 39.5% of 5.72 MiB
Deferred (lazy) 2.11 MiB · 45 files 🔺 +22 B (+0.0%) n/a — loads on demand
Loader dist/toolbar.js 1.1 KiB no change █░░░░░░░░░ 5.8% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
749.3 KiB dist/toolbar/toolbar-app-J2MBJBF4.css
588.4 KiB dist/toolbar/chunk-chunk-K5GKF7GY.js
484.7 KiB dist/toolbar/chunk-chunk-OX4ECFTJ.js
133.8 KiB dist/toolbar/chunk-chunk-QBW74TBI.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
71.3 KiB dist/toolbar/toolbar-app-SDJT2P3V.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
35.6 KiB dist/toolbar/chunk-chunk-4IRI3WTW.js
20.9 KiB dist/toolbar/chunk-chunk-KKY2CY6J.js
6.8 KiB dist/toolbar/chunk-chunk-DV7IWQNF.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

Dist folder size — 🔺 +222.2 KiB (+0.0%)

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

Total: 1435.23 MiB · 🔺 +222.2 KiB (+0.0%)

Playwright — all passed

All tests passed.

View test results →

The fixture only needs a four-digit number to exercise thousands grouping, so pick one that owes nothing to the case that prompted the change.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
@posthog

posthog Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

👋 Visual changes detected for this PR.

Review and approve in PostHog Visual Review

If these changes are unexpected, they may be caused by a flaky test or a broken snapshot on master. Don't approve — rerun the job or wait for a fix.

A generation can report reasoning tokens with no output token count at all. Those showed a bare "No output" box, because the notice only looked at the output count, so the case it most clearly explains was the one it stayed silent on.

Read both counts and name whichever are present. The cause now follows the data instead of listing both possibilities: reasoning tokens point at a spent thinking budget, and their absence points at a cut-short response or a capture gap. Providers disagree on whether reasoning tokens sit inside the output count or beside it, so neither is derived from the other.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
@trunk-io

trunk-io Bot commented Aug 27, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

The notice hedged between two causes because it only had token counts to reason from. `$ai_stop_reason` carries the provider's own account of why it stopped, and it already arrives on a third of generations, so read it and state the cause instead.

A response that ran out of room now says so, and one the provider blocked says that. A blocked response can arrive before anything is billed, so a known cause is worth showing on its own. Anything else keeps the previous wording.

Providers spell the same outcome several ways, and the OTel middlewares pass the value through verbatim, so match a normalized set. `max_tokens`, `MAX_TOKENS`, and `length` all mean the same thing.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
A generation can send an empty `$ai_output_choices` while `$ai_output` holds the response. A nullish check kept the empty container, so the panel rendered nothing and the new notice went on to state that no content was captured. The old bare box was vague enough to survive that; a sentence naming a cause is not. Skip a container with no entries so the fallback is reached, in the trace view, the replay inspector, and the lazy heavy-property load that backs both.

Reasoning tokens now only carry the blame when they account for the whole billed output. A small reasoning count beside a large output count leaves non-reasoning tokens unaccounted for, and blaming reasoning there is the wrong cause.

Two smaller gaps in the same path: the Vercel AI SDK hyphenates its stop reasons, so `content-filter` never matched the underscored set, and Bedrock spells it `content_filtered`. And posthog-ai below 7.3.0 sent token counts as `{ total, noCache, cacheRead }`, so events captured before ingestion normalized that shape made the notice vanish.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
… output

Comparing the reasoning count against the output count only works for providers that count reasoning inside the output. For providers that count it separately, a reasoning count at or above the output count can still sit beside billed text tokens, and blaming reasoning there names the wrong cause.

`$ai_text_output_tokens` states the split directly, so trust it when a provider reports it: an explicit zero means every billed output token was reasoning, and anything above zero keeps the general wording. The comparison stays as the fallback when the property is absent.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
… amounts

When the reasoning count matched the output count, the notice listed both and then blamed reasoning, which reads as half the billed tokens going unexplained. Under the accounting that comparison detects, the two numbers are one amount counted twice, so name it once: the output count, with the claim that all of it may have been reasoning.

The comparison also blamed reasoning when it exceeded a nonzero output count. Reasoning can never exceed the output it is counted inside of, so that shape proves the provider counts them separately, and the output tokens are missing content. That case now keeps the general wording.

An explicit zero in the text-token split now says what it means, that none of the output was text, instead of guessing reasoning while other output kinds could account for the rest.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
…y helpers

The sets and describeStopReason carry knowledge about what an `$ai_stop_reason` value means, not rendering logic, and this branch already homes that kind of helper in utils.ts. A component file also hides the repo's only stop-reason mapping from the next surface that wants it, such as the metadata header or a filter. describeEmptyOutput stays in the component, since it assembles display copy.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
The comment compressed the deduction so far that a reader had to reconstruct it. Name the two accounting styles and state what each observed shape can mean under each, so the gate conditions read as conclusions instead of assertions.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
@github-actions

github-actions Bot commented Aug 28, 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)
🛠️ Admin inspect & debug state in hogland
💤 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 d616e53 · box box-6ef0cb620035 · ready in 600s (push → usable) · build log · rebuilds on every push, torn down on close

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team August 28, 2026 10:07
@pr-assigner-resolver-posthog

Copy link
Copy Markdown

👀 Auto-assigned reviewers

These soft owners were skipped because they only have minor changes here. Nothing blocks merge, so self-assign if you'd like a look:

  • @PostHog/team-replay (frontend/src/scenes/owners.yaml)

Soft owners come from each directory's owners.yaml and each product's product.yaml (resolved nearest-file-wins). The locator after each owner is the file that decided it. Generated files and lockfiles are ignored when deciding ownership.

@carlos-marchal-ph carlos-marchal-ph 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.

Looks great, the screenshots were super helpful. You'll have to approve the manual visual screenshots task in CI once this is ready to merge, but first there's a couple of comments to address.

Comment thread products/ai_observability/frontend/ConversationDisplay/ConversationDisplay.tsx Outdated
Comment thread products/ai_observability/frontend/aiObservabilityAIDataLogic.ts Outdated
Preferring content over presence made a genuinely empty output read as missing, so useAIData ran the heavy-property lookup and showed a loading block before the notice this change exists to render. The selector now falls back to the first present value, so an empty container still counts as arrived. Renamed to selectAiValue, since it no longer only returns values with content.

The lazy loader gets the same treatment on both sides through one selectHeavyValue: an empty $ai_input no longer hides a populated $ai_input_state, and an all-empty ai_events row no longer falls through to the second, slower events query.

The metadata header gated its token tag on `typeof === 'number'` while the notice below read counts leniently, so a string or `{ total, … }` count silenced the header and contradicted the sentence under it. The lenient read now lives in utils as aiTokenCount and both surfaces use it.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98

@marco-g-pm marco-g-pm 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.

This is great! Now that I see the more detailed error messages, I do think "okay so how do I fix this?"

It might be worth it to add a corresponding update to our docs (maybe under troubleshooting?) with more detail + potential fixes for each issue

@bernatixer

bernatixer commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

This is great! Now that I see the more detailed error messages, I do think "okay so how do I fix this?"

It might be worth it to add a corresponding update to our docs (maybe under troubleshooting?) with more detail + potential fixes for each issue

I've also opened this PR in order to receive more stop reasons and avoid this "generic error message".

This two PRs will not solve it all but at least give users more information on what's happening and reduce the number of unknown cases.

I've added a small section under troubleshooting PostHog/posthog.com#19793

The notice names why a generation rendered no output but not what to do about it, and review feedback asked exactly that. Each sentence now ends with a link to the troubleshooting section that carries the fix for every cause the notice can name.

Generated-By: PostHog Desktop
Task-Id: 5cb23cd6-224c-4d86-bc24-97730d4c2f98
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.

3 participants