docs: scope client attempt-id and workspace-scoping for billing telemetry (Phase B) - #14228
Conversation
…etry phase B Adds a Problem/Options/Tradeoffs/Recommendation scoping doc for two incident-88 Phase B decisions: a client-side billing_attempt_id for pre-response failure correlation, and workspace_id/PostHog group association for workspace-level funnel breakdowns.
christian-byrne
left a comment
There was a problem hiding this comment.
Should be in docs/adr. remove any private context
Per review feedback, move the client billing_attempt_id and workspace_id/PostHog-group scoping doc into docs/adr/ following the existing ADR format, and strip internal repo/service references from the content.
|
@christian-byrne replying to your review (Should be in docs/adr. remove any private context): Done on both points:
Verified by grepping the final ADR file for Pushed to Generated by Claude Code |
… service selection
Cut ADR-0014 (billing telemetry attempt correlation and workspace scoping) from 131 to 94 lines (~37% fewer words) by trimming redundant phrasing throughout Context/Decision/Consequences/Notes, while preserving both decision options (client-side billing_attempt_id vs. the deferred backend-coordinated alternative; workspace_id payload field + PostHog group) and their recommendations/rationale in full. No comfy-infra mentions were found in this file or docs/adr/README.md.
|
@DrJKL @dante01yoon — requesting review; this PR had no reviewer requested at all until now. It should be quick: 2 files, docs only (a 94-line ADR plus the ADR index). Status is The PR body has a "how to review this" primer, links the public program dossier, and flags one thing worth deciding before this is accepted: later research found the backend keys billing events on the workspace creator while the frontend keys on the acting user, so any team operation by a non-creator shows 100% drop-off at the first backend step. That makes decision 2 a prerequisite for any working funnel rather than the cheap nice-to-have the ADR currently calls it — possibly worth an amendment. Merge order: after #14208 (ADR index conflict). |
dante01yoon
left a comment
There was a problem hiding this comment.
The overall direction is sound, but the workspace-scoping decision still permits incorrect attribution. The three inline issues need to be resolved before this ADR becomes the implementation gate for B4/B5.
| weaker for no savings. | ||
|
|
||
| **2. `workspace_id` on the shared payload, plus a PostHog group.** Read | ||
| `workspaceId` into `getBillingTelemetryEventPayload()`'s return spread so |
There was a problem hiding this comment.
issue: Reading workspaceId inside getBillingTelemetryEventPayload() captures the workspace at emission time, not the workspace that initiated the billing attempt. Several events are emitted after an await, and billingOperationStore.stopIfTimedOut() can emit before its inactive-workspace guard; switching from workspace A to B can therefore label A’s terminal event as B. Could the decision require workspace_id to be snapshotted with billing_attempt_id at attempt start, retained on the operation, and supplied on every event instead? PostHog’s per-event group attribution should use that same snapshot so $groups.workspace cannot disagree with workspace_id.
There was a problem hiding this comment.
Addressed in 61fc7cc. Decision 1 now requires workspace_id to be snapshotted alongside billing_attempt_id at attempt start and retained on the useBillingOperationStore operation record. Decision 2 now requires every event — including ones fired after an await or from stopIfTimedOut() — to read that snapshot rather than a live useTeamWorkspaceStore().workspaceId read at emission time, and requires posthog.group('workspace', workspaceId) to use that same snapshot, so $groups.workspace can never disagree with the workspace_id payload field.
Generated by Claude Code
|
|
||
| The payload field alone covers event-level segmentation for both tools but | ||
| skips PostHog's group UI, which needs a registered group. The group call | ||
| alone unlocks that UI but does nothing for Datadog RUM. Both are cheap |
There was a problem hiding this comment.
issue: The PR body documents a material fact that the durable ADR omits: backend billing records are keyed by workspace creator while frontend events are keyed by acting user, so team operations by non-creators appear as 100% first-step drop-off and personal-workspace testing masks it. That makes workspace_id the prerequisite common scope for a valid cross-layer funnel, not just a cheap enhancement for group UI. Please add this to Context and reflect the requirement in the decision/consequences so future readers understand why B5 is mandatory.
There was a problem hiding this comment.
Addressed in 61fc7cc. Added the creator-vs-acting-user key mismatch to Context (new paragraph after the "Workspace scoping" gap description), and reframed Decision 2's opening line and the Consequences/Positive bullets to state workspace_id is the prerequisite common scope for a valid cross-layer funnel, not an optional enhancement for group UI.
Generated by Claude Code
| - `workspace_id` as an event property doesn't give Datadog RUM | ||
| whole-session segmentation; a separate RUM global-context change is | ||
| still needed. | ||
| - Personal (non-team) workspace handling for the group call, and what |
There was a problem hiding this comment.
issue: Personal-workspace handling cannot remain an implementation follow-up because posthog.group() persists $groups under this project’s localStorage+cookie configuration. If the watcher simply skips personal workspaces, switching team → personal leaves subsequent events associated with the prior team, including after reload. Please decide the transition semantics here: either group every non-null workspace ID (including personal), or explicitly clear the workspace group when entering personal context.
There was a problem hiding this comment.
Addressed in 61fc7cc. Decided in Decision 2 rather than deferred: group every non-null workspaceId, including personal workspaces. useTeamWorkspaceStore already models a personal workspace as an ordinary entry with its own id (never a null/special state), so there's nothing to special-case, and it avoids the stale-$groups hazard under this project's persistence: 'localStorage+cookie' PostHog config that skipping personal workspaces would create on a team → personal transition. Removed the corresponding "open" item from Consequences/Negative and Notes.
Generated by Claude Code
…sonal-workspace grouping - Context: document the workspace-creator vs acting-user key mismatch that makes workspace_id a prerequisite for a valid cross-layer funnel, not an optional enhancement (dante01yoon review). - Decision 1/2: require workspace_id to be snapshotted alongside billing_attempt_id at attempt start and read from that snapshot on every event and the PostHog group call, instead of a live read at emission time, so payload and $groups.workspace can never disagree. - Decision 2: decide personal-workspace group transition now instead of deferring it — group every non-null workspaceId, including personal, consistent with useTeamWorkspaceStore modeling personal workspaces as ordinary entries and avoiding a stale-$groups hazard under localStorage+cookie persistence.
dante01yoon
left a comment
There was a problem hiding this comment.
The original three findings are addressed, but the revised carrier and group semantics introduce two correctness gaps. These need clarification before the ADR is a safe implementation gate.
| attempt start and stamp it on every emitted event for that attempt, | ||
| without sending it to the backend. At that same moment, read | ||
| `useTeamWorkspaceStore().workspaceId` once and retain both values together | ||
| on the operation record in `useBillingOperationStore` for the attempt's |
There was a problem hiding this comment.
issue: useBillingOperationStore cannot be the universal carrier described here. Its records require a server-issued opId and are created by startOperation() only after the initiating response, while the motivating pre-response failure has no record; resubscribe and legacy-rail mutations also emit without entering this store. Please define a rail-neutral attempt context, created in the initiating composable before its first async call, and carry billing_attempt_id plus snapshotted workspace_id on every BillingTelemetryEvent. When polling does begin, the same context can then be passed into startOperation() for the store to retain.
There was a problem hiding this comment.
Addressed in a5d661f. Decision 1 no longer names useBillingOperationStore as the carrier. It's now a rail-neutral attempt context — a plain object created by the initiating composable (useSubscriptionCheckout.ts, useResubscribe.ts, the legacy top-up rail) before its first await — holding billing_attempt_id and the workspace_id snapshot. Every BillingTelemetryEvent for the attempt, including pre-response failures, reads from that context, not the store. Once/if a server-issued opId arrives, the same context is passed into startOperation() so the store retains it for the polling-driven terminal events.
Generated by Claude Code
| inactive-workspace guard runs, so a mid-attempt switch (workspace A to B) | ||
| could otherwise mislabel A's terminal event as B. Read the snapshot into | ||
| `getBillingTelemetryEventPayload()`'s return spread so every provider gets | ||
| it for free, and call `posthog.group('workspace', workspaceId)` with that |
There was a problem hiding this comment.
issue: posthog.group() mutates persistent global $groups state; it is not an event-local association. If workspace B is active and a late terminal event from A calls group(..., A), that event is labeled correctly but every later unrelated event remains attributed to A because the active-workspace watcher will not fire again (and the configured persistence can carry this across reloads). Please make the active-workspace watcher the sole owner of posthog.group(), and require the billing capture itself to attach event-local $groups: { workspace: snapshottedWorkspaceId } alongside workspace_id without mutating global group state.
There was a problem hiding this comment.
Addressed in a5d661f. Decision 2 now makes a dedicated active-workspace watcher (analogous to the existing watch(tier, ...) in PostHogTelemetryProvider.setSubscriptionProperties()) the sole caller of posthog.group(), since that call mutates persistent $groups state via register() under this project's persistence: 'localStorage+cookie' config. The billing capture path no longer calls group() at all — it attaches the snapshotted workspace_id as an event-local override via $groups: { workspace: workspaceId } in the properties passed to posthog.capture(), alongside the workspace_id payload field, so a late/stale-workspace event is labeled correctly on itself without touching global group state that later unrelated events would inherit.
Generated by Claude Code
…0014 Round-2 review fixes: the carrier for billing_attempt_id/workspace_id is now a rail-neutral attempt context owned by the initiating composable (not useBillingOperationStore, which only exists post-opId and is unused by resubscribe/legacy rails), and posthog.group() is now owned solely by a dedicated active-workspace watcher, with the billing capture path attaching workspace_id via an event-local $groups override instead of mutating global group state.
📄 Knowledge reviewDosu skipped reviewing this PR because your organization has used its |
christian-byrne
left a comment
There was a problem hiding this comment.
Verdict
Post-merge review (this landed at 919d6ca4 on 2026-07-30) — so everything below is amendment-shaped, not blocking. The headline is good news: the thing I came here to flag is already fixed. Earlier drafts framed Decision 2 as a segmentation nicety; the merged Context section now states the creator-vs-actor mismatch outright and calls workspace_id "the prerequisite common scope for a valid cross-layer funnel, not an optional enhancement". That is exactly right, and it is the single most important sentence in the document. I verified every code claim in the ADR against main and they all hold.
Two things I would still amend. One is substantive: the ADR claims the frontend change closes the creator-vs-actor mismatch, and it does not — not on its own. The other is that the doc grew from 94 lines to 165 after the concision pass, and is now longer than every ADR in the recent series. Everything else is nits.
Findings
1. (moderate, overclaims the fix) workspace_id on the client does not by itself make the cross-layer funnel join
Line 130-134, Consequences → Positive:
workspace_idbecomes available on every billing event across PostHog
and Datadog RUM through one shared payload builder, closing the
creator-vs-actor key mismatch that otherwise breaks cross-layer funnels
for team operations.
The backend already sends workspace_id as a plain event property today, so after this change both sides will carry a matching property — useful for filtering, but PostHog funnels aggregate on the person by default, not on an arbitrary property. Making the funnel actually join requires it to be group-aggregated, and a group-aggregated insight silently excludes events that carry no $groups value. The backend forwarder captures under the workspace creator's user ID and sets no $groups at all.
So shipping Decision 2 as written moves the failure rather than removing it: instead of backend events landing on the wrong person, they drop out of a workspace-aggregated funnel entirely. Same 100% apparent drop-off, different cause — and harder to spot, because the frontend side will look correctly instrumented.
Proposed replacement for that bullet:
- `workspace_id` becomes available on every billing event across PostHog and
Datadog RUM through one shared payload builder, giving both layers a common
scope. This is the necessary half of closing the creator-vs-actor key
mismatch, not the whole of it: a PostHog funnel only joins on workspace if
it is group-aggregated, and group-aggregated insights exclude events with
no `$groups` value. The backend forwarder currently sets none, so a paired
backend change — emitting the same `workspace` group on forwarded billing
events — is required before any FE→BE funnel joins for team operations.
...and a matching entry under ### Negative:
- Until the backend forwarder emits a matching `workspace` group, backend
billing events will be absent from any workspace-aggregated funnel rather
than merely mis-attributed. Track that as an explicit dependency of this
ADR, not a follow-up nicety.
2. (minor, precision) "Backend billing records are keyed by the workspace creator" is not quite where the bug lives
Line 32-34:
Backend billing records are
keyed by the workspace creator, while frontend telemetry events are
keyed by the acting user.
The persisted billing records are not keyed that way — the substitution happens at the PostHog forwarding boundary, which captures under ws.CreatedByUserID. As written, a backend engineer reading this will go looking for the mis-keying in the billing tables, not find it, and conclude the ADR is wrong.
Proposed:
Backend billing events are forwarded to PostHog under the workspace
**creator's** user ID, while frontend telemetry events are captured under the
**acting user's** ID.
3. (minor, brevity/format drift) The ADR is now the longest of the recent series
165 lines / 1,228 words. It was trimmed to 94 lines on 2026-07-29 in response to the concision feedback, then grew back across 61fc7cc8 and a5d661f0. For scale: 0012 = 74 lines, 0010 = 92, 0011 = 120. Only 0008 (284) and 0009 (329) are longer, and both of those are far larger decisions.
The growth is concentrated in two places, and both are mechanism, not decision:
- Decision 1 is one unbroken 22-line paragraph (L50-71) that explains why the operation store can't be the carrier.
- Decision 2's
posthog.group()persistence explanation (L93-118) is 26 lines on super-property semantics and stale-group hazards.
Both are correct and worth keeping — but they read as implementation notes inside a decision section. Suggested follow-up: keep a two-or-three-sentence decision in each, and move the mechanics into a short ### Mechanics subsection or into ## Notes. That would land the doc around 110-120 lines, in line with 0011.
4. (nit) groupIdentify() appears in Consequences and Notes but is never introduced
L154 and L165 both refer to groupIdentify(), but the Decision section only ever decides on posthog.group(type, key). A reader meets the term for the first time in a consequence. Either name it in Decision 2 (posthog.group()'s third argument routes to groupIdentify), or reword both to "what additional properties the workspace group should carry".
5. (nit, format) Missing the status-legend comment
0010/0011/0012 and the template in docs/adr/README.md all carry this directly under the status value; 0014 omits it:
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
6. (nit) ## Context's payload-field list reads as exhaustive but isn't
L23-26 lists the builder's output as operation, stage, outcome, billing_op_id, tier, cycle, checkout_type, failure_category. getBillingTelemetryEventPayload() (src/platform/telemetry/types.ts:742) also spreads operation_type and payment_intent_source. Doesn't change the argument — the "no workspace identity" point stands — but "purely from ... fields" plus a list invites the reader to treat it as complete.
7. (nit) Proposed status carrying RFC-2119 MUST
L78: "Every emitted event for an attempt — terminal or otherwise — MUST use the workspace_id carried in the attempt context". Normative language in a Proposed ADR is a small mixed signal. Either flip to Accepted when b4/b5 are approved, or soften to "should".
What I checked and found fine
- Decision 2 is correctly framed as a prerequisite, not a nice-to-have. L28-46 states the creator-vs-actor mismatch, the 100%-drop-off consequence, and — importantly — why it survives testing (personal workspaces have creator == actor). That last clause is the part that makes the finding actionable rather than abstract. This was my main concern coming in and it is fully addressed.
- Every code claim verified against
main:posthog.group/groupIdentify/$groups— zero occurrences anywhere insrc/. Claim accurate.persistence: 'localStorage+cookie'— confirmed atPostHogTelemetryProvider.ts:154, so the super-property/reload-survival reasoning in Decision 2 is sound.- The
watch(tier, ...)analogue insetSubscriptionProperties()— confirmed atPostHogTelemetryProvider.ts:350-353. getBillingTelemetryEventPayload()carries no workspace identity — confirmed attypes.ts:742(see nit 6 for the field-list quibble).resubscribe()and the legacy top-up rail emitBillingTelemetryEvents without callingstartOperation()— confirmed; this is why the rail-neutral attempt context rather than the operation store is the right carrier. That reasoning is the strongest part of the ADR.
- Private context. Clean. Grepped the merged file for infra repo names, monitor/dashboard IDs, analytics project/insight IDs, Slack links or channel names, on-call handles, and personal names — zero hits. Unlike 0013, this one carries no residual Slack references. The PR body's
<!-- ccr-slack-attribution -->header carries no URL and no personal name; that scrub held through merge. - Numbering. No collision and no dangling
0013reference anywhere in the merged file. The0013slot the index leaves open is filled by #14208, which has since mergedmainand adds its row above the0014row with matching column widths. - Format. Heading order matches the README template and 0010-0012 exactly (
# N. Title/Date:/## Status/## Context/## Decision/## Consequences→### Positive/### Negative/## Notes). - Durability. No PR numbers presented as current state, no "we are currently doing X", no counts that will drift. The
Proposedstatus is honest about what merging did and didn't commit to. - The deferral in Decision 1 is well-argued. Rejecting a backend-issued request-plumbed ID as disproportionate until failure-rate data justifies it, while naming the exact open question that would settle it (pre- vs post-response failure split, first item in
## Notes), is the right shape for a deferral — it is falsifiable rather than open-ended.
| top-up rail get `billing_attempt_id`/`workspace_id` correlation for free, | ||
| without either being retrofitted into `useBillingOperationStore`. | ||
| - `workspace_id` becomes available on every billing event across PostHog | ||
| and Datadog RUM through one shared payload builder, closing the |
There was a problem hiding this comment.
This overclaims what the frontend change buys. The backend already sends workspace_id as a plain event property, so after this lands both sides carry a matching property — but PostHog funnels aggregate on the person by default, not on an arbitrary property. Joining on workspace requires a group-aggregated funnel, and group-aggregated insights exclude events carrying no $groups value. The backend forwarder captures under the workspace creator's user ID and sets no $groups at all.
So as written, Decision 2 relocates the failure rather than closing it: backend events go from landing on the wrong person to being absent from the funnel entirely — and it is harder to spot, because the frontend will look correctly instrumented.
Suggested replacement for this bullet:
- `workspace_id` becomes available on every billing event across PostHog and
Datadog RUM through one shared payload builder, giving both layers a common
scope. This is the necessary half of closing the creator-vs-actor key
mismatch, not the whole of it: a PostHog funnel only joins on workspace if
it is group-aggregated, and group-aggregated insights exclude events with
no `$groups` value. The backend forwarder currently sets none, so a paired
backend change — emitting the same `workspace` group on forwarded billing
events — is required before any FE-to-BE funnel joins for team operations.
Plus a matching ### Negative entry so the dependency is tracked rather than assumed.
| is already available via `useTeamWorkspaceStore().workspaceId`. | ||
|
|
||
| This gap is more than a segmentation nicety. Backend billing records are | ||
| keyed by the workspace **creator**, while frontend telemetry events are |
There was a problem hiding this comment.
Precision nit on an otherwise excellent paragraph — this whole Context addition is the right call and I would not want it weakened.
The persisted billing records aren't keyed by the creator; the substitution happens at the PostHog forwarding boundary, which captures under ws.CreatedByUserID. As written, a backend reader goes looking in the billing tables, doesn't find it, and concludes the ADR is wrong.
Backend billing events are forwarded to PostHog under the workspace
**creator's** user ID, while frontend telemetry events are captured under the
**acting user's** ID.
| **Workspace scoping.** `getBillingTelemetryEventPayload()` builds its | ||
| output purely from `BillingTelemetryEvent` fields (`operation`, `stage`, | ||
| `outcome`, `billing_op_id`, `tier`, `cycle`, `checkout_type`, | ||
| `failure_category`) — no workspace identity, and no PostHog group call |
There was a problem hiding this comment.
Nit: this list reads as exhaustive ("purely from ... fields") but getBillingTelemetryEventPayload() (src/platform/telemetry/types.ts:742) also spreads operation_type and payment_intent_source. Doesn't affect the argument — the "no workspace identity" point stands.
| - `workspace_id` as an event property doesn't give Datadog RUM | ||
| whole-session segmentation; a separate RUM global-context change is | ||
| still needed. | ||
| - What additional properties `groupIdentify()` should set (plan tier, seat |
There was a problem hiding this comment.
groupIdentify() is introduced for the first time here (and again in ## Notes at L165), but the Decision section only ever decides on posthog.group(type, key). Either name it in Decision 2 — posthog.group()'s third argument is what routes to groupIdentify — or reword both to "what additional properties the workspace group should carry".
|
|
||
| ## Status | ||
|
|
||
| Proposed |
There was a problem hiding this comment.
Nit, format: 0010/0011/0012 and the template in docs/adr/README.md all carry the status legend directly under the status value. 0014 omits it:
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
Separate nit on the same section: L78 uses RFC-2119 MUST while the status is Proposed. Worth either flipping to Accepted once b4/b5 are approved, or softening to "should".
…owflake+Hex) (Comfy-Org#14208) _Part of the billing & subscription telemetry observability program._ ## Program context **[📋 Program dossier](https://app.notion.com/p/3ad6d73d365081fea125e284b6ac9f13)** — the public plan doc for this program: the problem, the two-rail billing architecture, the tool-split decision this ADR records, the phase structure, and the standing constraints on every PR in the program. It is written to be read by agent reviewers as well as humans, and takes about three minutes. ## How to review this PR **What this PR does.** Adds `docs/adr/0013-telemetry-service-selection.md` (95 lines) and one index row in `docs/adr/README.md`. Documentation only — no application code, no tests. The ADR writes down a decision that was reached and agreed but never recorded: which telemetry tool owns which responsibility. | Tool | Role recorded by the ADR | | --- | --- | | Datadog (RUM + backend APM/monitors) | Sole alerting / incident-response backbone | | PostHog | Product analytics — funnels, cohorts, flags, experiments. Explicitly *not* a low-latency alerting source | | Snowflake + Hex | Ad hoc, human-authored SQL for investigation and reconciliation | Plus one deliberate exception: cohort/experiment-scoped alerts stay PostHog-native, since RUM has no equivalent to computed cohort membership. **Where it sits in the program.** This is the "write the decision down" step. Its practical consequence for every later phase is the **dual-emit** mechanism: billing events go to both PostHog and Datadog RUM through the existing `TelemetryRegistry` fan-out (`src/platform/telemetry/`), and alerts are then built off RUM. Phase C (RUM action coverage) and Phase D (RUM funnels and monitors) both assume this shape. **If you disagree with the split, this is the cheapest possible moment to say so** — after Phase C it is expensive to revisit. **Focus your review on:** 1. **Is the split correct, and completely stated?** In particular the non-obvious half: product- and funnel-shaped signals (frontend stability, release-over-release funnel drop-off) still alert **from Datadog**, not from PostHog. The boundary is *where the alert lives*, not *what kind of signal it is*. A reader who comes away thinking "funnels ⇒ PostHog alerts" has read it wrong. 2. **Is the carve-out drawn in the right place?** Feature-flag *decisioning* is a server-side rule engine and genuinely cannot be dual-emitted; flag *exposure* is a plain event and can. Behavioral cohort membership is a batch recompute, not an event. The ADR should not blur those three. 3. **Format and house style** — numbering, section order, tone, and brevity against the existing series (`docs/adr/README.md`, with ADR-0014 as the most recent example). **Deliberately out of scope.** No code changes. No monitor or dashboard definitions — alerting IaC lives in a private, access-controlled infra repository rather than here, and the ADR explains why. No Sentry migration decision: that is a separate call, and its scope turned out to be considerably larger than first assumed. **Merge order.** Comfy-Org#14228 has already merged into `main`; this branch has absorbed that index reflow, so there is no outstanding merge-order constraint left on this PR. ## Summary Adds ADR-0013 documenting the split of responsibility across telemetry tools (PostHog, Datadog RUM, Snowflake + Hex). The billing telemetry gap-closure work surfaced that this split was operating as an unwritten assumption, which is how contributors ended up adding alerting in whichever tool was nearest to hand. ## Changes - **New:** `docs/adr/0013-telemetry-service-selection.md` - Datadog (RUM + backend APM/monitors) becomes the alerting and incident-response backbone, dual-emitted alongside PostHog through the existing `TelemetryRegistry` fan-out. - PostHog remains the product-analytics / growth / exploratory layer (funnels, cohorts, feature flags and experiments, session recordings) and is no longer treated as a low-latency alerting source. - Snowflake + Hex is scoped to ad hoc cross-domain SQL for investigation and reconciliation, not real-time alerting. - Documents the one deliberate exception (cohort/experiment-scoped alerts stay PostHog-native). - **Modified:** `docs/adr/README.md` — one index row. - **Breaking:** none. Documentation only. ## Verification Documentation change — no test or behavior surface. CI green. ## Screenshots (if applicable) N/A — documentation change. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Connor Byrne <c.byrne@comfy.org>
…nts for incident-88 followups (#14229) _Part of the billing & subscription telemetry observability program._ ## Program context **[📋 Program dossier](https://app.notion.com/p/3ad6d73d365081fea125e284b6ac9f13)** — the public plan doc for this program: the problem, the two-rail billing architecture, why rail asymmetry is the recurring bug class, the tool split, the phase structure, and the standing constraints on every PR in the program. Written to be read by agent reviewers as well as humans; about three minutes. ##⚠️ Dependency chain — read before reviewing This PR is **stacked on #14216**, not on `main`. - **Base branch is `fix/incident-88-telemetry-gaps` (#14216's branch), so the diff you see here is only this PR's three fixes.** It builds directly on primitives #14216 introduces — `categorizeBillingApiError()`, the `downgrade_to_personal` `started` stage, and `billing_op_id` optionality — so it cannot target `main` until #14216 lands. - **Action required once #14216 merges: retarget this PR's base to `main`.** No rebase should be needed; the diff is already just the three fixes. - **Merge order: #14216 → this PR.** Reviewing this one before #14216 is fine and useful — the two are independently readable — but this cannot merge first. - Note the `CLEAN` mergeable state is relative to an unprotected base branch, so it is weaker evidence than it looks. Green CI is the meaningful signal. ## How to review this PR **What this PR does.** Three independent, mechanical follow-ups found by auditing #14216's diff against the outstanding correlation-gap list. Each applies a pattern #14216 already established elsewhere in the same subsystem, so none of them introduces a new concept: 1. **`duration_ms` on poller-backed billing events.** Call sites capture `attemptStartedAt` alongside the canonical `started` event and pass it through `billingOperationStore.ts`, while the store's separate `startedAt` remains the poll-timeout clock. Every terminal event the poller fires — `succeeded`/`failed`/`timeout`, across `subscription_checkout`, `operation`, `topup`, `downgrade_to_personal` — reports started-to-terminal `duration_ms`, including initiating-request latency. 2. **Real `failure_category` in `useDowngradeToPersonal.ts`.** Three sites hardcoded `'unknown'`, predating #14216's helper. The two that have a real caught error now route through `categorizeBillingApiError()`. The third (`missing_checkout_response`) is intentionally left `'unknown'`. 3. **Canonical `started` events for subscribe / cancel / top-up / resubscribe.** Previously only `downgrade_to_personal` had a `started` stage, which meant no billing flow had a funnel entry step. **Where it sits in the program.** Phase B tail (`b3-typed-failure-reasons`, plus the `started`-event half of the funnel work). These three fields are what turn terminal-outcome-only events into something a funnel and a latency dashboard can actually use: without a `started` event there is no funnel entry step to measure drop-off from, and without `duration_ms` there is no end-to-end latency signal at all. Phase D's funnel widgets depend on item 3 existing. **Focus your review on:** 1. **`getBillingTelemetryEventPayload()` in `types.ts` — check first.** Confirm `duration_ms` is added to the allowlist reconstruction. Adding a field to a type and forgetting the payload builder is a silent no-op, and it was itself one of the gaps #14216 had to fix. This is the highest-value single check in the PR. 2. **`billingOperationStore.ts`'s `handleSuccess`/`handleFailure`/`handleTimeout`** — confirm `duration_ms` is computed once per terminal transition and threaded through *every* event fired from that transition, including the paired `downgrade_to_personal` event. 3. **`useDowngradeToPersonal.ts`** — confirm the `missing_checkout_response` site is correctly *not* touched (there is no caught error there — a resolved-but-empty response, not an exception, matching the precedent #14216 set for the identical case), and that the outer catch's fallback `telemetryFailure ?? { failure_category: categorizeBillingApiError(error) }` does not change behavior for branches that already set a specific category (`validation`, `redirect`). 4. **`started`/terminal pairing.** A `started` event with no matching terminal event on some path would show as permanent funnel drop-off — a data-quality bug that looks exactly like a product bug. Check each new `started` site can actually reach a terminal event. 5. **Semantics of `duration_ms`.** It measures *client-observed* wall time from the canonical `started` event to the client seeing a terminal state, including the initiating request. On the `timeout` event in particular that is "how long the client watched", not "how long the operation took" — the underlying billing process can outlive the poll window. Worth confirming the field is not going to be read as backend latency. **Cancel idempotency contract.** An `ALREADY_CANCELED` response closes the canonical lifecycle with `billing.operation.succeeded` but has no `billing_op_id`, because no pollable operation was created. Consumers must tolerate the absent ID and must not treat cancel-success volume as one event per backend operation. **Deliberately out of scope** — tracked separately, please don't block on them: - **Legacy top-up `started` events.** Resubscribe is covered on both rails and both entry points: exactly one canonical `started` event fires before each attempt, legacy checkout launch does not emit a second start or a false terminal success, and workspace reactivation emits its real terminal outcome. Only the legacy top-up dialog remains out of scope, tracked separately per the program's rail-asymmetry rule. - `workspace_id`, `billing_attempt_id`, and `source_version` — design-gated on ADR-0014 (#14228), not in this PR. - Backend-side `duration_ms` / sync-lag measurement, which needs the backend correlation-ID work. **Housekeeping in this PR.** An earlier conflict-resolution merge on this branch had pulled in two unrelated `apps/website` files whose Tailwind utility classes had been reordered by the pre-commit formatter. Those have been reverted to their state on `main` (commit `1c34674`), so the diff is now telemetry-only — 15 files, no `apps/website` churn. If that reordering is desirable it belongs in its own formatting PR covering the whole app. ## Summary - **`src/platform/telemetry/types.ts`**: added `duration_ms?: number` to `SubscriptionCheckoutBillingEvent`, `BillingOperationBillingEvent`, `TopupBillingEvent`, `DowngradeToPersonalBillingEvent`; added `BillingStarted` to the `ResubscribeBillingEvent`/`SubscriptionCheckoutBillingEvent`/`BillingOperationBillingEvent`/`TopupBillingEvent` stage unions; added matching `BILLING_*_STARTED` name constants; updated `getBillingTelemetryEventPayload()` to pass through `duration_ms`. - **`src/platform/workspace/stores/billingOperationStore.ts`**: keep poll-timeout bookkeeping separate from `attemptStartedAt`, and compute started-to-terminal `duration_ms` once for every `succeeded`/`failed`/`timeout` transition. - **`src/platform/workspace/composables/useDowngradeToPersonal.ts`**: route the outer-catch default and member-removal failure through `categorizeBillingApiError()` instead of hardcoding `'unknown'`. - **`src/platform/workspace/composables/useSubscriptionCheckout.ts`**: fire `subscription_checkout` `started` only after non-terminal guards, pass the same attempt timestamp into the poller, and emit exactly one pre-call resubscribe start from the pricing-dialog entry point. - **`src/platform/workspace/composables/useWorkspaceBilling.ts`**: fire `operation` `started` (`operation_type: 'cancel'`) for committed cancel attempts, pass its timestamp into the poller, and close `ALREADY_CANCELED` attempts with a terminal success. - **`src/platform/workspace/composables/useResubscribe.ts`**: fire exactly one `resubscribe` `started` before the call on both rails; emit immediate success only for workspace billing, whose call is terminal. - **`src/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue`**: fire `topup` `started` with the attempt timestamp, pass it into the poller, and close an empty response with a canonical failure. - Updated/added tests in each touched file's `*.test.ts` using real billing error classes, exact-count assertions for one legacy resubscribe start per attempt, fake-timer end-to-end duration assertions, and provider payload contracts that retain `duration_ms`. **Breaking**: none — all changes are additive telemetry fields/stages and new call sites. ## Verification - `pnpm typecheck` — pass - `pnpm lint` (stylelint, oxlint, eslint) — pass (pre-existing warnings only, none in touched files) - `pnpm format:check` (oxfmt) — pass - `pnpm knip` — clean - `pnpm test:unit` — full suite: 1060 test files, 14033 tests passed, 8 skipped (pre-existing skips, unrelated) ## Screenshots (if applicable) N/A — telemetry-only change, no UI changes. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Christian Byrne <abolkonsky.rem@gmail.com> Co-authored-by: Connor Byrne <c.byrne@comfy.org> Co-authored-by: dante01yoon <bunggl@naver.com> Co-authored-by: Amp <amp@ampcode.com>
…dit (#14216) _Part of the billing & subscription telemetry observability program._ ## Program context **[📋 Program dossier](https://app.notion.com/p/3ad6d73d365081fea125e284b6ac9f13)** — the public plan doc for this program: the problem, the two-rail billing architecture, **why rail asymmetry is the recurring bug class this PR is fixing instances of**, the tool split, the phase structure, and the standing constraints on every PR in the program. Written to be read by agent reviewers as well as humans; about three minutes. ## How to review this PR **What this PR does.** Closes four gaps found by auditing the *shipped diff* of #14111 (the initial billing telemetry instrumentation) against production, rather than auditing its PR description: 1. Workspace-rail cancel silently dropped telemetry when the initiating API call itself failed. 2. The new canonical `billing.*` events reached only 2 of 9 registered telemetry providers — a **regression** against the older tracker call, which more providers implemented. 3. Legacy-rail resubscribe failure telemetry was unreachable, because the underlying `subscribe()` call swallows errors instead of rejecting. 4. `failure_category` was hardcoded to `'unknown'` at almost every call site, making the taxonomy decorative. **Where it sits in the program.** Phase B — the frontend gap-closure sweep (`b2-sibling-entry-points`, `b3-typed-failure-reasons`). It is a direct consequence of the program's central finding: fixes land on one rail's entry point and its structurally identical twin is left broken. Two things depend on this PR: - **#14229 is stacked on this branch** and reuses the primitives introduced here (`categorizeBillingApiError()`, the `started` stage, `billing_op_id` optionality). Merging this unblocks it and shrinks its diff. - Phase D's success-rate monitors read `failure_category`, so finding 4 is a prerequisite for those alerts being decomposable at all. **Focus your review on** — in descending order of where a real problem is likely to be: 1. **`failure_category` mapping (finding 4) — scrutinize hardest.** Check the category chosen at *each* site is semantically right given what is actually observable there, not merely that something other than `'unknown'` was picked. Specifically: is `provider_decline` justified for top-up/subscription poll failures and for the synchronous top-up-creation failure? Is the `api_rejected` vs `network` split on `WorkspaceApiError.status` sound? 2. **The new unwrapped `subscribeDirect` path** in `useSubscription.ts` — it mirrors the existing `purchaseCreditsDirect` pattern from #14111. Confirm it does not change resubscribe's success-path behavior, only its error propagation, and that existing `subscribe()` callers are untouched. 3. **Double-reporting on cancel (finding 1).** Confirm the `billingOpId === undefined` guard really distinguishes "never got an op id" from "op id obtained, poller failed later" — the latter must **not** emit a second `trackBillingEvent`. There is a test named for exactly this case. 4. **Sibling call sites.** This is the program's highest-yield review question: for each site touched, is its twin on the other rail (and its sibling entry points on the same rail) either fixed too, or explicitly declared out of scope below? **Deliberately out of scope** — please don't block on these; each is tracked separately: - **Fixing `wrapWithErrorHandlingAsync` itself.** This PR works *around* it by extracting an unwrapped path. The wrapper's report-and-resolve behavior is a correctness bug with a much wider blast radius (it is also the false-success-toast mechanism) and is tracked as its own item. - **Hosted-portal plan-change paths** (legacy in-app, legacy deep-link, workspace settings), which have no canonical events at all. Stripe's hosted portal is opaque to us on both sides and needs a product decision, not a telemetry patch. - **The remaining 7-of-9 providers receiving the full canonical event set.** This PR restores a *success* signal for parity; full canonical coverage is Phase C. - `duration_ms`, `workspace_id`, `billing_attempt_id`, `source_version`, and canonical `started` events — these are #14229 and #14228 (the `started` stage introduced for legacy resubscribe below is the one exception, added early because the resubscribe fix needed it). **Note for reviewers of the in-code comments.** A comment in this PR refers to "the seven providers" benefiting from the restored `trackMonthlySubscriptionSucceeded` call. That count is wrong: only a minority of the 9 registered providers ever implemented that method, so the real beneficiary set is smaller. Worth correcting in review; it does not affect behavior. ## Summary Fixes four telemetry-completeness gaps in the billing/subscription funnel, all re-verified against current `main` before fixing (see "What I found didn't match the audit" below). ## Changes - **Workspace-rail cancel-initiation failures are now telemetered.** `useWorkspaceBilling.ts`'s `cancelSubscription()` now fires `trackBillingEvent` directly from its catch block *only* when the initiating `workspaceApi.cancelSubscription()` call itself throws, before a `billing_op_id` exists (network error, 4xx/5xx). Once a `billing_op_id` is obtained and the poller (`billingOperationStore.startOperation`) takes over, the existing suppression in `CancelSubscriptionDialogContent.vue` is untouched — the poller's `handleFailure` still owns that telemetry. - **Restored `trackMonthlySubscriptionSucceeded` for multi-provider parity.** `billingOperationStore.ts`'s subscription-success handling and `useSubscriptionCheckout.ts`'s `handleSubscribeResponse` now call both the new canonical `trackBillingEvent` *and* the pre-existing `trackMonthlySubscriptionSucceeded`, so the registered providers that don't implement `trackBillingEvent` keep getting a success signal. - **Legacy-rail resubscribe now has success/failure telemetry, with rail-honest success semantics.** `useResubscribe.ts` and `useSubscriptionCheckout.ts` no longer gate the `trackBillingEvent` resubscribe calls behind `shouldUseWorkspaceBilling`. This required investigating how legacy resubscribe actually completes: `useLegacyBilling.resubscribe()` called `useSubscription().subscribe()`, which is wrapped in `wrapWithErrorHandlingAsync` — a helper that reports and *swallows* errors instead of rejecting, so a caught-error telemetry call there would have been permanently dead code. Fixed by extracting an unwrapped `subscribeDirect` in `useSubscription.ts` (mirroring the existing `purchaseCreditsDirect` pattern from #14111) and having `useLegacyBilling.resubscribe()` use it, so a real failure now propagates to the resubscribe call sites. On review (see "Review response" below), the legacy rail's *success* emission was corrected from `stage: 'succeeded'` at Stripe-checkout-tab-open to `stage: 'started'` / `outcome: 'pending'`, since opening a checkout tab is not a terminal payment outcome — the workspace rail is unaffected and still emits `succeeded` directly, because its `resubscribe()` call is itself the terminal reactivation. - **`failure_category` now reflects the real failure**, via a new `categorizeBillingApiError()` helper (`src/platform/telemetry/utils/billingFailureCategory.ts`) used at every hardcoded-`'unknown'` site named in the audit: - `WorkspaceApiError`/`AuthStoreError` with no HTTP status → `network` (request never reached the backend); with a status → `api_rejected` (backend responded and rejected it). - A `TypeError` whose message names fetch/network/load-failure connectivity (what `fetch` throws for that case) → `network`; any other `TypeError` (e.g. a genuine frontend bug like `x is not a function`) falls through to `unknown` instead of being mislabeled as a network issue. - `billingOperationStore.ts`'s poll-based failures have no caught error to inspect, so they're categorized by operation type instead: `subscription`/`topup` → `provider_decline` (failure after a payment attempt), `cancel` → `api_rejected` (no charge involved, so a rejection is the backend's). - `TopUpCreditsDialogContentWorkspace.vue`'s synchronous `status: 'failed'` topup-creation branch → `provider_decline` for the same payment-attempt reason. - Genuinely unclassifiable errors (e.g. a malformed-response `Error` with no error object to inspect, matching the existing `missing_checkout_response` precedent in `useDowngradeToPersonal.ts`) still fall back to `unknown` — not force-fit. - **Marketing and team deep-link subscribe failures are now telemetered too.** `subscriptionCheckoutUtil.ts`'s `performSubscriptionCheckout()` and `teamSubscriptionCheckoutUtil.ts`'s `performTeamSubscriptionCheckout()` now emit `trackBillingEvent` failure themselves before rethrowing, so both entry points that call them — the in-app `PricingTable.vue` and the marketing/team deep links in `CloudSubscriptionRedirectView.vue` — inherit the same failure signal. `PricingTable.vue`'s previous catch-and-emit (which only covered the in-app path) was removed in favor of the shared emit. - Sites fixed: `billingOperationStore.ts`, `TopUpCreditsDialogContentWorkspace.vue`, `TopUpCreditsDialogContentLegacy.vue`, `useResubscribe.ts`, `useSubscriptionCheckout.ts`, `PricingTable.vue`, `subscriptionCheckoutUtil.ts`, `teamSubscriptionCheckoutUtil.ts`. - **Breaking**: none — all changes are additive telemetry calls, a widened (optional) `billing_op_id` on `BillingOperationBillingEvent`, a new `started` stage on `ResubscribeBillingEvent`, and a new unwrapped `subscribeDirect` export alongside the existing `subscribe`. ## Review Focus — detail per finding This PR bundles all four audit findings into one PR since they're tightly coupled telemetry-completeness fixes in the same subsystem, several touching the same files (e.g. `useSubscriptionCheckout.ts` and `useResubscribe.ts` are touched by findings 2–4 simultaneously). Non-test diff is ~145 insertions / 59 deletions across 10 files plus one new ~30-line utility. 1. **Cancel-initiation telemetry** (`useWorkspaceBilling.ts`): confirm the `billingOpId === undefined` guard correctly distinguishes "never got an op id" from "op id obtained, poller failed" — the latter must *not* get a duplicate `trackBillingEvent` call (see the new test `does not duplicate telemetry for a poller failure once a billing_op_id exists`). 2. **`trackMonthlySubscriptionSucceeded` restoration**: confirm both calls fire (canonical + legacy) and that the legacy one isn't a no-op replay of the old zero-argument call — it's now populated with `tier`/`cycle`/`checkout_type`/`billing_op_id`. 3. **Legacy resubscribe telemetry**: confirm `subscribeDirect` genuinely doesn't change behavior for existing `subscribe()` callers (it's only swapped in for the resubscribe path in `useLegacyBilling.ts`), and that the legacy rail's telemetry now reports `started`/`pending` at checkout-tab-open rather than a terminal `succeeded` — see "Review response" below for the exact definition shipped. 4. **`failure_category` mapping** — the one to scrutinize hardest: check the category chosen at *each* site makes semantic sense given what's actually observable there, not just that something non-`'unknown'` was picked. In particular: is `provider_decline` for topup/subscription poll failures and synchronous topup-creation failures well justified? Is `api_rejected` vs `network` split on `WorkspaceApiError.status` sound? ## Review response (Christian Byrne) **Finding 1 (HIGH, legacy resubscribe "succeeded" at tab-open) — fixed via option (a).** `useResubscribe.ts` and `useSubscriptionCheckout.ts`'s `handleResubscribe()` now check `shouldUseWorkspaceBilling` after `resubscribe()` resolves: the workspace rail still emits `stage: 'succeeded'` / `outcome: 'success'` immediately (honest, since its `resubscribe()` call is itself the terminal reactivation); the legacy rail now emits `stage: 'started'` / `outcome: 'pending'` instead (added as a new variant on `ResubscribeBillingEvent`). **The definition of "resubscribe succeeded" the legacy rail now ships**: a terminal success is only ever observed via the pre-existing pending-checkout recovery in `useSubscription.ts` (`syncPendingSubscriptionSuccess`), which fires `trackMonthlySubscriptionSucceeded` once a later status poll confirms the checkout attempt became active. This PR does **not** additionally emit a canonical `billing.resubscribe.succeeded` from that recovery path — it's a considered decision, not an oversight: the recovery path is shared with plain (non-resubscribe) legacy subscribes via the same underlying `subscribeDirect`, and there is currently no discriminator that would let a later-confirmed pending checkout be attributed back to a resubscribe click specifically. Adding one correctly requires widening the `BillingActions.resubscribe()` interface (shared by both rails) to carry click-time context (`source`, `payment_intent_source`) through to the recovery path; doing that safely is more surface area than this fix warrants, so it's deferred rather than guessed at. The restored test `does not report checkout launch as terminal legacy success` (in both `useResubscribe.test.ts` and `useSubscriptionCheckout.test.ts`) now asserts `started`/`pending` fires instead of a terminal `succeeded`. **Finding 2 (MEDIUM, deep-link entry points get zero failure telemetry) — fixed for real, not deferred.** `performSubscriptionCheckout()` (`subscriptionCheckoutUtil.ts`) now emits `billing.subscription_checkout.failed` itself, inside its own try/catch, before rethrowing — so both `PricingTable.vue` (in-app) and `CloudSubscriptionRedirectView.vue` (marketing deep link `/cloud/subscribe?tier=…`) inherit the same failure signal, since both call this shared function. `PricingTable.vue`'s previous catch-and-emit (which only covered the in-app path) was removed in favor of the shared emit. `teamSubscriptionCheckoutUtil.ts`'s `performTeamSubscriptionCheckout()` — which had no failure telemetry of any kind — got the analogous fix, covering the team deep link too. **Finding 3 (LOW, wrong provider count in comment) — fixed as suggested.** Both `billingOperationStore.ts:273-275` and the looser twin in `useSubscriptionCheckout.ts:733-734` now name Mixpanel and GTM specifically and note PostHog implements both methods, verified against the actual provider files (`MixpanelTelemetryProvider.ts`, `GtmTelemetryProvider.ts`, `PostHogTelemetryProvider.ts`). **Finding 4 (LOW, over-broad `TypeError` → `network`) — fixed as suggested.** `categorizeBillingApiError` now requires the `TypeError` message to name fetch/network/load-failure before categorizing it as `network`; any other `TypeError` (e.g. `x is not a function`) falls through to `unknown`. Added a matching test case. **Finding 5 (LOW, test quality — 4 tests mock the classifier they test) — fixed as suggested.** `TopUpCreditsDialogContentWorkspace.test.ts`, `TopUpCreditsDialogContentLegacy.test.ts`, `useResubscribe.test.ts`, and `useSubscriptionCheckout.test.ts` no longer stub `categorizeBillingApiError`; each now constructs a real `WorkspaceApiError` (with a `status`) or `AuthStoreError` and lets the real classifier run, matching the pattern already used correctly in `useWorkspaceBilling.test.ts` and `PricingTable.test.ts`. ## What I found didn't match the audit - All four gaps were verified accurate against current `main` at the time of this PR: line numbers for `billingOperationStore.ts`'s two `failure_category: 'unknown'` hardcodes (~285, ~296) and `useSubscriptionCheckout.ts`'s two (~362, ~523) matched exactly. `PricingTable.vue`'s hardcode was at line 546 in the current diff, matching the audit's ~546 citation. - One nuance the audit didn't spell out: `useLegacyBilling.resubscribe()`'s failure telemetry is not just a gating fix — the legacy rail's `subscribe()` never actually rejects (it's wrapped in `wrapWithErrorHandlingAsync`, which reports-and-swallows), so simply removing the `shouldUseWorkspaceBilling` gate would have wired a failure branch that could never execute. Confirmed via `useSubscription.ts` and cross-referenced against #14111's own PR description, which flagged this exact swallow behavior as a known limitation for the resubscribe catch blocks. ## Verification - `pnpm typecheck` — pass - `pnpm lint` (eslint, oxlint, oxfmt via pre-commit hook) — pass - `pnpm knip` — clean - `pnpm vitest run` on all files touched by this PR and its review-response fixes — pass - `pnpm test:unit` — full suite: 1060+ test files pass ## Screenshots (if applicable) N/A — telemetry-only change, no UI changes. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Connor Byrne <c.byrne@comfy.org> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Christian Byrne <abolkonsky.rem@gmail.com> Co-authored-by: dante01yoon <bunggl@naver.com> Co-authored-by: Amp <amp@ampcode.com>
Part of the billing & subscription telemetry observability program.
Program context
📋 Program dossier — the public plan doc for this program: the problem, the two-rail billing architecture, the tool split, the phase structure, and the standing constraints on every PR in the program. Written to be read by agent reviewers as well as humans; about three minutes.
How to review this PR
What this PR does. Adds
docs/adr/0014-billing-telemetry-attempt-correlation-and-workspace-scoping.md(94 lines) and updates thedocs/adr/README.mdindex. Documentation only — no application code, no tests. Status is Proposed, so what you are approving is the recommendation, not shipped behavior.It covers two Phase B design decisions that had no written recommendation:
billing_attempt_id.billing_op_idis minted by the backend and only reaches the client once the initiating request succeeds. If that single initiating call throws or times out first, the resulting failure event has nothing to join against. Recommendation: mint a client-side UUID and stamp it on every event for that attempt; do not send it to the backend yet.workspace_id+ a PostHog group.getBillingTelemetryEventPayload()carries no workspace identity, andposthog.group()is called nowhere in the codebase. Recommendation: addworkspace_idto the shared payload builder (so every provider gets it for free, Datadog RUM included) and register the PostHog group.Where it sits in the program. This is the design gate in front of
b4-attempt-idandb5-workspace-scoping. Both implementation items are blocked on the recommendations here, so this is a decision review rather than a code review.Focus your review on:
## Notesare the right open questions, and that none of them is actually a blocker disguised as a follow-up — particularly whether personal (non-team) workspaces should register as a PostHog group at all.Known gap a reviewer should weigh. Program research since this ADR was drafted found that the backend keys its billing events on the workspace creator while the frontend keys on the acting user. Any team operation performed by someone who did not create the workspace therefore shows 100% drop-off at the first backend step, and personal workspaces are unaffected — which is exactly why it survives casual testing. That makes Decision 2 a prerequisite for any working funnel, not the "cheap nice-to-have" the ADR currently frames it as. It is worth deciding whether this ADR should be amended to say so before it is accepted.
Deliberately out of scope. No implementation — the payload builder, the store, and the providers are all untouched. No backend changes. No decision on Datadog RUM global-context registration (noted as a follow-up rather than decided).
docs/adr/README.mdwhile #14208 adds a single0013row, so the two will conflict. The intended sequence is: merge #14208, then rebase this branch. Please do not pre-resolve it here. (The index in this branch already leaves the0013slot to #14208 and adds only0014.)Summary
Adds ADR-0014 recording recommendations for two Phase B billing-telemetry design decisions: a client-side
billing_attempt_idfor correlating failures that occur before any server-issuedbilling_op_idexists, and aworkspace_id/ PostHog-group association so billing funnels and Datadog RUM sessions can be segmented by workspace.Each decision documents context, the alternatives considered, the recommendation, and consequences — grounded in the current
getBillingTelemetryEventPayload()payload shape,billingOperationStore.ts's operation lifecycle, and the existing (group-less) PostHog and Datadog RUM provider code.Changes
docs/adr/0014-billing-telemetry-attempt-correlation-and-workspace-scoping.md— statusProposed.docs/adr/README.md— index entry for 0014.Verification
Documentation change — no test or behavior surface. CI green.
Screenshots (if applicable)
N/A — documentation change.
🤖 Generated with Claude Code