Compare commits

...

11 Commits

Author SHA1 Message Date
Robin Huang
4f2c51ba4f chore: keep trackNodeSearch block byte-identical to main
The previous fix touched the legacy block (rename + reformat + comment
shuffle). PR aims for zero diff on the trackNodeSearch path now that
we've decided to leave it alone. Only additions remaining in this file
are the import and the new useSearchQueryTracking call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 13:58:48 -07:00
Robin Huang
5597a51769 fix(telemetry): preserve GA4 node-search signal + truncate query
Addresses review feedback from @thedatalife:
1. Restore the V1 NodeSearchBox debounced trackNodeSearch call so the
   pre-existing GA4 'search' signal (the only one GTM emits unmasked by
   DEFAULT_DISABLED_EVENTS) is not silently dropped by the refactor.
2. Truncate the captured query string to 100 chars to mirror GTM's
   sanitizeProperties cap. query_length keeps the pre-truncation length
   so distributional signal is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 10:53:48 -07:00
Robin Huang
e0f8c6ef7c Merge remote-tracking branch 'origin/main' into feat/search-keystroke-telemetry
# Conflicts:
#	src/components/sidebar/tabs/NodeLibrarySidebarTab.vue
#	src/platform/telemetry/TelemetryRegistry.ts
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts
#	src/platform/telemetry/types.ts
2026-06-03 19:52:49 -07:00
Robin Huang
64aa788f88 fix(telemetry): cancel pending debounce on scope dispose
Addresses PR review feedback:
- onScopeDispose(fire.cancel) so types-and-closes within the debounce
  window doesn't fire telemetry for an abandoned query
- Stop effectScope between tests so reactive state doesn't leak across cases
- Test for the new disposal cancellation behavior
- Add SearchQueryMetadata to TelemetryEventProperties union

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 15:18:29 -07:00
Robin Huang
fb2b408c40 refactor: pass results array to useSearchQueryTracking
Drops the inline `computed(() => ref.value.length)` wrappers at every
call site. The composable now accepts the underlying reactive results
collection (anything with .length) and reads .length at fire time.

Each call site becomes a single-line statement, which lets the existing
component-mount tests cover them. Lifts codecov patch coverage above the
threshold without writing new component tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 13:51:06 -07:00
Robin Huang
b64259e7af refactor(telemetry): rename to app:search_query + add result_count
- Event: app:search_keystroke -> app:search_query
- Surfaces collapsed to: node_modal, node_sidebar, apps, templates, settings
- New payload props: result_count (number), has_results (bool)
- Composable: useSearchQueryTracking(surface, queryRef, resultCountRef)

Lets us answer 'which surface has the most abandoned searches?'
directly from a single breakdown without surface-specific stitching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 13:30:33 -07:00
Robin Huang
8ba0dc5fc4 chore: remove paid-tier references from comments
The composable was de-gated previously; cleaning up stale documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 13:04:17 -07:00
Robin Huang
ff7a7d8e65 test: cover trackSearchKeystroke dispatch and PostHog provider
Lifts patch coverage above the codecov/patch threshold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 12:14:53 -07:00
Robin Huang
0c783ba895 feat: wire v2 node search modal, drop paid-tier gate
V2 search uses src/components/searchbox/v2/NodeSearchContent.vue (not
NodeSearchBox.vue), which is what users on the default 'Comfy.NodeSearchBoxImpl'
setting see. Threading the surface through there.

Drops the paid-tier gate in the composable. Filtering by subscription_tier
happens PostHog-side (event-time person property) or via a CDP drop
transformation if we want to skip ingest. Simpler code, no Pinia dep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 11:51:47 -07:00
Robin Huang
123a635cfe chore: drop trackSearchKeystroke from gtm and mixpanel providers
PostHog is the only target for this event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 10:27:51 -07:00
Robin Huang
9df6350385 feat: track search keystrokes across 5 surfaces for paid users
New app:search_keystroke event with surface discriminator (node modal,
node sidebar, apps sidebar, template search, settings search). Gated to
paid subscribers at fire time so query text never leaves the browser for
free users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:02:56 -07:00
15 changed files with 281 additions and 1 deletions

View File

