Skip to content

feat(analytics): a colleague's first open of a patch is a business event - #915

Merged
allisonmahmood merged 2 commits into
mainfrom
feat/colleague-first-open
Oct 24, 2026
Merged

allisonmahmood merged 2 commits into
mainfrom
feat/colleague-first-open

Conversation

@allisonmahmood

Copy link
Copy Markdown
Owner

Stacked on #909's branch for its welcome-page predicate; merges with the onboarding stack.

What changed

Activation is now decided: a patch someone else at the company opens within 7 days of the person joining, with the welcome page excluded. Patchy already recorded each member's opens for their own Home, but nothing reported "a colleague opened your patch for the first time", so the metric couldn't be read in PostHog. Now a member's first open of a colleague's company patch reports patch.first_opened, and docs/product.md › Analytics records the activation query and its public-patch gap.

Closes #912. Part of #906.

  • Patches.recordOpen now returns whether its upsert inserted (RETURNING xmax = 0, which works on any Postgres version). A member's first record of a patch is reported once, even when opens race. A stale version or an off patch records nothing and returns false.
  • Serving (Pages.ts) reports patch.first_opened when that open was a first and the opener isn't the owner. The event carries patchId, tier, ownerUserId and welcomePage (feat(ui): Final step, a panel over Home until your agent is connected #909's isWelcomePage), and its principal is the opener. It carries no page content. The open and its report are one uninterruptible step. sql-pg gives an interrupted statement 10 ms before cancelling it, so a reader who leaves mid-open could otherwise commit a first open that never reports; the next open isn't first, so that event would be lost for good. track only queues, so the page doesn't wait on PostHog, and an event failure never fails the page.
  • The catalogue (Analytics.ts) records this as the one exception to "serving isn't a business event", and says why. The Analytics and Patches glossaries, GLOSSARY-MAP.md and OPERATIONS' business-event list follow.
  • The known gap: public patches record no member's opens, even for a signed-in colleague, so they never count towards activation. Opens recorded before this release report nothing either. Both are written into product.md.

Look at

1. First-open detection under a race. The test holds an uncommitted insert of the same patch_opens row and waits until three recordOpen calls are blocked on it in pg_stat_activity. Then it rolls the insert back. Exactly one call must report the insert. Before (old recordOpen, same tests):

 FAIL  packages/patches/src/Patches.test.ts > Patches > remembers each member's opens of live patches, halving every week
AssertionError: expected undefined to be true
 FAIL  packages/patches/src/Patches.test.ts > Patches > lets exactly one of a member's racing opens be their first
AssertionError: expected +0 to deeply equal 1
 FAIL  packages/serving/src/Pages.test.ts > pages > reports a colleague's first open of a patch, and no other open
AssertionError: expected [] to deeply equal [ Array(1) ]
      Tests  3 failed | 5 passed | 68 skipped (76)

After:

 ✓ Patches > remembers each member's opens of live patches, halving every week 35ms
 ✓ Patches > lets exactly one of a member's racing opens be their first 14ms
 ✓ pages > records a member's open of the address, never the content URL or a public page 39ms
 ✓ pages > reports a colleague's first open of a patch, and no other open 37ms
 ✓ pages > serves the page when recording an open fails 16ms

The race test passed in each of 5 consecutive runs.

2. What emits and what doesn't (Pages.test.ts). These run against a real socket:

  • the owner's own first open: no event;
  • a colleague on a public patch: no event;
  • a colleague on ~v/1 after v2 is out: no event;
  • the colleague's first open of the current version: exactly one event, { patchId, ownerUserId, tier: 0, welcomePage: false };
  • opening it again: nothing more;
  • a colleague opening a welcome page: welcomePage: true.

3. A reader who leaves mid-open still counts. The test makes recording the open take a second and interrupts the request during it. It then requires both the patch_opens row and the event. Without Effect.uninterruptible the run fails with AssertionError: expected +0 to equal 1, because the cancelled open records nothing. With it, the run passes.

How it was verified

  • pnpm exec vitest run packages/patches/src/Patches.test.ts packages/serving/src/Pages.test.ts packages/analytics: 96 passed.
  • packages/portal/src/PortalPages.test.ts: 84 passed. It calls recordOpen and ignores the new result.
  • pnpm typecheck (src and tests) and pnpm lint: clean. Prettier ran on every changed file.
  • I didn't run pnpm verify, e2e or the browser suites, because this machine is shared.
  • The activation query is written as a definition over user.joined and patch.first_opened, keyed by ownerUserId. I haven't run it in PostHog: the event only exists once this ships.

Release impact

  • Breaking — requires action from anyone already running this
  • Not breaking — no action required

This adds one event and changes an internal return type. There's no schema change, migration, CLI or API change.

  • What does a revert leave behind? Only patch.first_opened events already sent to PostHog. patch_opens rows are written exactly as before.
  • Does the release before it still work with that? Yes.

Made by Claude Opus 5.5 in Claude Code, running in T3 Code.

🤖 Generated with Claude Code

@github-actions github-actions Bot added area:server Touches the server area area:docs Touches the docs area size:m 100-500 changed lines labels Oct 23, 2026
@allisonmahmood

Copy link
Copy Markdown
Owner Author

