Skip to content

Identify PostHog persons by Keycloak global_id, not Django pk - #3798

Merged
ChristopherChudzicki merged 9 commits into
mainfrom
hq-12601-posthog-guid-identify
Jul 31, 2026
Merged

Identify PostHog persons by Keycloak global_id, not Django pk#3798
ChristopherChudzicki merged 9 commits into
mainfrom
hq-12601-posthog-guid-identify

Conversation

@ChristopherChudzicki

@ChristopherChudzicki ChristopherChudzicki commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

Part of https://github.com/mitodl/hq/issues/12601 (step 3)

Description (What does it do?)

Identifies PostHog persons by the Keycloak global_id instead of the Django user pk, so identities are globally unique across the MIT applications sharing a PostHog project.

How can this be tested?

  1. I recommend adding a little client-side logging, here: (Posthog requests are encoded so hard to see)

    Add some logging
    diff --git a/frontend/public/src/store/posthogIdentify.js b/frontend/public/src/store/posthogIdentify.js
    index 2f55ff15..bd5a0609 100644
    --- a/frontend/public/src/store/posthogIdentify.js
    +++ b/frontend/public/src/store/posthogIdentify.js
    @@ -38,6 +38,7 @@ const posthogIdentifyMiddleware = () => (next: Function) => (action: any) => {
        // is an authenticated user who simply has no global id.
        if (currentUser && currentUser.is_anonymous) {
        if (posthog.get_property("$user_state") !== "anonymous") {
    +        console.log("Posthog resetting")
            posthog.reset()
        }
        } else if (currentUser && currentUser.global_id) {
    @@ -45,6 +46,7 @@ const posthogIdentifyMiddleware = () => (next: Function) => (action: any) => {
            environment: SETTINGS.environment,
            user_id:     currentUser.global_id
        })
    +      console.log("Posthog identifying as", currentUser.global_id)
        }
    }
  2. Load pretty much any page... If you're logged in, you should see a "Posthog Identifying GUID" call using your users keycloak GUID

    • This includes /cart with ?ecom-service, which is a page Learn definitely cares about
    • Posthog does NOT currently work (before or after this PR) on financial aid pages.
  3. If you log out in the same browser, you should see it "reset".

Additional Context

Identify moved out of Header.js into a redux-query store middleware (store/posthogIdentify.js) keyed on a successful /api/v0/users/current_user/ response, so it fires wherever that data loads regardless of which webpack bundle triggered it. Header.js was the wrong layer — it's suppressed on cart, orders, records, and anything with ?ecom-service, so those pages never identified even though App.js already fetched the user there. base.html wraps only {% block headercontent %} in the suppression conditional and index.html renders the root bundle inside {% block content %}, so /cart?ecom-service keeps identifying.

global_id is now also used for the feature-flag person properties (TopBar, CourseProductDetailEnroll) and for server-side flag evaluation (CheckoutInterstitialView, HomePage) — ol-django's _get_person_properties derives user_id from opt_unique_id, so client identify, client flag overrides, and server-side person_properties now agree on one value.

The email/name person properties the old DashboardPage identify sent are dropped deliberately; we don't set them anywhere else.