@@ -92,6 +92,7 @@ import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
@@ -126,6 +127,8 @@ const placeholder = computed(() => {
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
useSearchQueryTracking('node_modal', currentQuery, suggestions)
// Debounced search tracking (500ms as per implementation plan)
const debouncedTrackSearch = debounce((query: string) => {
if (query.trim()) {

View File

@@ -121,6 +121,7 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -344,6 +345,8 @@ const hoveredNodeDef = computed(
() => displayedResults.value[selectedIndex.value] ?? null
)
useSearchQueryTracking('node_modal', searchQuery, displayedResults)
watch(
hoveredNodeDef,
(newVal) => {

View File

@@ -166,6 +166,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -212,6 +213,7 @@ const filteredWorkflows = computed(() => {
workflow.path.toLocaleLowerCase().includes(lowerQuery)
)
})
useSearchQueryTracking('apps', searchQuery, filteredWorkflows)
const filteredRoot = computed<TreeNode>(() => {
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
})

View File

@@ -190,6 +190,7 @@ import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useLitegraphService } from '@/services/litegraphService'
import {
DEFAULT_GROUPING_ID,
@@ -337,6 +338,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
})
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
const filters: Ref<
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
> = ref([])

View File

@@ -190,6 +190,7 @@ import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import {
DEFAULT_SORTING_ID,
DEFAULT_TAB_ID,
@@ -289,6 +290,8 @@ const activeNodes = computed(() =>
: filteredNodeDefs.value
)
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
const hasNoMatches = computed(
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
)

View File

@@ -53,10 +53,15 @@ vi.mock('@/stores/systemStatsStore', () => ({
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackTemplateFilterChanged: vi.fn()
trackTemplateFilterChanged: vi.fn(),
trackSearchQuery: vi.fn()
}))
}))
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
useSearchQueryTracking: vi.fn()
}))
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {

View File

@@ -6,6 +6,7 @@ import type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -307,6 +308,7 @@ export function useTemplateFiltering(
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => visibleTemplates.value.length)
useSearchQueryTracking('templates', searchQuery, filteredTemplates)
// Template filter tracking (debounced to avoid excessive events)
const debouncedTrackFilterChange = debounce(() => {

View File

@@ -95,6 +95,7 @@ import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMess
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
ISettingGroup,
@@ -205,6 +206,8 @@ function onNavItemClick(id: string) {
const searchResults = computed<ISettingGroup[]>(() => getSearchResults(null))
useSearchQueryTracking('settings', searchQuery, searchResults)
// Scroll to and highlight the target setting once the correct category renders.
if (scrollToSettingId) {
const stopScrollWatch = watch(

View File

@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { TelemetryRegistry } from './TelemetryRegistry'
import type { TelemetryProvider } from './types'
describe('TelemetryRegistry', () => {
it('dispatches trackSearchQuery to every registered provider', () => {
const a: TelemetryProvider = { trackSearchQuery: vi.fn() }
const b: TelemetryProvider = { trackSearchQuery: vi.fn() }
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
registry.trackSearchQuery({
surface: 'templates',
query: 'flux',
query_length: 4,
result_count: 3,
has_results: true
})
const payload = {
surface: 'templates',
query: 'flux',
query_length: 4,
result_count: 3,
has_results: true
}
expect(a.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
})
it('skips providers that do not implement trackSearchQuery', () => {
const empty: TelemetryProvider = {}
const registry = new TelemetryRegistry()
registry.registerProvider(empty)
expect(() =>
registry.trackSearchQuery({
surface: 'settings',
query: 'theme',
query_length: 5,
result_count: 0,
has_results: false
})
).not.toThrow()
})
})

View File

@@ -15,6 +15,7 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
SettingChangedMetadata,
@@ -199,6 +200,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
)
}
trackSearchQuery(metadata: SearchQueryMetadata): void {
this.dispatch((provider) => provider.trackSearchQuery?.(metadata))
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.dispatch((provider) => provider.trackNodeAdded?.(metadata))
}

View File

@@ -172,6 +172,30 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures search queries with surface, query, length, and result count', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackSearchQuery({
surface: 'node_sidebar',
query: 'sampler',
query_length: 7,
result_count: 3,
has_results: true
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SEARCH_QUERY,
{
surface: 'node_sidebar',
query: 'sampler',
query_length: 7,
result_count: 3,
has_results: true
}
)
})
it('sets first_auth_at on new-user auth', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()

View File

@@ -24,6 +24,7 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
@@ -458,6 +459,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackSearchQuery(metadata: SearchQueryMetadata): void {
this.trackEvent(TelemetryEvents.SEARCH_QUERY, metadata)
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
}

View File

@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EffectScope, Ref } from 'vue'
import { effectScope, ref } from 'vue'
const hoisted = vi.hoisted(() => ({
trackSearchQuery: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSearchQuery: hoisted.trackSearchQuery })
}))
import { useSearchQueryTracking } from './useSearchQueryTracking'
const DEBOUNCE_FLUSH_MS = 600
const flush = () => new Promise((r) => setTimeout(r, DEBOUNCE_FLUSH_MS))
describe('useSearchQueryTracking', () => {
const scopes: EffectScope[] = []
function track(
query: Ref<string>,
results: Ref<{ length: number }>
): EffectScope {
const scope = effectScope()
scope.run(() => {
useSearchQueryTracking('node_sidebar', query, results)
})
scopes.push(scope)
return scope
}
beforeEach(() => {
hoisted.trackSearchQuery.mockClear()
})
afterEach(() => {
scopes.forEach((s) => s.stop())
scopes.length = 0
vi.useRealTimers()
})
it('fires with surface, trimmed query, length, and result_count', async () => {
const query = ref('')
const results = ref<string[]>(['a', 'b', 'c'])
track(query, results)
query.value = ' hello '
await flush()
expect(hoisted.trackSearchQuery).toHaveBeenCalledExactlyOnceWith({
surface: 'node_sidebar',
query: 'hello',
query_length: 5,
result_count: 3,
has_results: true
})
})
it('sets has_results false when results are empty', async () => {
const query = ref('')
const results = ref<string[]>([])
track(query, results)
query.value = 'nothingmatches'
await flush()
expect(hoisted.trackSearchQuery).toHaveBeenCalledExactlyOnceWith({
surface: 'node_sidebar',
query: 'nothingmatches',
query_length: 14,
result_count: 0,
has_results: false
})
})
it('skips empty queries', async () => {
const query = ref('seed')
const results = ref<string[]>(['a', 'b'])
track(query, results)
query.value = ' '
await flush()
expect(hoisted.trackSearchQuery).not.toHaveBeenCalled()
})
it('cancels a pending debounced call when the scope is disposed', async () => {
const query = ref('')
const results = ref<string[]>(['a', 'b'])
const scope = track(query, results)
query.value = 'hello'
scope.stop()
await flush()
expect(hoisted.trackSearchQuery).not.toHaveBeenCalled()
})
it('truncates query to 100 chars while preserving original length', async () => {
const query = ref('')
const results = ref<string[]>(['a'])
track(query, results)
const long = 'x'.repeat(250)
query.value = long
await flush()
expect(hoisted.trackSearchQuery).toHaveBeenCalledExactlyOnceWith({
surface: 'node_sidebar',
query: 'x'.repeat(100),
query_length: 250,
result_count: 1,
has_results: true
})
})
})

