diff --git a/.eslintrc.js b/.eslintrc.js index fb43d7405b68f..7aa24b651e2d9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -130,6 +130,7 @@ module.exports = { 'Radio', 'Divider', 'Popconfirm', + 'Table', ], message: 'please use the Lemon equivalent instead', }, diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 98424e82485be..8b6f5367c6e9f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,11 @@ -👉 *Stay up-to-date with [PostHog coding conventions](https://posthog.com/docs/contribute/coding-conventions) for a smoother review.* +👉 _Stay up-to-date with [PostHog coding conventions](https://posthog.com/docs/contribute/coding-conventions) for a smoother review._ + +## Does this work well for both Cloud and self-hosted? + + ## How did you test this code? diff --git a/.github/workflows/container-images-cd.yml b/.github/workflows/container-images-cd.yml index 4358341d32214..6a611f38622ed 100644 --- a/.github/workflows/container-images-cd.yml +++ b/.github/workflows/container-images-cd.yml @@ -85,7 +85,8 @@ jobs: event_type: posthog_deploy message: | { - "image_tag": "${{ steps.build.outputs.digest }}" + "image_tag": "${{ steps.build.outputs.digest }}", + "context": ${{ toJson(github) }} } - name: Check for changes in plugins directory @@ -103,7 +104,8 @@ jobs: event_type: ingestion_deploy message: | { - "image_tag": "${{ steps.build.outputs.digest }}" + "image_tag": "${{ steps.build.outputs.digest }}", + "context": ${{ toJson(github) }} } - name: Check for changes that affect batch exports temporal worker @@ -122,7 +124,8 @@ jobs: message: | { "image_tag": "${{ steps.build.outputs.digest }}", - "worker_name": "temporal-worker" + "worker_name": "temporal-worker", + "context": ${{ toJson(github) }} } - name: Check for changes that affect data warehouse temporal worker @@ -141,5 +144,6 @@ jobs: message: | { "image_tag": "${{ steps.build.outputs.digest }}", - "worker_name": "temporal-worker-data-warehouse" + "worker_name": "temporal-worker-data-warehouse", + "context": ${{ toJson(github) }} } diff --git a/cypress/e2e/experiments.cy.ts b/cypress/e2e/experiments.cy.ts index 77f42c50ccba7..fffd8120d8206 100644 --- a/cypress/e2e/experiments.cy.ts +++ b/cypress/e2e/experiments.cy.ts @@ -54,8 +54,8 @@ describe('Experiments', () => { // Select goal type cy.get('[data-attr="experiment-goal-type-select"]').click() - cy.contains('Trend').should('be.visible') - cy.contains('Conversion funnel').should('be.visible') + cy.get('.Popover__content').contains('Trend').should('be.visible') + cy.get('.Popover__content').contains('Conversion funnel').should('be.visible') // Add secondary metric const secondaryMetricName = `Secondary metric ${Math.floor(Math.random() * 10000000)}` @@ -65,8 +65,8 @@ describe('Experiments', () => { .type(secondaryMetricName) .should('have.value', secondaryMetricName) cy.get('[data-attr="metrics-selector"]').click() - cy.contains('Trends').should('be.visible') - cy.contains('Funnels').should('be.visible') + cy.get('.Popover__content').contains('Funnels').should('be.visible') + cy.get('.Popover__content').contains('Trends').should('be.visible') cy.get('[data-attr="create-annotation-submit"]').click() cy.contains(secondaryMetricName).should('exist') diff --git a/cypress/e2e/invites.cy.ts b/cypress/e2e/invites.cy.ts index 71627202e8896..b1a4041e9c85b 100644 --- a/cypress/e2e/invites.cy.ts +++ b/cypress/e2e/invites.cy.ts @@ -10,7 +10,7 @@ describe('Invite Signup', () => { cy.location('pathname').should('contain', '/settings/organization') cy.get('[id="invites"]').should('exist') - cy.contains('Pending Invites').should('exist') + cy.contains('Pending invites').should('exist') // Test invite creation flow cy.get('[data-attr=invite-teammate-button]').click() @@ -102,7 +102,7 @@ describe('Invite Signup', () => { // Change membership level cy.contains('[data-attr=org-members-table] tr', user).within(() => { - cy.get('[data-attr=membership-level]').last().should('contain', 'member') + cy.get('[data-attr=membership-level]').last().should('contain', 'Member') cy.get('[data-attr=more-button]').last().click() }) @@ -110,7 +110,7 @@ describe('Invite Signup', () => { cy.get('[data-test-level=8]').click() cy.contains('[data-attr=org-members-table] tr', user).within(() => { - cy.get('[data-attr=membership-level]').last().should('contain', 'admin') + cy.get('[data-attr=membership-level]').last().should('contain', 'Admin') }) // Delete member diff --git a/cypress/e2e/onboarding.cy.ts b/cypress/e2e/onboarding.cy.ts index c6e44e3d59331..ecf7b86154b96 100644 --- a/cypress/e2e/onboarding.cy.ts +++ b/cypress/e2e/onboarding.cy.ts @@ -19,13 +19,14 @@ describe('Onboarding', () => { cy.get('[data-attr=product_analytics-onboarding-card]').click() // Confirm product intro is not included as the first step in the upper right breadcrumbs - cy.get('[data-attr=onboarding-breadcrumbs] > :first-child > * span').should('not.contain', 'Product Intro') + cy.get('[data-attr=onboarding-breadcrumbs] > :first-child > * span').should('not.contain', 'Product intro') // Navigate to the product intro page by clicking the left side bar cy.get('[data-attr=menu-item-replay').click() // Confirm we're on the product_intro page - cy.get('[data-attr=top-bar-name] > span').contains('Product intro') + cy.get('[data-attr=top-bar-name] > span').contains('Onboarding') + cy.get('[data-attr=product-intro-title]').contains('Watch how users experience your app') // Go back to /products cy.visit('/products') @@ -37,6 +38,7 @@ describe('Onboarding', () => { cy.visit(urls.onboarding('session_replay', 'product_intro')) // Confirm we're on the product intro page - cy.get('[data-attr=top-bar-name] > span').contains('Product intro') + cy.get('[data-attr=top-bar-name] > span').contains('Onboarding') + cy.get('[data-attr=product-intro-title]').contains('Watch how users experience your app') }) }) diff --git a/cypress/productAnalytics/index.ts b/cypress/productAnalytics/index.ts index b523a4e970efb..00b9279410e1b 100644 --- a/cypress/productAnalytics/index.ts +++ b/cypress/productAnalytics/index.ts @@ -78,7 +78,10 @@ export const insight = { const networkInterceptAlias = interceptInsightLoad(tabName) cy.get(`[data-attr="insight-${(tabName === 'PATHS' ? 'PATH' : tabName).toLowerCase()}-tab"]`).click() - cy.wait(`@${networkInterceptAlias}`) + if (tabName !== 'FUNNELS') { + // funnel insights require two steps before making an api call + cy.wait(`@${networkInterceptAlias}`) + } }, newInsight: (insightType: string = 'TRENDS'): void => { const networkInterceptAlias = interceptInsightLoad(insightType) @@ -94,7 +97,10 @@ export const insight = { cy.get(`[data-attr-insight-type="${insightType}"]`).click() } - cy.wait(`@${networkInterceptAlias}`) + if (insightType !== 'FUNNELS') { + // funnel insights require two steps before making an api call + cy.wait(`@${networkInterceptAlias}`) + } }, visitInsight: (insightName: string): void => { cy.clickNavMenu('savedinsights') diff --git a/docker-compose.base.yml b/docker-compose.base.yml index f70790f0d0826..2e5ce0c2bdb9a 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -27,9 +27,7 @@ services: # image: ${CLICKHOUSE_SERVER_IMAGE:-clickhouse/clickhouse-server:23.11.2.11-alpine} restart: on-failure - depends_on: - - kafka - - zookeeper + zookeeper: image: zookeeper:3.7.0 restart: on-failure @@ -37,8 +35,6 @@ services: kafka: image: ghcr.io/posthog/kafka-container:v2.8.2 restart: on-failure - depends_on: - - zookeeper environment: KAFKA_BROKER_ID: 1001 KAFKA_CFG_RESERVED_BROKER_MAX_ID: 1001 @@ -50,8 +46,6 @@ services: kafka_ui: image: provectuslabs/kafka-ui:latest restart: on-failure - depends_on: - - kafka environment: KAFKA_CLUSTERS_0_NAME: local KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 @@ -94,12 +88,6 @@ services: PGUSER: posthog PGPASSWORD: posthog DEPLOYMENT: hobby - depends_on: - - db - - redis - - clickhouse - - kafka - - object_storage web: <<: *worker @@ -114,9 +102,7 @@ services: KAFKA_TOPIC: 'events_plugin_ingestion' KAFKA_HOSTS: 'kafka:9092' REDIS_URL: 'redis://redis:6379/' - depends_on: - - redis - - kafka + plugins: command: ./bin/plugin-server --no-restart-loop @@ -129,12 +115,6 @@ services: CLICKHOUSE_DATABASE: 'posthog' CLICKHOUSE_SECURE: 'false' CLICKHOUSE_VERIFY: 'false' - depends_on: - - db - - redis - - clickhouse - - kafka - - object_storage migrate: <<: *worker @@ -172,9 +152,7 @@ services: volumes: - /var/lib/elasticsearch/data temporal: - depends_on: - db: - condition: service_healthy + environment: - DB=postgresql @@ -194,16 +172,12 @@ services: volumes: - ./docker/temporal/dynamicconfig:/etc/temporal/config/dynamicconfig temporal-admin-tools: - depends_on: - - temporal environment: - TEMPORAL_CLI_ADDRESS=temporal:7233 image: temporalio/admin-tools:1.20.0 stdin_open: true tty: true temporal-ui: - depends_on: - - temporal environment: - TEMPORAL_ADDRESS=temporal:7233 - TEMPORAL_CORS_ORIGINS=http://localhost:3000 @@ -216,10 +190,4 @@ services: restart: on-failure environment: TEMPORAL_HOST: temporal - depends_on: - - db - - redis - - clickhouse - - kafka - - object_storage - - temporal + diff --git a/docker-compose.dev-full.yml b/docker-compose.dev-full.yml index 6d62266e6b8a1..ba940322fb3dd 100644 --- a/docker-compose.dev-full.yml +++ b/docker-compose.dev-full.yml @@ -37,6 +37,9 @@ services: - ./docker/clickhouse/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d - ./docker/clickhouse/config.xml:/etc/clickhouse-server/config.xml - ./docker/clickhouse/users-dev.xml:/etc/clickhouse-server/users.xml + depends_on: + - kafka + - zookeeper zookeeper: extends: @@ -49,6 +52,8 @@ services: service: kafka ports: - '9092:9092' + depends_on: + - zookeeper object_storage: extends: @@ -88,6 +93,12 @@ services: - .:/app/posthog environment: - DEBUG=1 + depends_on: + - db + - redis + - clickhouse + - kafka + - object_storage capture: extends: @@ -97,6 +108,9 @@ services: - 3000:3000 environment: - DEBUG=1 + depends_on: + - redis + - kafka plugins: extends: @@ -108,6 +122,12 @@ services: environment: - DEBUG=1 - DOCKER=1 + depends_on: + - db + - redis + - clickhouse + - kafka + - object_storage migrate: extends: @@ -136,12 +156,19 @@ services: extends: file: docker-compose.base.yml service: temporal-admin-tools + depends_on: + - temporal temporal-ui: extends: file: docker-compose.base.yml service: temporal-ui ports: - 8081:8080 + depends_on: + temporal: + condition: service_started + db: + condition: service_healthy temporal-django-worker: extends: file: docker-compose.base.yml @@ -149,3 +176,10 @@ services: build: . volumes: - .:/app/posthog + depends_on: + - db + - redis + - clickhouse + - kafka + - object_storage + - temporal \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b8d526b5e0f94..678868510482e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -50,6 +50,9 @@ services: - ./docker/clickhouse/users-dev.xml:/etc/clickhouse-server/users.xml extra_hosts: - 'host.docker.internal:host-gateway' + depends_on: + - kafka + - zookeeper zookeeper: extends: @@ -64,6 +67,8 @@ services: service: kafka ports: - '9092:9092' + depends_on: + - zookeeper kafka_ui: extends: @@ -71,6 +76,8 @@ services: service: kafka_ui ports: - '9093:8080' + depends_on: + - kafka object_storage: extends: @@ -98,6 +105,9 @@ services: - 3000:3000 environment: - DEBUG=1 + depends_on: + - redis + - kafka # Temporal containers elasticsearch: @@ -115,7 +125,14 @@ services: extends: file: docker-compose.base.yml service: temporal-admin-tools + depends_on: + - temporal temporal-ui: extends: file: docker-compose.base.yml service: temporal-ui + depends_on: + temporal: + condition: service_started + db: + condition: service_healthy diff --git a/docker-compose.hobby.yml b/docker-compose.hobby.yml index aea3280216c62..2720729a27634 100644 --- a/docker-compose.hobby.yml +++ b/docker-compose.hobby.yml @@ -52,6 +52,8 @@ services: extends: file: docker-compose.base.yml service: kafka + depends_on: + - zookeeper worker: extends: @@ -75,6 +77,12 @@ services: SITE_URL: https://$DOMAIN SECRET_KEY: $POSTHOG_SECRET RECORDINGS_INGESTER_URL: http://plugins:6738 + depends_on: + - db + - redis + - clickhouse + - kafka + - object_storage plugins: extends: @@ -85,6 +93,12 @@ services: SENTRY_DSN: $SENTRY_DSN SITE_URL: https://$DOMAIN SECRET_KEY: $POSTHOG_SECRET + depends_on: + - db + - redis + - clickhouse + - kafka + - object_storage caddy: image: caddy:2.6.1 @@ -130,12 +144,19 @@ services: extends: file: docker-compose.base.yml service: temporal-admin-tools + depends_on: + - temporal temporal-ui: extends: file: docker-compose.base.yml service: temporal-ui ports: - 8081:8080 + depends_on: + temporal: + condition: service_started + db: + condition: service_healthy temporal-django-worker: command: /compose/temporal-django-worker extends: @@ -147,6 +168,13 @@ services: environment: SENTRY_DSN: $SENTRY_DSN SITE_URL: https://$DOMAIN + depends_on: + - db + - redis + - clickhouse + - kafka + - object_storage + - temporal volumes: zookeeper-data: diff --git a/ee/api/dashboard_collaborator.py b/ee/api/dashboard_collaborator.py index 0e070f0c2860c..998eeba8238f9 100644 --- a/ee/api/dashboard_collaborator.py +++ b/ee/api/dashboard_collaborator.py @@ -88,7 +88,7 @@ class DashboardCollaboratorViewSet( mixins.DestroyModelMixin, viewsets.GenericViewSet, ): - scope_object = "INTERNAL" + scope_object = "dashboard" permission_classes = [CanEditDashboardCollaborator] pagination_class = None queryset = DashboardPrivilege.objects.select_related("dashboard").filter(user__is_active=True) diff --git a/ee/api/role.py b/ee/api/role.py index d4aab089c8c0a..44909f504eece 100644 --- a/ee/api/role.py +++ b/ee/api/role.py @@ -7,6 +7,7 @@ from ee.models.feature_flag_role_access import FeatureFlagRoleAccess from ee.models.organization_resource_access import OrganizationResourceAccess from ee.models.role import Role, RoleMembership +from posthog.api.organization_member import OrganizationMemberSerializer from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.models import OrganizationMembership @@ -105,20 +106,24 @@ def get_queryset(self): class RoleMembershipSerializer(serializers.ModelSerializer): user = UserBasicSerializer(read_only=True) + organization_member = OrganizationMemberSerializer(read_only=True) role_id = serializers.UUIDField(read_only=True) user_uuid = serializers.UUIDField(required=True, write_only=True) class Meta: model = RoleMembership - fields = ["id", "role_id", "user", "joined_at", "updated_at", "user_uuid"] - - read_only_fields = ["id", "role_id", "user"] + fields = ["id", "role_id", "organization_member", "user", "joined_at", "updated_at", "user_uuid"] + read_only_fields = ["id", "role_id", "organization_member", "user", "joined_at", "updated_at"] def create(self, validated_data): user_uuid = validated_data.pop("user_uuid") try: - validated_data["user"] = User.objects.filter(is_active=True).get(uuid=user_uuid) - except User.DoesNotExist: + validated_data["organization_member"] = OrganizationMembership.objects.select_related("user").get( + organization_id=self.context["organization_id"], user__uuid=user_uuid, user__is_active=True + ) + + validated_data["user"] = validated_data["organization_member"].user + except OrganizationMembership.DoesNotExist: raise serializers.ValidationError("User does not exist.") validated_data["role_id"] = self.context["role_id"] try: diff --git a/ee/api/test/test_role_membership.py b/ee/api/test/test_role_membership.py index 41d4bb3822a6d..f89796d9b7c4f 100644 --- a/ee/api/test/test_role_membership.py +++ b/ee/api/test/test_role_membership.py @@ -12,16 +12,34 @@ def setUp(self): self.eng_role = Role.objects.create(name="Engineering", organization=self.organization) self.marketing_role = Role.objects.create(name="Marketing", organization=self.organization) + def test_adds_member_to_a_role(self): + user = User.objects.create_and_join(self.organization, "a@x.com", None) + + self.organization_membership.level = OrganizationMembership.Level.ADMIN + self.organization_membership.save() + assert RoleMembership.objects.count() == 0 + + res = self.client.post( + f"/api/organizations/@current/roles/{self.eng_role.id}/role_memberships", + {"user_uuid": user.uuid}, + ) + + assert res.status_code == status.HTTP_201_CREATED + assert res.json()["id"] == str(RoleMembership.objects.first().id) + assert res.json()["role_id"] == str(self.eng_role.id) + assert res.json()["organization_member"]["user"]["id"] == user.id + assert res.json()["user"]["id"] == user.id + def test_only_organization_admins_and_higher_can_add_users(self): user_a = User.objects.create_and_join(self.organization, "a@x.com", None) user_b = User.objects.create_and_join(self.organization, "b@x.com", None) - self.assertEqual(self.organization_membership.level, OrganizationMembership.Level.MEMBER) + assert self.organization_membership.level == OrganizationMembership.Level.MEMBER add_user_b_res = self.client.post( f"/api/organizations/@current/roles/{self.eng_role.id}/role_memberships", {"user_uuid": user_b.uuid}, ) - self.assertEqual(add_user_b_res.status_code, status.HTTP_403_FORBIDDEN) + assert add_user_b_res.status_code == status.HTTP_403_FORBIDDEN self.organization_membership.level = OrganizationMembership.Level.ADMIN self.organization_membership.save() @@ -29,15 +47,15 @@ def test_only_organization_admins_and_higher_can_add_users(self): f"/api/organizations/@current/roles/{self.eng_role.id}/role_memberships", {"user_uuid": user_a.uuid}, ) - self.assertEqual(add_user_a_res.status_code, status.HTTP_201_CREATED) - self.assertEqual(RoleMembership.objects.count(), 1) - self.assertEqual(RoleMembership.objects.first().user, user_a) # type: ignore + assert add_user_a_res.status_code == status.HTTP_201_CREATED + assert RoleMembership.objects.count() == 1 + assert RoleMembership.objects.first().user == user_a def test_user_can_belong_to_multiple_roles(self): user_a = User.objects.create_and_join(self.organization, "a@potato.com", None) self.organization_membership.level = OrganizationMembership.Level.ADMIN self.organization_membership.save() - self.assertEqual(RoleMembership.objects.count(), 0) + assert RoleMembership.objects.count() == 0 self.client.post( f"/api/organizations/@current/roles/{self.eng_role.id}/role_memberships", @@ -47,7 +65,24 @@ def test_user_can_belong_to_multiple_roles(self): f"/api/organizations/@current/roles/{self.marketing_role.id}/role_memberships", {"user_uuid": user_a.uuid}, ) - self.assertEqual(RoleMembership.objects.count(), 2) + assert RoleMembership.objects.count() == 2 + + def test_user_can_be_removed_from_role(self): + user_a = User.objects.create_and_join(self.organization, "a@potato.com", None) + self.organization_membership.level = OrganizationMembership.Level.ADMIN + self.organization_membership.save() + assert RoleMembership.objects.count() == 0 + + res = self.client.post( + f"/api/organizations/@current/roles/{self.eng_role.id}/role_memberships", + {"user_uuid": user_a.uuid}, + ) + assert RoleMembership.objects.count() == 1 + delete_response = self.client.delete( + f"/api/organizations/@current/roles/{self.eng_role.id}/role_memberships/{res.json()['id']}", + ) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + assert RoleMembership.objects.count() == 0 def test_returns_correct_results_by_organization(self): self.organization_membership.level = OrganizationMembership.Level.ADMIN @@ -62,10 +97,10 @@ def test_returns_correct_results_by_organization(self): ) other_org_same_name_role = Role.objects.create(organization=other_org, name="Engineering") RoleMembership.objects.create(role=other_org_same_name_role, user=user_b) - self.assertEqual(RoleMembership.objects.count(), 2) + assert RoleMembership.objects.count() == 2 get_res = self.client.get( f"/api/organizations/@current/roles/{self.eng_role.id}/role_memberships", ) - self.assertEqual(get_res.json()["count"], 1) - self.assertEqual(get_res.json()["results"][0]["user"]["distinct_id"], user_a.distinct_id) - self.assertNotContains(get_res, str(user_b.email)) + assert get_res.json()["count"] == 1 + assert get_res.json()["results"][0]["user"]["distinct_id"] == user_a.distinct_id + assert str(user_b.email) not in get_res.content.decode() diff --git a/ee/api/test/test_team.py b/ee/api/test/test_team.py index 22ac5c9b17f56..4df41f7d91fa0 100644 --- a/ee/api/test/test_team.py +++ b/ee/api/test/test_team.py @@ -226,7 +226,7 @@ def test_rename_private_project_as_org_member_forbidden(self): self.team.refresh_from_db() self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) - self.assertEqual(self.team.name, "Default Project") + self.assertEqual(self.team.name, "Default project") def test_rename_private_project_current_as_org_outsider_forbidden(self): self.organization_membership.delete() @@ -368,7 +368,7 @@ def test_fetch_team_as_org_admin_works(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictContainsSubset( { - "name": "Default Project", + "name": "Default project", "access_control": False, "effective_membership_level": OrganizationMembership.Level.ADMIN, }, @@ -385,7 +385,7 @@ def test_fetch_team_as_org_member_works(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictContainsSubset( { - "name": "Default Project", + "name": "Default project", "access_control": False, "effective_membership_level": OrganizationMembership.Level.MEMBER, }, @@ -424,7 +424,7 @@ def test_fetch_private_team_as_org_member_and_project_member(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictContainsSubset( { - "name": "Default Project", + "name": "Default project", "access_control": True, "effective_membership_level": OrganizationMembership.Level.MEMBER, }, @@ -448,7 +448,7 @@ def test_fetch_private_team_as_org_member_and_project_admin(self): self.assertEqual(response.status_code, HTTP_200_OK) self.assertDictContainsSubset( { - "name": "Default Project", + "name": "Default project", "access_control": True, "effective_membership_level": OrganizationMembership.Level.ADMIN, }, diff --git a/ee/benchmarks/benchmarks.py b/ee/benchmarks/benchmarks.py index f4dc81eb2dfa5..83e82df068f9b 100644 --- a/ee/benchmarks/benchmarks.py +++ b/ee/benchmarks/benchmarks.py @@ -1,6 +1,6 @@ # isort: skip_file # Needs to be first to set up django environment -from .helpers import * +from .helpers import benchmark_clickhouse, no_materialized_columns, now from datetime import timedelta from typing import List, Tuple from ee.clickhouse.materialized_columns.analyze import ( diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr index 54b67fa7d359b..3c6b8ef78c385 100644 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr +++ b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results ''' - /* user_id:108 celery:posthog.tasks.tasks.sync_insight_caching_state */ + /* user_id:107 celery:posthog.tasks.tasks.sync_insight_caching_state */ SELECT team_id, date_diff('second', max(timestamp), now()) AS age FROM events diff --git a/ee/migrations/0016_rolemembership_organization_member.py b/ee/migrations/0016_rolemembership_organization_member.py new file mode 100644 index 0000000000000..d366581f31cbd --- /dev/null +++ b/ee/migrations/0016_rolemembership_organization_member.py @@ -0,0 +1,25 @@ +# Generated by Django 4.1.13 on 2024-03-14 13:40 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0397_projects_backfill"), + ("ee", "0015_add_verified_properties"), + ] + + operations = [ + migrations.AddField( + model_name="rolemembership", + name="organization_member", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="role_memberships", + related_query_name="role_membership", + to="posthog.organizationmembership", + ), + ), + ] diff --git a/ee/models/role.py b/ee/models/role.py index 5284972bd7cc1..00880812b9d82 100644 --- a/ee/models/role.py +++ b/ee/models/role.py @@ -36,12 +36,21 @@ class RoleMembership(UUIDModel): related_name="roles", related_query_name="role", ) + # TODO: Eventually remove this as we only need the organization membership user: models.ForeignKey = models.ForeignKey( "posthog.User", on_delete=models.CASCADE, related_name="role_memberships", related_query_name="role_membership", ) + + organization_member: models.ForeignKey = models.ForeignKey( + "posthog.OrganizationMembership", + on_delete=models.CASCADE, + related_name="role_memberships", + related_query_name="role_membership", + null=True, + ) joined_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) diff --git a/ee/session_recordings/ai/embeddings_runner.py b/ee/session_recordings/ai/embeddings_runner.py index 708b156c7981c..101c7175acb61 100644 --- a/ee/session_recordings/ai/embeddings_runner.py +++ b/ee/session_recordings/ai/embeddings_runner.py @@ -145,6 +145,7 @@ def run(self, items: List[Any], embeddings_preparation: type[EmbeddingPreparatio "session_id": session_id, "embeddings": embeddings, "source_type": source_type, + "input": input, } ) # we don't want to fail the whole batch if only a single recording fails @@ -198,7 +199,7 @@ def _num_tokens_for_input(self, string: str) -> int: def _flush_embeddings_to_clickhouse(self, embeddings: List[Dict[str, Any]], source_type: str) -> None: try: sync_execute( - "INSERT INTO session_replay_embeddings (session_id, team_id, embeddings, source_type) VALUES", + "INSERT INTO session_replay_embeddings (session_id, team_id, embeddings, source_type, input) VALUES", embeddings, ) SESSION_EMBEDDINGS_WRITTEN_TO_CLICKHOUSE.labels(source_type=source_type).inc(len(embeddings)) diff --git a/ee/session_recordings/ai/error_clustering.py b/ee/session_recordings/ai/error_clustering.py index 13cb741b856dc..7936297ce1e43 100644 --- a/ee/session_recordings/ai/error_clustering.py +++ b/ee/session_recordings/ai/error_clustering.py @@ -11,6 +11,13 @@ buckets=[0.5, 1, 2, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60], ) +CLUSTER_REPLAY_ERRORS_CLUSTER_COUNT = Histogram( + "posthog_session_recordings_errors_cluster_count", + "Count of clusters identified from error messages per team", + buckets=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20, 25, 30, 35, 40, 45, 50], + labelnames=["team_id"], +) + DBSCAN_EPS = settings.REPLAY_EMBEDDINGS_CLUSTERING_DBSCAN_EPS DBSCAN_MIN_SAMPLES = settings.REPLAY_EMBEDDINGS_CLUSTERING_DBSCAN_MIN_SAMPLES @@ -21,17 +28,19 @@ def error_clustering(team: Team): if not results: return [] - cluster_embeddings(results) + df = pd.DataFrame(results, columns=["session_id", "input", "embeddings"]) + + df["cluster"] = cluster_embeddings(df["embeddings"].tolist()) + + CLUSTER_REPLAY_ERRORS_CLUSTER_COUNT.labels(team_id=team.pk).observe(df["cluster"].nunique()) - df = pd.DataFrame(results, columns=["session_id", "embeddings"]) - df["cluster"] = cluster_embeddings(results) return construct_response(df) def fetch_error_embeddings(team_id: int): query = """ SELECT - session_id, embeddings + session_id, input, embeddings FROM session_replay_embeddings WHERE @@ -39,6 +48,7 @@ def fetch_error_embeddings(team_id: int): -- don't load all data for all time AND generation_timestamp > now() - INTERVAL 7 DAY AND source_type = 'error' + AND input != '' """ return sync_execute( @@ -58,7 +68,7 @@ def construct_response(df): return [ { "cluster": cluster, - "samples": rows.sample(n=DBSCAN_MIN_SAMPLES)[["session_id", "message"]].to_dict("records"), + "samples": rows.head(n=DBSCAN_MIN_SAMPLES)[["session_id", "input"]].to_dict("records"), "occurrences": rows.size, "unique_sessions": rows["session_id"].count(), } diff --git a/frontend/__snapshots__/components-cards-insight-card--insight-card--dark.png b/frontend/__snapshots__/components-cards-insight-card--insight-card--dark.png index 39e6f1913dd58..cde356c5dc702 100644 Binary files a/frontend/__snapshots__/components-cards-insight-card--insight-card--dark.png and b/frontend/__snapshots__/components-cards-insight-card--insight-card--dark.png differ diff --git a/frontend/__snapshots__/insights-funnelcorrelationtable--default--dark.png b/frontend/__snapshots__/insights-funnelcorrelationtable--default--dark.png new file mode 100644 index 0000000000000..84a18b7fb79c3 Binary files /dev/null and b/frontend/__snapshots__/insights-funnelcorrelationtable--default--dark.png differ diff --git a/frontend/__snapshots__/insights-funnelcorrelationtable--default--light.png b/frontend/__snapshots__/insights-funnelcorrelationtable--default--light.png new file mode 100644 index 0000000000000..e4be6621b8608 Binary files /dev/null and b/frontend/__snapshots__/insights-funnelcorrelationtable--default--light.png differ diff --git a/frontend/__snapshots__/insights-funnelpropertycorrelationtable--default--dark.png b/frontend/__snapshots__/insights-funnelpropertycorrelationtable--default--dark.png new file mode 100644 index 0000000000000..9985decbb9d50 Binary files /dev/null and b/frontend/__snapshots__/insights-funnelpropertycorrelationtable--default--dark.png differ diff --git a/frontend/__snapshots__/insights-funnelpropertycorrelationtable--default--light.png b/frontend/__snapshots__/insights-funnelpropertycorrelationtable--default--light.png new file mode 100644 index 0000000000000..130a1b2035d9f Binary files /dev/null and b/frontend/__snapshots__/insights-funnelpropertycorrelationtable--default--light.png differ diff --git a/frontend/__snapshots__/scenes-app-features--new-feature-flag--dark.png b/frontend/__snapshots__/scenes-app-features--new-feature-flag--dark.png index aef2d5474fdd7..a89ab7c32e206 100644 Binary files a/frontend/__snapshots__/scenes-app-features--new-feature-flag--dark.png and b/frontend/__snapshots__/scenes-app-features--new-feature-flag--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-features--new-feature-flag--light.png b/frontend/__snapshots__/scenes-app-features--new-feature-flag--light.png index fdc2a2e6ab914..b9ca780a2319c 100644 Binary files a/frontend/__snapshots__/scenes-app-features--new-feature-flag--light.png and b/frontend/__snapshots__/scenes-app-features--new-feature-flag--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--user-paths--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--user-paths--light--webkit.png index 9d45290bfa61a..39ca8a9d25273 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--user-paths--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--user-paths--light--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--dark.png b/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--dark.png index 53c9f1b1fb1a0..96b91c1b668a9 100644 Binary files a/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--dark.png and b/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--light.png b/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--light.png index 208c245f98273..aea9e0ec95e36 100644 Binary files a/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--light.png and b/frontend/__snapshots__/scenes-app-insights-error-empty-states--long-loading--light.png differ diff --git a/frontend/__snapshots__/scenes-app-saved-insights--list-view--light.png b/frontend/__snapshots__/scenes-app-saved-insights--list-view--light.png index 36c970c9976ad..cf3891a6bbec1 100644 Binary files a/frontend/__snapshots__/scenes-app-saved-insights--list-view--light.png and b/frontend/__snapshots__/scenes-app-saved-insights--list-view--light.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png index 03cfea0c8b8f3..5ec5ac0082931 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png index e305aa85a276a..8f438eb8ca140 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-billing--light.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-introduction--dark.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-introduction--dark.png deleted file mode 100644 index aa731c71e0a91..0000000000000 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-introduction--dark.png and /dev/null differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-introduction--light.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-introduction--light.png deleted file mode 100644 index c6f3444c8acd6..0000000000000 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-introduction--light.png and /dev/null differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--dark.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--dark.png index 252ca4e269a62..70367b47dfb12 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--dark.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--light.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--light.png index fd35c8b867a6f..335feafe88fcf 100644 Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--light.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-sd-ks--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png index d8fc24b620c04..47b9cd971ab36 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png b/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png index 49dcd5fc3623b..8a639fc3d765a 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-user--light.png b/frontend/__snapshots__/scenes-other-settings--settings-user--light.png index 9086fd0c28a79..e80ed34d11033 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-user--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-user--light.png differ diff --git a/frontend/src/layout/navigation-3000/components/TopBar.tsx b/frontend/src/layout/navigation-3000/components/TopBar.tsx index cfe87b292b61f..ac4a4e7d8a6fa 100644 --- a/frontend/src/layout/navigation-3000/components/TopBar.tsx +++ b/frontend/src/layout/navigation-3000/components/TopBar.tsx @@ -4,6 +4,7 @@ import { IconChevronDown } from '@posthog/icons' import { LemonButton, LemonSkeleton } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' +import { router } from 'kea-router' import { EditableField } from 'lib/components/EditableField/EditableField' import { IconMenu } from 'lib/lemon-ui/icons' import { Link } from 'lib/lemon-ui/Link' @@ -29,6 +30,7 @@ export function TopBar(): JSX.Element | null { // Always show in full on mobile, as there we are very constrained in width, but not so much height const effectiveCompactionRate = mobileLayout ? 0 : compactionRate + const isOnboarding = router.values.location.pathname.includes('/onboarding/') useLayoutEffect(() => { function handleScroll(): void { @@ -94,10 +96,14 @@ export function TopBar(): JSX.Element | null {
))} - +
)} - +
@@ -108,21 +114,24 @@ export function TopBar(): JSX.Element | null { interface BreadcrumbProps { breadcrumb: IBreadcrumb here?: boolean + isOnboarding?: boolean } -function Breadcrumb({ breadcrumb, here }: BreadcrumbProps): JSX.Element { +function Breadcrumb({ breadcrumb, here, isOnboarding }: BreadcrumbProps): JSX.Element { const { renameState } = useValues(breadcrumbsLogic) const { tentativelyRename, finishRenaming } = useActions(breadcrumbsLogic) const [popoverShown, setPopoverShown] = useState(false) const joinedKey = joinBreadcrumbKey(breadcrumb.key) + const breadcrumbName = isOnboarding && here ? 'Onboarding' : (breadcrumb.name as string) + let nameElement: JSX.Element if (breadcrumb.name != null && breadcrumb.onRename) { nameElement = ( tentativelyRename(joinedKey, newName)} onSave={(newName) => { void breadcrumb.onRename?.(newName) @@ -130,7 +139,7 @@ function Breadcrumb({ breadcrumb, here }: BreadcrumbProps): JSX.Element { mode={renameState && renameState[0] === joinedKey ? 'edit' : 'view'} onModeToggle={(newMode) => { if (newMode === 'edit') { - tentativelyRename(joinedKey, breadcrumb.name as string) + tentativelyRename(joinedKey, breadcrumbName) } else { finishRenaming() } @@ -142,7 +151,7 @@ function Breadcrumb({ breadcrumb, here }: BreadcrumbProps): JSX.Element { /> ) } else { - nameElement = {breadcrumb.name || Unnamed} + nameElement = {breadcrumbName || Unnamed} } const Component = breadcrumb.path ? Link : 'div' @@ -191,13 +200,15 @@ function Breadcrumb({ breadcrumb, here }: BreadcrumbProps): JSX.Element { interface HereProps { breadcrumb: IBreadcrumb + isOnboarding?: boolean } -function Here({ breadcrumb }: HereProps): JSX.Element { +function Here({ breadcrumb, isOnboarding }: HereProps): JSX.Element { const { renameState } = useValues(breadcrumbsLogic) const { tentativelyRename, finishRenaming } = useActions(breadcrumbsLogic) const joinedKey = joinBreadcrumbKey(breadcrumb.key) + const hereName = isOnboarding ? 'Onboarding' : (breadcrumb.name as string) return (

@@ -206,7 +217,7 @@ function Here({ breadcrumb }: HereProps): JSX.Element { ) : breadcrumb.onRename ? ( { tentativelyRename(joinedKey, newName) if (breadcrumb.forceEditMode) { @@ -222,7 +233,7 @@ function Here({ breadcrumb }: HereProps): JSX.Element { !breadcrumb.forceEditMode ? (newMode) => { if (newMode === 'edit') { - tentativelyRename(joinedKey, breadcrumb.name as string) + tentativelyRename(joinedKey, hereName) } else { finishRenaming() } @@ -235,7 +246,7 @@ function Here({ breadcrumb }: HereProps): JSX.Element { autoFocus /> ) : ( - {breadcrumb.name} + {hereName} )}

) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 477ea62ab3826..3b49c3be4f032 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1928,13 +1928,10 @@ const api = { password: string, schema: string ): Promise { - const queryParams = toParams({ host, port, dbname, user, password, schema }) - return await new ApiRequest() .externalDataSources() .withAction('database_schema') - .withQueryString(queryParams) - .get() + .create({ data: { host, port, dbname, user, password, schema } }) }, }, diff --git a/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts b/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts index 1727f0ed6bb8c..a9ec4e1ccdb83 100644 --- a/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts +++ b/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts @@ -3,6 +3,106 @@ import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' import { ActivityScope, InsightShortId } from '~/types' export const teamActivityResponseJson: ActivityLogItem[] = [ + { + user: { + first_name: 'Ben', + last_name: 'White', + email: 'ben@posthog.com', + }, + unread: false, + is_system: false, + activity: 'updated', + item_id: '2', + scope: ActivityScope.TEAM, + detail: { + merge: null, + name: '🦔 PostHog App + Website', + type: undefined, + changes: [ + { + type: ActivityScope.TEAM, + after: { + recordBody: false, + recordHeaders: false, + }, + field: 'session_recording_network_payload_capture_config', + action: 'changed', + before: { + recordBody: false, + recordHeaders: true, + }, + }, + ], + trigger: null, + short_id: null, + }, + created_at: '2024-03-08T12:55:02.795667Z', + }, + { + user: { + first_name: 'Paul', + last_name: "D'Ambra", + email: 'paul@posthog.com', + }, + unread: false, + is_system: false, + activity: 'updated', + item_id: '2', + scope: ActivityScope.TEAM, + detail: { + merge: null, + name: '🦔 PostHog App + Website', + type: undefined, + changes: [ + { + type: ActivityScope.TEAM, + after: { + recordBody: true, + recordHeaders: false, + }, + field: 'session_recording_network_payload_capture_config', + action: 'changed', + before: { + recordBody: false, + recordHeaders: true, + }, + }, + ], + trigger: null, + short_id: null, + }, + created_at: '2024-03-11T14:36:31.179297Z', + }, + { + user: { + first_name: 'sdavasdadadsadas', + last_name: '', + email: 'paul@posthog.com', + }, + unread: false, + is_staff: false, + is_system: false, + activity: 'updated', + item_id: '1', + scope: ActivityScope.TEAM, + detail: { + name: 'Default Project', + type: undefined, + merge: null, + changes: [ + { + type: ActivityScope.TEAM, + after: { poe_v2_enabled: true }, + field: 'extra_settings', + action: 'created', + before: null, + }, + ], + trigger: null, + short_id: null, + }, + created_at: '2024-02-08T19:23:53.530402Z', + }, { user: { first_name: 'sdavasdadadsadas', diff --git a/frontend/src/lib/components/AuthorizedUrlList/AuthorizedUrlList.tsx b/frontend/src/lib/components/AuthorizedUrlList/AuthorizedUrlList.tsx index fda698ec0e377..4d5f7eeaa2be5 100644 --- a/frontend/src/lib/components/AuthorizedUrlList/AuthorizedUrlList.tsx +++ b/frontend/src/lib/components/AuthorizedUrlList/AuthorizedUrlList.tsx @@ -183,7 +183,7 @@ export function AuthorizedUrlList({ onClick={() => { LemonDialog.open({ title: <>Remove {keyedURL.url} ?, - description: `Are you want to remove this authorized ${ + description: `Are you sure you want to remove this authorized ${ onlyAllowDomains ? 'domain' : 'URL' }?`, primaryButton: { diff --git a/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx b/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx index e174b688a36f6..1c9e4928ecb22 100644 --- a/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx +++ b/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx @@ -293,7 +293,18 @@ function DefinitionView({ group }: { group: TaxonomicFilterGroup }): JSX.Element onChange={(value) => setLocalDefinition({ id_field: value })} /> -