Skip to content
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

feat: RemoteConfig loader #1577

Merged
merged 16 commits into from
Dec 5, 2024
7 changes: 3 additions & 4 deletions playground/nextjs/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import { useEffect } from 'react'
import type { AppProps } from 'next/app'
import { useRouter } from 'next/router'

import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react'
import { CookieBanner } from '@/src/CookieBanner'
import '@/src/posthog'
import { posthog } from '@/src/posthog'
import Head from 'next/head'
import { PageHeader } from '@/src/Header'
import { useUser } from '@/src/auth'
Expand Down Expand Up @@ -46,10 +45,10 @@ export default function App({ Component, pageProps }: AppProps) {
http-equiv="Content-Security-Policy"
content={`
default-src 'self';
connect-src 'self' ${localhostDomain} https://*.posthog.com;
connect-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host;
script-src 'self' 'unsafe-eval' 'unsafe-inline' ${localhostDomain} https://*.posthog.com;
style-src 'self' 'unsafe-inline' ${localhostDomain} https://*.posthog.com;
img-src 'self' ${localhostDomain} https://*.posthog.com;
img-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host;
`}
/>
</Head>
Expand Down
12 changes: 11 additions & 1 deletion playground/nextjs/pages/_document.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { POSTHOG_USE_SNIPPET } from '@/src/posthog'
import { Html, Head, Main, NextScript } from 'next/document'
import Script from 'next/script'
import React from 'react'

export default function Document() {
return (
<Html lang="en">
<Head />
<Head>
{POSTHOG_USE_SNIPPET ? (
<Script id="posthog-script" strategy="beforeInteractive">
{`
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/array/phc_RovUaiJOvaGmo2NxYHh7jvy3DJaIPp7f3DFbbbGqmvH/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
`}
</Script>
) : null}
</Head>

<body>
<Main />
Expand Down
2 changes: 1 addition & 1 deletion playground/nextjs/pages/survey.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { usePostHog } from 'posthog-js/react'
import { useEffect, useState } from 'react'
import { Survey } from 'posthog-js'
import type { Survey } from 'posthog-js'

export default function SurveyForm() {
const posthog = usePostHog()
Expand Down
11 changes: 9 additions & 2 deletions playground/nextjs/src/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@
// import 'posthog-js/dist/exception-autocapture'
// import 'posthog-js/dist/tracing-headers'

import posthog, { PostHogConfig } from 'posthog-js'
import posthogJS, { PostHogConfig } from 'posthog-js'
import { User } from './auth'

export const PERSON_PROCESSING_MODE: 'always' | 'identified_only' | 'never' =
(process.env.NEXT_PUBLIC_POSTHOG_PERSON_PROCESSING_MODE as any) || 'identified_only'

export const POSTHOG_USE_SNIPPET: boolean = (process.env.NEXT_PUBLIC_POSTHOG_USE_SNIPPET as any) || false

export const posthog = POSTHOG_USE_SNIPPET
? typeof window !== 'undefined'
? (window as any).posthog
: null
: posthogJS
/**
* Below is an example of a consent-driven config for PostHog
* Lots of things start in a disabled state and posthog will not use cookies without consent
Expand Down Expand Up @@ -55,9 +62,9 @@ if (typeof window !== 'undefined') {
persistence: cookieConsentGiven() ? 'localStorage+cookie' : 'memory',
person_profiles: PERSON_PROCESSING_MODE === 'never' ? 'identified_only' : PERSON_PROCESSING_MODE,
persistence_name: `${process.env.NEXT_PUBLIC_POSTHOG_KEY}_nextjs`,
__preview_remote_config: true,
...configForConsent(),
})

// Help with debugging(window as any).posthog = posthog
}

Expand Down
22 changes: 11 additions & 11 deletions src/__tests__/autocapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ describe('Autocapture system', () => {
beforeEach(() => {
posthog.config.rageclick = true
// Trigger proper enabling
autocapture.afterDecideResponse({} as DecideResponse)
autocapture.onRemoteConfig({} as DecideResponse)
})

it('should capture rageclick', () => {
Expand Down Expand Up @@ -502,7 +502,7 @@ describe('Autocapture system', () => {

it('should not capture events when config returns false, when an element matching any of the event selectors is clicked', () => {
posthog.config.autocapture = false
autocapture.afterDecideResponse({} as DecideResponse)
autocapture.onRemoteConfig({} as DecideResponse)

const eventElement1 = document.createElement('div')
const eventElement2 = document.createElement('div')
Expand All @@ -524,7 +524,7 @@ describe('Autocapture system', () => {
})

it('should not capture events when config returns true but server setting is disabled', () => {
autocapture.afterDecideResponse({
autocapture.onRemoteConfig({
autocapture_opt_out: true,
} as DecideResponse)

Expand Down Expand Up @@ -932,7 +932,7 @@ describe('Autocapture system', () => {
type: 'click',
} as unknown as MouseEvent

autocapture.afterDecideResponse({
autocapture.onRemoteConfig({
elementsChainAsString: true,
} as DecideResponse)

Expand Down Expand Up @@ -1003,7 +1003,7 @@ describe('Autocapture system', () => {
beforeEach(() => {
document.title = 'test page'
posthog.config.mask_all_element_attributes = false
autocapture.afterDecideResponse({} as DecideResponse)
autocapture.onRemoteConfig({} as DecideResponse)
})

it('should capture click events', () => {
Expand Down Expand Up @@ -1056,7 +1056,7 @@ describe('Autocapture system', () => {
'when client side config is %p and remote opt out is %p - autocapture enabled should be %p',
(clientSideOptIn, serverSideOptOut, expected) => {
posthog.config.autocapture = clientSideOptIn
autocapture.afterDecideResponse({
autocapture.onRemoteConfig({
autocapture_opt_out: serverSideOptOut,
} as DecideResponse)
expect(autocapture.isEnabled).toBe(expected)
Expand All @@ -1065,29 +1065,29 @@ describe('Autocapture system', () => {

it('should call _addDomEventHandlders if autocapture is true in client config', () => {
posthog.config.autocapture = true
autocapture.afterDecideResponse({} as DecideResponse)
autocapture.onRemoteConfig({} as DecideResponse)
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalled()
})

it('should not call _addDomEventHandlders if autocapture is opted out in server config', () => {
autocapture.afterDecideResponse({ autocapture_opt_out: true } as DecideResponse)
autocapture.onRemoteConfig({ autocapture_opt_out: true } as DecideResponse)
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
})

it('should not call _addDomEventHandlders if autocapture is disabled in client config', () => {
expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
posthog.config.autocapture = false

autocapture.afterDecideResponse({} as DecideResponse)
autocapture.onRemoteConfig({} as DecideResponse)

expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled()
})

it('should NOT call _addDomEventHandlders when the token has already been initialized', () => {
autocapture.afterDecideResponse({} as DecideResponse)
autocapture.onRemoteConfig({} as DecideResponse)
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1)

autocapture.afterDecideResponse({} as DecideResponse)
autocapture.onRemoteConfig({} as DecideResponse)
expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1)
})
})
Expand Down
87 changes: 82 additions & 5 deletions src/__tests__/decide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { Decide } from '../decide'
import { PostHogPersistence } from '../posthog-persistence'
import { RequestRouter } from '../utils/request-router'
import { PostHog } from '../posthog-core'
import { DecideResponse, PostHogConfig, Properties } from '../types'
import { DecideResponse, PostHogConfig, Properties, RemoteConfig } from '../types'
import '../entrypoints/external-scripts-loader'
import { assignableWindow } from '../utils/globals'

const expectDecodedSendRequest = (
send_request: PostHog['_send_request'],
Expand Down Expand Up @@ -52,10 +53,12 @@ describe('Decide', () => {
get_property: (key: string) => posthog.persistence!.props[key],
capture: jest.fn(),
_addCaptureHook: jest.fn(),
_afterDecideResponse: jest.fn(),
_onRemoteConfig: jest.fn(),
get_distinct_id: jest.fn().mockImplementation(() => 'distinctid'),
_send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: {} })),
featureFlags: {
resetRequestQueue: jest.fn(),
reloadFeatureFlags: jest.fn(),
receivedFeatureFlags: jest.fn(),
setReloadingPaused: jest.fn(),
_startReloadTimer: jest.fn(),
Expand Down Expand Up @@ -200,7 +203,7 @@ describe('Decide', () => {
subject({} as DecideResponse)

expect(posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith({}, false)
expect(posthog._afterDecideResponse).toHaveBeenCalledWith({})
expect(posthog._onRemoteConfig).toHaveBeenCalledWith({})
})

it('Make sure receivedFeatureFlags is called with errors if the decide response fails', () => {
Expand All @@ -225,7 +228,7 @@ describe('Decide', () => {
} as unknown as DecideResponse
subject(decideResponse)

expect(posthog._afterDecideResponse).toHaveBeenCalledWith(decideResponse)
expect(posthog._onRemoteConfig).toHaveBeenCalledWith(decideResponse)
expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled()
})

Expand All @@ -242,8 +245,82 @@ describe('Decide', () => {
} as unknown as DecideResponse
subject(decideResponse)

expect(posthog._afterDecideResponse).toHaveBeenCalledWith(decideResponse)
expect(posthog._onRemoteConfig).toHaveBeenCalledWith(decideResponse)
expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled()
})
})

describe('remote config', () => {
const config = { surveys: true } as RemoteConfig

beforeEach(() => {
posthog.config.__preview_remote_config = true
assignableWindow._POSTHOG_CONFIG = undefined
assignableWindow.POSTHOG_DEBUG = true

assignableWindow.__PosthogExtensions__.loadExternalDependency = jest.fn(
(_ph: PostHog, _name: string, cb: (err?: any) => void) => {
assignableWindow._POSTHOG_CONFIG = config as RemoteConfig
cb()
}
)

posthog._send_request = jest.fn().mockImplementation(({ callback }) => callback?.({ json: config }))
})

it('properly pulls from the window and uses it if set', () => {
assignableWindow._POSTHOG_CONFIG = config as RemoteConfig
decide().call()

expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).not.toHaveBeenCalled()
expect(posthog._send_request).not.toHaveBeenCalled()

expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config)
})

it('loads the script if window config not set', () => {
decide().call()

expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).toHaveBeenCalledWith(
posthog,
'remote-config',
expect.any(Function)
)
expect(posthog._send_request).not.toHaveBeenCalled()
expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config)
})

it('loads the json if window config not set and js failed', () => {
assignableWindow.__PosthogExtensions__.loadExternalDependency = jest.fn(
(_ph: PostHog, _name: string, cb: (err?: any) => void) => {
cb()
}
)

decide().call()

expect(assignableWindow.__PosthogExtensions__.loadExternalDependency).toHaveBeenCalled()
expect(posthog._send_request).toHaveBeenCalledWith({
method: 'GET',
url: 'https://test.com/array/testtoken/config',
callback: expect.any(Function),
})
expect(posthog._onRemoteConfig).toHaveBeenCalledWith(config)
})

it.each([
[true, true],
[false, false],
[undefined, true],
])('conditionally reloads feature flags - hasFlags: %s, shouldReload: %s', (hasFeatureFlags, shouldReload) => {
assignableWindow._POSTHOG_CONFIG = { hasFeatureFlags } as RemoteConfig
decide().call()

if (shouldReload) {
expect(posthog.featureFlags.reloadFeatureFlags).toHaveBeenCalled()
} else {
expect(posthog.featureFlags.reloadFeatureFlags).not.toHaveBeenCalled()
}
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('Exception Observer', () => {

describe('when enabled', () => {
beforeEach(() => {
exceptionObserver.afterDecideResponse({ autocaptureExceptions: true } as DecideResponse)
exceptionObserver.onRemoteConfig({ autocaptureExceptions: true } as DecideResponse)
})

it('should instrument handlers when started', () => {
Expand Down Expand Up @@ -173,7 +173,7 @@ describe('Exception Observer', () => {
window!.onerror = originalOnError
window!.onunhandledrejection = originalOnUnhandledRejection

exceptionObserver.afterDecideResponse({ autocaptureExceptions: true } as DecideResponse)
exceptionObserver.onRemoteConfig({ autocaptureExceptions: true } as DecideResponse)
})

it('should wrap original onerror handler if one was present when wrapped', () => {
Expand Down Expand Up @@ -232,7 +232,7 @@ describe('Exception Observer', () => {

describe('when disabled', () => {
beforeEach(() => {
exceptionObserver.afterDecideResponse({ autocaptureExceptions: false } as DecideResponse)
exceptionObserver.onRemoteConfig({ autocaptureExceptions: false } as DecideResponse)
})

it('cannot be started', () => {
Expand Down
Loading
Loading