View File

@@ -0,0 +1,44 @@
import { debounce } from 'es-toolkit/compat'
import { onScopeDispose, watch } from 'vue'
import type { Ref } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { SearchSurface } from '@/platform/telemetry/types'
const DEBOUNCE_MS = 500
const MAX_QUERY_CHARS = 100
/**
* Fires `app:search_query` for the given surface, debounced 500ms.
* Empty queries are skipped. `results` is read at fire time and only its
* `.length` is observed, so callers can pass any reactive array (filtered
* suggestions, displayed results, etc.). The pending debounced call is
* cancelled on scope dispose so a user who types-and-closes within the
* window doesn't emit a stale event.
*
* The captured `query` is truncated to MAX_QUERY_CHARS to cap PII
* exposure (mirrors GTM provider's sanitizeProperties cap). `query_length`
* remains the full pre-truncation length so we keep the distributional
* signal.
*/
export function useSearchQueryTracking(
surface: SearchSurface,
query: Ref<string>,
results: Ref<{ length: number }>
): void {
const fire = debounce((value: string) => {
const trimmed = value.trim()
if (!trimmed) return
const count = results.value.length
useTelemetry()?.trackSearchQuery({
surface,
query: trimmed.slice(0, MAX_QUERY_CHARS),
query_length: trimmed.length,
result_count: count,
has_results: count > 0
})
}, DEBOUNCE_MS)
watch(query, fire)
onScopeDispose(() => fire.cancel())
}

View File

@@ -246,6 +246,25 @@ export interface NodeSearchMetadata {
query: string
}
/**
* Search query metadata. One event per debounced query change across
* each search surface.
*/
export type SearchSurface =
| 'node_modal'
| 'node_sidebar'
| 'apps'
| 'templates'
| 'settings'
export interface SearchQueryMetadata {
surface: SearchSurface
query: string
query_length: number
result_count: number
has_results: boolean
}
/**
* Node added metadata. `source` indicates how the user initiated the add.
* Bulk additions during workflow load are excluded — workflow_imported
@@ -469,6 +488,9 @@ export interface TelemetryProvider {
trackNodeSearch?(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
// Search query analytics
trackSearchQuery?(metadata: SearchQueryMetadata): void
// Node-added-to-canvas analytics
trackNodeAdded?(metadata: NodeAddedMetadata): void
@@ -558,6 +580,7 @@ export const TelemetryEvents = {
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
SEARCH_QUERY: 'app:search_query',
NODE_ADDED: 'app:node_added_to_workflow',
// Template Filter Analytics
@@ -616,6 +639,7 @@ export type TelemetryEventProperties =
| TabCountMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| SearchQueryMetadata
| TemplateFilterMetadata
| SettingChangedMetadata
| UiButtonClickMetadata