GPT 6.1 Sol (xhigh) reviewed this. Two should-fixes, both fixed in f860a0e, and the branch is rebased onto #916's latest commit:

  1. Effect.uninterruptible around the whole open meant a disconnected reader's statement could wait on locks indefinitely. Enough of those could exhaust the pool and hold up shutdown. The open now runs through withReportedCommit: the insert and its lock waits stay cancellable, and only the commit and its report are protected. The disconnect test now asserts that an abandoned open leaves nothing recorded and unreported.
  2. The privacy sentence in docs/product.md now says what reaches PostHog: patch.first_opened is the one business event for opens, and the request diagnostics (wide.request) already carry viewer and patch ids for every company-page open.

@allisonmahmood
allisonmahmood force-pushed the feat/colleague-first-open branch from 3e38cf6 to f860a0e Compare October 23, 2026 23:08
Base automatically changed from feat/final-step-panel to main October 24, 2026 00:55
allisonmahmood and others added 2 commits October 24, 2026 03:19
Activation is a patch someone else at the company opens within 7 days of
the person joining, welcome page aside. Patchy recorded each member's opens
for their own Home, but nothing said "a colleague opened your patch for the
first time", so the metric couldn't be read in PostHog.

Patches.recordOpen now answers whether its upsert inserted (`xmax = 0`), so
exactly one open is ever a member's first, even when opens race. When it is,
and the opener isn't the owner, serving reports `patch.first_opened` with the
patch, its tier, its owner and whether it is the owner's welcome page. The
open and its report are one uninterruptible step, so a reader who leaves
mid-open can't leave a first open recorded and never reported.

Public patches record no member's opens, so they never count; the catalogue,
OPERATIONS and the new product.md analytics section say so and record the
activation query.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ays cancellable

From review: making the whole open uninterruptible kept a disconnected
reader's statement waiting on locks, which could exhaust the pool and
hold up shutdown. The open now runs through withReportedCommit: the
insert and its lock waits stay cancellable, and only the commit and its
report are protected. product.md also says plainly that request
diagnostics already carry viewer and patch ids for every open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@allisonmahmood
allisonmahmood force-pushed the feat/colleague-first-open branch from f860a0e to 07bc0ba Compare October 24, 2026 01:19
@allisonmahmood
allisonmahmood merged commit b23e6ad into main Oct 24, 2026
14 checks passed
@allisonmahmood
allisonmahmood deleted the feat/colleague-first-open branch October 24, 2026 01:29
allisonmahmood added a commit that referenced this pull request Oct 24, 2026
## What changed

This PR adds release 18's What's new entry, written from `b23e6ad`. It
covers everything merged since release 17's `through` (`5dd94f3`), and
it sets the `patchy` release to `0.18.0` in
`packages/patchy/package.json` and `CURRENT_RELEASE`. With this release,
onboarding ends in a Final step on Home that follows the person's agent
through setup and lets them confirm it right there (#906).

| Change | PRs |
| --- | --- |
| New: A Final step to connect your AI agent | #916, #917 |
| New: Confirm your computer without leaving Home | #918 |
| Improved: Setting up ends with your first page | #914 |
| Improved: The logo takes you Home | #913 |
| Behind the scenes: the first time someone opens a colleague's tool is
counted, for activation | #915 |

One merged PR gets no line: #905 is release 17's own entry.

## Look at

- **The entry**, at the top of `releases` in
`packages/core/src/whatsNew.ts`. Its labels are the pages' own: Final
step, Later, Not me, Home, Patches.
- **Rollback safety, from the live commit `a049a3c` to `b23e6ad`.**
- No PR in the range is labelled `one-way`, and every PR answers both
Release impact questions with "Yes".
- Migrations 0026 and 0027 are additive: the `setup_prompt_sign_ins` and
`setup_links` tables, and a nullable `device_logins.setup_link_id` with
`ON DELETE SET NULL`. Release 17 never reads them.
  - `rollback-rehearsal` exercised both migrations on the landed heads:
- #916: `Exercised rollback paths passed: a049a3c → 6037d86 → a049a3c →
6037d86, in 234s.`
- #917: `Exercised rollback paths passed: a049a3c → d03a5bf → a049a3c →
d03a5bf, in 217s.`
- #918 and #915 change no migration or handler descriptor, so they had
nothing to rehearse.
- A rollback takes the Final step panel and setup links away. Home goes
back to the old guide, and a CLI at `0.18.0` stays usable against
release 17: `--setup` is only sent when `/llms.txt` prints it, and
release 17's `/llms.txt` doesn't.

## How it was verified

- `pnpm typecheck` and `pnpm lint` pass, and so do `packages/core`,
`packages/api`, `packages/portal` and the skill lock test (245 tests),
including `whatsNew.test.ts`, `schemas.test.ts` and
`WhatsNewPages.test.ts`.
- The live commit was pinned from `/healthz/deep`: `a049a3c`, release
17. The Deploy run updated last succeeded, and none is unfinished.
- Each PR in the range was read, including its Release impact answers.
All six landed with the reviewed trees: each squash left `main`'s tree
identical to its PR's reviewed head.

## Release impact

- [ ] **Breaking** — requires action from anyone already running this
- [x] **Not breaking** — no action required

This PR changes only the changelog text and the `patchy` version. As
with every release, a CLI installed before this deploy is told to
upgrade.

- **What does a revert leave behind?** Nothing. People whose seen marker
reached 18 would just see no new release.
- **Does the release before it still work with that?** Yes.

If this release misbehaves, roll back to
`a049a3cf544ae89691eec547dea46773febfdf05` (release 17).

Made by Claude Opus 5.5 in Claude Code, running in T3 Code.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs Touches the docs area area:server Touches the server area size:m 100-500 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(analytics): a colleague's first open of a patch is a business event

1 participant