-
Notifications
You must be signed in to change notification settings - Fork 141
Add crm telemetry #6695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add crm telemetry #6695
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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</a | ||
| rel="noreferrer" | ||
| onclick={() => | ||
| captureEvent('calendly_opened', { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First, why isn't this using the constant in the other file?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this Calendly URL is now hardcoded here and also in |
||
| })}>Upgrade</a | ||
| > | ||
| </Popover> | ||
| {/if} | ||
|
|
||
| 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) | ||
| }) | ||
| }) |
| 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) | ||
| } |
| 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() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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
telemetryas iswe dont know if anyone parses this and relies on this key to be there, low risk but can be avoided
There was a problem hiding this comment.
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