mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 06:20:10 +00:00
fix: route gtm through telemetry entrypoint (#8354)
Wire checkout attribution into GTM events and checkout POST payloads.
This updates the cloud telemetry flow so the backend team can correlate checkout events without relying on frontend cookie parsing. We now surface GA4 identity via a GTM-provided global and include attribution on both `begin_checkout` telemetry and the checkout POST body. The backend should continue to derive the Firebase UID from the auth header; the checkout POST body does not include a user ID.
GTM events pushed (unchanged list, updated payloads):
- `page_view` (page title/location/referrer as before)
- `sign_up` / `login`
- `begin_checkout` now includes:
- `user_id`, `tier`, `cycle`, `checkout_type`, `previous_tier` (if change flow)
- `ga_client_id`, `ga_session_id`, `ga_session_number`
- `gclid`, `gbraid`, `wbraid`
Backend-facing change:
- `POST /customers/cloud-subscription-checkout/:tier` now includes a JSON body with attribution fields only:
- `ga_client_id`, `ga_session_id`, `ga_session_number`
- `gclid`, `gbraid`, `wbraid`
- Backend should continue to derive the Firebase UID from the auth header.
Required GTM setup:
- Provide `window.__ga_identity__` via a GTM Custom HTML tag (after GA4/Google tag) with `{ client_id, session_id, session_number }`. The frontend reads this to populate the GA fields.
<img width="1416" height="1230" alt="image" src="https://github.com/user-attachments/assets/b77cf0ed-be69-4497-a540-86e5beb7bfac" />
## Screenshots (if applicable)
<img width="991" height="385" alt="image" src="https://github.com/user-attachments/assets/8309cd9e-5ab5-4fba-addb-2d101aaae7e9"/>
Manual Testing:
<img width="3839" height="2020" alt="image" src="https://github.com/user-attachments/assets/36901dfd-08db-4c07-97b8-a71e6783c72f"/>
<img width="2141" height="851" alt="image" src="https://github.com/user-attachments/assets/2e9f7aa4-4716-40f7-b147-1c74b0ce8067"/>
<img width="2298" height="982" alt="image" src="https://github.com/user-attachments/assets/72cbaa53-9b92-458a-8539-c987cf753b02"/>
<img width="2125" height="999" alt="image" src="https://github.com/user-attachments/assets/4b22387e-8027-4f50-be49-a410282a1adc"/>
To manually test, you will need to override api/features in devtools to also return this:
```
"gtm_container_id": "GTM-NP9JM6K7"
```
┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8354-fix-route-gtm-through-telemetry-entrypoint-2f66d73d36508138afacdeffe835f28a) by [Unito](https://www.unito.io)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
* **New Features**
* Analytics expanded: page view tracking, richer auth telemetry (includes user IDs), and checkout begin events with attribution.
* Google Tag Manager support and persistent checkout attribution (GA/client/session IDs, gclid/gbraid/wbraid).
* **Chores**
* Telemetry reworked to support multiple providers via a registry with cloud-only initialization.
* Workflow module refactored for clearer exports.
* **Tests**
* Added/updated tests for attribution, telemetry, and subscription flows.
* **CI**
* New check prevents telemetry from leaking into distribution artifacts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
157
src/platform/workflow/management/stores/comfyWorkflow.ts
Normal file
157
src/platform/workflow/management/stores/comfyWorkflow.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
*/
|
||||
changeTracker: ChangeTracker | null = null
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
*/
|
||||
constructor(options: { path: string; modified: number; size: number }) {
|
||||
super(options.path, options.modified, options.size)
|
||||
}
|
||||
|
||||
override get key() {
|
||||
return this.path.substring(ComfyWorkflow.basePath.length)
|
||||
}
|
||||
|
||||
get activeState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.activeState ?? null
|
||||
}
|
||||
|
||||
get initialState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.initialState ?? null
|
||||
}
|
||||
|
||||
override get isLoaded(): boolean {
|
||||
return this.changeTracker !== null
|
||||
}
|
||||
|
||||
override get isModified(): boolean {
|
||||
return this._isModified
|
||||
}
|
||||
|
||||
override set isModified(value: boolean) {
|
||||
this._isModified = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow content from remote storage. Directly returns the loaded
|
||||
* workflow if the content is already loaded.
|
||||
*
|
||||
* @param force Whether to force loading the content even if it is already loaded.
|
||||
* @returns this
|
||||
*/
|
||||
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
||||
this & LoadedComfyWorkflow
|
||||
> {
|
||||
const { useWorkflowDraftStore } =
|
||||
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
let draft = !force ? draftStore.getDraft(this.path) : undefined
|
||||
let draftState: ComfyWorkflowJSON | null = null
|
||||
let draftContent: string | null = null
|
||||
|
||||
if (draft) {
|
||||
if (draft.updatedAt < this.lastModified) {
|
||||
draftStore.removeDraft(this.path)
|
||||
draft = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
try {
|
||||
draftState = JSON.parse(draft.data)
|
||||
draftContent = draft.data
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse workflow draft, clearing it', err)
|
||||
draftStore.removeDraft(this.path)
|
||||
}
|
||||
}
|
||||
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
}
|
||||
|
||||
const initialState = JSON.parse(this.originalContent)
|
||||
const { ChangeTracker } = await import('@/scripts/changeTracker')
|
||||
this.changeTracker = markRaw(new ChangeTracker(this, initialState))
|
||||
if (draftState && draftContent) {
|
||||
this.changeTracker.activeState = draftState
|
||||
this.content = draftContent
|
||||
this._isModified = true
|
||||
draftStore.markDraftUsed(this.path)
|
||||
}
|
||||
return this as this & LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
override unload(): void {
|
||||
this.changeTracker = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
override async save() {
|
||||
const { useWorkflowDraftStore } =
|
||||
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
// Force save to ensure the content is updated in remote storage incase
|
||||
// the isModified state is screwed by changeTracker.
|
||||
const ret = await super.save({ force: true })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
draftStore.removeDraft(this.path)
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the workflow as a new file.
|
||||
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
|
||||
* @returns this
|
||||
*/
|
||||
override async saveAs(path: string) {
|
||||
const { useWorkflowDraftStore } =
|
||||
await import('@/platform/workflow/persistence/stores/workflowDraftStore')
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
const result = await super.saveAs(path)
|
||||
draftStore.removeDraft(path)
|
||||
return result
|
||||
}
|
||||
|
||||
async promptSave(): Promise<string | null> {
|
||||
const { useDialogService } = await import('@/services/dialogService')
|
||||
return await useDialogService().prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilenamePrompt'),
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
changeTracker: ChangeTracker
|
||||
initialState: ComfyWorkflowJSON
|
||||
activeState: ComfyWorkflowJSON
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
@@ -18,10 +17,7 @@ import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/wo
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
@@ -32,149 +28,9 @@ import {
|
||||
import { generateUUID, getPathDetails } from '@/utils/formatUtil'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
*/
|
||||
changeTracker: ChangeTracker | null = null
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
*/
|
||||
constructor(options: { path: string; modified: number; size: number }) {
|
||||
super(options.path, options.modified, options.size)
|
||||
}
|
||||
|
||||
override get key() {
|
||||
return this.path.substring(ComfyWorkflow.basePath.length)
|
||||
}
|
||||
|
||||
get activeState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.activeState ?? null
|
||||
}
|
||||
|
||||
get initialState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.initialState ?? null
|
||||
}
|
||||
|
||||
override get isLoaded(): boolean {
|
||||
return this.changeTracker !== null
|
||||
}
|
||||
|
||||
override get isModified(): boolean {
|
||||
return this._isModified
|
||||
}
|
||||
|
||||
override set isModified(value: boolean) {
|
||||
this._isModified = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow content from remote storage. Directly returns the loaded
|
||||
* workflow if the content is already loaded.
|
||||
*
|
||||
* @param force Whether to force loading the content even if it is already loaded.
|
||||
* @returns this
|
||||
*/
|
||||
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
||||
this & LoadedComfyWorkflow
|
||||
> {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
let draft = !force ? draftStore.getDraft(this.path) : undefined
|
||||
let draftState: ComfyWorkflowJSON | null = null
|
||||
let draftContent: string | null = null
|
||||
|
||||
if (draft) {
|
||||
if (draft.updatedAt < this.lastModified) {
|
||||
draftStore.removeDraft(this.path)
|
||||
draft = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
try {
|
||||
draftState = JSON.parse(draft.data)
|
||||
draftContent = draft.data
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse workflow draft, clearing it', err)
|
||||
draftStore.removeDraft(this.path)
|
||||
}
|
||||
}
|
||||
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
}
|
||||
|
||||
const initialState = JSON.parse(this.originalContent)
|
||||
this.changeTracker = markRaw(new ChangeTracker(this, initialState))
|
||||
if (draftState && draftContent) {
|
||||
this.changeTracker.activeState = draftState
|
||||
this.content = draftContent
|
||||
this._isModified = true
|
||||
draftStore.markDraftUsed(this.path)
|
||||
}
|
||||
return this as this & LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
override unload(): void {
|
||||
this.changeTracker = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
override async save() {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
// Force save to ensure the content is updated in remote storage incase
|
||||
// the isModified state is screwed by changeTracker.
|
||||
const ret = await super.save({ force: true })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
draftStore.removeDraft(this.path)
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the workflow as a new file.
|
||||
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
|
||||
* @returns this
|
||||
*/
|
||||
override async saveAs(path: string) {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
const result = await super.saveAs(path)
|
||||
draftStore.removeDraft(path)
|
||||
return result
|
||||
}
|
||||
|
||||
async promptSave(): Promise<string | null> {
|
||||
return await useDialogService().prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilenamePrompt'),
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
changeTracker: ChangeTracker
|
||||
initialState: ComfyWorkflowJSON
|
||||
activeState: ComfyWorkflowJSON
|
||||
}
|
||||
import { ComfyWorkflow } from './comfyWorkflow'
|
||||
import type { LoadedComfyWorkflow } from './comfyWorkflow'
|
||||
export { ComfyWorkflow, type LoadedComfyWorkflow }
|
||||
|
||||
/**
|
||||
* Exposed store interface for the workflow store.
|
||||
|
||||
Reference in New Issue
Block a user