Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions crates/pipeline-manager/src/api/endpoints/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ impl BuildInformation {

#[derive(Serialize, ToSchema)]
pub(crate) struct Configuration {
/// Telemetry key.
pub telemetry: String,

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.

tnis is public facing IIRC we should just keep the name telemetry as is
we dont know if anyone parses this and relies on this key to be there, low risk but can be avoided

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.

@gz this is used only for web-console telemetry so realistically only used by us. I would like to update it to keep naming more meaningful and consistent with other providers, since again nothing else should be impacted. What I didn't touch for now are pipeline-manager options so that our deployments are not affected

/// 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`.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions crates/pipeline-manager/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down
22 changes: 19 additions & 3 deletions crates/pipeline-manager/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,13 +863,27 @@ pub struct ApiServerConfig {
#[arg(long, default_values_t = default_demos_dir())]
pub demos_dir: Vec<String>,

/// 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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions js-packages/web-console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<FelderaLogoBlackDetail class="h-8 pl-4 opacity-40"></FelderaLogoBlackDetail>
{/if}
<div class="flex flex-col gap-1 md:flex-row">
<BookADemo class="btn justify-start px-4 hover:bg-surface-50-950">
<BookADemo class="btn justify-start px-4 hover:bg-surface-50-950" triggerLocation="footer">
<!-- svelte-ignore block_empty -->
{#snippet icon()}{/snippet}
Book a demo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@
<CreatePipelineButton inputClass="max-w-64" btnClass="preset-filled-surface-50-950"
></CreatePipelineButton>
</div>
<BookADemo class="btn-icon preset-filled-surface-50-950"></BookADemo>
<BookADemo class="btn-icon preset-filled-surface-50-950" triggerLocation="pipeline_editor"
></BookADemo>
<Tooltip class="">Book a demo</Tooltip>
{/if}
{/snippet}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<script lang="ts">
import { captureEvent } from '$lib/services/analytics'
import type { Snippet } from '$lib/types/svelte'

const {
class: _class,
children,
icon = defaultIcon
}: { class?: string; children?: Snippet; icon?: Snippet } = $props()
icon = defaultIcon,
triggerLocation
}: { class?: string; children?: Snippet; icon?: Snippet; triggerLocation?: string } = $props()

const calendlyUrl = 'https://calendly.com/d/cqnj-p63-mbq/feldera-demo'
</script>

{#snippet defaultIcon()}
Expand All @@ -14,9 +18,11 @@

<a
class={_class}
href="https://calendly.com/d/cqnj-p63-mbq/feldera-demo"
href={calendlyUrl}
target="_blank"
rel="noreferrer"
onclick={() =>
captureEvent('calendly_opened', { url: calendlyUrl, trigger_location: triggerLocation })}
>
{@render icon()}
{@render children?.()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
</script>

{#if demo}
<div class="flex flex-col card border border-surface-100-900 p-4">
<div class="text-sm text-surface-700-300">{demo.type}</div>
<button class="text-left" onclick={() => tryPipeline(demo)}>
<button class="text-left" onclick={() => tryPipeline(demo, triggerLocation)}>
<span class="py-2 font-semibold">{demo.title}</span>
<!-- <span class="fd fd-arrow-right inline-block w-2 text-[20px]"></span> -->
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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</a
rel="noreferrer"
onclick={() =>
captureEvent('calendly_opened', {

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.

First, why isn't this using the constant in the other file?
Second, why is this in this file which seems unrelated to the demos?

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.

Here, there is a contextual tooltip that offers users to upgrade to enterprise license to be able to stop the pipelines. I will edit the code to re-use the calendly link as a constant across multiple files

url: 'https://calendly.com/d/cqnj-p63-mbq/feldera-demo'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: this Calendly URL is now hardcoded here and also in BookADemo.svelte (where it was extracted to a const). Consider sharing a single constant so there is one place to update if it ever changes.

})}>Upgrade</a
>
</Popover>
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const baseConfig = {
edition: 'Open source',
revision: 'abc123',
runtime_revision: 'rt123',
telemetry: '',
posthog: '',
version: '0.0.0'
} as unknown as Configuration

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Demo, 'title'>) => {
return async (pipeline: Omit<Demo, 'title'>, 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,
Expand Down
32 changes: 32 additions & 0 deletions js-packages/web-console/src/lib/services/analytics.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
14 changes: 14 additions & 0 deletions js-packages/web-console/src/lib/services/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
posthog.capture(event, properties)
trackConceptualHq(event, properties)
}
122 changes: 122 additions & 0 deletions js-packages/web-console/src/lib/services/conceptualHq.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <script>, so we can assert on it without a
// real document. `getElementsByTagName` must return an existing script whose
// parent receives the insert.
const stubDom = () => {
const script: Record<string, unknown> = {}
const insertBefore = vi.fn()
const existing = { parentNode: { insertBefore } }
vi.stubGlobal('document', {
createElement: () => script,
getElementsByTagName: () => [existing]
})
return { script, insertBefore }
}

// Each case re-imports a fresh module so the one-shot `initialized` flag and
// `window.ca` start clean.
const freshModule = async (browser: boolean) => {
vi.resetModules()
vi.stubGlobal('window', browser ? {} : undefined)
return import('./conceptualHq')
}

// Queue entries are `arguments` objects; normalize to plain arrays for assertions.
const queued = () => (window.ca?.q ?? []).map((a) => Array.from(a))

afterEach(() => {
vi.unstubAllGlobals()
})

describe('initConceptualHq', () => {
it('injects the loader keyed by config, then identifies and tracks signin', async () => {
const { script } = stubDom()
const { initConceptualHq } = await freshModule(true)
initConceptualHq(config('my-key'), profile)

expect(script.src).toBe('https://oqiset.feldera.com/analytics/loader-v1.js?key=my-key&v=1.1.0')
expect(queued()).toEqual([
['identify', 'a@b.com', { email: 'a@b.com', name: 'Ann' }],
['track', 'signin']
])
})

it('does not track signup (OIDC login cannot distinguish it)', async () => {
stubDom()
const { initConceptualHq } = await freshModule(true)
initConceptualHq(config('my-key'), profile)
expect(queued().some((call) => call[1] === 'signup')).toBe(false)
})

it('falls back to the user id when email is missing', async () => {
stubDom()
const { initConceptualHq } = await freshModule(true)
initConceptualHq(config('my-key'), { id: 'user-1' })
expect(queued()[0]).toEqual(['identify', 'user-1', { email: undefined, name: undefined }])
})

it('is idempotent: a second call after success is a no-op', async () => {
stubDom()
const { initConceptualHq } = await freshModule(true)
initConceptualHq(config('my-key'), profile)
const afterFirst = queued().length
initConceptualHq(config('my-key'), profile)
expect(queued().length).toBe(afterFirst)
})

it('does nothing when the key is empty', async () => {
stubDom()
const { initConceptualHq } = await freshModule(true)
initConceptualHq(config(''), profile)
expect(window.ca).toBeUndefined()
})

it('does nothing outside the browser', async () => {
stubDom()
const { initConceptualHq } = await freshModule(false)
// Must not throw when `window` is undefined.
expect(() => initConceptualHq(config('my-key'), profile)).not.toThrow()
})
})

describe('trackConceptualHq', () => {
it('enqueues a track call with properties once the loader is installed', async () => {
stubDom()
const { initConceptualHq, trackConceptualHq } = await freshModule(true)
initConceptualHq(config('my-key'), profile)
const before = queued().length
trackConceptualHq('demo_opened', { demo: 'x', already_created: true })
expect(queued().slice(before)).toEqual([
['track', 'demo_opened', { demo: 'x', already_created: true }]
])
})

it('omits the properties argument when none are given', async () => {
stubDom()
const { initConceptualHq, trackConceptualHq } = await freshModule(true)
initConceptualHq(config('my-key'), profile)
const before = queued().length
trackConceptualHq('some_event')
expect(queued().slice(before)).toEqual([['track', 'some_event']])
})

it('is a no-op when analytics is disabled (loader never installed)', async () => {
const { trackConceptualHq } = await freshModule(true)
expect(() => trackConceptualHq('demo_opened', { demo: 'x' })).not.toThrow()
expect(window.ca).toBeUndefined()
})

it('is a no-op outside the browser', async () => {
const { trackConceptualHq } = await freshModule(false)
expect(() => trackConceptualHq('demo_opened')).not.toThrow()
})
})
Loading
Loading