diff --git a/.github/workflows/ci-hobby.yml b/.github/workflows/ci-hobby.yml index c5b878c8d2184..0025e656c8204 100644 --- a/.github/workflows/ci-hobby.yml +++ b/.github/workflows/ci-hobby.yml @@ -35,7 +35,16 @@ jobs: token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }} - name: Get python deps run: pip install python-digitalocean==1.17.0 requests==2.28.1 + - name: Setup DO Hobby Instance + run: python3 bin/hobby-ci.py create + env: + DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} - name: Run smoke tests on DO - run: python3 bin/hobby-ci.py $GITHUB_HEAD_REF + run: python3 bin/hobby-ci.py test $GITHUB_HEAD_REF + env: + DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} + - name: Post-cleanup step + if: always() + run: python3 bin/hobby-ci.py destroy env: DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }} diff --git a/bin/docker-worker-celery b/bin/docker-worker-celery index 5d1e7567fcabe..bbd9949d88352 100755 --- a/bin/docker-worker-celery +++ b/bin/docker-worker-celery @@ -71,6 +71,10 @@ FLAGS+=("-n node@%h") # On Heroku $WEB_CONCURRENCY contains suggested number of forks per dyno type # https://github.com/heroku/heroku-buildpack-python/blob/main/vendor/WEB_CONCURRENCY.sh [[ -n "${WEB_CONCURRENCY}" ]] && FLAGS+=" --concurrency $WEB_CONCURRENCY" +# Restart worker process after it processes this many tasks (to mitigate memory leaks) +[[ -n "${CELERY_MAX_TASKS_PER_CHILD}" ]] && FLAGS+=" --max-tasks-per-child $CELERY_MAX_TASKS_PER_CHILD" +# Restart worker process after it exceeds this much memory usage (to mitigate memory leaks) +[[ -n "${CELERY_MAX_MEMORY_PER_CHILD}" ]] && FLAGS+=" --max-memory-per-child $CELERY_MAX_MEMORY_PER_CHILD" if [[ -z "${CELERY_WORKER_QUEUES}" ]]; then source ./bin/celery-queues.env diff --git a/bin/hobby-ci.py b/bin/hobby-ci.py index 7eed9237e6c83..c19022692ece7 100644 --- a/bin/hobby-ci.py +++ b/bin/hobby-ci.py @@ -3,8 +3,6 @@ import datetime import os import random -import re -import signal import string import sys import time @@ -12,43 +10,73 @@ import digitalocean import requests -letters = string.ascii_lowercase -random_bit = "".join(random.choice(letters) for i in range(4)) -name = f"do-ci-hobby-deploy-{random_bit}" -region = "sfo3" -image = "ubuntu-22-04-x64" -size = "s-4vcpu-8gb" -release_tag = "latest-release" -branch_regex = re.compile("release-*.*") -branch = sys.argv[1] -if branch_regex.match(branch): - release_tag = f"{branch}-unstable" -hostname = f"{name}.posthog.cc" -user_data = ( - f"#!/bin/bash \n" - "mkdir hobby \n" - "cd hobby \n" - "sed -i \"s/#\\$nrconf{restart} = 'i';/\\$nrconf{restart} = 'a';/g\" /etc/needrestart/needrestart.conf \n" - "git clone https://github.com/PostHog/posthog.git \n" - "cd posthog \n" - f"git checkout {branch} \n" - "cd .. \n" - f"chmod +x posthog/bin/deploy-hobby \n" - f"./posthog/bin/deploy-hobby {release_tag} {hostname} 1 \n" -) -token = os.getenv("DIGITALOCEAN_TOKEN") + +DOMAIN = "posthog.cc" class HobbyTester: - def __init__(self, domain, droplet, record): - # Placeholders for DO resources + def __init__( + self, + token=None, + name=None, + region="sfo3", + image="ubuntu-22-04-x64", + size="s-4vcpu-8gb", + release_tag="latest-release", + branch=None, + hostname=None, + domain=DOMAIN, + droplet_id=None, + droplet=None, + record_id=None, + record=None, + ): + if not token: + token = os.getenv("DIGITALOCEAN_TOKEN") + self.token = token + self.branch = branch + self.release_tag = release_tag + + random_bit = "".join(random.choice(string.ascii_lowercase) for i in range(4)) + + if not name: + name = f"do-ci-hobby-deploy-{self.release_tag}-{random_bit}" + self.name = name + + if not hostname: + hostname = f"{name}.{DOMAIN}" + self.hostname = hostname + + self.region = region + self.image = image + self.size = size + self.domain = domain self.droplet = droplet + if droplet_id: + self.droplet = digitalocean.Droplet(token=self.token, id=droplet_id) + self.record = record + if record_id: + self.record = digitalocean.Record(token=self.token, id=record_id) - @staticmethod - def block_until_droplet_is_started(droplet): - actions = droplet.get_actions() + self.user_data = ( + f"#!/bin/bash \n" + "mkdir hobby \n" + "cd hobby \n" + "sed -i \"s/#\\$nrconf{restart} = 'i';/\\$nrconf{restart} = 'a';/g\" /etc/needrestart/needrestart.conf \n" + "git clone https://github.com/PostHog/posthog.git \n" + "cd posthog \n" + f"git checkout {self.branch} \n" + "cd .. \n" + f"chmod +x posthog/bin/deploy-hobby \n" + f"./posthog/bin/deploy-hobby {self.release_tag} {self.hostname} 1 \n" + ) + + def block_until_droplet_is_started(self): + if not self.droplet: + return + actions = self.droplet.get_actions() up = False while not up: for action in actions: @@ -60,42 +88,43 @@ def block_until_droplet_is_started(droplet): print("Droplet not booted yet - waiting a bit") time.sleep(5) - @staticmethod - def get_public_ip(droplet): + def get_public_ip(self): + if not self.droplet: + return ip = None while not ip: time.sleep(1) - droplet.load() - ip = droplet.ip_address + self.droplet.load() + ip = self.droplet.ip_address print(f"Public IP found: {ip}") # type: ignore return ip - @staticmethod - def create_droplet(ssh_enabled=False): + def create_droplet(self, ssh_enabled=False): keys = None if ssh_enabled: - manager = digitalocean.Manager(token=token) + manager = digitalocean.Manager(token=self.token) keys = manager.get_all_sshkeys() - droplet = digitalocean.Droplet( - token=token, - name=name, - region=region, - image=image, - size_slug=size, - user_data=user_data, + self.droplet = digitalocean.Droplet( + token=self.token, + name=self.name, + region=self.region, + image=self.image, + size_slug=self.size, + user_data=self.user_data, ssh_keys=keys, tags=["ci"], ) - droplet.create() - return droplet + self.droplet.create() + return self.droplet - @staticmethod - def wait_for_instance(hostname, timeout=20, retry_interval=15): + def test_deployment(self, timeout=20, retry_interval=15): + if not self.hostname: + return # timeout in minutes # return true if success or false if failure print("Attempting to reach the instance") print(f"We will time out after {timeout} minutes") - url = f"https://{hostname}/_health" + url = f"https://{self.hostname}/_health" start_time = datetime.datetime.now() while datetime.datetime.now() < start_time + datetime.timedelta(minutes=timeout): try: @@ -115,9 +144,29 @@ def wait_for_instance(hostname, timeout=20, retry_interval=15): print("Failure - we timed out before receiving a heartbeat") return False + def create_dns_entry(self, type, name, data, ttl=30): + self.domain = digitalocean.Domain(token=self.token, name=DOMAIN) + self.record = self.domain.create_new_domain_record(type=type, name=name, data=data, ttl=ttl) + return self.record + + def create_dns_entry_for_instance(self): + if not self.droplet: + return + self.record = self.create_dns_entry(type="A", name=self.name, data=self.get_public_ip()) + return self.record + + def destroy_self(self, retries=3): + if not self.droplet or not self.domain or not self.record: + return + droplet_id = self.droplet.id + self.destroy_environment(droplet_id, self.domain, self.record["domain_record"]["id"], retries=retries) + @staticmethod - def destroy_environment(droplet, domain, record, retries=3): + def destroy_environment(droplet_id, record_id, retries=3): print("Destroying the droplet") + token = os.getenv("DIGITALOCEAN_TOKEN") + droplet = digitalocean.Droplet(token=token, id=droplet_id) + domain = digitalocean.Domain(token=token, name=DOMAIN) attempts = 0 while attempts <= retries: attempts += 1 @@ -131,36 +180,83 @@ def destroy_environment(droplet, domain, record, retries=3): while attempts <= retries: attempts += 1 try: - domain.delete_domain_record(id=record["domain_record"]["id"]) + domain.delete_domain_record(id=record_id) break except Exception as e: print(f"Could not destroy the dns entry because\n{e}") def handle_sigint(self): - self.destroy_environment(self.droplet, self.domain, self.record) + self.destroy_self() + + def export_droplet(self): + if not self.droplet: + print("Droplet not found. Exiting") + exit(1) + if not self.record: + print("DNS record not found. Exiting") + exit(1) + record_id = self.record["domain_record"]["id"] + record_name = self.record["domain_record"]["name"] + droplet_id = self.droplet.id + + print(f"Exporting the droplet ID: {self.droplet.id} and DNS record ID: {record_id} for name {self.name}") + env_file_name = os.getenv("GITHUB_ENV") + with open(env_file_name, "a") as env_file: + env_file.write(f"HOBBY_DROPLET_ID={droplet_id}\n") + with open(env_file_name, "a") as env_file: + env_file.write(f"HOBBY_DNS_RECORD_ID={record_id}\n") + env_file.write(f"HOBBY_DNS_RECORD_NAME={record_name}\n") + env_file.write(f"HOBBY_NAME={self.name}\n") + + def ensure_droplet(self, ssh_enabled=True): + self.create_droplet(ssh_enabled=ssh_enabled) + self.block_until_droplet_is_started() + self.create_dns_entry_for_instance() + self.export_droplet() def main(): - print("Creating droplet on Digitalocean for testing Hobby Deployment") - droplet = HobbyTester.create_droplet(ssh_enabled=True) - HobbyTester.block_until_droplet_is_started(droplet) - public_ip = HobbyTester.get_public_ip(droplet) - domain = digitalocean.Domain(token=token, name="posthog.cc") - record = domain.create_new_domain_record(type="A", name=name, data=public_ip) - - hobby_tester = HobbyTester(domain, droplet, record) - signal.signal(signal.SIGINT, hobby_tester.handle_sigint) # type: ignore - signal.signal(signal.SIGHUP, hobby_tester.handle_sigint) # type: ignore - print("Instance has started. You will be able to access it here after PostHog boots (~15 minutes):") - print(f"https://{hostname}") - health_success = HobbyTester.wait_for_instance(hostname) - HobbyTester.destroy_environment(droplet, domain, record) - if health_success: - print("We succeeded") - exit() - else: - print("We failed") - exit(1) + command = sys.argv[1] + if command == "create": + print("Creating droplet on Digitalocean for testing Hobby Deployment") + ht = HobbyTester() + ht.ensure_droplet(ssh_enabled=True) + print("Instance has started. You will be able to access it here after PostHog boots (~15 minutes):") + print(f"https://{ht.hostname}") + + if command == "destroy": + print("Destroying droplet on Digitalocean for testing Hobby Deployment") + droplet_id = os.environ.get("HOBBY_DROPLET_ID") + domain_record_id = os.environ.get("HOBBY_DNS_RECORD_ID") + print(f"Droplet ID: {droplet_id}") + print(f"Record ID: {domain_record_id}") + HobbyTester.destroy_environment(droplet_id=droplet_id, record_id=domain_record_id) + + if command == "test": + if len(sys.argv) < 3: + print("Please provide the branch name to test") + exit(1) + branch = sys.argv[2] + name = os.environ.get("HOBBY_NAME") + record_id = os.environ.get("HOBBY_DNS_RECORD_ID") + droplet_id = os.environ.get("HOBBY_DROPLET_ID") + print(f"Testing the deployment for {name} on branch {branch}") + print(f"Record ID: {record_id}") + print(f"Droplet ID: {droplet_id}") + + ht = HobbyTester( + branch=branch, + name=name, + record_id=record_id, + droplet_id=droplet_id, + ) + health_success = ht.test_deployment() + if health_success: + print("We succeeded") + exit() + else: + print("We failed") + exit(1) if __name__ == "__main__": diff --git a/cypress/e2e/featureFlags.cy.ts b/cypress/e2e/featureFlags.cy.ts index 78f0bcd0ab8bd..e4f7e35edb718 100644 --- a/cypress/e2e/featureFlags.cy.ts +++ b/cypress/e2e/featureFlags.cy.ts @@ -119,8 +119,9 @@ describe('Feature Flags', () => { // select "add filter" and "property" cy.get('[data-attr=property-select-toggle-0').click() - // select the third property + // select the first property cy.get('[data-attr=taxonomic-filter-searchfield]').click() + cy.get('[data-attr=taxonomic-filter-searchfield]').type('is_demo') cy.get('[data-attr=taxonomic-tab-person_properties]').click() // select numeric $browser_version cy.get('[data-attr=prop-filter-person_properties-2]').click({ force: true }) diff --git a/cypress/e2e/trends.cy.ts b/cypress/e2e/trends.cy.ts index a1aa9d31a5594..36809958d7c25 100644 --- a/cypress/e2e/trends.cy.ts +++ b/cypress/e2e/trends.cy.ts @@ -24,7 +24,7 @@ describe('Trends', () => { cy.get('[data-attr=trend-element-subject-1]').click() cy.get('[data-attr=taxonomic-tab-actions]').click() cy.get('[data-attr=taxonomic-filter-searchfield]').click().type('home') - cy.contains('Hogflix homepage view').click({ force: true }) + cy.contains('Hogflix homepage view').click() // then cy.get('[data-attr=trend-line-graph]').should('exist') @@ -66,15 +66,15 @@ describe('Trends', () => { it('Apply specific filter on default pageview event', () => { cy.get('[data-attr=trend-element-subject-0]').click() cy.get('[data-attr=taxonomic-filter-searchfield]').click().type('Pageview') - cy.get('.taxonomic-infinite-list').find('.taxonomic-list-row').contains('Pageview').click({ force: true }) + cy.get('.taxonomic-infinite-list').find('.taxonomic-list-row').contains('Pageview').click() cy.get('[data-attr=trend-element-subject-0]').should('have.text', 'Pageview') // Apply a property filter cy.get('[data-attr=show-prop-filter-0]').click() cy.get('[data-attr=property-select-toggle-0]').click() - cy.get('[data-attr=prop-filter-event_properties-1]').click({ force: true }) + cy.get('[data-attr=prop-filter-event_properties-1]').click() - cy.get('[data-attr=prop-val]').click({ force: true }) + cy.get('[data-attr=prop-val]').click() // cypress is odd and even though when a human clicks this the right dropdown opens // in the test that doesn't happen cy.get('body').then(($body) => { @@ -88,14 +88,14 @@ describe('Trends', () => { it('Apply 1 overall filter', () => { cy.get('[data-attr=trend-element-subject-0]').click() cy.get('[data-attr=taxonomic-filter-searchfield]').click().type('Pageview') - cy.get('.taxonomic-infinite-list').find('.taxonomic-list-row').contains('Pageview').click({ force: true }) + cy.get('.taxonomic-infinite-list').find('.taxonomic-list-row').contains('Pageview').click() cy.get('[data-attr=trend-element-subject-0]').should('have.text', 'Pageview') cy.get('[data-attr$=add-filter-group]').click() cy.get('[data-attr=property-select-toggle-0]').click() cy.get('[data-attr=taxonomic-filter-searchfield]').click() - cy.get('[data-attr=prop-filter-event_properties-1]').click({ force: true }) - cy.get('[data-attr=prop-val]').click({ force: true }) + cy.get('[data-attr=prop-filter-event_properties-1]').click() + cy.get('[data-attr=prop-val]').click() // cypress is odd and even though when a human clicks this the right dropdown opens // in the test that doesn't happen cy.get('body').then(($body) => { @@ -103,7 +103,7 @@ describe('Trends', () => { cy.get('[data-attr=taxonomic-value-select]').click() } }) - cy.get('[data-attr=prop-val-0]').click({ force: true }) + cy.get('[data-attr=prop-val-0]').click() cy.get('[data-attr=trend-line-graph]', { timeout: 8000 }).should('exist') }) @@ -117,14 +117,14 @@ describe('Trends', () => { it('Apply pie filter', () => { cy.get('[data-attr=chart-filter]').click() - cy.get('.Popover').find('.LemonButton').contains('Pie').click({ force: true }) + cy.get('.Popover').find('.LemonButton').contains('Pie').click() cy.get('[data-attr=trend-pie-graph]').should('exist') }) it('Apply table filter', () => { cy.get('[data-attr=chart-filter]').click() - cy.get('.Popover').find('.LemonButton').contains('Table').click({ force: true }) + cy.get('.Popover').find('.LemonButton').contains('Table').click() cy.get('[data-attr=insights-table-graph]').should('exist') @@ -144,7 +144,7 @@ describe('Trends', () => { it('Apply property breakdown', () => { cy.get('[data-attr=add-breakdown-button]').click() - cy.get('[data-attr=prop-filter-event_properties-1]').click({ force: true }) + cy.get('[data-attr=prop-filter-event_properties-1]').click() cy.get('[data-attr=trend-line-graph]').should('exist') }) @@ -154,4 +154,16 @@ describe('Trends', () => { cy.contains('All Users*').click() cy.get('[data-attr=trend-line-graph]').should('exist') }) + + it('Show warning on MAU math in total value insight', () => { + cy.get('[data-attr=chart-filter]').click() + cy.get('.Popover').find('.LemonButton').contains('Pie').click() + cy.get('[data-attr=trend-pie-graph]').should('exist') // Make sure the pie chart is loaded before proceeding + + cy.get('[data-attr=math-selector-0]').click() + cy.get('[data-attr=math-monthly_active-0] .LemonIcon').should('exist') // This should be the warning icon + + cy.get('[data-attr=math-monthly_active-0]').trigger('mouseenter') // Activate warning tooltip + cy.get('.Tooltip').contains('we recommend using "Unique users" here instead').should('exist') + }) }) diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png index 10c71817bc470..4946aedfeb38a 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png index 0952b5a96532a..b7eef2e8b826c 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png index dd4c5ed9fd9a8..6767462f173e0 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png index 0f3cdaf9bdd5e..18f965fbc6bf6 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--dark.png b/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--dark.png index 4a6a270e80ade..605ac3e495e9c 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--dark.png and b/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--light.png b/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--light.png index 62d41c8a92f04..5108cb2db25da 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--light.png and b/frontend/__snapshots__/scenes-other-invitesignup--invalid-link--light.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--logged-in--dark.png b/frontend/__snapshots__/scenes-other-invitesignup--logged-in--dark.png index 1dc24c67f58cf..fc126241a2102 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--logged-in--dark.png and b/frontend/__snapshots__/scenes-other-invitesignup--logged-in--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--logged-in--light.png b/frontend/__snapshots__/scenes-other-invitesignup--logged-in--light.png index 74bd780e5f791..8783f29981d49 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--logged-in--light.png and b/frontend/__snapshots__/scenes-other-invitesignup--logged-in--light.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--dark.png b/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--dark.png index cea25c91e4463..1aa1af55b6ba0 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--dark.png and b/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--light.png b/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--light.png index f2f87bcabf0e4..e11aec2c721fe 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--light.png and b/frontend/__snapshots__/scenes-other-invitesignup--self-hosted--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud--dark.png b/frontend/__snapshots__/scenes-other-login--cloud--dark.png index 79be2f6da084b..7a14791959cef 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud--dark.png and b/frontend/__snapshots__/scenes-other-login--cloud--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud--light.png b/frontend/__snapshots__/scenes-other-login--cloud--light.png index 089113c318132..187cb08b520a1 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud--light.png and b/frontend/__snapshots__/scenes-other-login--cloud--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png b/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png index 3759b88e7ce6c..1708f185a9db4 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png and b/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png b/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png index 34ff210eb5352..2f675dc967660 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png and b/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--dark.png b/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--dark.png index 5e3dc60309789..d0e0904bb1796 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--dark.png and b/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--light.png b/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--light.png index e67a6a0e2ff33..0f51547c0ad9b 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--light.png and b/frontend/__snapshots__/scenes-other-login--cloud-with-google-login-enforcement--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--second-factor--dark.png b/frontend/__snapshots__/scenes-other-login--second-factor--dark.png index 3be47d83ee98e..442c6d925ac8f 100644 Binary files a/frontend/__snapshots__/scenes-other-login--second-factor--dark.png and b/frontend/__snapshots__/scenes-other-login--second-factor--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--second-factor--light.png b/frontend/__snapshots__/scenes-other-login--second-factor--light.png index 661e5be1063bb..7f2ee5e3636e9 100644 Binary files a/frontend/__snapshots__/scenes-other-login--second-factor--light.png and b/frontend/__snapshots__/scenes-other-login--second-factor--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--self-hosted--dark.png b/frontend/__snapshots__/scenes-other-login--self-hosted--dark.png index 4d54c69d31e1b..00219a94898ad 100644 Binary files a/frontend/__snapshots__/scenes-other-login--self-hosted--dark.png and b/frontend/__snapshots__/scenes-other-login--self-hosted--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--self-hosted--light.png b/frontend/__snapshots__/scenes-other-login--self-hosted--light.png index f859af95307bb..12d1a15ba4fdb 100644 Binary files a/frontend/__snapshots__/scenes-other-login--self-hosted--light.png and b/frontend/__snapshots__/scenes-other-login--self-hosted--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png index f83fd6633b093..752d4e4ee3c7d 100644 Binary files a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png and b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png index bc653908edc34..02c129a707f6d 100644 Binary files a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png and b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--sso-error--dark.png b/frontend/__snapshots__/scenes-other-login--sso-error--dark.png index 2d4ed894b8f09..d3e245bf489c0 100644 Binary files a/frontend/__snapshots__/scenes-other-login--sso-error--dark.png and b/frontend/__snapshots__/scenes-other-login--sso-error--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--sso-error--light.png b/frontend/__snapshots__/scenes-other-login--sso-error--light.png index 9702eef8a92dd..a6286b20801f7 100644 Binary files a/frontend/__snapshots__/scenes-other-login--sso-error--light.png and b/frontend/__snapshots__/scenes-other-login--sso-error--light.png differ diff --git a/frontend/__snapshots__/scenes-other-password-reset-complete--default--dark.png b/frontend/__snapshots__/scenes-other-password-reset-complete--default--dark.png index b3969f7948c77..81780bef94ddb 100644 Binary files a/frontend/__snapshots__/scenes-other-password-reset-complete--default--dark.png and b/frontend/__snapshots__/scenes-other-password-reset-complete--default--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-password-reset-complete--default--light.png b/frontend/__snapshots__/scenes-other-password-reset-complete--default--light.png index cf50642150875..c721ccfce7107 100644 Binary files a/frontend/__snapshots__/scenes-other-password-reset-complete--default--light.png and b/frontend/__snapshots__/scenes-other-password-reset-complete--default--light.png differ diff --git a/frontend/__snapshots__/scenes-other-password-reset-complete--invalid-link--light.png b/frontend/__snapshots__/scenes-other-password-reset-complete--invalid-link--light.png index d94a85300a4bd..4e8728bcfada1 100644 Binary files a/frontend/__snapshots__/scenes-other-password-reset-complete--invalid-link--light.png and b/frontend/__snapshots__/scenes-other-password-reset-complete--invalid-link--light.png differ diff --git a/frontend/__snapshots__/scenes-other-preflight--preflight--dark.png b/frontend/__snapshots__/scenes-other-preflight--preflight--dark.png index 0f038280670e4..da76826c30718 100644 Binary files a/frontend/__snapshots__/scenes-other-preflight--preflight--dark.png and b/frontend/__snapshots__/scenes-other-preflight--preflight--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-preflight--preflight--light.png b/frontend/__snapshots__/scenes-other-preflight--preflight--light.png index 1fb61449ce120..e312576737901 100644 Binary files a/frontend/__snapshots__/scenes-other-preflight--preflight--light.png and b/frontend/__snapshots__/scenes-other-preflight--preflight--light.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--cloud--dark.png b/frontend/__snapshots__/scenes-other-signup--cloud--dark.png index 22483eefa81c9..3812da12f2360 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--cloud--dark.png and b/frontend/__snapshots__/scenes-other-signup--cloud--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--cloud--light.png b/frontend/__snapshots__/scenes-other-signup--cloud--light.png index 05ee6352a0fad..06af20259734b 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--cloud--light.png and b/frontend/__snapshots__/scenes-other-signup--cloud--light.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--self-hosted--dark.png b/frontend/__snapshots__/scenes-other-signup--self-hosted--dark.png index d5e0428cc0cef..4070b44e89d74 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--self-hosted--dark.png and b/frontend/__snapshots__/scenes-other-signup--self-hosted--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--self-hosted--light.png b/frontend/__snapshots__/scenes-other-signup--self-hosted--light.png index cccbf29d688de..cf918675e9bfe 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--self-hosted--light.png and b/frontend/__snapshots__/scenes-other-signup--self-hosted--light.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png index 7744a05479fe6..cdb034b88aba3 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png and b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png index d2e55de38afac..51591a33cb059 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png and b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png differ diff --git a/frontend/__snapshots__/scenes-other-unsubscribe--unsubscribe-scene--light.png b/frontend/__snapshots__/scenes-other-unsubscribe--unsubscribe-scene--light.png index 88a84bc3e21f3..2f60dada396b9 100644 Binary files a/frontend/__snapshots__/scenes-other-unsubscribe--unsubscribe-scene--light.png and b/frontend/__snapshots__/scenes-other-unsubscribe--unsubscribe-scene--light.png differ diff --git a/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--dark.png b/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--dark.png index a996167a4d6f2..dd975c4acc401 100644 Binary files a/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--dark.png and b/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--light.png b/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--light.png index fee2278660097..c45bfc650efc8 100644 Binary files a/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--light.png and b/frontend/__snapshots__/scenes-other-verify-email--verify-email-invalid--light.png differ diff --git a/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--dark.png b/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--dark.png index fa760226c666b..0d23b6e09b819 100644 Binary files a/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--dark.png and b/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--light.png b/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--light.png index fe0bff82ad081..a867c9f022e0a 100644 Binary files a/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--light.png and b/frontend/__snapshots__/scenes-other-verify-email--verify-email-pending--light.png differ diff --git a/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--dark.png b/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--dark.png index 2df3dabc00d20..2bf2567a3e6db 100644 Binary files a/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--dark.png and b/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--light.png b/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--light.png index 3cfa63c456086..f76014d8ffa51 100644 Binary files a/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--light.png and b/frontend/__snapshots__/scenes-other-verify-email--verify-email-success--light.png differ diff --git a/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.test.ts b/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.test.ts index f0ff2f6404de7..3bd8f0590f20d 100644 --- a/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.test.ts +++ b/frontend/src/layout/FeaturePreviews/featurePreviewsLogic.test.ts @@ -2,6 +2,7 @@ import { expectLogic } from 'kea-test-utils' import { MOCK_DEFAULT_USER } from 'lib/api.mock' import { userLogic } from 'scenes/userLogic' +import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' import { featurePreviewsLogic } from './featurePreviewsLogic' @@ -10,6 +11,11 @@ describe('featurePreviewsLogic', () => { let logic: ReturnType beforeEach(() => { + useMocks({ + post: { + 'https://posthoghelp.zendesk.com/api/v2/requests.json': [200, {}], + }, + }) initKeaTests() logic = featurePreviewsLogic() logic.mount() diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activation/activationLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/activation/activationLogic.ts index e8afd00aea1ed..b0be99d68f438 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/activation/activationLogic.ts +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activation/activationLogic.ts @@ -2,6 +2,7 @@ import { actions, connect, events, kea, listeners, path, reducers, selectors } f import { loaders } from 'kea-loaders' import { router } from 'kea-router' import api from 'lib/api' +import { reverseProxyCheckerLogic } from 'lib/components/ReverseProxyChecker/reverseProxyCheckerLogic' import { permanentlyMount } from 'lib/utils/kea-logic-builders' import posthog from 'posthog-js' import { membersLogic } from 'scenes/organization/membersLogic' @@ -58,6 +59,8 @@ export const activationLogic = kea([ ['insights'], dashboardsModel, ['rawDashboards'], + reverseProxyCheckerLogic, + ['hasReverseProxy'], ], actions: [ inviteLogic, @@ -193,6 +196,7 @@ export const activationLogic = kea([ s.customEventsCount, s.installedPlugins, s.currentTeamSkippedTasks, + s.hasReverseProxy, ], ( currentTeam, @@ -202,7 +206,8 @@ export const activationLogic = kea([ dashboards, customEventsCount, installedPlugins, - skippedTasks + skippedTasks, + hasReverseProxy ) => { const tasks: ActivationTaskType[] = [] for (const task of Object.values(ActivationTasks)) { @@ -286,7 +291,7 @@ export const activationLogic = kea([ id: ActivationTasks.SetUpReverseProxy, name: 'Set up a reverse proxy', description: 'Send your events from your own domain to avoid tracking blockers', - completed: false, + completed: hasReverseProxy || false, canSkip: true, skipped: skippedTasks.includes(ActivationTasks.SetUpReverseProxy), url: 'https://posthog.com/docs/advanced/proxy', diff --git a/frontend/src/lib/components/AnnotationsOverlay/annotationsOverlayLogic.test.ts b/frontend/src/lib/components/AnnotationsOverlay/annotationsOverlayLogic.test.ts index 02d59125c852f..b5ec3468d12f1 100644 --- a/frontend/src/lib/components/AnnotationsOverlay/annotationsOverlayLogic.test.ts +++ b/frontend/src/lib/components/AnnotationsOverlay/annotationsOverlayLogic.test.ts @@ -143,6 +143,7 @@ function useInsightMocks(interval: string = 'day', timezone: string = 'UTC'): vo [`/api/projects/:team_id/insights/${MOCK_INSIGHT_NUMERIC_ID}`]: () => { return [200, insight] }, + '/api/users/@me/': [200, {}], }, }) } @@ -162,6 +163,7 @@ function useAnnotationsMocks(): void { MOCK_ANNOTATION_PROJECT_SCOPED_FROM_INSIGHT_3, ], }, + '/api/users/@me/': [200, {}], }, }) } @@ -171,6 +173,7 @@ describe('annotationsOverlayLogic', () => { beforeEach(() => { useAnnotationsMocks() + initKeaTests() }) afterEach(() => { @@ -178,8 +181,6 @@ describe('annotationsOverlayLogic', () => { }) it('loads annotations on mount', async () => { - initKeaTests() - useInsightMocks() logic = annotationsOverlayLogic({ @@ -193,8 +194,6 @@ describe('annotationsOverlayLogic', () => { }) describe('relevantAnnotations', () => { - initKeaTests() - it('returns annotations scoped to the insight for a saved insight', async () => { useInsightMocks() @@ -224,8 +223,6 @@ describe('annotationsOverlayLogic', () => { }) it('returns annotations scoped to the project for a new insight', async () => { - initKeaTests() - useInsightMocks() logic = annotationsOverlayLogic({ @@ -250,8 +247,6 @@ describe('annotationsOverlayLogic', () => { }) it('excludes annotations that are outside of insight date range', async () => { - initKeaTests() - useInsightMocks() logic = annotationsOverlayLogic({ @@ -506,8 +501,6 @@ describe('annotationsOverlayLogic', () => { } it(`merges groups when one tick covers more than one date (UTC)`, async () => { - initKeaTests(true, MOCK_DEFAULT_TEAM) - useInsightMocks() logic = annotationsOverlayLogic({ @@ -572,8 +565,6 @@ describe('annotationsOverlayLogic', () => { }) it(`merges groups when one tick covers more than one hour (UTC)`, async () => { - initKeaTests(true, MOCK_DEFAULT_TEAM) - useInsightMocks('hour') logic = annotationsOverlayLogic({ diff --git a/frontend/src/lib/components/AuthorizedUrlList/authorizedUrlListLogic.test.ts b/frontend/src/lib/components/AuthorizedUrlList/authorizedUrlListLogic.test.ts index b21f9012925bb..772646e28882a 100644 --- a/frontend/src/lib/components/AuthorizedUrlList/authorizedUrlListLogic.test.ts +++ b/frontend/src/lib/components/AuthorizedUrlList/authorizedUrlListLogic.test.ts @@ -27,6 +27,9 @@ describe('the authorized urls list logic', () => { return [200, { result: ['result from api'] }] }, }, + patch: { + '/api/projects/:team': [200, {}], + }, }) initKeaTests() logic = authorizedUrlListLogic({ diff --git a/frontend/src/lib/components/BridgePage/BridgePage.scss b/frontend/src/lib/components/BridgePage/BridgePage.scss index a95676cd869fd..cbaa3daa9631c 100644 --- a/frontend/src/lib/components/BridgePage/BridgePage.scss +++ b/frontend/src/lib/components/BridgePage/BridgePage.scss @@ -23,7 +23,13 @@ } .BridgePage__content-wrapper { - max-width: 100%; + width: 100%; + max-width: 380px; + + @include screen($md) { + width: auto; + max-width: 100%; + } } .BridgePage__left-wrapper { diff --git a/frontend/src/lib/components/CustomerLogo.tsx b/frontend/src/lib/components/CustomerLogo.tsx new file mode 100644 index 0000000000000..659f739d1d7dc --- /dev/null +++ b/frontend/src/lib/components/CustomerLogo.tsx @@ -0,0 +1,23 @@ +interface CustomerProps { + image: string + alt: string + className?: string +} + +interface LogoProps { + src: string + alt: string + className?: string +} + +const Logo = ({ src, alt, className = '' }: LogoProps): JSX.Element => ( + {alt} +) + +export const CustomerLogo = ({ image, alt, className = '' }: CustomerProps): JSX.Element => { + return ( +
  • + +
  • + ) +} diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx index 644b01f74b063..d416c1e0502c3 100644 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx @@ -9,7 +9,6 @@ import { propertyFilterLogic } from 'lib/components/PropertyFilters/propertyFilt import { PropertyFilterInternalProps } from 'lib/components/PropertyFilters/types' import { isGroupPropertyFilter, - isPersonPropertyFilter, isPropertyFilterWithOperator, PROPERTY_FILTER_TYPE_TO_TAXONOMIC_FILTER_GROUP_TYPE, propertyFilterTypeToTaxonomicFilterType, @@ -64,7 +63,7 @@ export function TaxonomicPropertyFilter({ value, item ) => { - selectItem(taxonomicGroup, value, item) + selectItem(taxonomicGroup, value, item?.propertyFilterType) if ( taxonomicGroup.type === TaxonomicFilterGroupType.Cohorts || taxonomicGroup.type === TaxonomicFilterGroupType.HogQLExpression @@ -215,7 +214,6 @@ export function TaxonomicPropertyFilter({ value: newValue || null, operator: newOperator, type: filter?.type, - ...(isPersonPropertyFilter(filter) ? { table: filter?.table } : {}), ...(isGroupPropertyFilter(filter) ? { group_type_index: filter.group_type_index } : {}), diff --git a/frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts b/frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts index 48760e5ab6747..aa1a1ca685cc7 100644 --- a/frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts +++ b/frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts @@ -51,10 +51,14 @@ export const taxonomicPropertyFilterLogic = kea ({ + selectItem: ( + taxonomicGroup: TaxonomicFilterGroup, + propertyKey?: TaxonomicFilterValue, + itemPropertyFilterType?: PropertyFilterType + ) => ({ taxonomicGroup, propertyKey, - item, + itemPropertyFilterType, }), openDropdown: true, closeDropdown: true, @@ -89,8 +93,7 @@ export const taxonomicPropertyFilterLogic = kea ({ - selectItem: ({ taxonomicGroup, propertyKey, item }) => { - const itemPropertyFilterType = item?.propertyFilterType as PropertyFilterType + selectItem: ({ taxonomicGroup, propertyKey, itemPropertyFilterType }) => { const propertyType = itemPropertyFilterType ?? taxonomicFilterTypeToPropertyFilterType(taxonomicGroup.type) if (propertyKey && propertyType) { if (propertyType === PropertyFilterType.Cohort) { @@ -126,8 +129,8 @@ export const taxonomicPropertyFilterLogic = kea { + useMocks({ + post: { + '/api/projects/:team/query': () => [ + 200, + { + results, + }, + ], + }, + }) +} + +describe('reverseProxyCheckerLogic', () => { + let logic: ReturnType + + beforeEach(() => { + initKeaTests() + localStorage.clear() + logic = reverseProxyCheckerLogic() + }) + + afterEach(() => { + logic.unmount() + }) + + it('should not have a reverse proxy set - when no data', async () => { + useMockedValues([]) + + logic.mount() + await expectLogic(logic).toFinishAllListeners().toMatchValues({ + hasReverseProxy: false, + }) + }) + + it('should not have a reverse proxy set - when data with no lib_custom_api_host values', async () => { + useMockedValues(doesNotHaveReverseProxyValues) + + logic.mount() + await expectLogic(logic).toFinishAllListeners().toMatchValues({ + hasReverseProxy: false, + }) + }) + + it('should have a reverse proxy set', async () => { + useMockedValues(hasReverseProxyValues) + + logic.mount() + await expectLogic(logic).toFinishAllListeners().toMatchValues({ + hasReverseProxy: true, + }) + }) +}) diff --git a/frontend/src/lib/components/ReverseProxyChecker/reverseProxyCheckerLogic.ts b/frontend/src/lib/components/ReverseProxyChecker/reverseProxyCheckerLogic.ts new file mode 100644 index 0000000000000..6b945e5c94c48 --- /dev/null +++ b/frontend/src/lib/components/ReverseProxyChecker/reverseProxyCheckerLogic.ts @@ -0,0 +1,49 @@ +import { afterMount, kea, path, reducers } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' + +import { HogQLQuery, NodeKind } from '~/queries/schema' +import { hogql } from '~/queries/utils' + +import type { reverseProxyCheckerLogicType } from './reverseProxyCheckerLogicType' + +const CHECK_INTERVAL_MS = 1000 * 60 * 60 // 1 hour + +export const reverseProxyCheckerLogic = kea([ + path(['components', 'ReverseProxyChecker', 'reverseProxyCheckerLogic']), + loaders({ + hasReverseProxy: [ + false as boolean | null, + { + loadHasReverseProxy: async () => { + const query: HogQLQuery = { + kind: NodeKind.HogQLQuery, + query: hogql`SELECT properties.$lib_custom_api_host AS lib_custom_api_host + FROM events + WHERE timestamp >= now() - INTERVAL 1 DAY + AND timestamp <= now() + ORDER BY timestamp DESC + limit 10`, + } + + const res = await api.query(query) + return !!res.results?.find((x) => !!x[0]) + }, + }, + ], + }), + reducers({ + lastCheckedTimestamp: [ + 0, + { persist: true }, + { + loadHasReverseProxySuccess: () => Date.now(), + }, + ], + }), + afterMount(({ actions, values }) => { + if (values.lastCheckedTimestamp < Date.now() - CHECK_INTERVAL_MS) { + actions.loadHasReverseProxy() + } + }), +]) diff --git a/frontend/src/lib/components/Subscriptions/subscriptionLogic.test.ts b/frontend/src/lib/components/Subscriptions/subscriptionLogic.test.ts index 820e8eb7d9786..1adc197e2c03c 100644 --- a/frontend/src/lib/components/Subscriptions/subscriptionLogic.test.ts +++ b/frontend/src/lib/components/Subscriptions/subscriptionLogic.test.ts @@ -32,6 +32,7 @@ describe('subscriptionLogic', () => { useMocks({ get: { '/api/projects/:team/subscriptions/1': fixtureSubscriptionResponse(1), + '/api/projects/:team/integrations': { count: 0, results: [] }, }, }) initKeaTests() diff --git a/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx index b5904b8957056..cc3e727f7b10d 100644 --- a/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx +++ b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx @@ -28,8 +28,7 @@ import { cohortsModel } from '~/models/cohortsModel' import { dashboardsModel } from '~/models/dashboardsModel' import { groupPropertiesModel } from '~/models/groupPropertiesModel' import { groupsModel } from '~/models/groupsModel' -import { personPropertiesModel } from '~/models/personPropertiesModel' -import { updateListOfPropertyDefinitions } from '~/models/propertyDefinitionsModel' +import { updatePropertyDefinitions } from '~/models/propertyDefinitionsModel' import { AnyDataNode, DatabaseSchemaQueryResponseField, NodeKind } from '~/queries/schema' import { ActionType, @@ -77,7 +76,7 @@ export const taxonomicFilterLogic = kea([ props({} as TaxonomicFilterLogicProps), key((props) => `${props.taxonomicFilterLogicKey}`), path(['lib', 'components', 'TaxonomicFilter', 'taxonomicFilterLogic']), - connect((props: TaxonomicFilterLogicProps) => ({ + connect({ values: [ teamLogic, ['currentTeamId'], @@ -87,13 +86,8 @@ export const taxonomicFilterLogic = kea([ ['allGroupProperties'], dataWarehouseSceneLogic, ['externalTables'], - personPropertiesModel({ - propertyAllowList: props.propertyAllowList, - taxonomicFilterLogicKey: props.taxonomicFilterLogicKey, - }), - ['combinedPersonProperties'], ], - })), + }), actions(() => ({ moveUp: true, moveDown: true, @@ -171,7 +165,6 @@ export const taxonomicFilterLogic = kea([ s.metadataSource, s.excludedProperties, s.propertyAllowList, - s.taxonomicFilterLogicKey, ], ( teamId, @@ -181,8 +174,7 @@ export const taxonomicFilterLogic = kea([ schemaColumns, metadataSource, excludedProperties, - propertyAllowList, - taxonomicFilterLogicKey + propertyAllowList ): TaxonomicFilterGroup[] => { const groups: TaxonomicFilterGroup[] = [ { @@ -333,15 +325,14 @@ export const taxonomicFilterLogic = kea([ name: 'Person properties', searchPlaceholder: 'person properties', type: TaxonomicFilterGroupType.PersonProperties, - logic: personPropertiesModel({ propertyAllowList, taxonomicFilterLogicKey }), - value: 'combinedPersonProperties', + endpoint: combineUrl(`api/projects/${teamId}/property_definitions`, { + type: 'person', + properties: propertyAllowList?.[TaxonomicFilterGroupType.PersonProperties] + ? propertyAllowList[TaxonomicFilterGroupType.PersonProperties].join(',') + : undefined, + }).url, getName: (personProperty: PersonProperty) => personProperty.name, - getValue: (personProperty: PersonProperty) => { - if (personProperty.table) { - return personProperty.id - } - return personProperty.name - }, + getValue: (personProperty: PersonProperty) => personProperty.name, propertyAllowList: propertyAllowList?.[TaxonomicFilterGroupType.PersonProperties], ...propertyTaxonomicGroupProps(true), }, @@ -707,7 +698,14 @@ export const taxonomicFilterLogic = kea([ groupType === TaxonomicFilterGroupType.NumericalEventProperties) ) { const propertyDefinitions: PropertyDefinition[] = results.results as PropertyDefinition[] - updateListOfPropertyDefinitions(propertyDefinitions, groupType) + const apiType = groupType === TaxonomicFilterGroupType.PersonProperties ? 'person' : 'event' + const newPropertyDefinitions = Object.fromEntries( + propertyDefinitions.map((propertyDefinition) => [ + `${apiType}/${propertyDefinition.name}`, + propertyDefinition, + ]) + ) + updatePropertyDefinitions(newPropertyDefinitions) } }, })), diff --git a/frontend/src/lib/components/TaxonomicFilter/types.ts b/frontend/src/lib/components/TaxonomicFilter/types.ts index a3edff51c16f0..964847c6eacaf 100644 --- a/frontend/src/lib/components/TaxonomicFilter/types.ts +++ b/frontend/src/lib/components/TaxonomicFilter/types.ts @@ -1,5 +1,5 @@ import Fuse from 'fuse.js' -import { BuiltLogic, LogicWrapper } from 'kea' +import { LogicWrapper } from 'kea' import { DataWarehouseTableType } from 'scenes/data-warehouse/types' import { LocalFilter } from 'scenes/insights/filters/ActionFilter/entityFilterLogic' @@ -61,7 +61,7 @@ export interface TaxonomicFilterGroup { scopedEndpoint?: string expandLabel?: (props: { count: number; expandedCount: number }) => React.ReactNode options?: Record[] - logic?: LogicWrapper | BuiltLogic + logic?: LogicWrapper value?: string searchAlias?: string valuesEndpoint?: (key: string) => string diff --git a/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts b/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts index cc26d0eff45fc..ce53ba46d5db8 100644 --- a/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts +++ b/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts @@ -7,7 +7,7 @@ import { hogql } from '~/queries/utils' import type { versionCheckerLogicType } from './versionCheckerLogicType' -const CHECK_INTERVAL_MS = 1000 * 60 * 60 // 6 hour +const CHECK_INTERVAL_MS = 1000 * 60 * 60 * 6 // 6 hour export type SDKVersion = { version: string diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index f9d7a552e6be7..b4ef15a8bea8f 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -1,15 +1,23 @@ import { LemonSelectOptions } from '@posthog/lemon-ui' -import { ChartDisplayType, Region, SSOProvider } from '../types' +import { ChartDisplayCategory, ChartDisplayType, Region, SSOProvider } from '../types' + +// Sync with backend DISPLAY_TYPES_TO_CATEGORIES +export const DISPLAY_TYPES_TO_CATEGORIES: Record = { + [ChartDisplayType.ActionsLineGraph]: ChartDisplayCategory.TimeSeries, + [ChartDisplayType.ActionsBar]: ChartDisplayCategory.TimeSeries, + [ChartDisplayType.ActionsAreaGraph]: ChartDisplayCategory.TimeSeries, + [ChartDisplayType.ActionsLineGraphCumulative]: ChartDisplayCategory.CumulativeTimeSeries, + [ChartDisplayType.BoldNumber]: ChartDisplayCategory.TotalValue, + [ChartDisplayType.ActionsPie]: ChartDisplayCategory.TotalValue, + [ChartDisplayType.ActionsBarValue]: ChartDisplayCategory.TotalValue, + [ChartDisplayType.ActionsTable]: ChartDisplayCategory.TotalValue, + [ChartDisplayType.WorldMap]: ChartDisplayCategory.TotalValue, +} +export const NON_TIME_SERIES_DISPLAY_TYPES = Object.entries(DISPLAY_TYPES_TO_CATEGORIES) + .filter(([, category]) => category === ChartDisplayCategory.TotalValue) + .map(([displayType]) => displayType as ChartDisplayType) -/** Display types which don't allow grouping by unit of time. Sync with backend NON_TIME_SERIES_DISPLAY_TYPES. */ -export const NON_TIME_SERIES_DISPLAY_TYPES = [ - ChartDisplayType.ActionsTable, - ChartDisplayType.ActionsPie, - ChartDisplayType.ActionsBarValue, - ChartDisplayType.WorldMap, - ChartDisplayType.BoldNumber, -] /** Display types for which `breakdown` is hidden and ignored. Sync with backend NON_BREAKDOWN_DISPLAY_TYPES. */ export const NON_BREAKDOWN_DISPLAY_TYPES = [ChartDisplayType.BoldNumber] /** Display types which only work with a single series. */ @@ -149,7 +157,7 @@ export const FEATURE_FLAGS = { POSTHOG_3000_NAV: 'posthog-3000-nav', // owner: @Twixes HEDGEHOG_MODE: 'hedgehog-mode', // owner: @benjackwhite HEDGEHOG_MODE_DEBUG: 'hedgehog-mode-debug', // owner: @benjackwhite - GENERIC_SIGNUP_BENEFITS: 'generic-signup-benefits', // experiment, owner: @raquelmsmith + SIGNUP_BENEFITS: 'signup-benefits', // experiment, owner: @zlwaterfield WEB_ANALYTICS: 'web-analytics', // owner @robbie-c #team-web-analytics WEB_ANALYTICS_SAMPLING: 'web-analytics-sampling', // owner @robbie-c #team-web-analytics HIGH_FREQUENCY_BATCH_EXPORTS: 'high-frequency-batch-exports', // owner: @tomasfarias @@ -204,6 +212,7 @@ export const FEATURE_FLAGS = { AUDIT_LOGS_ACCESS: 'audit-logs-access', // owner: #team-growth SUBSCRIBE_FROM_PAYGATE: 'subscribe-from-paygate', // owner: #team-growth REVERSE_PROXY_ONBOARDING: 'reverse-proxy-onboarding', // owner: @zlwaterfield + SESSION_REPLAY_MOBILE_ONBOARDING: 'session-replay-mobile-onboarding', // owner: #team-replay } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/lib/customers/airbus.svg b/frontend/src/lib/customers/airbus.svg new file mode 100644 index 0000000000000..ff18cae1c8c0f --- /dev/null +++ b/frontend/src/lib/customers/airbus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/customers/hasura.svg b/frontend/src/lib/customers/hasura.svg new file mode 100644 index 0000000000000..1eb0373ecf1f4 --- /dev/null +++ b/frontend/src/lib/customers/hasura.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/customers/staples.svg b/frontend/src/lib/customers/staples.svg new file mode 100644 index 0000000000000..0e1ff76715798 --- /dev/null +++ b/frontend/src/lib/customers/staples.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/customers/y-combinator.svg b/frontend/src/lib/customers/y-combinator.svg new file mode 100644 index 0000000000000..1d19c5ff15d4a --- /dev/null +++ b/frontend/src/lib/customers/y-combinator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/lib/lemon-ui/LemonSelect/LemonSelect.tsx b/frontend/src/lib/lemon-ui/LemonSelect/LemonSelect.tsx index 49e6c6c59190c..8e06a932310ab 100644 --- a/frontend/src/lib/lemon-ui/LemonSelect/LemonSelect.tsx +++ b/frontend/src/lib/lemon-ui/LemonSelect/LemonSelect.tsx @@ -154,6 +154,7 @@ export function LemonSelect({ } : null } + tooltip={activeLeaf?.tooltip} {...buttonProps} > diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 3b4d5c2577a7e..e155aa67cf805 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -10,6 +10,7 @@ import { MOCK_PERSON_PROPERTIES, MOCK_SECOND_ORGANIZATION_MEMBER, } from 'lib/api.mock' +import { ResponseComposition, RestContext, RestRequest } from 'msw' import { getAvailableFeatures } from '~/mocks/features' import { SharingConfigurationType } from '~/types' @@ -25,6 +26,19 @@ export const toPaginatedResponse = (results: any[]): typeof EMPTY_PAGINATED_RESP previous: null, }) +// this really returns MaybePromise> +// but MSW doesn't export MaybePromise 🤷 +function posthogCORSResponse(req: RestRequest, res: ResponseComposition, ctx: RestContext): any { + return res( + ctx.status(200), + ctx.json('ok'), + // some of our tests try to make requests via posthog-js e.g. userLogic calls identify + // they have to have CORS allowed, or they pass but print noise to the console + ctx.set('Access-Control-Allow-Origin', req.referrer.length ? req.referrer : 'http://localhost'), + ctx.set('Access-Control-Allow-Credentials', 'true') + ) +} + export const defaultMocks: Mocks = { get: { '/api/projects/:team_id/activity_log/important_changes/': EMPTY_PAGINATED_RESPONSE, @@ -108,12 +122,13 @@ export const defaultMocks: Mocks = { }, }, post: { - 'https://us.i.posthog.com/e/': (): MockSignature => [200, 'ok'], - '/e/': (): MockSignature => [200, 'ok'], - 'https://us.i.posthog.com/decide/': (): MockSignature => [200, 'ok'], - '/decide/': (): MockSignature => [200, 'ok'], - 'https://us.i.posthog.com/engage/': (): MockSignature => [200, 'ok'], + 'https://us.i.posthog.com/e/': (req, res, ctx): MockSignature => posthogCORSResponse(req, res, ctx), + '/e/': (req, res, ctx): MockSignature => posthogCORSResponse(req, res, ctx), + 'https://us.i.posthog.com/decide/': (req, res, ctx): MockSignature => posthogCORSResponse(req, res, ctx), + '/decide/': (req, res, ctx): MockSignature => posthogCORSResponse(req, res, ctx), + 'https://us.i.posthog.com/engage/': (req, res, ctx): MockSignature => posthogCORSResponse(req, res, ctx), '/api/projects/:team_id/insights/:insight_id/viewed/': (): MockSignature => [201, null], + 'api/projects/:team_id/query': [200, { results: [] }], }, } export const handlers = mocksToHandlers(defaultMocks) diff --git a/frontend/src/models/personPropertiesModel.ts b/frontend/src/models/personPropertiesModel.ts deleted file mode 100644 index f319095c3ba81..0000000000000 --- a/frontend/src/models/personPropertiesModel.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { connect, events, kea, key, listeners, path, props, selectors } from 'kea' -import { loaders } from 'kea-loaders' -import { combineUrl, router } from 'kea-router' -import api from 'lib/api' -import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { dataWarehouseJoinsLogic } from 'scenes/data-warehouse/external/dataWarehouseJoinsLogic' -import { teamLogic } from 'scenes/teamLogic' - -import { updateListOfPropertyDefinitions } from '~/models/propertyDefinitionsModel' -import { PersonProperty, PropertyDefinition } from '~/types' - -import type { personPropertiesModelType } from './personPropertiesModelType' -import { PersonPropertiesModelProps } from './types' - -const WHITELISTED = ['/insights', '/events', '/sessions', '/dashboard', '/person'] - -export const personPropertiesModel = kea([ - props({} as PersonPropertiesModelProps), - path(['models', 'personPropertiesModel']), - key((props) => props.taxonomicFilterLogicKey), - connect({ - values: [ - teamLogic, - ['currentTeamId'], - dataWarehouseJoinsLogic, - ['columnsJoinedToPersons'], - featureFlagLogic, - ['featureFlags'], - ], - }), - loaders(({ values }) => ({ - personProperties: [ - [] as PersonProperty[], - { - loadPersonProperties: async () => { - const url = combineUrl(`api/projects/${values.currentTeamId}/property_definitions`, { - type: 'person', - properties: values.propertyAllowList?.[TaxonomicFilterGroupType.PersonProperties] - ? values.propertyAllowList[TaxonomicFilterGroupType.PersonProperties].join(',') - : undefined, - }).url - return (await api.get(url)).results - }, - }, - ], - })), - listeners(() => ({ - loadPersonPropertiesSuccess: ({ personProperties }) => { - updateListOfPropertyDefinitions( - personProperties as PropertyDefinition[], - TaxonomicFilterGroupType.PersonProperties - ) - }, - })), - selectors(() => ({ - combinedPersonProperties: [ - (s) => [s.personProperties, s.columnsJoinedToPersons, s.featureFlags], - (personProperties, columnsJoinedToPersons, featureFlags) => { - // Hack to make sure person properties only show data warehouse in specific instances for now - if ( - featureFlags[FEATURE_FLAGS.DATA_WAREHOUSE] && - WHITELISTED.some((path) => router.values.location.pathname.includes(path)) - ) { - return [...personProperties, ...columnsJoinedToPersons] - } - return [...personProperties] - }, - ], - propertyAllowList: [ - () => [(_, props) => props.propertyAllowList], - (propertyAllowList) => propertyAllowList as PersonPropertiesModelProps['propertyAllowList'], - ], - })), - events(({ actions }) => ({ - afterMount: actions.loadPersonProperties, - })), -]) diff --git a/frontend/src/models/propertyDefinitionsModel.ts b/frontend/src/models/propertyDefinitionsModel.ts index b7bba27261714..338e60a5e956f 100644 --- a/frontend/src/models/propertyDefinitionsModel.ts +++ b/frontend/src/models/propertyDefinitionsModel.ts @@ -1,6 +1,6 @@ import { actions, kea, listeners, path, reducers, selectors } from 'kea' import api, { ApiMethodOptions } from 'lib/api' -import { TaxonomicFilterGroupType, TaxonomicFilterValue } from 'lib/components/TaxonomicFilter/types' +import { TaxonomicFilterValue } from 'lib/components/TaxonomicFilter/types' import { dayjs } from 'lib/dayjs' import { captureTimeToSeeData } from 'lib/internalMetrics' import { colonDelimitedDuration } from 'lib/utils' @@ -46,18 +46,6 @@ export const updatePropertyDefinitions = (propertyDefinitions: PropertyDefinitio propertyDefinitionsModel.findMounted()?.actions.updatePropertyDefinitions(propertyDefinitions) } -export const updateListOfPropertyDefinitions = ( - results: PropertyDefinition[], - groupType: TaxonomicFilterGroupType -): void => { - const propertyDefinitions: PropertyDefinition[] = results - const apiType = groupType === TaxonomicFilterGroupType.PersonProperties ? 'person' : 'event' - const newPropertyDefinitions = Object.fromEntries( - propertyDefinitions.map((propertyDefinition) => [`${apiType}/${propertyDefinition.name}`, propertyDefinition]) - ) - updatePropertyDefinitions(newPropertyDefinitions) -} - export type PropValue = { id?: number name?: string | boolean diff --git a/frontend/src/models/types.ts b/frontend/src/models/types.ts deleted file mode 100644 index b3f4c22f60d4d..0000000000000 --- a/frontend/src/models/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' - -export interface PersonPropertiesModelProps { - propertyAllowList?: { [key in TaxonomicFilterGroupType]?: string[] } // only return properties in this list, currently only working for EventProperties and PersonProperties - taxonomicFilterLogicKey: string -} diff --git a/frontend/src/queries/nodes/DataNode/dataNodeLogic.queryCancellation.test.ts b/frontend/src/queries/nodes/DataNode/dataNodeLogic.queryCancellation.test.ts index 55a417bdff91e..ead4227a3c793 100644 --- a/frontend/src/queries/nodes/DataNode/dataNodeLogic.queryCancellation.test.ts +++ b/frontend/src/queries/nodes/DataNode/dataNodeLogic.queryCancellation.test.ts @@ -39,6 +39,9 @@ describe('dataNodeLogic - query cancellation', () => { ) }, }, + delete: { + '/api/projects/:team_id/query/uuid-first': [200, {}], + }, }) }) afterEach(() => logic?.unmount()) diff --git a/frontend/src/queries/query.ts b/frontend/src/queries/query.ts index f866b2f336d31..e2149303733b6 100644 --- a/frontend/src/queries/query.ts +++ b/frontend/src/queries/query.ts @@ -222,7 +222,7 @@ export async function query( if (hogQLInsightsLiveCompareEnabled) { const legacyFunction = (): any => { try { - return legacyUrl ? fetchLegacyUrl : fetchLegacyInsights + return legacyUrl ? fetchLegacyUrl() : fetchLegacyInsights() } catch (e) { console.error('Error fetching legacy insights', e) } @@ -258,11 +258,17 @@ export async function query( res2 = res2[0]?.people.map((n: any) => n.id) res1 = res1.map((n: any) => n[0].id) // Sort, since the order of the results is not guaranteed + const bv = (v: any): string => + [null, 'null', 'none', '9007199254740990', 9007199254740990].includes(v) + ? '$$_posthog_breakdown_null_$$' + : ['Other', '9007199254740991', 9007199254740991].includes(v) + ? '$$_posthog_breakdown_other_$$' + : String(v) res1.sort((a: any, b: any) => - (a.breakdown_value ?? a.label ?? a).localeCompare(b.breakdown_value ?? b.label ?? b) + bv(a.breakdown_value ?? a.label ?? a).localeCompare(bv(b.breakdown_value ?? b.label ?? b)) ) res2.sort((a: any, b: any) => - (a.breakdown_value ?? a.label ?? a).localeCompare(b.breakdown_value ?? b.label ?? b) + bv(a.breakdown_value ?? a.label ?? a).localeCompare(bv(b.breakdown_value ?? b.label ?? b)) ) } diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 400ef8d1774e3..eeac4bb951269 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -501,14 +501,14 @@ "ChartDisplayType": { "enum": [ "ActionsLineGraph", - "ActionsLineGraphCumulative", + "ActionsBar", "ActionsAreaGraph", - "ActionsTable", + "ActionsLineGraphCumulative", + "BoldNumber", "ActionsPie", - "ActionsBar", "ActionsBarValue", - "WorldMap", - "BoldNumber" + "ActionsTable", + "WorldMap" ], "type": "string" }, @@ -3460,9 +3460,6 @@ "operator": { "$ref": "#/definitions/PropertyOperator" }, - "table": { - "type": "string" - }, "type": { "const": "person", "description": "Person properties", diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 9bd04dd3c62a9..fc45ff6ecadcb 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -4,6 +4,7 @@ import { Breakdown, BreakdownKeyType, BreakdownType, + ChartDisplayCategory, ChartDisplayType, CountPerActorMathType, EventPropertyFilter, @@ -26,6 +27,8 @@ import { TrendsFilterType, } from '~/types' +export { ChartDisplayCategory } + // Type alias for number to be reflected as integer in json-schema. /** @asType integer */ type integer = number diff --git a/frontend/src/scenes/authentication/signup/SignupContainer.tsx b/frontend/src/scenes/authentication/signup/SignupContainer.tsx index fa5fbc8d293c3..0544f035d60a1 100644 --- a/frontend/src/scenes/authentication/signup/SignupContainer.tsx +++ b/frontend/src/scenes/authentication/signup/SignupContainer.tsx @@ -2,15 +2,21 @@ import { IconCheckCircle } from '@posthog/icons' import { useValues } from 'kea' import { router } from 'kea-router' import { BridgePage } from 'lib/components/BridgePage/BridgePage' +import { CustomerLogo } from 'lib/components/CustomerLogo' import { CLOUD_HOSTNAMES, FEATURE_FLAGS } from 'lib/constants' import { Link } from 'lib/lemon-ui/Link' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic' +import { ReactNode } from 'react' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { SceneExport } from 'scenes/sceneTypes' import { userLogic } from 'scenes/userLogic' import { Region } from '~/types' +import airbus from '../../../lib/customers/airbus.svg' +import hasura from '../../../lib/customers/hasura.svg' +import staples from '../../../lib/customers/staples.svg' +import yCombinator from '../../../lib/customers/y-combinator.svg' import { SignupForm } from './signupForm/SignupForm' export const scene: SceneExport = { @@ -30,13 +36,13 @@ export function SignupContainer(): JSX.Element | null { +
    {footerHighlights[preflight?.cloud ? 'cloud' : 'selfHosted'].map((val, idx) => ( - +

    {val} - +

    ))} - +
    } sideLogo leftContainerContent={} @@ -46,51 +52,82 @@ export function SignupContainer(): JSX.Element | null { ) : null } +type ProductBenefit = { + benefit: string + description: string | ReactNode +} + +const getProductBenefits = (featureFlags: FeatureFlagsSet): ProductBenefit[] => { + const signupBenefitsFlag = featureFlags[FEATURE_FLAGS.SIGNUP_BENEFITS] + switch (signupBenefitsFlag) { + case 'generic-language': + return [ + { + benefit: 'Free usage every month - even on paid plans', + description: '1M free events, 5K free session recordings, and more. Every month. Forever.', + }, + { + benefit: 'Start collecting data immediately', + description: 'Integrate with developer-friendly APIs or a low-code web snippet.', + }, + { + benefit: 'Join industry leaders that run on PostHog', + description: + 'Airbus, Hasura, Y Combinator, Staples, and thousands more trust PostHog as their Product OS.', + }, + ] + case 'logos': + return [ + { + benefit: '1M events free every month', + description: 'Product analytics, feature flags, experiments, and more.', + }, + { + benefit: 'Start collecting events immediately', + description: 'Integrate with developer-friendly APIs or use our easy autocapture script.', + }, + { + benefit: 'Join industry leaders that run on PostHog', + description: ( +
    + {[airbus, hasura, yCombinator, staples].map((company, i) => ( + + + + ))} +
    + ), + }, + ] + default: + return [ + { + benefit: 'Free for 1M events every month', + description: 'Product analytics, feature flags, experiments, and more.', + }, + { + benefit: 'Start collecting events immediately', + description: 'Integrate with developer-friendly APIs or use our easy autocapture script.', + }, + { + benefit: 'Join industry leaders that run on PostHog', + description: + 'Airbus, Hasura, Y Combinator, Staples, and thousands more trust PostHog as their Product OS.', + }, + ] + } +} + export function SignupLeftContainer(): JSX.Element { const { preflight } = useValues(preflightLogic) const { featureFlags } = useValues(featureFlagLogic) - const showGenericSignupBenefits: boolean = featureFlags[FEATURE_FLAGS.GENERIC_SIGNUP_BENEFITS] === 'test' - const getRegionUrl = (region: string): string => { const { pathname, search, hash } = router.values.currentLocation return `https://${CLOUD_HOSTNAMES[region]}${pathname}${search}${hash}` } - const productBenefits: { - benefit: string - description: string - }[] = showGenericSignupBenefits - ? [ - { - benefit: 'Free usage every month - even on paid plans', - description: '1M free events, 5K free session recordings, and more. Every month. Forever.', - }, - { - benefit: 'Start collecting data immediately', - description: 'Integrate with developer-friendly APIs or low-code web snippet.', - }, - { - benefit: 'Join industry leaders that run on PostHog', - description: - 'ClickHouse, Airbus, Hasura, Y Combinator, and thousands more trust PostHog as their Product OS.', - }, - ] - : [ - { - benefit: 'Free for 1M events every month', - description: 'Product analytics, feature flags, experiments, and more.', - }, - { - benefit: 'Start collecting events immediately', - description: 'Integrate with developer-friendly APIs or use our easy autocapture script.', - }, - { - benefit: 'Join industry leaders that run on PostHog', - description: - 'ClickHouse, Airbus, Hasura, Y Combinator, and thousands more trust PostHog as their Product OS.', - }, - ] + const productBenefits = getProductBenefits(featureFlags) return ( <> diff --git a/frontend/src/scenes/data-management/events/DefinitionHeader.tsx b/frontend/src/scenes/data-management/events/DefinitionHeader.tsx index 5dcbc8c3604d1..9ecf8b43ce2ed 100644 --- a/frontend/src/scenes/data-management/events/DefinitionHeader.tsx +++ b/frontend/src/scenes/data-management/events/DefinitionHeader.tsx @@ -1,4 +1,4 @@ -import { IconBadge, IconBolt, IconCursor, IconEye, IconLeave, IconList, IconLogomark, IconServer } from '@posthog/icons' +import { IconBadge, IconBolt, IconCursor, IconEye, IconLeave, IconList, IconLogomark } from '@posthog/icons' import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { IconSelectAll } from 'lib/lemon-ui/icons' @@ -24,14 +24,6 @@ export function getPropertyDefinitionIcon(definition: PropertyDefinition): JSX.E ) } - if (definition.table) { - return ( - - - - ) - } - return ( diff --git a/frontend/src/scenes/data-warehouse/external/dataWarehouseJoinsLogic.ts b/frontend/src/scenes/data-warehouse/external/dataWarehouseJoinsLogic.ts index 6c6a3af715664..b5f493b2d7f17 100644 --- a/frontend/src/scenes/data-warehouse/external/dataWarehouseJoinsLogic.ts +++ b/frontend/src/scenes/data-warehouse/external/dataWarehouseJoinsLogic.ts @@ -1,19 +1,13 @@ -import { afterMount, connect, kea, path, selectors } from 'kea' +import { afterMount, kea, path } from 'kea' import { loaders } from 'kea-loaders' import api from 'lib/api' -import { capitalizeFirstLetter } from 'lib/utils' -import { DatabaseSchemaQueryResponseField } from '~/queries/schema' -import { DataWarehouseViewLink, PropertyDefinition, PropertyType } from '~/types' +import { DataWarehouseViewLink } from '~/types' import type { dataWarehouseJoinsLogicType } from './dataWarehouseJoinsLogicType' -import { dataWarehouseSceneLogic } from './dataWarehouseSceneLogic' export const dataWarehouseJoinsLogic = kea([ path(['scenes', 'data-warehouse', 'external', 'dataWarehouseJoinsLogic']), - connect(() => ({ - values: [dataWarehouseSceneLogic, ['externalTablesMap']], - })), loaders({ joins: [ [] as DataWarehouseViewLink[], @@ -25,40 +19,6 @@ export const dataWarehouseJoinsLogic = kea([ }, ], }), - selectors({ - personTableJoins: [(s) => [s.joins], (joins) => joins.filter((join) => join.source_table_name === 'persons')], - tablesJoinedToPersons: [ - (s) => [s.externalTablesMap, s.personTableJoins], - (externalTablesMap, personTableJoins) => { - return personTableJoins.map((join: DataWarehouseViewLink) => { - // valid join should have a joining table name - const table = externalTablesMap[join.joining_table_name as string] - return { - table, - join, - } - }) - }, - ], - columnsJoinedToPersons: [ - (s) => [s.tablesJoinedToPersons], - (tablesJoinedToPersons) => { - return tablesJoinedToPersons.reduce((acc, { table, join }) => { - if (table) { - acc.push( - ...table.columns.map((column: DatabaseSchemaQueryResponseField) => ({ - id: column.key, - name: join.field_name + ': ' + column.key, - table: join.field_name, - property_type: capitalizeFirstLetter(column.type) as PropertyType, - })) - ) - } - return acc - }, [] as PropertyDefinition[]) - }, - ], - }), afterMount(({ actions }) => { actions.loadJoins() }), diff --git a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts index b88946646be5c..0ac26e5540b5e 100644 --- a/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts +++ b/frontend/src/scenes/funnels/funnelCorrelationFeedbackLogic.test.ts @@ -1,6 +1,7 @@ import { expectLogic } from 'kea-test-utils' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import posthog from 'posthog-js' +import { teamLogic } from 'scenes/teamLogic' import { useAvailableFeatures } from '~/mocks/features' import { initKeaTests } from '~/test/init' @@ -14,6 +15,7 @@ describe('funnelCorrelationFeedbackLogic', () => { beforeEach(() => { useAvailableFeatures([AvailableFeature.CORRELATION_ANALYSIS]) initKeaTests(false) + teamLogic.mount() }) const defaultProps: InsightLogicProps = { diff --git a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx index b27d271f53d24..8ebb237640060 100644 --- a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx +++ b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx @@ -7,13 +7,22 @@ import { IconPlusSmall } from '@posthog/icons' import clsx from 'clsx' import { BindLogic, useActions, useValues } from 'kea' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { DISPLAY_TYPES_TO_CATEGORIES as DISPLAY_TYPES_TO_CATEGORY } from 'lib/constants' import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton' import { verticalSortableListCollisionDetection } from 'lib/sortable' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import React, { useEffect } from 'react' import { RenameModal } from 'scenes/insights/filters/ActionFilter/RenameModal' +import { isTrendsFilter } from 'scenes/insights/sharedUtils' -import { ActionFilter as ActionFilterType, FilterType, FunnelExclusionLegacy, InsightType, Optional } from '~/types' +import { + ActionFilter as ActionFilterType, + ChartDisplayType, + FilterType, + FunnelExclusionLegacy, + InsightType, + Optional, +} from '~/types' import { teamLogic } from '../../../teamLogic' import { ActionFilterRow, MathAvailability } from './ActionFilterRow/ActionFilterRow' @@ -147,6 +156,9 @@ export const ActionFilter = React.forwardRef( mathAvailability, customRowSuffix, hasBreakdown: !!filters.breakdown, + trendsDisplayCategory: isTrendsFilter(filters) + ? DISPLAY_TYPES_TO_CATEGORY[filters.display || ChartDisplayType.ActionsLineGraph] + : null, actionsTaxonomicGroupTypes, propertiesTaxonomicGroupTypes, propertyFiltersPopover, diff --git a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx index bca5a483baf48..0cb3eaeb086b3 100644 --- a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx +++ b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx @@ -3,7 +3,7 @@ import './ActionFilterRow.scss' import { DraggableSyntheticListeners } from '@dnd-kit/core' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { IconCopy, IconFilter, IconPencil, IconTrash } from '@posthog/icons' +import { IconCopy, IconFilter, IconPencil, IconTrash, IconWarning } from '@posthog/icons' import { LemonSelect, LemonSelectOption, LemonSelectOptions } from '@posthog/lemon-ui' import { BuiltLogic, useActions, useValues } from 'kea' import { EntityFilterInfo } from 'lib/components/EntityFilterInfo' @@ -39,6 +39,7 @@ import { ActionFilter, ActionFilter as ActionFilterType, BaseMathType, + ChartDisplayCategory, CountPerActorMathType, EntityType, EntityTypes, @@ -115,6 +116,7 @@ export interface ActionFilterRowProps { renameRowButton, deleteButton, }: Record) => JSX.Element // build your own row given these components + trendsDisplayCategory: ChartDisplayCategory | null } export function ActionFilterRow({ @@ -142,6 +144,7 @@ export function ActionFilterRow({ disabled = false, readOnly = false, renderRow, + trendsDisplayCategory, }: ActionFilterRowProps): JSX.Element { const { entityFilterVisible } = useValues(logic) const { @@ -377,6 +380,7 @@ export function ActionFilterRow({ disabled={readOnly} style={{ maxWidth: '100%', width: 'initial' }} mathAvailability={mathAvailability} + trendsDisplayCategory={trendsDisplayCategory} /> {mathDefinitions[math || BaseMathType.TotalCount]?.category === MathCategory.PropertyValue && ( @@ -514,6 +518,7 @@ interface MathSelectorProps { disabled?: boolean disabledReason?: string onMathSelect: (index: number, value: any) => any + trendsDisplayCategory: ChartDisplayCategory | null style?: React.CSSProperties } @@ -525,11 +530,14 @@ function isCountPerActorMath(math: string | undefined): math is CountPerActorMat return !!math && math in COUNT_PER_ACTOR_MATH_DEFINITIONS } +const TRAILING_MATH_TYPES = new Set([BaseMathType.WeeklyActiveUsers, BaseMathType.MonthlyActiveUsers]) + function useMathSelectorOptions({ math, index, mathAvailability, onMathSelect, + trendsDisplayCategory, }: MathSelectorProps): LemonSelectOptions { const mountedInsightDataLogic = insightDataLogic.findMounted() const query = mountedInsightDataLogic?.values?.query @@ -550,19 +558,33 @@ function useMathSelectorOptions({ mathAvailability != MathAvailability.ActorsOnly ? staticMathDefinitions : staticActorsOnlyMathDefinitions ) .filter(([key]) => { - if (!isStickiness) { - return true + if (isStickiness) { + // Remove WAU and MAU from stickiness insights + return !TRAILING_MATH_TYPES.has(key) + } + return true + }) + .map(([key, definition]) => { + const shouldWarnAboutTrailingMath = + TRAILING_MATH_TYPES.has(key) && trendsDisplayCategory === ChartDisplayCategory.TotalValue + return { + value: key, + icon: shouldWarnAboutTrailingMath ? : undefined, + label: definition.name, + tooltip: !shouldWarnAboutTrailingMath ? ( + definition.description + ) : ( + <> +

    {definition.description}

    + + In total value insights, it's usually not clear what date range "{definition.name}" refers + to. For full clarity, we recommend using "Unique users" here instead. + + + ), + 'data-attr': `math-${key}-${index}`, } - - // Remove WAU and MAU from stickiness insights - return key !== BaseMathType.WeeklyActiveUsers && key !== BaseMathType.MonthlyActiveUsers }) - .map(([key, definition]) => ({ - value: key, - label: definition.name, - tooltip: definition.description, - 'data-attr': `math-${key}-${index}`, - })) if (mathAvailability !== MathAvailability.ActorsOnly) { options.splice(1, 0, { @@ -580,7 +602,6 @@ function useMathSelectorOptions({ options={Object.entries(COUNT_PER_ACTOR_MATH_DEFINITIONS).map(([key, definition]) => ({ value: key, label: definition.shortName, - tooltip: definition.description, 'data-attr': `math-${key}-${index}`, }))} onClick={(e) => e.stopPropagation()} diff --git a/frontend/src/scenes/insights/insightVizDataLogic.test.ts b/frontend/src/scenes/insights/insightVizDataLogic.test.ts index d1de2cd7b8af5..a0a535c7e1686 100644 --- a/frontend/src/scenes/insights/insightVizDataLogic.test.ts +++ b/frontend/src/scenes/insights/insightVizDataLogic.test.ts @@ -23,6 +23,7 @@ describe('insightVizDataLogic', () => { useMocks({ get: { '/api/projects/:team_id/insights/trend': [], + '/api/projects/:team_id/insights/': { results: [{}] }, }, }) initKeaTests() diff --git a/frontend/src/scenes/notebooks/Nodes/utils.test.tsx b/frontend/src/scenes/notebooks/Nodes/utils.test.tsx index af46f229b2cd8..09cfe7c1ceebd 100644 --- a/frontend/src/scenes/notebooks/Nodes/utils.test.tsx +++ b/frontend/src/scenes/notebooks/Nodes/utils.test.tsx @@ -1,6 +1,6 @@ import { NodeViewProps } from '@tiptap/core' import { useSyncedAttributes } from './utils' -import { renderHook, act } from '@testing-library/react-hooks' +import { renderHook, act } from '@testing-library/react' describe('notebook node utils', () => { jest.useFakeTimers() diff --git a/frontend/src/scenes/onboarding/Onboarding.tsx b/frontend/src/scenes/onboarding/Onboarding.tsx index 52efdd5d47f0a..adcd6d3476b4d 100644 --- a/frontend/src/scenes/onboarding/Onboarding.tsx +++ b/frontend/src/scenes/onboarding/Onboarding.tsx @@ -1,11 +1,13 @@ import { useActions, useValues } from 'kea' -import { SESSION_REPLAY_MINIMUM_DURATION_OPTIONS } from 'lib/constants' +import { FEATURE_FLAGS, SESSION_REPLAY_MINIMUM_DURATION_OPTIONS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { useEffect, useState } from 'react' +import { AndroidInstructions } from 'scenes/onboarding/sdks/session-replay' import { SceneExport } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' import { userLogic } from 'scenes/userLogic' -import { AvailableFeature, ProductKey } from '~/types' +import { AvailableFeature, ProductKey, SDKKey } from '~/types' import { OnboardingBillingStep } from './OnboardingBillingStep' import { OnboardingInviteTeammates } from './OnboardingInviteTeammates' @@ -108,6 +110,9 @@ const SessionReplayOnboarding = (): JSX.Element => { const { hasAvailableFeature } = useValues(userLogic) const { currentTeam } = useValues(teamLogic) + const { featureFlags } = useValues(featureFlagLogic) + const hasAndroidOnBoarding = !!featureFlags[FEATURE_FLAGS.SESSION_REPLAY_MOBILE_ONBOARDING] + const configOptions: ProductConfigOption[] = [ { type: 'toggle', @@ -139,11 +144,16 @@ const SessionReplayOnboarding = (): JSX.Element => { }) } + const sdkInstructionMap = SessionReplaySDKInstructions + if (hasAndroidOnBoarding) { + sdkInstructionMap[SDKKey.ANDROID] = AndroidInstructions + } + return ( @@ -151,6 +161,7 @@ const SessionReplayOnboarding = (): JSX.Element => { ) } + const FeatureFlagsOnboarding = (): JSX.Element => { return ( diff --git a/frontend/src/scenes/onboarding/onboardingLogic.tsx b/frontend/src/scenes/onboarding/onboardingLogic.tsx index 4c8b35542109e..b53b14afc6b62 100644 --- a/frontend/src/scenes/onboarding/onboardingLogic.tsx +++ b/frontend/src/scenes/onboarding/onboardingLogic.tsx @@ -316,9 +316,9 @@ export const onboardingLogic = kea([ actionToUrl(({ values }) => ({ setStepKey: ({ stepKey }) => { if (stepKey) { - return [`/onboarding/${values.productKey}`, { step: stepKey }] + return [`/onboarding/${values.productKey}`, { ...router.values.searchParams, step: stepKey }] } else { - return [`/onboarding/${values.productKey}`] + return [`/onboarding/${values.productKey}`, router.values.searchParams] } }, goToNextStep: () => { @@ -327,9 +327,12 @@ export const onboardingLogic = kea([ ) const nextStep = values.allOnboardingSteps[currentStepIndex + 1] if (nextStep) { - return [`/onboarding/${values.productKey}`, { step: nextStep.props.stepKey }] + return [ + `/onboarding/${values.productKey}`, + { ...router.values.searchParams, step: nextStep.props.stepKey }, + ] } else { - return [`/onboarding/${values.productKey}`] + return [`/onboarding/${values.productKey}`, router.values.searchParams] } }, goToPreviousStep: () => { @@ -338,9 +341,12 @@ export const onboardingLogic = kea([ ) const previousStep = values.allOnboardingSteps[currentStepIndex - 1] if (previousStep) { - return [`/onboarding/${values.productKey}`, { step: previousStep.props.stepKey }] + return [ + `/onboarding/${values.productKey}`, + { ...router.values.searchParams, step: previousStep.props.stepKey }, + ] } else { - return [`/onboarding/${values.productKey}`] + return [`/onboarding/${values.productKey}`, router.values.searchParams] } }, updateCurrentTeamSuccess(val) { diff --git a/frontend/src/scenes/onboarding/sdks/SDKs.tsx b/frontend/src/scenes/onboarding/sdks/SDKs.tsx index 33555a1f17ca9..610caee92ee4f 100644 --- a/frontend/src/scenes/onboarding/sdks/SDKs.tsx +++ b/frontend/src/scenes/onboarding/sdks/SDKs.tsx @@ -118,6 +118,7 @@ export function SDKs({ {sdks?.map((sdk) => ( setSelectedSDK(sdk) : undefined} fullWidth diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx index 365f8685a8a74..b6a1fb3c9520f 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx @@ -1,4 +1,12 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { FlaggedFeature } from 'lib/components/FlaggedFeature' +import { FEATURE_FLAGS } from 'lib/constants' +import { LemonTag } from 'lib/lemon-ui/LemonTag' +import { Link } from 'lib/lemon-ui/Link' +import { OnboardingStepKey } from 'scenes/onboarding/onboardingLogic' +import { urls } from 'scenes/urls' + +import { SDKKey } from '~/types' import { SDKInstallAndroidInstructions } from '../sdk-install-instructions' @@ -6,12 +14,31 @@ function AndroidCaptureSnippet(): JSX.Element { return {`PostHog.capture(event = "test-event")`} } +function AdvertiseAndroidReplay(): JSX.Element { + return ( +
    +

    + Session Replay for Android NEW +

    +
    + Session replay is now in beta for Android.{' '} + + Learn how to set it up + +
    +
    + ) +} + export function ProductAnalyticsAndroidInstructions(): JSX.Element { return ( <>

    Send an Event

    + + + ) } diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/android.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/android.tsx index ff740be34f4fd..103a87f183508 100644 --- a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/android.tsx +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/android.tsx @@ -1,8 +1,14 @@ +import { Link } from '@posthog/lemon-ui' import { useValues } from 'kea' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { apiHostOrigin } from 'lib/utils/apiHost' import { teamLogic } from 'scenes/teamLogic' +export interface AndroidSetupProps { + includeReplay?: boolean +} + function AndroidInstallSnippet(): JSX.Element { return ( @@ -13,7 +19,7 @@ function AndroidInstallSnippet(): JSX.Element { ) } -function AndroidSetupSnippet(): JSX.Element { +function AndroidSetupSnippet({ includeReplay }: AndroidSetupProps): JSX.Element { const { currentTeam } = useValues(teamLogic) return ( @@ -33,6 +39,18 @@ function AndroidSetupSnippet(): JSX.Element { apiKey = POSTHOG_API_KEY, host = POSTHOG_HOST ) + ${ + includeReplay + ? ` + // check https://posthog.com/docs/session-replay/mobile#installation + // for more config and to learn about how we capture sessions on mobile + // and what to expect + config.sessionReplay = true + // choose whether to mask images or text + config.sessionReplayConfig.maskAllImages = false + config.sessionReplayConfig.maskAllTextInputs = true` + : '' + } // Setup PostHog with the given Context and Config PostHogAndroid.setup(this, config) @@ -41,13 +59,24 @@ function AndroidSetupSnippet(): JSX.Element { ) } -export function SDKInstallAndroidInstructions(): JSX.Element { +export function SDKInstallAndroidInstructions(props: AndroidSetupProps): JSX.Element { return ( <> + {props.includeReplay ? ( + + 🚧 NOTE: Mobile recording is + currently in beta. We are keen to gather as much feedback as possible so if you try this out please + let us know. You can send feedback via the{' '} + + in-app support panel + {' '} + or one of our other support options. + + ) : null}

    Install

    Configure

    - + ) } diff --git a/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx b/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx index df4a13d8adaf1..a46984ee8f897 100644 --- a/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx +++ b/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx @@ -1,5 +1,6 @@ import { actions, afterMount, connect, events, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' +import { urlToAction } from 'kea-router' import api from 'lib/api' import { LemonSelectOptions } from 'lib/lemon-ui/LemonSelect/LemonSelect' @@ -11,7 +12,7 @@ import { onboardingLogic } from '../onboardingLogic' import { allSDKs } from './allSDKs' import type { sdksLogicType } from './sdksLogicType' -/* +/* To add SDK instructions for your product: 1. If needed, add a new ProductKey enum value in ~/types.ts 2. Create a folder in this directory for your product @@ -118,14 +119,16 @@ export const sdksLogic = kea([ loadSnippetEvents: async () => { const query: HogQLQuery = { kind: NodeKind.HogQLQuery, - query: hogql`SELECT properties.$lib_version AS lib_version, max(timestamp) AS latest_timestamp, count(lib_version) as count - FROM events - WHERE timestamp >= now() - INTERVAL 3 DAY - AND timestamp <= now() - AND properties.$lib = 'web' - GROUP BY lib_version - ORDER BY latest_timestamp DESC - limit 10`, + query: hogql`SELECT properties.$lib_version AS lib_version, + max(timestamp) AS latest_timestamp, + count(lib_version) as count + FROM events + WHERE timestamp >= now() - INTERVAL 3 DAY + AND timestamp <= now() + AND properties.$lib = 'web' + GROUP BY lib_version + ORDER BY latest_timestamp DESC + limit 10`, } const res = await api.query(query) @@ -188,4 +191,12 @@ export const sdksLogic = kea([ afterMount(({ actions }) => { actions.loadSnippetEvents() }), + urlToAction(({ actions }) => ({ + '/onboarding/:productKey': (_productKey, { sdk }) => { + const matchedSDK = allSDKs.find((s) => s.key === sdk) + if (matchedSDK) { + actions.setSelectedSDK(matchedSDK) + } + }, + })), ]) diff --git a/frontend/src/scenes/onboarding/sdks/session-replay/SessionReplaySDKInstructions.tsx b/frontend/src/scenes/onboarding/sdks/session-replay/SessionReplaySDKInstructions.tsx index 7e43a06b7faba..16db14dbd1d85 100644 --- a/frontend/src/scenes/onboarding/sdks/session-replay/SessionReplaySDKInstructions.tsx +++ b/frontend/src/scenes/onboarding/sdks/session-replay/SessionReplaySDKInstructions.tsx @@ -7,4 +7,6 @@ export const SessionReplaySDKInstructions: SDKInstructionsMap = { [SDKKey.HTML_SNIPPET]: HTMLSnippetInstructions, [SDKKey.NEXT_JS]: NextJSInstructions, [SDKKey.REACT]: ReactInstructions, + // added by feature flag in Onboarding.tsx until released + //[SDKKey.ANDROID]: AndroidInstructions, } diff --git a/frontend/src/scenes/onboarding/sdks/session-replay/android.tsx b/frontend/src/scenes/onboarding/sdks/session-replay/android.tsx new file mode 100644 index 0000000000000..4afb1dc91ce60 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/session-replay/android.tsx @@ -0,0 +1,11 @@ +import { SDKInstallAndroidInstructions } from '../sdk-install-instructions' +import { SessionReplayFinalSteps } from '../shared-snippets' + +export function AndroidInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/session-replay/index.tsx b/frontend/src/scenes/onboarding/sdks/session-replay/index.tsx index bee13a5ce58bb..1ef01349747b4 100644 --- a/frontend/src/scenes/onboarding/sdks/session-replay/index.tsx +++ b/frontend/src/scenes/onboarding/sdks/session-replay/index.tsx @@ -1,3 +1,4 @@ +export * from './android' export * from './html-snippet' export * from './js-web' export * from './next-js' diff --git a/frontend/src/scenes/paths/pathsDataLogic.test.ts b/frontend/src/scenes/paths/pathsDataLogic.test.ts index 99e97de3b031f..e22ec58c79aae 100644 --- a/frontend/src/scenes/paths/pathsDataLogic.test.ts +++ b/frontend/src/scenes/paths/pathsDataLogic.test.ts @@ -1,6 +1,7 @@ import { expectLogic } from 'kea-test-utils' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { pathsDataLogic } from 'scenes/paths/pathsDataLogic' +import { teamLogic } from 'scenes/teamLogic' import { initKeaTests } from '~/test/init' import { InsightLogicProps, InsightType, PathType } from '~/types' @@ -25,6 +26,7 @@ async function initPathsDataLogic(): Promise { describe('pathsDataLogic', () => { beforeEach(async () => { initKeaTests(false) + teamLogic.mount() await initPathsDataLogic() }) diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx index 8442cdd4a28aa..5741c225a66a0 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx @@ -1,7 +1,8 @@ -import { LemonButton, LemonDivider, LemonTabs, LemonTag, LemonTagType, Link } from '@posthog/lemon-ui' +import { LemonButton, LemonDivider, LemonTabs, LemonTag, LemonTagType } from '@posthog/lemon-ui' import clsx from 'clsx' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' import { Dayjs, dayjs } from 'lib/dayjs' +import { Link } from 'lib/lemon-ui/Link' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { humanFriendlyMilliseconds, humanizeBytes, isURL } from 'lib/utils' import { Fragment, useState } from 'react' diff --git a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts index 236cc3b5b8dc5..4f0bf12fc81cd 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts @@ -2,6 +2,7 @@ import { expectLogic } from 'kea-test-utils' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { playerInspectorLogic } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' +import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' const playerLogicProps = { sessionRecordingId: '1', playerKey: 'playlist' } @@ -10,6 +11,11 @@ describe('playerInspectorLogic', () => { let logic: ReturnType beforeEach(() => { + useMocks({ + get: { + 'api/projects/:team_id/session_recordings/1/': {}, + }, + }) initKeaTests() featureFlagLogic.mount() logic = playerInspectorLogic(playerLogicProps) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts index ccbc8d962f1da..4e5002f5dabf7 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts @@ -29,6 +29,8 @@ describe('sessionRecordingsPlaylistLogic', () => { ], }, + 'api/projects/:team/property_definitions/seen_together': { $pageview: true }, + '/api/projects/:team/session_recordings': (req) => { const { searchParams } = req.url if ( diff --git a/frontend/src/scenes/trends/persons-modal/peronsModalLogic.test.ts b/frontend/src/scenes/trends/persons-modal/peronsModalLogic.test.ts index 70958019ed94c..f2666ba43f58f 100644 --- a/frontend/src/scenes/trends/persons-modal/peronsModalLogic.test.ts +++ b/frontend/src/scenes/trends/persons-modal/peronsModalLogic.test.ts @@ -1,5 +1,6 @@ import { expectLogic } from 'kea-test-utils' +import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' import { personsModalLogic } from './personsModalLogic' @@ -8,6 +9,11 @@ describe('personsModalLogic', () => { let logic: ReturnType beforeEach(() => { + useMocks({ + get: { + 'api/projects/:team_id/persons/trends': {}, + }, + }) initKeaTests() }) diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 943ebbaa80bb2..13262c0eb3656 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -16,6 +16,7 @@ import { PipelineTab, ProductKey, ReplayTabs, + SDKKey, } from '~/types' import { OnboardingStepKey } from './onboarding/onboardingLogic' @@ -175,8 +176,10 @@ export const urls = { `/verify_email${userUuid ? `/${userUuid}` : ''}${token ? `/${token}` : ''}`, inviteSignup: (id: string): string => `/signup/${id}`, products: (): string => '/products', - onboarding: (productKey: string, stepKey?: OnboardingStepKey): string => - `/onboarding/${productKey}${stepKey ? '?step=' + stepKey : ''}`, + onboarding: (productKey: string, stepKey?: OnboardingStepKey, sdk?: SDKKey): string => + `/onboarding/${productKey}${stepKey ? '?step=' + stepKey : ''}${ + sdk && stepKey ? '&sdk=' + sdk : sdk ? '?sdk=' + sdk : '' + }`, // Cloud only organizationBilling: (products?: ProductKey[]): string => `/organization/billing${products && products.length ? `?products=${products.join(',')}` : ''}`, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 131598f9a79d2..a583fe34c26d2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -672,7 +672,6 @@ export interface EventPropertyFilter extends BasePropertyFilter { export interface PersonPropertyFilter extends BasePropertyFilter { type: PropertyFilterType.Person operator: PropertyOperator - table?: string } export interface DataWarehousePropertyFilter extends BasePropertyFilter { @@ -1838,14 +1837,19 @@ export interface DatedAnnotationType extends Omit export enum ChartDisplayType { ActionsLineGraph = 'ActionsLineGraph', - ActionsLineGraphCumulative = 'ActionsLineGraphCumulative', + ActionsBar = 'ActionsBar', ActionsAreaGraph = 'ActionsAreaGraph', - ActionsTable = 'ActionsTable', + ActionsLineGraphCumulative = 'ActionsLineGraphCumulative', + BoldNumber = 'BoldNumber', ActionsPie = 'ActionsPie', - ActionsBar = 'ActionsBar', ActionsBarValue = 'ActionsBarValue', + ActionsTable = 'ActionsTable', WorldMap = 'WorldMap', - BoldNumber = 'BoldNumber', +} +export enum ChartDisplayCategory { + TimeSeries = 'TimeSeries', + CumulativeTimeSeries = 'CumulativeTimeSeries', + TotalValue = 'TotalValue', } export type BreakdownType = 'cohort' | 'person' | 'event' | 'group' | 'session' | 'hogql' | 'data_warehouse' @@ -2808,9 +2812,6 @@ export interface PropertyDefinition { verified?: boolean verified_at?: string verified_by?: string - - // For Data warehouse person properties - table?: string } export enum PropertyDefinitionState { @@ -2823,10 +2824,9 @@ export enum PropertyDefinitionState { export type Definition = EventDefinition | PropertyDefinition export interface PersonProperty { - id: string | number + id: number name: string count: number - table?: string } export type GroupTypeIndex = 0 | 1 | 2 | 3 | 4 diff --git a/plugin-server/src/main/ingestion-queues/session-recording/utils.ts b/plugin-server/src/main/ingestion-queues/session-recording/utils.ts index 53ce953e5bd92..2c5637726743e 100644 --- a/plugin-server/src/main/ingestion-queues/session-recording/utils.ts +++ b/plugin-server/src/main/ingestion-queues/session-recording/utils.ts @@ -255,38 +255,34 @@ export const reduceRecordingMessages = (messages: IncomingRecordingMessage[]): I const reducedMessages: Record = {} for (const message of messages) { - const clonedMessage = cloneObject(message) - const key = `${clonedMessage.team_id}-${clonedMessage.session_id}` + const key = `${message.team_id}-${message.session_id}` if (!reducedMessages[key]) { - reducedMessages[key] = clonedMessage + reducedMessages[key] = cloneObject(message) } else { const existingMessage = reducedMessages[key] - for (const [windowId, events] of Object.entries(clonedMessage.eventsByWindowId)) { + for (const [windowId, events] of Object.entries(message.eventsByWindowId)) { if (existingMessage.eventsByWindowId[windowId]) { existingMessage.eventsByWindowId[windowId].push(...events) } else { existingMessage.eventsByWindowId[windowId] = events } } - existingMessage.metadata.rawSize += clonedMessage.metadata.rawSize + existingMessage.metadata.rawSize += message.metadata.rawSize // Update the events ranges existingMessage.metadata.lowOffset = Math.min( existingMessage.metadata.lowOffset, - clonedMessage.metadata.lowOffset + message.metadata.lowOffset ) existingMessage.metadata.highOffset = Math.max( existingMessage.metadata.highOffset, - clonedMessage.metadata.highOffset + message.metadata.highOffset ) // Update the events ranges - existingMessage.eventsRange.start = Math.min( - existingMessage.eventsRange.start, - clonedMessage.eventsRange.start - ) - existingMessage.eventsRange.end = Math.max(existingMessage.eventsRange.end, clonedMessage.eventsRange.end) + existingMessage.eventsRange.start = Math.min(existingMessage.eventsRange.start, message.eventsRange.start) + existingMessage.eventsRange.end = Math.max(existingMessage.eventsRange.end, message.eventsRange.end) } } diff --git a/plugin-server/src/types.ts b/plugin-server/src/types.ts index 114547cfe605f..d70238307eaac 100644 --- a/plugin-server/src/types.ts +++ b/plugin-server/src/types.ts @@ -868,7 +868,6 @@ export interface EventPropertyFilter extends PropertyFilterWithOperator { /** Sync with posthog/frontend/src/types.ts */ export interface PersonPropertyFilter extends PropertyFilterWithOperator { type: 'person' - table?: string } /** Sync with posthog/frontend/src/types.ts */ diff --git a/posthog/api/query.py b/posthog/api/query.py index d8f45531253a0..e30853655c749 100644 --- a/posthog/api/query.py +++ b/posthog/api/query.py @@ -67,9 +67,10 @@ def create(self, request, *args, **kwargs) -> Response: if data.async_: query_status = enqueue_process_query_task( team_id=self.team.pk, + user_id=self.request.user.pk, query_json=request.data["query"], query_id=client_query_id, - refresh_requested=data.refresh, + refresh_requested=data.refresh or False, ) return Response(query_status.model_dump(), status=status.HTTP_202_ACCEPTED) diff --git a/posthog/clickhouse/client/execute_async.py b/posthog/clickhouse/client/execute_async.py index 06f7fc639f824..4671b0060299b 100644 --- a/posthog/clickhouse/client/execute_async.py +++ b/posthog/clickhouse/client/execute_async.py @@ -1,5 +1,6 @@ import datetime import json +from typing import Optional import uuid import structlog @@ -69,11 +70,12 @@ def delete_query_status(self): def execute_process_query( - team_id, - query_id, - query_json, - limit_context, - refresh_requested, + team_id: int, + user_id: int, + query_id: str, + query_json: dict, + limit_context: Optional[LimitContext], + refresh_requested: bool, ): manager = QueryStatusManager(query_id, team_id) @@ -91,7 +93,7 @@ def execute_process_query( QUERY_WAIT_TIME.observe(wait_duration) try: - tag_queries(client_query_id=query_id, team_id=team_id) + tag_queries(client_query_id=query_id, team_id=team_id, user_id=user_id) results = process_query( team=team, query_json=query_json, limit_context=limit_context, refresh_requested=refresh_requested ) @@ -113,12 +115,13 @@ def execute_process_query( def enqueue_process_query_task( - team_id, - query_json, - query_id=None, - refresh_requested=False, - bypass_celery=False, - force=False, + team_id: int, + user_id: int, + query_json: dict, + query_id: Optional[str] = None, + refresh_requested: bool = False, + force: bool = False, + _test_only_bypass_celery: bool = False, ) -> QueryStatus: if not query_id: query_id = uuid.uuid4().hex @@ -136,14 +139,23 @@ def enqueue_process_query_task( query_status = QueryStatus(id=query_id, team_id=team_id, start_time=datetime.datetime.now(datetime.timezone.utc)) manager.store_query_status(query_status) - if bypass_celery: - # Call directly ( for testing ) + if _test_only_bypass_celery: process_query_task( - team_id, query_id, query_json, limit_context=LimitContext.QUERY_ASYNC, refresh_requested=refresh_requested + team_id, + user_id, + query_id, + query_json, + limit_context=LimitContext.QUERY_ASYNC, + refresh_requested=refresh_requested, ) else: task = process_query_task.delay( - team_id, query_id, query_json, limit_context=LimitContext.QUERY_ASYNC, refresh_requested=refresh_requested + team_id, + user_id, + query_id, + query_json, + limit_context=LimitContext.QUERY_ASYNC, + refresh_requested=refresh_requested, ) query_status.task_id = task.id manager.store_query_status(query_status) diff --git a/posthog/clickhouse/client/test/test_execute_async.py b/posthog/clickhouse/client/test/test_execute_async.py index 0d7a7281e6a4b..085e7708b9232 100644 --- a/posthog/clickhouse/client/test/test_execute_async.py +++ b/posthog/clickhouse/client/test/test_execute_async.py @@ -24,6 +24,7 @@ def setUp(self): self.organization = Organization.objects.create(name="test") self.team = Team.objects.create(organization=self.organization) self.team_id = self.team.pk + self.user_id = 1337 self.query_id = "test_query_id" self.query_json = {} self.limit_context = None @@ -41,7 +42,9 @@ def test_execute_process_query(self, mock_process_query, mock_redis_client): mock_process_query.return_value = [float("inf"), float("-inf"), float("nan"), 1.0, "👍"] - execute_process_query(self.team_id, self.query_id, self.query_json, self.limit_context, self.refresh_requested) + execute_process_query( + self.team_id, self.user_id, self.query_id, self.query_json, self.limit_context, self.refresh_requested + ) mock_redis_client.assert_called_once() mock_process_query.assert_called_once() @@ -55,15 +58,16 @@ def test_execute_process_query(self, mock_process_query, mock_redis_client): class ClickhouseClientTestCase(TestCase, ClickhouseTestMixin): def setUp(self): - self.organization = Organization.objects.create(name="test") - self.team = Team.objects.create(organization=self.organization) - self.team_id = self.team.pk + self.organization: Organization = Organization.objects.create(name="test") + self.team: Team = Team.objects.create(organization=self.organization) + self.team_id: int = self.team.pk + self.user_id: int = 2137 @snapshot_clickhouse_queries def test_async_query_client(self): query = build_query("SELECT 1+1") team_id = self.team_id - query_id = client.enqueue_process_query_task(team_id, query, bypass_celery=True).id + query_id = client.enqueue_process_query_task(team_id, self.user_id, query, _test_only_bypass_celery=True).id result = client.get_query_status(team_id, query_id) self.assertFalse(result.error, result.error_message) self.assertTrue(result.complete) @@ -74,11 +78,13 @@ def test_async_query_client_errors(self): self.assertRaises( HogQLException, client.enqueue_process_query_task, - **{"team_id": (self.team_id), "query_json": query, "bypass_celery": True}, + **{"team_id": self.team_id, "user_id": self.user_id, "query_json": query, "_test_only_bypass_celery": True}, ) query_id = uuid.uuid4().hex try: - client.enqueue_process_query_task(self.team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + self.team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) except Exception: pass @@ -89,7 +95,7 @@ def test_async_query_client_errors(self): def test_async_query_client_uuid(self): query = build_query("SELECT toUUID('00000000-0000-0000-0000-000000000000')") team_id = self.team_id - query_id = client.enqueue_process_query_task(team_id, query, bypass_celery=True).id + query_id = client.enqueue_process_query_task(team_id, self.user_id, query, _test_only_bypass_celery=True).id result = client.get_query_status(team_id, query_id) self.assertFalse(result.error, result.error_message) self.assertTrue(result.complete) @@ -99,7 +105,7 @@ def test_async_query_client_does_not_leak(self): query = build_query("SELECT 1+1") team_id = self.team_id wrong_team = 5 - query_id = client.enqueue_process_query_task(team_id, query, bypass_celery=True).id + query_id = client.enqueue_process_query_task(team_id, self.user_id, query, _test_only_bypass_celery=True).id try: client.get_query_status(wrong_team, query_id) @@ -111,13 +117,19 @@ def test_async_query_client_is_lazy(self, execute_sync_mock): query = build_query("SELECT 4 + 4") query_id = uuid.uuid4().hex team_id = self.team_id - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) # Try the same query again - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) # Try the same query again (for good measure!) - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) # Assert that we only called clickhouse once execute_sync_mock.assert_called_once() @@ -127,13 +139,19 @@ def test_async_query_client_is_lazy_but_not_too_lazy(self, execute_sync_mock): query = build_query("SELECT 8 + 8") query_id = uuid.uuid4().hex team_id = self.team_id - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) # Try the same query again, but with force - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True, force=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True, force=True + ) # Try the same query again (for good measure!) - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) # Assert that we called clickhouse twice self.assertEqual(execute_sync_mock.call_count, 2) @@ -145,13 +163,19 @@ def test_async_query_client_manual_query_uuid(self, execute_sync_mock): query = build_query("SELECT 8 + 8") team_id = self.team_id query_id = "I'm so unique" - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) # Try the same query again, but with force - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True, force=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True, force=True + ) # Try the same query again (for good measure!) - client.enqueue_process_query_task(team_id, query, query_id=query_id, bypass_celery=True) + client.enqueue_process_query_task( + team_id, self.user_id, query, query_id=query_id, _test_only_bypass_celery=True + ) # Assert that we called clickhouse twice self.assertEqual(execute_sync_mock.call_count, 2) @@ -186,4 +210,4 @@ def test_client_strips_comments_from_request(self): # Make sure it still includes the "annotation" comment that includes # request routing information for debugging purposes - self.assertIn("/* request:1 */", first_query) + self.assertIn(f"/* user_id:{self.user_id} request:1 */", first_query) diff --git a/posthog/errors.py b/posthog/errors.py index afa0cdd8648e7..a6e3536042a7f 100644 --- a/posthog/errors.py +++ b/posthog/errors.py @@ -1,6 +1,6 @@ from dataclasses import dataclass import re -from typing import Dict +from typing import Dict, Optional from clickhouse_driver.errors import ServerException @@ -8,9 +8,10 @@ class InternalCHQueryError(ServerException): - code_name: str + code_name: Optional[str] + """Can be null if re-raised from a thread (see `failhard_threadhook_context`).""" - def __init__(self, message, *, code=None, nested=None, code_name): + def __init__(self, message, *, code=None, nested=None, code_name=None): self.code_name = code_name super().__init__(message, code, nested) diff --git a/posthog/hogql/database/schema/channel_type.py b/posthog/hogql/database/schema/channel_type.py index 4954cc5be2b29..1552a0e6aa6d4 100644 --- a/posthog/hogql/database/schema/channel_type.py +++ b/posthog/hogql/database/schema/channel_type.py @@ -63,7 +63,10 @@ def create_channel_type_expr( gad_source: ast.Expr, ) -> ast.Expr: def wrap_with_null_if_empty(expr: ast.Expr) -> ast.Expr: - return ast.Call(name="nullIf", args=[expr, ast.Constant(value="")]) + return ast.Call( + name="nullIf", + args=[ast.Call(name="nullIf", args=[expr, ast.Constant(value="")]), ast.Constant(value="null")], + ) return parse_expr( """ diff --git a/posthog/hogql/database/schema/test/test_channel_type.py b/posthog/hogql/database/schema/test/test_channel_type.py index 10cd4ea4ae009..97dba3e13ba38 100644 --- a/posthog/hogql/database/schema/test/test_channel_type.py +++ b/posthog/hogql/database/schema/test/test_channel_type.py @@ -121,6 +121,21 @@ def test_direct_empty_string(self): ), ) + def test_direct_null_string(self): + self.assertEqual( + "Direct", + self._get_initial_channel_type( + { + "$initial_referring_domain": "$direct", + "$initial_utm_source": "null", + "$initial_utm_medium": "null", + "$initial_utm_campaign": "null", + "$initial_gclid": "null", + "$initial_gad_source": "null", + } + ), + ) + def test_cross_network(self): self.assertEqual( "Cross Network", diff --git a/posthog/hogql/property.py b/posthog/hogql/property.py index ba9f92443b4e8..98019cdaa54b7 100644 --- a/posthog/hogql/property.py +++ b/posthog/hogql/property.py @@ -147,10 +147,7 @@ def property_to_expr( value = property.value if property.type == "person" and scope != "person": - if property.table: - chain = ["person", property.table] - else: - chain = ["person", "properties"] + chain = ["person", "properties"] elif property.type == "group": chain = [f"group_{property.group_type_index}", "properties"] elif property.type == "data_warehouse": diff --git a/posthog/hogql/query.py b/posthog/hogql/query.py index 69b5656020904..f47c14c5cef86 100644 --- a/posthog/hogql/query.py +++ b/posthog/hogql/query.py @@ -148,6 +148,7 @@ def execute_hogql_query( has_joins="JOIN" in clickhouse_sql, has_json_operations="JSONExtract" in clickhouse_sql or "JSONHas" in clickhouse_sql, timings=timings_dict, + modifiers={k: v for k, v in modifiers.model_dump().items() if v is not None} if modifiers else {}, ) error = None diff --git a/posthog/models/property/property.py b/posthog/models/property/property.py index 4bd44646ec4a9..d0e0f94439cf5 100644 --- a/posthog/models/property/property.py +++ b/posthog/models/property/property.py @@ -202,7 +202,6 @@ class Property: total_periods: Optional[int] min_periods: Optional[int] negation: Optional[bool] = False - table: Optional[str] _data: Dict def __init__( @@ -225,7 +224,6 @@ def __init__( seq_time_value: Optional[int] = None, seq_time_interval: Optional[OperatorInterval] = None, negation: Optional[bool] = None, - table: Optional[str] = None, **kwargs, ) -> None: self.key = key @@ -243,7 +241,6 @@ def __init__( self.seq_time_value = seq_time_value self.seq_time_interval = seq_time_interval self.negation = None if negation is None else str_to_bool(negation) - self.table = table if value is None and self.operator in ["is_set", "is_not_set"]: self.value = self.operator diff --git a/posthog/schema.py b/posthog/schema.py index 9d83587351683..17ad11fc4f236 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -121,14 +121,14 @@ class ChartAxis(BaseModel): class ChartDisplayType(str, Enum): ActionsLineGraph = "ActionsLineGraph" - ActionsLineGraphCumulative = "ActionsLineGraphCumulative" + ActionsBar = "ActionsBar" ActionsAreaGraph = "ActionsAreaGraph" - ActionsTable = "ActionsTable" + ActionsLineGraphCumulative = "ActionsLineGraphCumulative" + BoldNumber = "BoldNumber" ActionsPie = "ActionsPie" - ActionsBar = "ActionsBar" ActionsBarValue = "ActionsBarValue" + ActionsTable = "ActionsTable" WorldMap = "WorldMap" - BoldNumber = "BoldNumber" class CohortPropertyFilter(BaseModel): @@ -1369,7 +1369,6 @@ class PersonPropertyFilter(BaseModel): key: str label: Optional[str] = None operator: PropertyOperator - table: Optional[str] = None type: Literal["person"] = Field(default="person", description="Person properties") value: Optional[Union[str, float, List[Union[str, float]]]] = None diff --git a/posthog/tasks/tasks.py b/posthog/tasks/tasks.py index 5eff6afd33fe2..bead27cbd1eec 100644 --- a/posthog/tasks/tasks.py +++ b/posthog/tasks/tasks.py @@ -1,5 +1,5 @@ import time -from typing import Any, Optional +from typing import Optional from uuid import UUID from celery import shared_task @@ -9,6 +9,7 @@ from prometheus_client import Gauge from posthog.cloud_utils import is_cloud +from posthog.hogql.constants import LimitContext from posthog.metrics import pushed_metrics_registry from posthog.ph_client import get_ph_client from posthog.redis import get_client @@ -33,7 +34,12 @@ def redis_heartbeat() -> None: @shared_task(ignore_result=True, queue=CeleryQueue.ANALYTICS_QUERIES.value) def process_query_task( - team_id: str, query_id: str, query_json: Any, limit_context: Any = None, refresh_requested: bool = False + team_id: int, + user_id: int, + query_id: str, + query_json: dict, + limit_context: Optional[LimitContext] = None, + refresh_requested: bool = False, ) -> None: """ Kick off query @@ -43,6 +49,7 @@ def process_query_task( execute_process_query( team_id=team_id, + user_id=user_id, query_id=query_id, query_json=query_json, limit_context=limit_context,