mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
Compare commits
1 Commits
split/node
...
bl/confide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e9158a57d |
@@ -87,6 +87,13 @@ export const capturePreservedQuery = (
|
||||
writeToStorage(namespace, payload)
|
||||
}
|
||||
|
||||
export const getPreservedQueryParam = (
|
||||
namespace: string,
|
||||
key: string
|
||||
): string | undefined => {
|
||||
return preservedQueries.get(namespace)?.[key]
|
||||
}
|
||||
|
||||
export const mergePreservedQueryIntoQuery = (
|
||||
namespace: string,
|
||||
query?: LocationQueryRaw
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
@@ -182,6 +183,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
|
||||
}
|
||||
|
||||
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackShareLinkOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SubscriptionSuccessMetadata,
|
||||
SurveyResponses,
|
||||
@@ -139,6 +140,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
const payload = {
|
||||
method: metadata.method,
|
||||
...(metadata.user_id ? { user_id: metadata.user_id } : {}),
|
||||
...(metadata.share_id ? { share_id: metadata.share_id } : {}),
|
||||
...(metadata.email
|
||||
? {
|
||||
user_data: {
|
||||
@@ -287,7 +289,16 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.pushEvent('share_flow', {
|
||||
step: metadata.step,
|
||||
source: metadata.source
|
||||
source: metadata.source,
|
||||
share_id: metadata.share_id
|
||||
})
|
||||
}
|
||||
|
||||
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
|
||||
this.pushEvent('share_link_opened', {
|
||||
share_id: metadata.share_id,
|
||||
is_authenticated: metadata.is_authenticated,
|
||||
is_new_user: metadata.is_new_user
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionTriggerSource,
|
||||
ExecutionErrorMetadata,
|
||||
@@ -384,6 +385,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_LINK_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
@@ -488,6 +489,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
}
|
||||
|
||||
trackShareLinkOpened(metadata: ShareLinkOpenedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SHARE_LINK_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface AuthMetadata {
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
utm_campaign?: string
|
||||
/**
|
||||
* Share link id when the user authenticated while opening a shared
|
||||
* workflow URL. Powers the "new users via shares" acquisition metric.
|
||||
*/
|
||||
share_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +102,11 @@ export interface ExecutionContext {
|
||||
toolkit_node_names: string[]
|
||||
toolkit_node_count: number
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
/**
|
||||
* Share link id when the active workflow was imported from a shared URL
|
||||
* this session. Makes shared workflow runs attributable to their share.
|
||||
*/
|
||||
share_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +175,12 @@ export interface WorkflowImportMetadata {
|
||||
| 'template'
|
||||
| 'shared_url'
|
||||
| 'unknown'
|
||||
/**
|
||||
* Share link id when the workflow was opened from a shared URL
|
||||
* (open_source = 'shared_url'). Joins recipient-side events to the
|
||||
* sharer-side share_flow link_created event.
|
||||
*/
|
||||
share_id?: string
|
||||
}
|
||||
|
||||
export interface EnterLinearMetadata {
|
||||
@@ -189,6 +205,16 @@ type ShareFlowStep =
|
||||
export interface ShareFlowMetadata {
|
||||
step: ShareFlowStep
|
||||
source?: 'app_mode' | 'graph_mode'
|
||||
share_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipient-side share link open metadata
|
||||
*/
|
||||
export interface ShareLinkOpenedMetadata {
|
||||
share_id: string
|
||||
is_authenticated: boolean
|
||||
is_new_user?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,6 +503,7 @@ export interface TelemetryProvider {
|
||||
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
trackShareFlow?(metadata: ShareFlowMetadata): void
|
||||
trackShareLinkOpened?(metadata: ShareLinkOpenedMetadata): void
|
||||
|
||||
// Page visibility events
|
||||
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
|
||||
@@ -570,6 +597,7 @@ export const TelemetryEvents = {
|
||||
WORKFLOW_OPENED: 'app:workflow_opened',
|
||||
ENTER_LINEAR_MODE: 'app:app_mode_opened',
|
||||
SHARE_FLOW: 'app:share_flow',
|
||||
SHARE_LINK_OPENED: 'app:share_link_opened',
|
||||
|
||||
// Page Visibility
|
||||
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
|
||||
@@ -649,6 +677,7 @@ export type TelemetryEventProperties =
|
||||
| WorkflowCreatedMetadata
|
||||
| EnterLinearMetadata
|
||||
| ShareFlowMetadata
|
||||
| ShareLinkOpenedMetadata
|
||||
| WorkflowSavedMetadata
|
||||
| DefaultViewSetMetadata
|
||||
| SubscriptionMetadata
|
||||
|
||||
@@ -8,6 +8,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
mockActiveWorkflow: null as null | {
|
||||
filename: string
|
||||
fullFilename: string
|
||||
path?: string
|
||||
},
|
||||
mockKnownTemplateNames: new Set<string>(),
|
||||
mockTemplateByName: null as null | { sourceModule?: string }
|
||||
@@ -64,6 +65,8 @@ vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: {} }
|
||||
}))
|
||||
|
||||
import { recordShareProvenance } from '@/platform/workflow/sharing/utils/shareProvenance'
|
||||
|
||||
import { getExecutionContext } from './getExecutionContext'
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
@@ -167,6 +170,33 @@ describe('getExecutionContext', () => {
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
})
|
||||
|
||||
describe('share attribution', () => {
|
||||
it('includes share_id when the active workflow was imported from a share link', () => {
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'shared-workflow',
|
||||
fullFilename: 'shared-workflow.json',
|
||||
path: 'workflows/shared-workflow.json'
|
||||
}
|
||||
recordShareProvenance('workflows/shared-workflow.json', 'abc123def456')
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.share_id).toBe('abc123def456')
|
||||
})
|
||||
|
||||
it('omits share_id for workflows without share provenance', () => {
|
||||
hoisted.mockActiveWorkflow = {
|
||||
filename: 'my-own-workflow',
|
||||
fullFilename: 'my-own-workflow.json',
|
||||
path: 'workflows/my-own-workflow.json'
|
||||
}
|
||||
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.share_id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('template detection', () => {
|
||||
it('detects a regular template by name', () => {
|
||||
hoisted.mockKnownTemplateNames = new Set(['flux-dev'])
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TOOLKIT_NODE_NAMES
|
||||
} from '@/constants/toolkitNodes'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { getShareProvenance } from '@/platform/workflow/sharing/utils/shareProvenance'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -28,6 +29,9 @@ export function getExecutionContext(): ExecutionContext {
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
const shareId = activeWorkflow
|
||||
? getShareProvenance(activeWorkflow.path)
|
||||
: undefined
|
||||
|
||||
const nodeCounts = reduceAllNodes<NodeMetrics>(
|
||||
app.rootGraph,
|
||||
@@ -101,6 +105,7 @@ export function getExecutionContext(): ExecutionContext {
|
||||
template_models: englishMetadata?.models ?? template?.models,
|
||||
template_use_case: englishMetadata?.useCase ?? template?.useCase,
|
||||
template_license: englishMetadata?.license ?? template?.license,
|
||||
share_id: shareId,
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
@@ -108,6 +113,7 @@ export function getExecutionContext(): ExecutionContext {
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: activeWorkflow.filename,
|
||||
share_id: shareId,
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
@@ -115,6 +121,7 @@ export function getExecutionContext(): ExecutionContext {
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: undefined,
|
||||
share_id: shareId,
|
||||
...nodeCounts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { url } = defineProps<{
|
||||
const { url, shareId } = defineProps<{
|
||||
url: string
|
||||
shareId: string
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -43,7 +44,8 @@ async function handleCopy() {
|
||||
copied.value = true
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_copied',
|
||||
source: isAppMode.value ? 'app_mode' : 'graph_mode'
|
||||
source: isAppMode.value ? 'app_mode' : 'graph_mode',
|
||||
share_id: shareId
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -112,7 +112,10 @@
|
||||
</template>
|
||||
|
||||
<template v-if="dialogState === 'shared' && publishResult">
|
||||
<ShareUrlCopyField :url="publishResult.shareUrl" />
|
||||
<ShareUrlCopyField
|
||||
:url="publishResult.shareUrl"
|
||||
:share-id="publishResult.shareId"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
v-if="publishResult.publishedAt"
|
||||
@@ -437,7 +440,8 @@ const {
|
||||
acknowledged.value = false
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_created',
|
||||
source: getShareSource()
|
||||
source: getShareSource(),
|
||||
share_id: result.shareId
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { getShareProvenance } from '@/platform/workflow/sharing/utils/shareProvenance'
|
||||
|
||||
const preservedQueryMocks = vi.hoisted(() => ({
|
||||
clearPreservedQuery: vi.fn(),
|
||||
@@ -52,6 +53,37 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTrackShareLinkOpened = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackShareLinkOpened: mockTrackShareLinkOpened
|
||||
})
|
||||
}))
|
||||
|
||||
const mockAuthState = vi.hoisted(() => ({
|
||||
isAuthenticated: false,
|
||||
currentUser: null as {
|
||||
metadata: { creationTime?: string; lastSignInTime?: string }
|
||||
} | null
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mockAuthState
|
||||
}))
|
||||
|
||||
const mockActiveWorkflow = vi.hoisted(() => ({
|
||||
current: null as { path: string } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.current
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
@@ -174,6 +206,9 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockAuthState.isAuthenticated = false
|
||||
mockAuthState.currentUser = null
|
||||
mockActiveWorkflow.current = null
|
||||
mockDialogStack.length = 0
|
||||
mockShowLayoutDialog.mockImplementation(createDialogInstance)
|
||||
mockUpdateDialog.mockImplementation(
|
||||
@@ -234,7 +269,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
true,
|
||||
true,
|
||||
'Test Workflow',
|
||||
{ openSource: 'shared_url' }
|
||||
{ openSource: 'shared_url', shareId: 'share-id-1' }
|
||||
)
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
@@ -411,7 +446,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
true,
|
||||
true,
|
||||
'Test Workflow',
|
||||
{ openSource: 'shared_url' }
|
||||
{ openSource: 'shared_url', shareId: 'share-id-1' }
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -541,7 +576,93 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
true,
|
||||
true,
|
||||
'Open shared workflow',
|
||||
{ openSource: 'shared_url' }
|
||||
{ openSource: 'shared_url', shareId: 'share-id-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks share_link_opened with auth state when share param is valid', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockAuthState.isAuthenticated = true
|
||||
mockAuthState.currentUser = {
|
||||
metadata: {
|
||||
creationTime: 'Mon, 01 Jun 2026 12:00:00 GMT',
|
||||
lastSignInTime: 'Mon, 08 Jun 2026 12:00:00 GMT'
|
||||
}
|
||||
}
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithCancel()
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
|
||||
share_id: 'share-id-1',
|
||||
is_authenticated: true,
|
||||
is_new_user: false
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks share_link_opened with is_new_user=true for a just-created account', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockAuthState.isAuthenticated = true
|
||||
mockAuthState.currentUser = {
|
||||
metadata: {
|
||||
creationTime: 'Mon, 08 Jun 2026 12:00:00 GMT',
|
||||
lastSignInTime: 'Mon, 08 Jun 2026 12:00:05 GMT'
|
||||
}
|
||||
}
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithCancel()
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
|
||||
share_id: 'share-id-1',
|
||||
is_authenticated: true,
|
||||
is_new_user: true
|
||||
})
|
||||
})
|
||||
|
||||
it('omits is_new_user when unauthenticated', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithCancel()
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
|
||||
share_id: 'share-id-1',
|
||||
is_authenticated: false,
|
||||
is_new_user: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track share_link_opened for absent or invalid share params', async () => {
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
mockQueryParams = { share: '../../../etc/passwd' }
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(mockTrackShareLinkOpened).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records share provenance for the imported workflow', async () => {
|
||||
mockQueryParams = { share: 'share-id-1' }
|
||||
mockActiveWorkflow.current = { path: 'workflows/shared-test.json' }
|
||||
mockShowLayoutDialog.mockImplementation(() => {
|
||||
resolveDialogWithConfirm(makePayload())
|
||||
})
|
||||
|
||||
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
|
||||
await loadSharedWorkflowFromUrl()
|
||||
|
||||
expect(getShareProvenance('workflows/shared-test.json')).toBe('share-id-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { User } from 'firebase/auth'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -11,9 +12,13 @@ import {
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { recordShareProvenance } from '@/platform/workflow/sharing/utils/shareProvenance'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
type SharedWorkflowUrlLoadStatus =
|
||||
@@ -47,6 +52,22 @@ export function useSharedWorkflowUrlLoader() {
|
||||
return /^[a-zA-Z0-9_.-]+$/.test(param)
|
||||
}
|
||||
|
||||
function isNewlyCreatedUser(user: User | null): boolean | undefined {
|
||||
const creationTime = user?.metadata.creationTime
|
||||
const lastSignInTime = user?.metadata.lastSignInTime
|
||||
if (!creationTime || !lastSignInTime) return undefined
|
||||
return Date.parse(lastSignInTime) - Date.parse(creationTime) < 60_000
|
||||
}
|
||||
|
||||
function trackShareLinkOpened(shareId: string) {
|
||||
const authStore = useAuthStore()
|
||||
useTelemetry()?.trackShareLinkOpened({
|
||||
share_id: shareId,
|
||||
is_authenticated: authStore.isAuthenticated,
|
||||
is_new_user: isNewlyCreatedUser(authStore.currentUser)
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureShareQueryFromIntent() {
|
||||
hydratePreservedQuery(SHARE_NAMESPACE)
|
||||
const mergedQuery = mergePreservedQueryIntoQuery(
|
||||
@@ -140,6 +161,8 @@ export function useSharedWorkflowUrlLoader() {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
trackShareLinkOpened(shareParam)
|
||||
|
||||
const result = await showOpenSharedWorkflowDialog(shareParam)
|
||||
|
||||
if (result.action === 'cancel') {
|
||||
@@ -182,9 +205,14 @@ export function useSharedWorkflowUrlLoader() {
|
||||
true,
|
||||
workflowName,
|
||||
{
|
||||
openSource: 'shared_url'
|
||||
openSource: 'shared_url',
|
||||
shareId: payload.shareId
|
||||
}
|
||||
)
|
||||
const workflowKey = useWorkflowStore().activeWorkflow?.path
|
||||
if (workflowKey) {
|
||||
recordShareProvenance(workflowKey, payload.shareId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useSharedWorkflowUrlLoader] Failed to load workflow graph:',
|
||||
|
||||
18
src/platform/workflow/sharing/utils/shareProvenance.ts
Normal file
18
src/platform/workflow/sharing/utils/shareProvenance.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Session-scoped record of which workflows were imported from a share link.
|
||||
* Keyed by workflow path so telemetry (e.g. execution_start) can attribute
|
||||
* runs of a shared workflow to the share_id that delivered it. Intentionally
|
||||
* in-memory only: attribution does not survive a reload or a save/rename.
|
||||
*/
|
||||
const shareIdByWorkflowKey = new Map<string, string>()
|
||||
|
||||
export function recordShareProvenance(
|
||||
workflowKey: string,
|
||||
shareId: string
|
||||
): void {
|
||||
shareIdByWorkflowKey.set(workflowKey, shareId)
|
||||
}
|
||||
|
||||
export function getShareProvenance(workflowKey: string): string | undefined {
|
||||
return shareIdByWorkflowKey.get(workflowKey)
|
||||
}
|
||||
@@ -1138,6 +1138,7 @@ export class ComfyApp {
|
||||
options: {
|
||||
checkForRerouteMigration?: boolean
|
||||
openSource?: WorkflowOpenSource
|
||||
shareId?: string
|
||||
deferWarnings?: boolean
|
||||
skipAssetScans?: boolean
|
||||
silentAssetErrors?: boolean
|
||||
@@ -1146,6 +1147,7 @@ export class ComfyApp {
|
||||
const {
|
||||
checkForRerouteMigration = false,
|
||||
openSource,
|
||||
shareId,
|
||||
deferWarnings = false,
|
||||
skipAssetScans = false,
|
||||
silentAssetErrors = false
|
||||
@@ -1429,7 +1431,8 @@ export class ComfyApp {
|
||||
typeof node === 'string' ? node : node.type
|
||||
),
|
||||
missing_node_packs: groupMissingNodesByPack(missingNodeTypes),
|
||||
open_source: openSource ?? 'unknown'
|
||||
open_source: openSource ?? 'unknown',
|
||||
share_id: shareId
|
||||
}
|
||||
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
||||
useTelemetry()?.trackWorkflowImported(telemetryPayload)
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { User, UserCredential } from 'firebase/auth'
|
||||
import * as firebaseAuth from 'firebase/auth'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import type { Mock } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as vuefire from 'vuefire'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
@@ -582,6 +583,45 @@ describe('useAuthStore', () => {
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
describe('share intent attribution', () => {
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem('Comfy.PreservedQuery.share')
|
||||
clearPreservedQuery('share')
|
||||
})
|
||||
|
||||
it('attaches preserved share_id to sign-up telemetry', async () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.PreservedQuery.share',
|
||||
JSON.stringify({ share: 'abc123def456' })
|
||||
)
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(
|
||||
firebaseAuth.createUserWithEmailAndPassword
|
||||
).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
)
|
||||
|
||||
await store.register('new@example.com', 'password')
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ share_id: 'abc123def456' })
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks no share_id when no share intent is preserved', async () => {
|
||||
const mockUserCredential = { user: mockUser }
|
||||
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
||||
mockUserCredential as Partial<UserCredential> as UserCredential
|
||||
)
|
||||
|
||||
await store.login('user@example.com', 'password')
|
||||
|
||||
expect(mockTrackAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ share_id: undefined })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sign-up telemetry OR logic', () => {
|
||||
const mockUserCredential = {
|
||||
user: mockUser
|
||||
|
||||
@@ -23,6 +23,11 @@ import { useFirebaseAuth } from 'vuefire'
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
getPreservedQueryParam,
|
||||
hydratePreservedQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
@@ -318,6 +323,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getShareIntentId = (): string | undefined => {
|
||||
hydratePreservedQuery(PRESERVED_QUERY_NAMESPACES.SHARE)
|
||||
return getPreservedQueryParam(PRESERVED_QUERY_NAMESPACES.SHARE, 'share')
|
||||
}
|
||||
|
||||
const login = async (
|
||||
email: string,
|
||||
password: string
|
||||
@@ -333,7 +343,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
method: 'email',
|
||||
is_new_user: false,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined
|
||||
email: result.user.email ?? undefined,
|
||||
share_id: getShareIntentId()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,7 +366,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
method: 'email',
|
||||
is_new_user: true,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined
|
||||
email: result.user.email ?? undefined,
|
||||
share_id: getShareIntentId()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -377,7 +389,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
is_new_user:
|
||||
options?.isNewUser || additionalUserInfo?.isNewUser || false,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined
|
||||
email: result.user.email ?? undefined,
|
||||
share_id: getShareIntentId()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -399,7 +412,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
is_new_user:
|
||||
options?.isNewUser || additionalUserInfo?.isNewUser || false,
|
||||
user_id: result.user.uid,
|
||||
email: result.user.email ?? undefined
|
||||
email: result.user.email ?? undefined,
|
||||
share_id: getShareIntentId()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user