diff --git a/bun.lock b/bun.lock index ba64a39f6d5..aeb637f0b2c 100644 --- a/bun.lock +++ b/bun.lock @@ -240,6 +240,7 @@ "prettier": "3.8.1", "prettier-plugin-svelte": "3.5.0", "prettier-plugin-tailwindcss": "0.7.2", + "product-fruits": "^1.1.0", "profiler-layout": "workspace:*", "profiler-lib": "workspace:*", "runed": "0.37.1", @@ -1803,6 +1804,8 @@ "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "product-fruits": ["product-fruits@1.1.0", "", {}, "sha512-eDNdYrf4zJDbu2RSWJ10UxX1QdESqYFfrsOjwfrQG4oJvj88b540geS+lhHeKoEVLA9QEPZlWX9M+sKQois+bA=="], + "profiler-app": ["profiler-app@workspace:js-packages/profiler-app"], "profiler-layout": ["profiler-layout@workspace:js-packages/profiler-layout"], diff --git a/crates/pipeline-manager/src/api/endpoints/config.rs b/crates/pipeline-manager/src/api/endpoints/config.rs index 8d6b85c96fc..470aa27d44f 100644 --- a/crates/pipeline-manager/src/api/endpoints/config.rs +++ b/crates/pipeline-manager/src/api/endpoints/config.rs @@ -68,8 +68,12 @@ impl BuildInformation { #[derive(Serialize, ToSchema)] pub(crate) struct Configuration { - /// Telemetry key. - pub telemetry: String, + /// PostHog telemetry key. Empty when disabled. + pub posthog: String, + /// ConceptualHQ analytics key. Empty when disabled. + pub conceptualhq: String, + /// Product Fruits workspace code for in-app onboarding. Empty when disabled. + pub product_fruits: String, /// Feldera edition: "Open source" or "Enterprise" pub edition: String, /// The version corresponding to the type of `edition`. @@ -106,7 +110,9 @@ impl Configuration { let license_check = LicenseCheck::validate(state).await.unwrap_or_default(); Configuration { - telemetry: state.config.telemetry.clone(), + posthog: state.config.telemetry.clone(), + conceptualhq: state.config.conceptualhq.clone(), + product_fruits: state.config.product_fruits.clone(), edition: if cfg!(feature = "feldera-enterprise") { "Enterprise" } else { diff --git a/crates/pipeline-manager/src/auth.rs b/crates/pipeline-manager/src/auth.rs index 00be4085d8f..83b4c762414 100644 --- a/crates/pipeline-manager/src/auth.rs +++ b/crates/pipeline-manager/src/auth.rs @@ -1077,6 +1077,8 @@ mod test { allowed_origins: None, demos_dir: vec![], telemetry: "".to_owned(), + conceptualhq: "".to_owned(), + product_fruits: "".to_owned(), support_data_collection_frequency: 15, support_data_retention: 3, authorized_groups: vec![], diff --git a/crates/pipeline-manager/src/config.rs b/crates/pipeline-manager/src/config.rs index 7c6f7e923f4..048d63d59c3 100644 --- a/crates/pipeline-manager/src/config.rs +++ b/crates/pipeline-manager/src/config.rs @@ -863,13 +863,27 @@ pub struct ApiServerConfig { #[arg(long, default_values_t = default_demos_dir())] pub demos_dir: Vec, - /// Telemetry key. + /// PostHog telemetry key. /// - /// If a telemetry key is set, anonymous usage data will be collected - /// and sent to our telemetry service. + /// If a key is set, anonymous usage data will be collected + /// and sent to our PostHog telemetry service. #[arg(long, default_value = "", env = "FELDERA_TELEMETRY")] pub telemetry: String, + /// ConceptualHQ analytics key. + /// + /// If set, the WebConsole loads ConceptualHQ analytics to identify the + /// signed-in user and track sign-in events. Leave empty to disable. + #[arg(long, default_value = "", env = "FELDERA_CONCEPTUALHQ")] + pub conceptualhq: String, + + /// Product Fruits workspace code. + /// + /// If set, the WebConsole loads Product Fruits for in-app onboarding + /// (tours, hints, surveys). Leave empty to disable. + #[arg(long, default_value = "", env = "FELDERA_PRODUCT_FRUITS")] + pub product_fruits: String, + /// Support data collection frequency (in seconds). /// /// This parameter determines how often data should be collected and stored @@ -965,6 +979,8 @@ impl ApiServerConfig { dev_mode: false, allowed_origins: None, telemetry: "test".to_string(), + conceptualhq: "".to_string(), + product_fruits: "".to_string(), demos_dir: vec!["demos".to_string()], dump_openapi: false, issuer_tenant: false, diff --git a/js-packages/web-console/package.json b/js-packages/web-console/package.json index 19011fffbac..1fe612e02c8 100644 --- a/js-packages/web-console/package.json +++ b/js-packages/web-console/package.json @@ -72,6 +72,7 @@ "prettier": "3.8.1", "prettier-plugin-svelte": "3.5.0", "prettier-plugin-tailwindcss": "0.7.2", + "product-fruits": "^1.1.0", "profiler-layout": "workspace:*", "profiler-lib": "workspace:*", "runed": "0.37.1", diff --git a/js-packages/web-console/src/lib/components/layout/Footer.svelte b/js-packages/web-console/src/lib/components/layout/Footer.svelte index eb71d30f4b9..26ad1fe82f2 100644 --- a/js-packages/web-console/src/lib/components/layout/Footer.svelte +++ b/js-packages/web-console/src/lib/components/layout/Footer.svelte @@ -15,7 +15,7 @@ {/if}
- + {#snippet icon()}{/snippet} Book a demo diff --git a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte index cd89e429947..66e7e172d07 100644 --- a/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte +++ b/js-packages/web-console/src/lib/components/layout/pipelines/PipelineEditLayout.svelte @@ -299,7 +299,8 @@
- + Book a demo {/if} {/snippet} diff --git a/js-packages/web-console/src/lib/components/other/BookADemo.svelte b/js-packages/web-console/src/lib/components/other/BookADemo.svelte index a9a819e70a8..dd25f124271 100644 --- a/js-packages/web-console/src/lib/components/other/BookADemo.svelte +++ b/js-packages/web-console/src/lib/components/other/BookADemo.svelte @@ -1,11 +1,15 @@ {#snippet defaultIcon()} @@ -14,9 +18,11 @@ + captureEvent('calendly_opened', { url: calendlyUrl, trigger_location: triggerLocation })} > {@render icon()} {@render children?.()} diff --git a/js-packages/web-console/src/lib/components/other/DemoTile.svelte b/js-packages/web-console/src/lib/components/other/DemoTile.svelte index 8f78fffcb57..06b26d15f91 100644 --- a/js-packages/web-console/src/lib/components/other/DemoTile.svelte +++ b/js-packages/web-console/src/lib/components/other/DemoTile.svelte @@ -2,14 +2,14 @@ import { useTryPipeline } from '$lib/compositions/pipelines/useTryPipeline' import type { Demo } from '$lib/services/pipelineManager' - let { demo }: { demo: Demo | null } = $props() + let { demo, triggerLocation }: { demo: Demo | null; triggerLocation?: string } = $props() const tryPipeline = useTryPipeline() {#if demo}
{demo.type}
- diff --git a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte index 3fb2b504bff..c55d9ae930a 100644 --- a/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte +++ b/js-packages/web-console/src/lib/components/pipelines/list/Actions.svelte @@ -50,6 +50,7 @@ groups related actions into multi-action dropdowns when multiple options are ava import JSONDialog from '$lib/components/dialogs/JSONDialog.svelte' import PipelineConfigurationsPopup from '$lib/components/layout/pipelines/PipelineConfigurationsPopup.svelte' import { duplicatePipeline, duplicatePipelineTooltip } from '$lib/compositions/duplicatePipeline' + import { captureEvent } from '$lib/services/analytics' import { useGlobalDialog } from '$lib/compositions/layout/useGlobalDialog.svelte' import { useIsMobile } from '$lib/compositions/layout/useIsMobile.svelte' import { usePipelineActionCallbacks } from '$lib/compositions/pipelines/usePipelineActionCallbacks.svelte' @@ -849,7 +850,11 @@ groups related actions into multi-action dropdowns when multiple options are ava class="block pt-2 underline" href="https://calendly.com/d/cqnj-p63-mbq/feldera-demo" target="_blank" - rel="noreferrer">Upgrade
+ captureEvent('calendly_opened', { + url: 'https://calendly.com/d/cqnj-p63-mbq/feldera-demo' + })}>Upgrade {/if} diff --git a/js-packages/web-console/src/lib/compositions/configCache.spec.ts b/js-packages/web-console/src/lib/compositions/configCache.spec.ts index 2549f7c28ca..0ff55cd99de 100644 --- a/js-packages/web-console/src/lib/compositions/configCache.spec.ts +++ b/js-packages/web-console/src/lib/compositions/configCache.spec.ts @@ -54,7 +54,7 @@ const baseConfig = { edition: 'Open source', revision: 'abc123', runtime_revision: 'rt123', - telemetry: '', + posthog: '', version: '0.0.0' } as unknown as Configuration diff --git a/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts b/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts index d65f22e5059..5c8a72f6398 100644 --- a/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts +++ b/js-packages/web-console/src/lib/compositions/pipelines/useTryPipeline.ts @@ -1,13 +1,24 @@ import { goto } from '$app/navigation' -import { useUpdatePipelineList } from '$lib/compositions/pipelines/usePipelineList.svelte' +import { + usePipelineList, + useUpdatePipelineList +} from '$lib/compositions/pipelines/usePipelineList.svelte' import { resolve } from '$lib/functions/svelte' +import { captureEvent } from '$lib/services/analytics' import type { Demo } from '$lib/services/manager' import { usePipelineManager } from '../usePipelineManager.svelte' export const useTryPipeline = () => { const { updatePipelines } = useUpdatePipelineList() + const pipelineList = usePipelineList() const api = usePipelineManager() - return async (pipeline: Omit) => { + return async (pipeline: Omit, trigger_location?: string) => { + const already_created = (pipelineList.pipelines ?? []).some((p) => p.name === pipeline.name) + captureEvent('demo_opened', { + demo: pipeline.name, + already_created, + trigger_location + }) try { const newPipeline = await api.postPipeline({ name: pipeline.name, diff --git a/js-packages/web-console/src/lib/services/analytics.spec.ts b/js-packages/web-console/src/lib/services/analytics.spec.ts new file mode 100644 index 00000000000..dfcc557f12d --- /dev/null +++ b/js-packages/web-console/src/lib/services/analytics.spec.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// vi.mock is hoisted above imports, so its factory may only touch vars created +// with vi.hoisted (also hoisted), not plain module-level consts. +const { capture, trackConceptualHq } = vi.hoisted(() => ({ + capture: vi.fn(), + trackConceptualHq: vi.fn() +})) +vi.mock('posthog-js', () => ({ default: { capture } })) +vi.mock('$lib/services/conceptualHq', () => ({ trackConceptualHq })) + +import { captureEvent } from './analytics' + +afterEach(() => { + capture.mockClear() + trackConceptualHq.mockClear() +}) + +describe('captureEvent', () => { + it('forwards the event and properties to both PostHog and ConceptualHQ', () => { + const props = { demo: 'fraud', already_created: true, source: 'home' } + captureEvent('demo_opened', props) + expect(capture).toHaveBeenCalledExactlyOnceWith('demo_opened', props) + expect(trackConceptualHq).toHaveBeenCalledExactlyOnceWith('demo_opened', props) + }) + + it('forwards events with no properties to both backends', () => { + captureEvent('signin') + expect(capture).toHaveBeenCalledExactlyOnceWith('signin', undefined) + expect(trackConceptualHq).toHaveBeenCalledExactlyOnceWith('signin', undefined) + }) +}) diff --git a/js-packages/web-console/src/lib/services/analytics.ts b/js-packages/web-console/src/lib/services/analytics.ts new file mode 100644 index 00000000000..c7e8d613fcf --- /dev/null +++ b/js-packages/web-console/src/lib/services/analytics.ts @@ -0,0 +1,14 @@ +import posthog from 'posthog-js' + +import { trackConceptualHq } from '$lib/services/conceptualHq' + +/** + * Report a product-analytics event to every configured backend (PostHog and + * ConceptualHQ). Each backend no-ops when its integration is disabled, so + * callers fire unconditionally. Use snake_case event names and property keys for consistency + * across both tools. + */ +export const captureEvent = (event: string, properties?: Record) => { + posthog.capture(event, properties) + trackConceptualHq(event, properties) +} diff --git a/js-packages/web-console/src/lib/services/conceptualHq.spec.ts b/js-packages/web-console/src/lib/services/conceptualHq.spec.ts new file mode 100644 index 00000000000..2ad785ebf9f --- /dev/null +++ b/js-packages/web-console/src/lib/services/conceptualHq.spec.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Configuration } from '$lib/services/manager' +import type { UserProfile } from '$lib/types/auth' + +const profile: UserProfile = { id: 'user-1', email: 'a@b.com', name: 'Ann' } + +// Only `conceptualhq` is read; the rest is filler to satisfy the type. +const config = (conceptualhq: string) => ({ conceptualhq }) as Configuration + +// Fake DOM that records the injected