PostHog's LLM analytics logs every model call your app makes as an $ai_generation event, and most of what you'd want (the model, the cost, the token counts, the latency) sits right on that event as properties you can query like any other. The two fields I actually needed while debugging a bad call, the prompt I sent and the completion that came back, were not there. This post is where they really live and how to pull them.
Heads up before you copy anything: PostHog's internals move, so treat the table and column names here as "true when I did this," and check them against your own project's schema before you build on top.
Look on the Event First (and Watch It Come Back Empty)
The obvious move is to read the payload straight off the event, the same way you'd read the model or the cost:
SELECT
properties.$ai_model,
properties.$ai_input,
properties.$ai_output_choices
FROM events
WHERE event = '$ai_generation'
AND properties.$ai_trace_id = '<trace-id>'
The model comes back fine. $ai_input and $ai_output_choices come back empty. Not an error, just null, which is worse, because it looks like the call had no prompt rather than telling you you're reading the wrong place.
That's by design. PostHog keeps the big payload fields off the event row on purpose. An event is meant to stay small and cheap to scan; stuffing full prompts and completions into properties would bloat every trends query you ever run against generations. So the metadata stays on the event, and the heavy content goes somewhere else.
The Content Lives on posthog.ai_events
The prompt, the completion, and the tool definitions ($ai_input, $ai_output_choices, $ai_tools in taxonomy terms) live as native columns on a separate table, posthog.ai_events, keyed on trace_id:
SELECT input, output_choices
FROM posthog.ai_events
WHERE trace_id = '<trace-id>'
ORDER BY timestamp
That returns what you were actually after: the messages array you sent and the choices object that came back.
Watch the naming, it trips you up. On the event they're $ai_-prefixed properties (properties.$ai_input). On ai_events they're bare native columns (input, output_choices). Same data, different name, different table. Query them like the property names carry over and you get nulls again.
Anchor on trace_id, Never Scan by Timestamp
ai_events is ordered by (team_id, trace_id, timestamp). The fast path into it is trace_id. If your instinct is to filter by a time window and scan for the row you want, you're fighting the sort key and it'll get slow as the table grows.
So the real flow for "show me everything about this one request" is two hops: get the request's $ai_trace_id off the events table first, then look that id up on ai_events. Which is the other reason $ai_trace_id earns its place beyond drawing trace trees: it's the join key to the content. If your app isn't setting it, you can see that a generation happened, but you can't cleanly pull back what was in it.
The 30-Day Cliff
The catch that actually matters for anything audit or eval shaped: ai_events rows drop after 30 days by default. The content has a shorter life than the metadata.
The token counts, cost, model, latency, and $ai_trace_id all stay on events and follow your project's normal retention. The prompts and completions on ai_events do not. So an old generation keeps its numbers long after it has lost its actual text.
If you need the prompts and completions kept, for a regression set, a compliance trail, whatever, export them off ai_events inside the window and store them yourself. Do not assume they'll be there next quarter. The metadata will be; the content won't.
Done
The whole thing in one line: metadata lives on events, content lives on posthog.ai_events, and trace_id is the bridge. If you're charting spend, latency, or error rate, stay on events, it's all there and it sticks around. If you're debugging one specific call and need the exact prompt and reply, grab its trace_id off events, then read input and output_choices off ai_events before the 30-day clock runs out.
Verify the column names against your own schema before you lean on this. It's PostHog internals, and internals drift.