Skip to content

fix(observability): group AI generations by their real trace, and attribute spend per repo - #10187

Merged
JSONbored merged 2 commits into
mainfrom
claude/ai-observability-trace-groups
Jul 31, 2026
Merged

fix(observability): group AI generations by their real trace, and attribute spend per repo#10187
JSONbored merged 2 commits into
mainfrom
claude/ai-observability-trace-groups

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Closes #10185

Two faults made PostHog's AI observability structurally unable to answer the questions that matter most for a product where AI is the product. Both are fixed here; both are covered on each branch.

1. Every generation was its own trace

capturePostHogAiGeneration set $ai_trace_id to a fresh randomUUID() per call. Live project state: 13,428 AI events across 13,428 distinct trace IDs. A review fans out across RAG embeddings, both dual-review legs, N retries per model, and any self-consistency runs — each landing as an unrelated single-event trace, so what a review actually did, and where its time and money went, was unrecoverable.

The fix was already in the file. operationalProperties has called currentOtelTraceIds() since #8296 and attached the real ids as plain trace_id / span_id properties — then the line below minted a UUID and PostHog grouped by that instead. withReviewPipelineSpan already wraps a whole review, so the ambient trace id is exactly the grouping key wanted.

randomUUID() stays as the fallback, not removed: AI_EMBED / AI_VISION / AI_ADVISORY run outside any review span, so an orphan trace is a legitimate outcome there — but such an event must not claim a span id it doesn't have, so $ai_span_id is omitted rather than faked.

2. AI spend was unattributable

Every AI event carried distinct_id: "loopover-selfhost" and no $groups, so cost per repository was answerable only by SQL against ai_usage_events — never in PostHog. For scale: claude-sonnet-5 is $489.87 across 2,282 calls in ~5 days.

A repo group is now stamped on every AI event. It deliberately reads the already-processed repo value off operationalProperties rather than the raw context: under the shared LOOPOVER_CENTRAL_POSTHOG_KEY that value is HMAC-anonymized, and is dropped entirely when the anon secret hasn't been injected — so the group inherits that fail-closed behavior for free, and a raw private repo name cannot reach the group index by this path.

Out of scope

Content capture ($ai_input / $ai_output_choices) is deliberately untouched — the metadata-only policy is documented in self-hosting-operations.mdx, and changing it is a privacy decision, not a wiring one.

Known-adjacent bug, filed not fixed: #10186 — on the failure path $ai_model reports the @cf/ placeholder resolveModel discards, so every failed call is labelled with a model that never ran. It needs an optional resolvedModel() on SelfHostAi threaded through all four provider factories, which is a wider change than this PR.

Verification

  • npm run typecheck clean
  • test/unit/selfhost-posthog.test.ts 99 passed (94 pre-existing + 5 new, covering ambient-span vs none and repo-present vs dropped)

…he MCP event contract

Umami is fully decommissioned and PostHog is the only analytics sink.