Still uncovered: pages where the template suppresses the header and only the django bundle loads (/certificate/*, /checkout/*, Wagtail pages with ?ecom-service). That bundle never reaches posthog.init() — a module side effect in lib/util.js — and each webpack entry gets its own posthog instance, so identify() there is a no-op regardless of the caller. Not worth closing: certificate pages moved to the Next.js site, /checkout/ is a transient redirect, and the financial-aid form is rare.

Companion xPro PR for step 4: mitodl/mitxpro#4025

@github-actions

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for v0.yaml:
No changes detected

## Changes for v1.yaml:
No changes detected

## Changes for v2.yaml:
No changes detected

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@ChristopherChudzicki
ChristopherChudzicki force-pushed the hq-12601-posthog-guid-identify branch from eb172e9 to 50ebea7 Compare July 29, 2026 14:56
@ChristopherChudzicki
ChristopherChudzicki marked this pull request as draft July 29, 2026 15:49
@ChristopherChudzicki
ChristopherChudzicki force-pushed the hq-12601-posthog-guid-identify branch from 940d3ca to 5eaf9e6 Compare July 29, 2026 17:49
Comment thread cms/models.py
show_auto_daily_featured_items = is_enabled(
features.ENABLE_AUTO_DAILY_FEATURED_ITEMS,
False, # noqa: FBT003
user,

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.

This is the only use of the user var above. It's for checking a feature flag.

@ChristopherChudzicki
ChristopherChudzicki marked this pull request as ready for review July 30, 2026 19:05
Comment thread frontend/public/src/store/posthogIdentify.js
ChristopherChudzicki and others added 8 commits July 30, 2026 20:56
For mitodl/hq#12601

Header.js was identifying by the local pk on every page, while
DashboardPage.js separately (and correctly) identified by global_id
on the dashboard only, creating two disagreeing identities. Header
now guards on global_id (skipping identify if absent) and the
now-redundant identify call in DashboardPage was removed.
Header.js is only mounted on some pages (it's skipped on cart/checkout/
orders/certificate pages), so identify() never fired there. Moving it to
App.js wouldn't help either: Wagtail-rendered pages (course/program/home,
financial aid) never mount App.js at all.

Instead, hook into the redux-query REQUEST_SUCCESS action for
/api/v0/users/current_user/, which every page that loads currentUser
triggers regardless of which component asked for it. This fires once per
successful fetch instead of once per Header render, and posthog.init()
(a synchronous module-level side effect in lib/util.js) is always evaluated
before any REQUEST_SUCCESS can be dispatched, since that requires an async
network round trip.
The middleware fires on every successful current_user response and has no
dedup guard. Point at posthog's docs for why one isn't needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The identify call was switched to global_id, but the pk was still being
sent as the user_id person property and as the distinct_id for
server-side flag evaluation, so client identify, client flag overrides,
and ol-django's person_properties disagreed on who the person was.

ol-django's _get_person_properties derives user_id from opt_unique_id,
so switching the identifier fixes the distinct_id and the property
together.

HomePage now falls through to the existing anonymous_session_id path for
a user without a global_id, rather than to the HOSTNAME fallback inside
is_enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"handles checkFeatureFlag returning true" and "falls back to default URL
when MIT_LEARN_DASHBOARD_URL is not set" were both the same test as
"redirects when feature flag is enabled" -- the beforeEach/afterEach
already leave mit_learn_dashboard_url unset, so the fallback case was
never distinct. Dropped both and let each remaining case pin one branch.

makeUser() already generates a global_id, so the "test-guid-*" overrides
were fighting the factory and forcing the literal to be repeated in each
withArgs and assertion. Use the factory's own value instead.

Also removes the performance.mark polyfill from beforeEach. It never did
anything for the tests it was added for: sandbox.useFakeTimers() in the
inner beforeEach runs afterwards and swaps global.performance for
sinon's, which only carries the methods it can copy off
globalThis.Performance.prototype. That constructor is absent before node
18, so the mark stub goes missing on older local node -- but CI runs
20.18, where it exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Flow infers mockSettings.posthog_api_host as string from its
initializer, so assigning null to it is an error. global.SETTINGS is the
same object but loosely typed, which is what this test used before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The middleware mirrored mit-learn's ConfiguredPostHogProvider but dropped
its posthog.reset() branch. identify() latches $user_state to
"identified" and only reset() clears it, so a logged-out browser went on
attributing events to whoever logged in last, and nothing else in
frontend/public/src calls reset().

That was contained while MITxOnline had its own project and pk
identities. Once step 5 puts it in Learn's project it means
shared-computer cross-attribution, plus churn as Learn resets what
MITxOnline left identified.

Signin and signout both happen on the SSO server, so there is no
client-side signout to hook. Instead reset whenever the current_user
response turns out to be anonymous while posthog still thinks it is
identified -- the same approach, and the same reasoning, as mit-learn.

Both branches key off what the response affirmatively says rather than
one being the other's else, because two cases must not reset:

- a 2xx carrying no user at all. transform is objOf("currentUser"), so
  an empty body yields {currentUser: undefined}.
- an authenticated user with no global_id. They are unidentifiable, not
  anonymous. is_anonymous separates those; !global_id does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CheckoutInterstitialView passed request.user.global_id straight through,
but is_enabled does `unique_id = opt_unique_id or default_unique_id()`
and default_unique_id() returns settings.HOSTNAME. So every user without
a global_id collapsed into one shared bucket -- a percentage rollout
becomes all-or-nothing for them -- and the result cached under a cache
key derived from that shared id.

global_id is nullable and blankable by design ("we may want a Django
user that's not connected to an SSO user"), and request.user.id was
always truthy, so this only became reachable when the identifier
changed.

Guard it the way the other two global_id flag calls already do:
_should_redirect_to_learn in this same module and main/views.py. Passing
default/opt_unique_id by keyword matches those callers and drops the
FBT003 noqa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChristopherChudzicki
ChristopherChudzicki force-pushed the hq-12601-posthog-guid-identify branch from ae0c757 to bb1d26f Compare July 31, 2026 00:56
Comment thread frontend/public/src/store/posthogIdentify.js
The stub returned "identified" for every key, and posthog reads its own
feature flags through get_property too. A stray render elsewhere in the
suite checking "new-cart-design" therefore hit `'new-cart-design' in
"identified"` and failed whichever posthogIdentify test happened to be
running, as an uncaught error.

Returning undefined for other keys is what posthog already handles, so
scoping the stub leaves the flag path alone while the middleware's own
$user_state read is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread cms/models.py
@gumaerc gumaerc self-assigned this Jul 31, 2026

@gumaerc gumaerc 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.

👍

@ChristopherChudzicki
ChristopherChudzicki merged commit c67cfc3 into main Jul 31, 2026
14 checks passed
@ChristopherChudzicki
ChristopherChudzicki deleted the hq-12601-posthog-guid-identify branch July 31, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants