Skip to content

Commit

Permalink
feat: RemoteConfig loader (#1577)
Browse files Browse the repository at this point in the history
Co-authored-by: Paul D'Ambra <[email protected]>
  • Loading branch information
benjackwhite and pauldambra authored Dec 5, 2024
1 parent 909eda0 commit bd75190
Show file tree
Hide file tree
Showing 29 changed files with 380 additions and 199 deletions.
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

0 comments on commit bd75190

Please sign in to comment.