Web analytics (#8293, #8299). posthog-js replaces the hand-rolled Umami
beacon in routes/__root.tsx, behind the same first-party /stats proxy the
Umami collect endpoint used. lib/analytics.ts is the single seam; the SDK is
loaded via a dynamic import so an unconfigured build pays zero bytes, matching
browser-sentry.ts. Env is read at call time, not module scope, so the token
gate is stubbable. Umami parity was audited against the removed beacon's actual
payload: posthog-js supplies url/referrer/screen/language automatically,
page_title is added explicitly (it has no $title equivalent), geo still comes
from the proxy's x-forwarded-for, and respect_dnt replaces the beacon's own
doNotTrack check. persistence is "localStorage", not "memory" — memory resets
identity every reload and inflates unique visitors, the regression
JSONbored/metagraphed#8210 had to correct. cookieless_mode was rejected on its
documented behavior: it strips the IP before GeoIP runs, which would drop the
country data #8299 requires.

The proxy keeps its allowlist, one level looser (path prefixes, not exact
paths) so a posthog-js upgrade cannot silently break capture. Its original
justification was Umami-specific — that host served its admin API from the
collect origin — which does not carry over, but bounding a public
unauthenticated route still does. Session-replay snapshots get their own 2 MiB
ceiling so enabling #8295 cannot silently 413.

One behavior deliberately changes: the beacon fired once per page load and
never on SPA navigation, so pageview counts run legitimately higher after
cutover.

MCP telemetry (#10175). $mcp_tool_call was emitting LoopOver's own snake_case
property names under PostHog's reserved event name, so their built-in MCP
dashboards ingested every event and rendered every breakdown empty. It now
carries the canonical $mcp_* properties, with LoopOver's surface/transport/
category alongside them (PostHog's custom-server docs sanction extra
properties, and those three have no canonical equivalent). usage_event is
untouched — it stays the minimal, payload-free LoopOver event in its own
vocabulary, and the allowlist meta-test now checks the two vocabularies
separately.

Adds the two missing canonical events: $mcp_initialize, sourced from the
handshake's own clientInfo rather than the x-loopover-mcp-* headers that only
our published client sets, and $mcp_tools_list, reporting what this server
registered. Both carry $session_id from Mcp-Session-Id, which is what makes a
funnel across handshake, discovery, and tool calls possible at all. The closed
LoopOver error-code set is projected onto PostHog's closed $mcp_error_type set
so their error breakdown populates without losing the precise code.

Reshaping the wire format is free right now and stops being free immediately:
the project holds zero $mcp_tool_call events, because POSTHOG_API_KEY was never
set on the Worker.

Prettier (#10176). A root prettier run found no config, fell back to the
80-column default, and rewrapped backend source that nothing in CI formats —
repeatedly, inside unrelated PRs. Adds a default-deny root .prettierignore that
re-admits only the three governed workspaces, restates the generated-file and
content/docs exemptions that a root run cannot inherit, and pins ui-kit to its
current effective width so it is stated rather than inherited from a vendor
default. Verified by running prettier --write . from the root and confirming a
no-op. No existing code is reformatted.

Also formats two ui-kit test files that were already failing their own
format:check on main, which would otherwise have turned this PR red.

Closes #8293
Closes #8299
Closes #10175
Closes #10176
…ribute spend per repo

Two faults made PostHog's AI observability structurally unable to answer the
questions that matter most for a product where AI is the product.

Every generation was its own trace. capturePostHogAiGeneration set
$ai_trace_id to a fresh randomUUID() per call, producing 13,428 AI events
across 13,428 distinct trace ids on the live project. A review fans out across
RAG embeddings, both dual-review legs, N retries per model, and any
self-consistency runs — and each landed as an unrelated single-event trace, so
what a review actually did, and where its time and money went, was not
recoverable.

The fix was already in the file. operationalProperties has called
currentOtelTraceIds() since #8296 and attached the real ids as plain
trace_id/span_id properties; the line below then minted a UUID and PostHog
grouped by that instead. withReviewPipelineSpan already wraps a whole review,
so the ambient trace id is exactly the grouping key wanted — every provider
attempt inside one review now nests under one PostHog trace. $ai_span_id and a
readable $ai_span_name come from the same source.

randomUUID() stays as the fallback rather than being removed: AI_EMBED,
AI_VISION and AI_ADVISORY run outside any review span, so an orphan trace is a
legitimate outcome there — but such an event must not claim a span id it does
not have, so $ai_span_id is omitted rather than faked.

AI spend was unattributable. Every AI event carried distinct_id
"loopover-selfhost" and no $groups at all, so cost per repository was
answerable only by SQL against ai_usage_events, never in PostHog. A repo group
is now stamped on every event. It deliberately reads the already-processed repo
value off operationalProperties rather than the raw context: under the shared
LOOPOVER_CENTRAL_POSTHOG_KEY that value is HMAC-anonymized, and is dropped
entirely when the anon secret has not been injected, so the group inherits that
fail-closed behavior for free and a raw private repo name cannot reach the
group index by this path.

Both branches of both changes are covered: ambient span vs none, and repo
present vs dropped.

Content capture ($ai_input/$ai_output_choices) is deliberately untouched — the
metadata-only policy is documented in self-hosting-operations.mdx and changing
it is a privacy decision, not a wiring one.

Closes #10185
@loopover-orb

loopover-orb Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏳ LoopOver is waiting…

LoopOver has seen this pull request and is waiting on CI checks to finish before reviewing it. This comment will update once the review runs.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟨 Waiting

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Bundle Report

Changes will decrease total bundle size by 214 bytes (-0.0%) ⬇️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
loopover-ui 7.95MB -214 bytes (-0.0%) ⬇️

Affected Assets, Files, and Routes:

view changes for bundle: loopover-ui

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/add-scalar-classes-BI_PFNXQ.js (New) 2.16MB 2.16MB 100.0% 🚀
assets/tanstack-vendor-BRNwXT9z.js (New) 956.85kB 956.85kB 100.0% 🚀
openapi.json -116 bytes 762.56kB -0.02%
assets/docs.fumadocs-spike-api-reference-CCIMMOBH.js (New) 443.45kB 443.45kB 100.0% 🚀
assets/AgentScalarChatInterface.vue-B7X_oshJ.js (New) 201.7kB 201.7kB 100.0% 🚀
assets/modal-BVVRzIUL.js (New) 184.5kB 184.5kB 100.0% 🚀
assets/client-WJhVTBHZ.js (New) 151.47kB 151.47kB 100.0% 🚀
assets/maintainer-panel-jf2W4ghb.js (New) 78.97kB 78.97kB 100.0% 🚀
assets/routes-G6Dsq_vT.js (New) 35.88kB 35.88kB 100.0% 🚀
assets/owner-panel-ChU-ccJo.js (New) 28.39kB 28.39kB 100.0% 🚀
assets/app-D9Y0FL2D.js (New) 25.82kB 25.82kB 100.0% 🚀
assets/ui-vendor-B9oip0d9.js (New) 24.57kB 24.57kB 100.0% 🚀
assets/fairness-DVMlWA57.js (New) 23.44kB 23.44kB 100.0% 🚀
assets/miner-panel-BvMecFqe.js (New) 20.49kB 20.49kB 100.0% 🚀
assets/api._op-Cf63TBl1.js (New) 17.51kB 17.51kB 100.0% 🚀
assets/self-hosting-docs-audit-BgY-FCAm.js (New) 16.6kB 16.6kB 100.0% 🚀
assets/docs-client-loader-Cdr-c1fk.js (New) 15.68kB 15.68kB 100.0% 🚀
assets/playground-panel-Bcqkmaj2.js (New) 14.35kB 14.35kB 100.0% 🚀
assets/app.audit-1kRWjReM.js (New) 10.22kB 10.22kB 100.0% 🚀
assets/app.config-generator-BPcnIBHk.js (New) 10.06kB 10.06kB 100.0% 🚀
assets/maintainers-CBVYzs0Y.js (New) 8.06kB 8.06kB 100.0% 🚀
assets/miners-DfSxjHhJ.js (New) 7.91kB 7.91kB 100.0% 🚀
assets/agents-DypXJ7Oc.js (New) 7.74kB 7.74kB 100.0% 🚀
assets/proof._owner._repo-DIHQV4PO.js (New) 7.11kB 7.11kB 100.0% 🚀
assets/commands-panel-zTJHeqOh.js (New) 6.74kB 6.74kB 100.0% 🚀
assets/maintainer-workflow-QsTif9ok.js (New) 6.52kB 6.52kB 100.0% 🚀
assets/docs-nav-CjojiXeQ.js (New) 6.19kB 6.19kB 100.0% 🚀
assets/digest-panel-CZg6A_XJ.js (New) 6.15kB 6.15kB 100.0% 🚀
assets/repos._owner._repo.quality-DlnM2ipd.js (New) 6.14kB 6.14kB 100.0% 🚀
assets/docs.index-CwsJ3txE.js (New) 6.01kB 6.01kB 100.0% 🚀
assets/api.index-CbhwvH0c.js (New) 4.7kB 4.7kB 100.0% 🚀
assets/docs-Ce_OKQ88.js (New) 3.01kB 3.01kB 100.0% 🚀
assets/api-BYHqTYZ4.js (New) 2.66kB 2.66kB 100.0% 🚀
assets/docs-page-OSWNf6JC.js (New) 2.14kB 2.14kB 100.0% 🚀
assets/table-CYEELhRZ.js (New) 1.75kB 1.75kB 100.0% 🚀
assets/app.workbench-DgU785mo.js (New) 1.58kB 1.58kB 100.0% 🚀
assets/tabs-BI3Z5M0k.js (New) 1.39kB 1.39kB 100.0% 🚀
assets/app.repos-D_1Q4YrN.js (New) 1.07kB 1.07kB 100.0% 🚀
assets/input-CQrFN_S5.js (New) 796 bytes 796 bytes 100.0% 🚀
assets/file-cog-ylR0VYSD.js (New) 758 bytes 758 bytes 100.0% 🚀
assets/docs._slug-CLEV3Djv.js (New) 554 bytes 554 bytes 100.0% 🚀
assets/app.maintainer-Mnk5zZZo.js (New) 502 bytes 502 bytes 100.0% 🚀
assets/app.owner-B-ozWBes.js (New) 474 bytes 474 bytes 100.0% 🚀
assets/app.commands-Dhn0L0Ru.js (New) 455 bytes 455 bytes 100.0% 🚀
assets/app.playground-DDbk64wg.js (New) 442 bytes 442 bytes 100.0% 🚀
assets/index-OSzXzxro.js (New) 438 bytes 438 bytes 100.0% 🚀
assets/app.digest-8QEtA5de.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/eye-off-BriiYgr9.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/app.miner-D-WNKIHD.js (New) 422 bytes 422 bytes 100.0% 🚀
assets/key-round-5uyQlF6q.js (New) 355 bytes 355 bytes 100.0% 🚀
assets/bot-B1l1sg4p.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/git-pull-request-arrow-Ds6WALPa.js (New) 321 bytes 321 bytes 100.0% 🚀
assets/list-checks-D5U2YNjW.js (New) 279 bytes 279 bytes 100.0% 🚀
assets/compass-DX7nub1B.js (New) 251 bytes 251 bytes 100.0% 🚀
assets/history-BRnfJSGn.js (New) 237 bytes 237 bytes 100.0% 🚀
assets/message-square-2LCF98tI.js (New) 233 bytes 233 bytes 100.0% 🚀
assets/lock-zo6RAPEU.js (New) 206 bytes 206 bytes 100.0% 🚀
assets/play-HnP43Cp0.js (New) 190 bytes 190 bytes 100.0% 🚀
assets/circle-check-CmjdgeKI.js (New) 178 bytes 178 bytes 100.0% 🚀
assets/add-scalar-classes-C7FWb7ho.js (Deleted) -2.16MB 0 bytes -100.0% 🗑️
assets/tanstack-vendor-DVhHt0oR.js (Deleted) -956.91kB 0 bytes -100.0% 🗑️
assets/docs.fumadocs-spike-api-reference-BfilKaIB.js (Deleted) -443.45kB 0 bytes -100.0% 🗑️
assets/AgentScalarChatInterface.vue-Beq1Xl-W.js (Deleted) -201.7kB 0 bytes -100.0% 🗑️
assets/modal-Dwg52MvP.js (Deleted) -184.5kB 0 bytes -100.0% 🗑️
assets/client-IxC7np7x.js (Deleted) -151.47kB 0 bytes -100.0% 🗑️
assets/maintainer-panel-DIbUGElv.js (Deleted) -78.97kB 0 bytes -100.0% 🗑️
assets/routes-DvnDm29V.js (Deleted) -35.88kB 0 bytes -100.0% 🗑️
assets/owner-panel-BpxF6y7K.js (Deleted) -28.42kB 0 bytes -100.0% 🗑️
assets/app-zsTMt2B5.js (Deleted) -25.82kB 0 bytes -100.0% 🗑️
assets/ui-vendor-CNVFVWYS.js (Deleted) -24.57kB 0 bytes -100.0% 🗑️
assets/fairness-HE8j-HnL.js (Deleted) -23.44kB 0 bytes -100.0% 🗑️
assets/miner-panel-BdPR0hN3.js (Deleted) -20.49kB 0 bytes -100.0% 🗑️
assets/api._op-BFs0ZBoZ.js (Deleted) -17.51kB 0 bytes -100.0% 🗑️
assets/self-hosting-docs-audit-tfolEOEv.js (Deleted) -16.6kB 0 bytes -100.0% 🗑️
assets/docs-client-loader-CY__nL4E.js (Deleted) -15.68kB 0 bytes -100.0% 🗑️
assets/playground-panel-BcGcWXqq.js (Deleted) -14.35kB 0 bytes -100.0% 🗑️
assets/app.audit-DQK03M23.js (Deleted) -10.22kB 0 bytes -100.0% 🗑️
assets/app.config-generator-DLNqO4Gu.js (Deleted) -10.06kB 0 bytes -100.0% 🗑️
assets/maintainers-75Ba9l4x.js (Deleted) -8.06kB 0 bytes -100.0% 🗑️
assets/miners-B6EoGYyD.js (Deleted) -7.91kB 0 bytes -100.0% 🗑️
assets/agents-Cf6G6WK9.js (Deleted) -7.74kB 0 bytes -100.0% 🗑️
assets/proof._owner._repo-DSqv6bor.js (Deleted) -7.11kB 0 bytes -100.0% 🗑️
assets/commands-panel-2yC6qbxY.js (Deleted) -6.74kB 0 bytes -100.0% 🗑️
assets/maintainer-workflow-DyzyvX7r.js (Deleted) -6.52kB 0 bytes -100.0% 🗑️
assets/docs-nav-DMcIQmw1.js (Deleted) -6.19kB 0 bytes -100.0% 🗑️
assets/digest-panel-CoVyjYWf.js (Deleted) -6.15kB 0 bytes -100.0% 🗑️
assets/repos._owner._repo.quality-Bgo4WC5U.js (Deleted) -6.14kB 0 bytes -100.0% 🗑️
assets/docs.index-CbnpMntu.js (Deleted) -6.01kB 0 bytes -100.0% 🗑️
assets/api.index-CMN7t3-o.js (Deleted) -4.7kB 0 bytes -100.0% 🗑️
assets/docs-BrK-0INe.js (Deleted) -3.01kB 0 bytes -100.0% 🗑️
assets/api-XpkSZqBX.js (Deleted) -2.66kB 0 bytes -100.0% 🗑️
assets/docs-page-6J2fSIv1.js (Deleted) -2.14kB 0 bytes -100.0% 🗑️
assets/table-BUh5T_cx.js (Deleted) -1.75kB 0 bytes -100.0% 🗑️
assets/app.workbench-B738RMEK.js (Deleted) -1.58kB 0 bytes -100.0% 🗑️
assets/tabs-IJyY9PjI.js (Deleted) -1.39kB 0 bytes -100.0% 🗑️
assets/app.repos-ecK__h7J.js (Deleted) -1.07kB 0 bytes -100.0% 🗑️
assets/input-uPUmxlik.js (Deleted) -796 bytes 0 bytes -100.0% 🗑️
assets/file-cog-DuDrJ2A5.js (Deleted) -758 bytes 0 bytes -100.0% 🗑️
assets/docs._slug-*.js (Deleted) -554 bytes 0 bytes -100.0% 🗑️
assets/app.maintainer-ClPp0K_Z.js (Deleted) -502 bytes 0 bytes -100.0% 🗑️
assets/app.owner-TGZYXqqv.js (Deleted) -474 bytes 0 bytes -100.0% 🗑️
assets/app.commands-Dd9NDIMq.js (Deleted) -455 bytes 0 bytes -100.0% 🗑️
assets/app.playground-Ne1toahh.js (Deleted) -442 bytes 0 bytes -100.0% 🗑️
assets/index-0Hd1Hc24.js (Deleted) -438 bytes 0 bytes -100.0% 🗑️
assets/app.digest-Ctd880DH.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/eye-off-COYgHW5O.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/app.miner-_0jKC_3j.js (Deleted) -422 bytes 0 bytes -100.0% 🗑️
assets/key-round-C8-NIiuk.js (Deleted) -355 bytes 0 bytes -100.0% 🗑️
assets/bot-B8h11oy7.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/git-pull-request-arrow-DGvSWH6v.js (Deleted) -321 bytes 0 bytes -100.0% 🗑️
assets/list-checks-DdLVeYkC.js (Deleted) -279 bytes 0 bytes -100.0% 🗑️
assets/compass-CV1i58N5.js (Deleted) -251 bytes 0 bytes -100.0% 🗑️
assets/history-Cm7uKd-c.js (Deleted) -237 bytes 0 bytes -100.0% 🗑️
assets/message-square-BYI2qdr0.js (Deleted) -233 bytes 0 bytes -100.0% 🗑️
assets/lock-4Q5wUs-p.js (Deleted) -206 bytes 0 bytes -100.0% 🗑️
assets/play-BBWcRPB7.js (Deleted) -190 bytes 0 bytes -100.0% 🗑️
assets/circle-check-352p4Oc_.js (Deleted) -178 bytes 0 bytes -100.0% 🗑️

@JSONbored
JSONbored merged commit 693aed8 into main Jul 31, 2026
7 of 8 checks passed
@JSONbored
JSONbored deleted the claude/ai-observability-trace-groups branch July 31, 2026 11:34
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
26315 1 26314 21
View the top 1 failed test(s) by shortest run time
test/integration/api.test.ts > api routes > serves private MCP tool listing and tool calls
Stack Traces | 0.682s run time
AssertionError: expected 500 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 500

 ❯ test/integration/api.test.ts:5098:44

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

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.

ai(observability): every generation is its own trace, and AI spend has no repo attribution

1 participant