Skip to content

Profile trait history: fixes + surface traits/history in UI#539

Merged
izadoesdev merged 14 commits into
mainfrom
staging
Jul 4, 2026
Merged

Profile trait history: fixes + surface traits/history in UI#539
izadoesdev merged 14 commits into
mainfrom
staging

Conversation

@izadoesdev

@izadoesdev izadoesdev commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

Two data-correctness fixes for the trait-history work, plus the UI/RPC surface that finally exposes traits and trait history in the product (they were stored but rendered nowhere).

Fixes (from #538 review)

  • GDPR cascade (Greptile P1): composite FK profile_trait_changes.(website_id, profile_id) → profiles with ON DELETE CASCADE, replacing the standalone website_id → websites ref. Deleting a profile now takes its trait history with it.
  • First-identify race (cubic + Greptile P1): pg_advisory_xact_lock on the (websiteId, profileId) pair at the top of the upsertProfile transaction (FOR UPDATE can't lock a not-yet-existent row). Serializes concurrent first-time identifies.

New: expose traits + history in the users UI

  • profiles.getHistory RPC — website-read-gated, returns profile_trait_changes newest-first (cap 200), each entry with changed keys (old/new) and the post-change snapshot. Two integration tests (ordering/shape + cross-org FORBIDDEN).
  • User detail page — Traits section (renders stored plan/etc., hidden when empty) and a trait-history timeline (date, source badge, per-key old → new diffs). Desktop sidebar + mobile.
  • Users list page — refresh button in the top bar (spins while fetching), wired to the query's refetch.

Deploy notes

  • Composite FK already applied to production Postgres (verified via pg_constraint: on_delete: c, 0 orphans before apply).

Not addressed here

  • sessions.ts CTE scan expansion (pre-existing) — separate perf PR.
  • UI not yet eyeball-verified in a running renderer (dev server not started); typecheck + lint + unit tests green, integration tests run in CI.

Testing

  • monorepo typecheck 34/34, api 155/155, lint clean
  • new getHistory integration tests (run in CI with Docker Postgres)

@vercel

vercel Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dashboard (staging) Ready Ready Preview, Comment Jul 4, 2026 9:01am
databuddy-status Ready Ready Preview, Comment Jul 4, 2026 9:01am
documentation (staging) Ready Ready Preview, Comment Jul 4, 2026 9:01am

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e294b81-b162-4af1-be80-48b44ae1d5b4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch staging

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dosubot

dosubot Bot commented Jul 4, 2026

Copy link
Copy Markdown

📄 Knowledge review

✏️ Documentation updates

1 page was updated by changes in this PR.

Page Library Status
Profile Trait Change Tracking Databuddy's Space ✅ Updated
📝 Profile Trait Change Tracking — changes
@@ -18,8 +18,8 @@
 | Column | Type | Description |
 |---|---|---|
 | `id` | `text` (UUID) | Primary key, generated with `crypto.randomUUID()` |
-| `website_id` | `text` | Foreign key → `websites.id` (cascade delete) |
-| `profile_id` | `text` | The profile this change belongs to |
+| `website_id` | `text` | Composite foreign key → `profiles.(website_id, profile_id)` (cascade delete) |
+| `profile_id` | `text` | Composite foreign key → `profiles.(website_id, profile_id)` (cascade delete) |
 | `traits` | `jsonb` | Full merged non-PII trait snapshot **after** this change |
 | `changes` | `jsonb` | Diff object: `{ [traitKey]: { old, new } }` for every key that changed |
 | `source` | `text` | Origin of the change — `"identify"` or `"billing"` |
@@ -41,7 +41,7 @@
 Although trait history is append-only time-series data, it is stored in Postgres for three reasons:
 
 1. **Transactional consistency** — the change record is written inside the same database transaction as the profile upsert, so the profile and its history are always in sync.
-2. **Trivial GDPR deletes** — deleting a website cascades to all of its trait history automatically; no cross-database coordination is needed.
+2. **Trivial GDPR deletes** — deleting a profile cascades to all of its trait history automatically; no cross-database coordination is needed.
 3. **Events already carry traits** — Databuddy events store the traits that were active at event time, so there is no need to join against a separate time-series store to reconstruct the state at any past moment.
 
 ### Indexes
@@ -92,11 +92,12 @@
 
 Trait history is recorded inside `upsertProfile` in [`packages/services/src/identity.ts`](https://github.com/databuddy-analytics/Databuddy/blob/main/packages/services/src/identity.ts). When the call includes trait updates, the following steps execute inside a single Postgres transaction:
 
-1. **`SELECT … FOR UPDATE`** — the existing profile row is read with a row-level lock to prevent concurrent upserts from interleaving their history records.
-2. **`INSERT … ON CONFLICT DO UPDATE`** — the profile is created or its traits are merged in place.
-3. **`applyTraits`** — the diff is computed against the traits fetched in step 1.
-4. **Conditional insert** — if `changes.length > 0`, a new row is inserted into `profile_trait_changes` containing the diff, the merged trait snapshot, and the source.
-5. **Return** — the function returns the `ProfileTraitUpdate` (or `null` if there were no trait-level changes to process).
+1. **`pg_advisory_xact_lock`** — an advisory lock is acquired on the `(websiteId, profileId)` pair to serialize concurrent first-time identifies. `FOR UPDATE` can't lock a not-yet-existent row, so this prevents race conditions when two concurrent identify calls try to create history for the same profile that doesn't exist yet.
+2. **Read existing traits** — the profile's current trait snapshot is read (without a row lock, since the advisory lock provides serialization).
+3. **`INSERT … ON CONFLICT DO UPDATE`** — the profile is created or its traits are merged in place.
+4. **`applyTraits`** — the diff is computed against the traits fetched in step 2.
+5. **Conditional insert** — if `changes.length > 0`, a new row is inserted into `profile_trait_changes` containing the diff, the merged trait snapshot, and the source.
+6. **Return** — the function returns the `ProfileTraitUpdate` (or `null` if there were no trait-level changes to process).
 
 Because the history record is written in the same transaction, it is impossible for the profile to be updated without a corresponding history entry, or for a history entry to exist whose state does not match what the profile row holds.
 
@@ -171,21 +172,58 @@
 
 ## Querying Profile History
 
-### The `get_profile_history` AI Tool
-
-Profile trait history is queryable through the `get_profile_history` tool, registered in [`packages/ai/src/ai/tools/profiles.ts`](https://github.com/databuddy-analytics/Databuddy/blob/main/packages/ai/src/ai/tools/profiles.ts) inside `buildProfileTools`. The tool is available to both the **in-app agent** and the **MCP** integration, and website-access enforcement is applied automatically via `opts.resolveSite`.
+### The `profiles.getHistory` RPC Endpoint
+
+Profile trait history is exposed through the `profiles.getHistory` RPC endpoint, defined in [`packages/rpc/src/routers/profiles.ts`](https://github.com/databuddy-analytics/Databuddy/blob/main/packages/rpc/src/routers/profiles.ts). The endpoint is available to authenticated users with website read permission and returns the trait change timeline for a specific profile, newest first.
+
+#### Route
+
+```
+POST /profiles/getHistory
+```
 
 #### Parameters
 
 | Parameter | Type | Description |
 |---|---|---|
 | `websiteId` | `string` | The website to query (access-checked) |
-| `profileId` | `string` | The profile whose trait history to retrieve |
+| `profileId` | `string` | The profile whose trait history to retrieve (min: 1, max: 128 characters) |
 | `limit` | `number` | Number of entries to return. Min: `1`, max: `200`, default: `50` |
 
 #### Response
 
-The tool returns an object with two fields:
+Returns an array of trait change entries, ordered **most recent first**. Each entry provides:
+
+```ts
+Array<{
+  changes: Record<string, { old: TraitValue; new: TraitValue }>;
+  traits:  Record<string, unknown>;
+  source:  string;
+  createdAt: Date;
+}>
+```
+
+- `changes` — the diff showing exactly which traits changed and their old/new values.
+- `traits` — the complete trait snapshot immediately after this change.
+- `source` — `"identify"` or `"billing"`.
+- `createdAt` — the timestamp of the change with millisecond precision.
+
+Because each row carries the full snapshot, answering "what were the traits at time T" requires only selecting the first entry with `createdAt <= T` — no diff accumulation is needed.
+
+#### Example Usage
+
+The endpoint is designed to answer questions like:
+
+- **"When did the plan change?"** — filter the response array for entries where `changes.plan` exists.
+- **"What were the traits at time T?"** — take the first entry with `createdAt <= T`.
+- **"Show me all changes to the email trait"** — filter for entries where `changes.email` is present.
+- **"How many times has this profile been updated?"** — use the length of the returned array.
+
+### The `get_profile_history` AI Tool
+
+Profile trait history is also queryable through the `get_profile_history` tool, registered in [`packages/ai/src/ai/tools/profiles.ts`](https://github.com/databuddy-analytics/Databuddy/blob/main/packages/ai/src/ai/tools/profiles.ts) inside `buildProfileTools`. The tool is available to both the **in-app agent** and the **MCP** integration, and website-access enforcement is applied automatically via `opts.resolveSite`.
+
+The tool accepts the same parameters as the RPC endpoint and returns an object with two fields:
 
 ```ts
 {
@@ -199,23 +237,6 @@
 }
 ```
 
-Results are ordered **most recent first**. Each entry provides:
-- `changes` — the diff showing exactly which traits changed and their old/new values.
-- `traits` — the complete trait snapshot immediately after this change.
-- `source` — `"identify"` or `"billing"`.
-- `changedAt` — the timestamp of the change with millisecond precision.
-
-Because each row carries the full snapshot, answering "what were the traits at time T" requires only selecting the first entry with `changedAt <= T` — no diff accumulation is needed.
-
-#### Example Questions
-
-The tool is designed to answer questions like:
-
-- **"When did the plan change?"** — filter `history` for entries where `changes.plan` exists.
-- **"What were the traits at time T?"** — take the first entry with `changedAt <= T`.
-- **"Show me all changes to the email trait"** — filter for entries where `changes.email` is present.
-- **"How many times has this profile been updated?"** — use `count` from the response.
-
 
 ## Use Cases
 
@@ -262,7 +283,7 @@
 
 ### GDPR Deletion
 
-Deleting a website row cascades to all `profile_trait_changes` rows for that website, because the `website_id` foreign key is defined with `ON DELETE CASCADE`. This ensures that when a customer exercises their right to erasure and their website is deleted, all associated trait history is removed in the same database operation with no additional cleanup steps required.
+Deleting a profile row cascades to all `profile_trait_changes` rows for that profile, because the `(website_id, profile_id)` composite foreign key is defined with `ON DELETE CASCADE`. This ensures that when a customer exercises their right to erasure and their profile is deleted, all associated trait history is removed in the same database operation with no additional cleanup steps required.
 
 
 ## Technical Reference
@@ -276,9 +297,7 @@
   "profile_trait_changes",
   {
     id: text().primaryKey(),
-    websiteId: text("website_id")
-      .notNull()
-      .references(() => websites.id, { onDelete: "cascade" }),
+    websiteId: text("website_id").notNull(),
     profileId: text("profile_id").notNull(),
     // Full merged non-PII traits after this change — state at time T is the
     // latest row with createdAt <= T, no diff folding needed.
@@ -304,6 +323,11 @@
       table.createdAt
     ),
     index("profile_trait_changes_changes_gin_idx").using("gin", table.changes),
+    foreignKey({
+      columns: [table.websiteId, table.profileId],
+      foreignColumns: [profiles.websiteId, profiles.profileId],
+      name: "profile_trait_changes_profile_fkey",
+    }).onDelete("cascade"),
   ]
 );
 ```

Leave Feedback Ask Dosu about Databuddy Add Dosu to your team

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 2 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant UI as Client App
    participant API as Identity API
    participant DB as PostgreSQL
    participant Lock as Advisory Lock

    Note over UI,DB: Profile Delete Flow with Trait History

    UI->>API: DELETE /profiles/{websiteId}/{profileId}
    API->>DB: DELETE FROM profiles WHERE website_id=w AND profile_id=p
    DB->>DB: CASCADE: DELETE profile_trait_changes WHERE (website_id,profile_id)=(w,p)
    DB-->>API: 200 OK
    API-->>UI: Deletion confirmed

    Note over UI,DB: Website Delete Flow (transitive cascade)

    UI->>API: DELETE /websites/{websiteId}
    API->>DB: DELETE FROM websites WHERE id=w
    DB->>DB: CASCADE: DELETE profiles WHERE website_id=w
    DB->>DB: CASCADE: DELETE profile_trait_changes WHERE (website_id,profile_id) IN (deleted profiles)
    DB-->>API: 200 OK
    API-->>UI: Deletion confirmed

    Note over UI,DB: First-Time Identify Flow (concurrent safe)

    UI->>API: POST /identify (websiteId, profileId, traits)
    API->>DB: BEGIN TRANSACTION
    API->>Lock: SELECT pg_advisory_xact_lock(hash('profile:w:p'))
    alt First time (no existing profile)
        DB->>API: Lock acquired
        API->>DB: SELECT traits FROM profiles WHERE website_id=w AND profile_id=p LIMIT 1
        DB-->>API: Empty result
        API->>DB: INSERT INTO profiles (website_id, profile_id, traits)
        API->>DB: INSERT INTO profile_trait_changes (website_id, profile_id, changes)
        API->>DB: COMMIT
        DB-->>API: Profile created with initial traits
    else Existing profile
        DB->>API: Lock acquired
        API->>DB: SELECT traits FROM profiles WHERE website_id=w AND profile_id=p LIMIT 1
        DB-->>API: Existing traits
        API->>DB: UPDATE profiles SET traits = merge(traits, new_traits) WHERE website_id=w AND profile_id=p
        API->>DB: INSERT INTO profile_trait_changes (website_id, profile_id, changes)
        API->>DB: COMMIT
        DB-->>API: Profile updated
    end
    API-->>UI: Identify result

    Note over UI,DB: Concurrent First-Time Identify (race prevented)

    UI->>API: POST /identify (w, p, traits_A)
    User2->>API: POST /identify (w, p, traits_B)
    API->>Lock: SELECT pg_advisory_xact_lock(hash('profile:w:p'))
    User2->>Lock: SELECT pg_advisory_xact_lock(hash('profile:w:p'))
    Lock-->>API: Lock acquired
    API->>DB: SELECT traits... (returns empty)
    API->>DB: INSERT profile + trait_history
    API->>DB: COMMIT
    API->>Lock: Lock released
    Lock-->>User2: Lock acquired (now sees existing profile)
    User2->>DB: SELECT traits... (returns traits_A)
    User2->>DB: UPDATE profile + INSERT trait_history
    User2->>DB: COMMIT
    User2->>Lock: Lock released
    API-->>UI: Identify result (traits_A)
    User2-->>User2: Identify result (merged traits)
Loading

Shadow auto-approve: would require human review. Database schema change (composite FK) and transaction concurrency modification; requires human review due to potential data integrity and performance implications.

Re-trigger cubic

@greptile-apps

greptile-apps Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR addresses two data-correctness issues from the previous trait-history shipping: it replaces the standalone website_id → websites FK on profile_trait_changes with a composite (website_id, profile_id) → profiles FK with ON DELETE CASCADE, and it serializes concurrent first-time identifies with pg_advisory_xact_lock instead of a SELECT … FOR UPDATE that cannot lock a non-existent row.

  • Schema (identity.ts): The composite FK correctly restores cascade-on-profile-delete for trait history; profile_aliases has the same structural gap (only website_id → websites cascade, no cascade from profiles) and will leave orphaned rows when a profile is GDPR-deleted.
  • Service (identity.ts): The advisory-lock approach is sound — the key is parameterized in Drizzle's sql tag so there is no injection risk, the lock is transaction-scoped, and the seed-0 hashtextextended call returns a valid bigint for pg_advisory_xact_lock.

Confidence Score: 4/5

Safe to merge; both targeted fixes are correct and the advisory-lock replacement is sound.

The two fixes are well-reasoned and correctly implemented. The one gap worth noting before closing the GDPR story is that profile_aliases still has no cascade from profiles, so a profile deletion leaves alias rows pointing at the removed profile — the same class of problem the schema fix addresses for trait history.

packages/db/src/drizzle/schema/identity.ts — the profileAliases table should be checked for a matching cascade FK before GDPR deletes are considered fully covered.

Important Files Changed

Filename Overview
packages/db/src/drizzle/schema/identity.ts Replaces standalone website FK on profileTraitChanges with a composite (websiteId, profileId) → profiles FK with ON DELETE CASCADE. Correct fix for GDPR cascade. profileAliases has a parallel gap: no cascade from profiles means aliases survive profile deletion.
packages/services/src/identity.ts Replaces .for("update") with a pg_advisory_xact_lock on the (websiteId, profileId) pair to serialize concurrent first-time identifies. Approach is sound: lock is transaction-scoped, key is parameterized (no injection risk), and the same pattern exists elsewhere in the codebase.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C1 as Concurrent Identify #1
    participant C2 as Concurrent Identify #2
    participant PG as PostgreSQL

    C1->>PG: BEGIN transaction
    C1->>PG: pg_advisory_xact_lock('profile:ws:uid')
    Note over PG: Lock acquired by C1

    C2->>PG: BEGIN transaction
    C2->>PG: pg_advisory_xact_lock('profile:ws:uid')
    Note over PG: C2 blocks — same hash key

    C1->>PG: "SELECT traits FROM profiles (reads {})"
    C1->>PG: INSERT profiles ON CONFLICT DO UPDATE
    C1->>PG: "INSERT profile_trait_changes (delta: plan=pro)"
    C1->>PG: COMMIT → lock released

    Note over C2: Unblocked
    C2->>PG: SELECT traits FROM profiles (reads updated traits)
    C2->>PG: INSERT profiles ON CONFLICT DO UPDATE (merge)
    C2->>PG: INSERT profile_trait_changes (delta only if changed)
    C2->>PG: COMMIT
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C1 as Concurrent Identify #1
    participant C2 as Concurrent Identify #2
    participant PG as PostgreSQL

    C1->>PG: BEGIN transaction
    C1->>PG: pg_advisory_xact_lock('profile:ws:uid')
    Note over PG: Lock acquired by C1

    C2->>PG: BEGIN transaction
    C2->>PG: pg_advisory_xact_lock('profile:ws:uid')
    Note over PG: C2 blocks — same hash key

    C1->>PG: "SELECT traits FROM profiles (reads {})"
    C1->>PG: INSERT profiles ON CONFLICT DO UPDATE
    C1->>PG: "INSERT profile_trait_changes (delta: plan=pro)"
    C1->>PG: COMMIT → lock released

    Note over C2: Unblocked
    C2->>PG: SELECT traits FROM profiles (reads updated traits)
    C2->>PG: INSERT profiles ON CONFLICT DO UPDATE (merge)
    C2->>PG: INSERT profile_trait_changes (delta only if changed)
    C2->>PG: COMMIT
Loading

Comments Outside Diff (1)

  1. packages/db/src/drizzle/schema/identity.ts, line 85-104 (link)

    P2 profileAliases orphaned when a profile is deleted

    The PR fixes the GDPR cascade for profileTraitChanges, but profileAliases still has no FK relationship to profiles — only the websiteId → websites.id cascade. Deleting a profile leaves every (websiteId, anonymousId) → profileId alias row in place. For the "trivial GDPR deletes" goal stated in the PR description this is a gap: the identity graph still maps devices to a removed profile, and a future call to upsertAlias for an old anonymous ID would re-attach to a re-created profile without any indication that the original was erased. The fix is a composite FK identical in shape to the one added for profileTraitChanges.

Reviews (1): Last reviewed commit: "fix(services): cascade trait history on ..." | Re-trigger Greptile

@unkey-deploy

unkey-deploy Bot commented Jul 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Unkey Deploy

Name Status Preview Inspect Updated (UTC)
api (preview) Ready Visit Preview Inspect Jul 4, 2026 9:00am

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 6 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread apps/dashboard/app/(main)/websites/[id]/users/[userId]/page.tsx Outdated
@izadoesdev izadoesdev changed the title fix(identity): cascade trait history on profile delete and serialize first-identify Profile trait history: fixes + surface traits/history in UI Jul 4, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Shadow auto-approve: would require human review. Database schema changes (composite FK, migration), concurrency control (advisory lock), and new RPC endpoints with UI surface — any of these can break production data or require domain review.

Re-trigger cubic

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Shadow auto-approve: would require human review. Contains database schema change (composite FK with cascade) and critical locking change in upsertProfile transaction; requires human review to verify migration safety and concurrency behavior.

Re-trigger cubic

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Shadow auto-approve: would require human review. These changes affect core data integrity and the user-facing API, including a new RPC endpoint and updated profile logic. They are high-impact and require human review.

Re-trigger cubic

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.

1 participant