Skip to content

docs: scope client attempt-id and workspace-scoping for billing telemetry (Phase B) - #14228

Merged
christian-byrne merged 8 commits into
mainfrom
docs/phase-b-scoping-v2
Jul 30, 2026
Merged

docs: scope client attempt-id and workspace-scoping for billing telemetry (Phase B)#14228
christian-byrne merged 8 commits into
mainfrom
docs/phase-b-scoping-v2

Conversation

@claude

@claude claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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 the docs/adr/README.md index. 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:

  1. Client-side billing_attempt_id. billing_op_id is 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.
  2. workspace_id + a PostHog group. getBillingTelemetryEventPayload() carries no workspace identity, and posthog.group() is called nowhere in the codebase. Recommendation: add workspace_id to 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-id and b5-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:

  1. Decision 1 — is deferring the backend-coordinated attempt ID the right call? The ADR rejects a backend-issued, request-plumbed ID as disproportionate "until failure-rate data justifies it," and is explicit that the client-side-only version cannot join backend records. That trade is the substance of this PR. The honest counter-argument is that the program's whole reason for existing is that frontend attempts and backend outcomes cannot be joined; if you think this defers the actual problem, say so here.
  2. Decision 2 — is shipping both the payload field and the group call justified? The ADR argues the field alone misses PostHog's group UI and the group call alone does nothing for Datadog RUM. Check that reasoning holds.
  3. The open questions in ## Notes are 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.
  4. Format and house style against the existing series (ADR-0011/0012 are the most recent examples).

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).

⚠️ Merge order — #14208 must merge before this PR. This PR reflows every column of the ADR index table in docs/adr/README.md while #14208 adds a single 0013 row, 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 the 0013 slot to #14208 and adds only 0014.)

Summary

Adds ADR-0014 recording recommendations for two Phase B billing-telemetry design decisions: a client-side billing_attempt_id for correlating failures that occur before any server-issued billing_op_id exists, and a workspace_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

  • New: docs/adr/0014-billing-telemetry-attempt-correlation-and-workspace-scoping.md — status Proposed.
  • Modified: docs/adr/README.md — index entry for 0014.
  • 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

…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.
@claude
claude Bot requested a review from a team July 28, 2026 21:36
@dosubot dosubot Bot added the size:M This PR changes 30-99 lines, ignoring generated files. label Jul 28, 2026

@christian-byrne christian-byrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@christian-byrne replying to your review (Should be in docs/adr. remove any private context):

Done on both points:

  • Moved the scoping doc into docs/adr/0013-billing-telemetry-attempt-correlation-and-workspace-scoping.md, matching the existing ADR format (Status/Context/Decision/Consequences/Notes) used by ADR-0001–0012, and added it to the docs/adr/README.md index. The old docs/telemetry/phase-b-attempt-id-and-workspace-scoping.md is deleted.
  • Stripped private context from the ADR: no Comfy-Org/cloud/comfy-infra repo names, no Slack links, no internal dashboard/PostHog/Datadog IDs, no internal person names, and no PR numbers of uncertain provenance. Where the original said a decision "requires a Comfy-Org/cloud change," it now reads "requires a corresponding backend change to accept and persist the field" — same technical shape, no private repo name. Also removed the incident-88 labeling and the dead link to a non-existent internal master-plan doc from the PR description prose (the required Slack-thread attribution line stays, per the separate attribution mechanism).

Verified by grepping the final ADR file for Comfy-Org/cloud, comfy-infra, and comfy-organization.slack.com — no matches.

Pushed to docs/phase-b-scoping-v2 at 52b6c9c.


Generated by Claude Code

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Jul 28, 2026
claude and others added 3 commits July 28, 2026 23:16
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.
@christian-byrne

Copy link
Copy Markdown
Contributor

@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 Proposed, so what you'd be approving is the recommendation, not shipped behavior. It covers two Phase B design decisions — a client-side billing_attempt_id, and workspace_id + a PostHog group on the shared telemetry payload. @dante01yoon these are from your correlation-gap list, so your read on decision 1 (deferring a backend-coordinated attempt ID) is the one that matters most.

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 dante01yoon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@dante01yoon dante01yoon removed their assignment Jul 30, 2026
…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 dante01yoon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@christian-byrne
christian-byrne added this pull request to the merge queue Jul 30, 2026
Merged via the queue into main with commit 919d6ca Jul 30, 2026
42 checks passed
@christian-byrne
christian-byrne deleted the docs/phase-b-scoping-v2 branch July 30, 2026 20:25
@dosubot

dosubot Bot commented Jul 30, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-08-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about ComfyUI_frontend Add Dosu to your team

@christian-byrne christian-byrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_id becomes 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 / $groupszero occurrences anywhere in src/. Claim accurate.
    • persistence: 'localStorage+cookie' — confirmed at PostHogTelemetryProvider.ts:154, so the super-property/reload-survival reasoning in Decision 2 is sound.
    • The watch(tier, ...) analogue in setSubscriptionProperties() — confirmed at PostHogTelemetryProvider.ts:350-353.
    • getBillingTelemetryEventPayload() carries no workspace identity — confirmed at types.ts:742 (see nit 6 for the field-list quibble).
    • resubscribe() and the legacy top-up rail emit BillingTelemetryEvents without calling startOperation() — 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 0013 reference anywhere in the merged file. The 0013 slot the index leaves open is filled by #14208, which has since merged main and adds its row above the 0014 row 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 Proposed status 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

pull Bot pushed a commit to Mu-L/ComfyUI_frontend that referenced this pull request Jul 31, 2026
…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>
dante01yoon added a commit that referenced this pull request Aug 7, 2026
…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>
christian-byrne added a commit that referenced this pull request Aug 9, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants