From 7484120c341c72bd478681513951924390051bc8 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:27:28 +0000 Subject: [PATCH] Add property event usage list on property detail using metadata Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- frontend/src/generated/core/api.schemas.ts | 47 +++++ frontend/src/generated/core/api.ts | 37 ++++ .../definition/DefinitionView.test.tsx | 100 +++++++++++ .../definition/DefinitionView.tsx | 110 +++++++++++- .../definition/definitionLogic.test.ts | 95 +++++++++- .../definition/definitionLogic.ts | 61 ++++++- posthog/api/test/test_property_definition.py | 168 +++++++++++++++++- posthog/taxonomy/property_definition_api.py | 145 ++++++++++++++- services/mcp/definitions/core.yaml | 3 + services/mcp/src/api/generated.ts | 47 +++++ 10 files changed, 799 insertions(+), 14 deletions(-) create mode 100644 frontend/src/scenes/data-management/definition/DefinitionView.test.tsx diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts index acf17f7496ec..77ea23f663ae 100644 --- a/frontend/src/generated/core/api.schemas.ts +++ b/frontend/src/generated/core/api.schemas.ts @@ -3066,6 +3066,39 @@ export interface PatchedEnterprisePropertyDefinitionApi { hidden?: boolean | null } +export interface PropertyDefinitionEventUsageApi { + /** Event definition ID. */ + id: string + /** Event name. */ + name: string + /** + * Last time this event definition was seen by ingestion. This is event-level freshness, not a per-property volume or last-seen timestamp. + * @nullable + */ + last_seen_at: string | null +} + +export interface PropertyDefinitionEventUsageResponseApi { + /** Number of current event definitions whose event-property metadata includes this property. */ + count: number + /** + * URL for the next page of event definitions, or null when there is no next page. + * @nullable + */ + next: string | null + /** + * URL for the previous page of event definitions, or null when there is no previous page. + * @nullable + */ + previous: string | null + /** Current event definitions that have been seen with this property. */ + results: PropertyDefinitionEventUsageApi[] + /** Metadata source used for the association. `event_property_metadata` is populated asynchronously during ingestion from distinct event/property pairs. */ + source: string + /** Freshness semantics for the result set. The list is metadata-backed, not a live event-data scan: rows mean the property has been seen on the event at least once, deleted event definitions are omitted, and an empty result means no current event definition is known to use this property. */ + freshness: string +} + /** * * `add` - add * * `remove` - remove @@ -3976,6 +4009,20 @@ export const PropertyDefinitionsListType = { Session: 'session', } as const +export type PropertyDefinitionsEventsRetrieveParams = { + /** + * Maximum number of event definitions to return. Defaults to 10, maximum 100. + * @minimum 1 + * @maximum 100 + */ + limit?: number + /** + * Number of matching event definitions to skip before returning results. + * @minimum 0 + */ + offset?: number +} + export type UsersListParams = { email?: string is_staff?: boolean diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts index 219aba6def88..c37bc07dcda5 100644 --- a/frontend/src/generated/core/api.ts +++ b/frontend/src/generated/core/api.ts @@ -70,6 +70,8 @@ import type { ProjectSecretAPIKeyApi, ProjectSecretApiKeysListParams, PromotedProductIntentApi, + PropertyDefinitionEventUsageResponseApi, + PropertyDefinitionsEventsRetrieveParams, PropertyDefinitionsListParams, SharingConfigurationApi, UserApi, @@ -2865,6 +2867,41 @@ export const propertyDefinitionsDestroy = async ( }) } +export const getPropertyDefinitionsEventsRetrieveUrl = ( + projectId: string, + id: string, + params?: PropertyDefinitionsEventsRetrieveParams +) => { + const normalizedParams = new URLSearchParams() + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }) + + const stringifiedParams = normalizedParams.toString() + + return stringifiedParams.length > 0 + ? `/api/projects/${projectId}/property_definitions/${id}/events/?${stringifiedParams}` + : `/api/projects/${projectId}/property_definitions/${id}/events/` +} + +export const propertyDefinitionsEventsRetrieve = async ( + projectId: string, + id: string, + params?: PropertyDefinitionsEventsRetrieveParams, + options?: RequestInit +): Promise => { + return apiMutator( + getPropertyDefinitionsEventsRetrieveUrl(projectId, id, params), + { + ...options, + method: 'GET', + } + ) +} + export const getPropertyDefinitionsBulkUpdateTagsCreateUrl = (projectId: string) => { return `/api/projects/${projectId}/property_definitions/bulk_update_tags/` } diff --git a/frontend/src/scenes/data-management/definition/DefinitionView.test.tsx b/frontend/src/scenes/data-management/definition/DefinitionView.test.tsx new file mode 100644 index 000000000000..8aad59d7acb0 --- /dev/null +++ b/frontend/src/scenes/data-management/definition/DefinitionView.test.tsx @@ -0,0 +1,100 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, screen } from '@testing-library/react' +import { router } from 'kea-router' + +import { urls } from 'scenes/urls' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { mockEventPropertyDefinition } from '~/test/mocks' + +import { DefinitionView } from './DefinitionView' + +const propertyEventsResponse = { + count: 2, + next: null, + previous: null, + results: [ + { + id: 'event-definition-1', + name: '$pageview', + last_seen_at: '2026-06-01T12:00:00Z', + }, + { + id: 'event-definition-2', + name: 'checkout completed', + last_seen_at: null, + }, + ], + source: 'event_property_metadata', + freshness: + 'Updated asynchronously from ingestion metadata. Rows mean this property has been seen on the event at least once; deleted event definitions are omitted.', +} + +describe('DefinitionView property event usage', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/property_definitions/:id': mockEventPropertyDefinition, + '/api/projects/:team/property_definitions/:id/events/': propertyEventsResponse, + }, + }) + initKeaTests() + router.actions.push(urls.propertyDefinition('1')) + }) + + afterEach(() => { + cleanup() + }) + + it('renders events that use the property with links to event definitions', async () => { + render() + + expect(await screen.findByText('Events using this property')).toBeInTheDocument() + expect(await screen.findByText('$pageview')).toBeInTheDocument() + expect( + screen.getByText((content) => + content.includes('2 current event definitions include this property in ingestion metadata.') + ) + ).toBeInTheDocument() + expect(screen.getByText(propertyEventsResponse.freshness)).toBeInTheDocument() + + const eventLink = await screen.findByRole('link', { name: '$pageview' }) + expect(eventLink).toHaveAttribute('href', expect.stringContaining(urls.eventDefinition('event-definition-1'))) + expect(screen.getByText('checkout completed')).toBeInTheDocument() + }) + + it('renders the empty state when no current event definitions use the property', async () => { + useMocks({ + get: { + '/api/projects/:team/property_definitions/:id': mockEventPropertyDefinition, + '/api/projects/:team/property_definitions/:id/events/': { + ...propertyEventsResponse, + count: 0, + results: [], + }, + }, + }) + + render() + + expect( + await screen.findByText('No current event definitions are known to use this property') + ).toBeInTheDocument() + }) + + it('renders an error state when event usage fails to load', async () => { + useMocks({ + get: { + '/api/projects/:team/property_definitions/:id': mockEventPropertyDefinition, + '/api/projects/:team/property_definitions/:id/events/': () => [500, { detail: 'Server error' }], + }, + }) + + render() + + expect(await screen.findByText('Failed to load events that use this property.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/scenes/data-management/definition/DefinitionView.tsx b/frontend/src/scenes/data-management/definition/DefinitionView.tsx index 3567d2635929..2208d56c2671 100644 --- a/frontend/src/scenes/data-management/definition/DefinitionView.tsx +++ b/frontend/src/scenes/data-management/definition/DefinitionView.tsx @@ -15,11 +15,18 @@ import { TZLabel } from 'lib/components/TZLabel' import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' import ViewRecordingsPlaylistButton from 'lib/components/ViewRecordingButton/ViewRecordingsPlaylistButton' import { FEATURE_FLAGS } from 'lib/constants' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' +import { LemonTable, LemonTableColumns } from 'lib/lemon-ui/LemonTable' +import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' import { getPrimaryPropertyForEvent } from 'lib/utils/primaryEventProperty' -import { DefinitionLogicProps, definitionLogic } from 'scenes/data-management/definition/definitionLogic' +import { + DefinitionLogicProps, + PROPERTY_DEFINITION_EVENTS_LIMIT, + definitionLogic, +} from 'scenes/data-management/definition/definitionLogic' import { EventDefinitionExperiments } from 'scenes/data-management/events/EventDefinitionExperiments' import { EventDefinitionInsights } from 'scenes/data-management/events/EventDefinitionInsights' import { EventDefinitionProperties } from 'scenes/data-management/events/EventDefinitionProperties' @@ -28,6 +35,7 @@ import { LinkedHogFunctions } from 'scenes/hog-functions/list/LinkedHogFunctions import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' +import type { PropertyDefinitionEventUsageApi } from '~/generated/core/api.schemas' import { SceneContent } from '~/layout/scenes/components/SceneContent' import { SceneDivider } from '~/layout/scenes/components/SceneDivider' import { SceneSection } from '~/layout/scenes/components/SceneSection' @@ -124,9 +132,23 @@ function PrimaryPropertyDetail({ definition }: { definition: EventDefinition }): export function DefinitionView(props: DefinitionLogicProps): JSX.Element { const logic = definitionLogic(props) - const { definition, definitionLoading, definitionMissing, singular, isEvent, isProperty, metrics, metricsLoading } = - useValues(logic) - const { deleteDefinition } = useActions(logic) + const { + definition, + definitionLoading, + definitionMissing, + singular, + isEvent, + isProperty, + metrics, + metricsLoading, + propertyEvents, + propertyEventsLoading, + propertyEventsLoadFailed, + propertyEventsOffset, + propertyEventsNextOffset, + propertyEventsPreviousOffset, + } = useValues(logic) + const { deleteDefinition, loadPropertyEvents } = useActions(logic) const memoizedQuery = useMemo(() => { const columnsToUse = @@ -168,6 +190,23 @@ export function DefinitionView(props: DefinitionLogicProps): JSX.Element { isEvent ? TaxonomicFilterGroupType.Events : TaxonomicFilterGroupType.EventProperties ) + const propertyEventColumns: LemonTableColumns = [ + { + title: 'Event', + key: 'event', + render: function Render(_, eventDefinition) { + return + }, + }, + { + title: 'Event last seen', + key: 'last_seen_at', + render: function Render(_, eventDefinition) { + return eventDefinition.last_seen_at ? : '—' + }, + }, + ] + return ( + {isProperty && definition.id !== 'new' && ( + <> + + {propertyEventsLoadFailed ? ( + +
+ Failed to load events that use this property. + loadPropertyEvents(definition.id, propertyEventsOffset)} + > + Retry + +
+
+ ) : ( + <> + + {propertyEvents?.freshness ?? + 'Updated asynchronously from ingestion metadata. Rows mean this property has been seen on the event at least once; deleted event definitions are omitted.'} + + loadPropertyEvents(definition.id, propertyEventsNextOffset) + : undefined, + onBackward: + propertyEventsPreviousOffset !== null + ? () => loadPropertyEvents(definition.id, propertyEventsPreviousOffset) + : undefined, + }} + loading={propertyEventsLoading} + /> + + )} +
+ + + )} + {isEvent && definition.id !== 'new' && ( <> diff --git a/frontend/src/scenes/data-management/definition/definitionLogic.test.ts b/frontend/src/scenes/data-management/definition/definitionLogic.test.ts index 495a483a97af..dea98657f242 100644 --- a/frontend/src/scenes/data-management/definition/definitionLogic.test.ts +++ b/frontend/src/scenes/data-management/definition/definitionLogic.test.ts @@ -8,6 +8,27 @@ import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' import { mockEventDefinitions, mockEventPropertyDefinition } from '~/test/mocks' +const mockPropertyEventsResponse = { + count: 2, + next: null, + previous: null, + results: [ + { + id: 'event-definition-1', + name: '$pageview', + last_seen_at: '2026-06-01T12:00:00Z', + }, + { + id: 'event-definition-2', + name: 'checkout completed', + last_seen_at: null, + }, + ], + source: 'event_property_metadata', + freshness: + 'Updated asynchronously from ingestion metadata. Rows mean this property has been seen on the event at least once; deleted event definitions are omitted.', +} + describe('definitionLogic', () => { let logic: ReturnType @@ -15,7 +36,10 @@ describe('definitionLogic', () => { useMocks({ get: { '/api/projects/:team/event_definitions/:id': mockEventDefinitions[0], + '/api/projects/:team/event_definitions/:id/metrics/': { query_usage_30_day: 0 }, + '/api/projects/:team/object_media_previews': { results: [] }, '/api/projects/:team/property_definitions/:id': mockEventPropertyDefinition, + '/api/projects/:team/property_definitions/:id/events/': mockPropertyEventsResponse, }, }) initKeaTests() @@ -49,9 +73,18 @@ describe('definitionLogic', () => { router.actions.push(urls.propertyDefinition('1')) logic = definitionLogic({ id: '1' }) logic.mount() - await expectLogic(logic).toDispatchActions(['loadDefinition', 'loadDefinitionSuccess']).toMatchValues({ - definition: mockEventPropertyDefinition, - }) + await expectLogic(logic) + .toDispatchActions([ + 'loadDefinition', + 'loadDefinitionSuccess', + 'loadPropertyEvents', + 'loadPropertyEventsSuccess', + ]) + .toMatchValues({ + definition: mockEventPropertyDefinition, + propertyEvents: mockPropertyEventsResponse, + propertyEventsLoadFailed: false, + }) }) it('load new definition on mount', async () => { @@ -65,5 +98,61 @@ describe('definitionLogic', () => { definition: createNewDefinition(false), }) }) + + it('loads paginated property event usage', async () => { + let requestedOffset: string | null = null + useMocks({ + get: { + '/api/projects/:team/event_definitions/:id': mockEventDefinitions[0], + '/api/projects/:team/property_definitions/:id': mockEventPropertyDefinition, + '/api/projects/:team/property_definitions/:id/events/': (req) => { + requestedOffset = req.url.searchParams.get('offset') + return [ + 200, + { + ...mockPropertyEventsResponse, + next: null, + previous: '/api/projects/1/property_definitions/1/events/?limit=10&offset=0', + results: [mockPropertyEventsResponse.results[1]], + }, + ] + }, + }, + }) + router.actions.push(urls.propertyDefinition('1')) + logic = definitionLogic({ id: '1' }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadPropertyEventsSuccess']) + requestedOffset = null + + await expectLogic(logic, () => { + logic.actions.loadPropertyEvents('1', 10) + }) + .toDispatchActions(['loadPropertyEvents', 'loadPropertyEventsSuccess']) + .toMatchValues({ + propertyEventsOffset: 10, + propertyEventsPreviousOffset: 0, + propertyEventsNextOffset: null, + }) + + expect(requestedOffset).toBe('10') + }) + + it('tracks property event usage load failures', async () => { + useMocks({ + get: { + '/api/projects/:team/event_definitions/:id': mockEventDefinitions[0], + '/api/projects/:team/property_definitions/:id': mockEventPropertyDefinition, + '/api/projects/:team/property_definitions/:id/events/': () => [500, { detail: 'Server error' }], + }, + }) + router.actions.push(urls.propertyDefinition('1')) + logic = definitionLogic({ id: '1' }) + logic.mount() + + await expectLogic(logic) + .toDispatchActions(['loadPropertyEventsFailure']) + .toMatchValues({ propertyEventsLoadFailed: true }) + }) }) }) diff --git a/frontend/src/scenes/data-management/definition/definitionLogic.ts b/frontend/src/scenes/data-management/definition/definitionLogic.ts index 8adde4b47084..fc69c517b394 100644 --- a/frontend/src/scenes/data-management/definition/definitionLogic.ts +++ b/frontend/src/scenes/data-management/definition/definitionLogic.ts @@ -1,4 +1,4 @@ -import { actions, afterMount, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { router } from 'kea-router' @@ -6,8 +6,11 @@ import api from 'lib/api' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { Scene } from 'scenes/sceneTypes' +import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' +import { propertyDefinitionsEventsRetrieve } from '~/generated/core/api' +import type { PropertyDefinitionEventUsageResponseApi } from '~/generated/core/api.schemas' import { updatePropertyDefinitions } from '~/models/propertyDefinitionsModel' import { getFilterLabel } from '~/taxonomy/helpers' import { Breadcrumb, Definition, EventDefinitionMetrics, ObjectMediaPreview, PropertyDefinition } from '~/types' @@ -17,6 +20,8 @@ import { eventDefinitionsTableLogic } from '../events/eventDefinitionsTableLogic import { propertyDefinitionsTableLogic } from '../properties/propertyDefinitionsTableLogic' import type { definitionLogicType } from './definitionLogicType' +export const PROPERTY_DEFINITION_EVENTS_LIMIT = 10 + export const createNewDefinition = (isEvent: boolean): Definition => ({ id: 'new', name: `New ${isEvent ? 'Event' : 'Event property'}`, @@ -36,10 +41,14 @@ export const definitionLogic = kea([ path(['scenes', 'data-management', 'definition', 'definitionViewLogic']), props({} as DefinitionLogicProps), key((props) => props.id || 'new'), + connect(() => ({ + values: [teamLogic, ['currentProjectId']], + })), actions({ setDefinition: (definition: Partial, options: SetDefinitionProps = {}) => ({ definition, options }), loadDefinition: (id: Definition['id']) => ({ id }), loadMetrics: (id: Definition['id']) => ({ id }), + loadPropertyEvents: (id: Definition['id'], offset = 0) => ({ id, offset }), setDefinitionMissing: true, loadPreviews: true, createMediaPreview: (uploadedMediaId: string, metadata?: Record) => ({ @@ -55,6 +64,21 @@ export const definitionLogic = kea([ setDefinitionMissing: () => true, }, ], + propertyEventsOffset: [ + 0, + { + loadDefinition: () => 0, + loadPropertyEvents: (_, { offset }) => offset, + }, + ], + propertyEventsLoadFailed: [ + false, + { + loadPropertyEvents: () => false, + loadPropertyEventsSuccess: () => false, + loadPropertyEventsFailure: () => true, + }, + ], })), loaders(({ values, actions }) => ({ definition: [ @@ -103,6 +127,21 @@ export const definitionLogic = kea([ }, }, ], + propertyEvents: [ + null as PropertyDefinitionEventUsageResponseApi | null, + { + loadPropertyEvents: async ({ id, offset }) => { + if (values.isEvent || id === 'new') { + return null + } + + return await propertyDefinitionsEventsRetrieve(String(values.currentProjectId), id, { + limit: PROPERTY_DEFINITION_EVENTS_LIMIT, + offset, + }) + }, + }, + ], metrics: [ null as EventDefinitionMetrics | null, { @@ -149,6 +188,16 @@ export const definitionLogic = kea([ isEvent: [() => [router.selectors.location], ({ pathname }) => pathname.includes(urls.eventDefinitions())], isProperty: [(s) => [s.isEvent], (isEvent) => !isEvent], singular: [(s) => [s.isEvent], (isEvent): string => (isEvent ? 'event' : 'property')], + propertyEventsNextOffset: [ + (s) => [s.propertyEvents, s.propertyEventsOffset], + (propertyEvents, propertyEventsOffset): number | null => + propertyEvents?.next ? propertyEventsOffset + PROPERTY_DEFINITION_EVENTS_LIMIT : null, + ], + propertyEventsPreviousOffset: [ + (s) => [s.propertyEvents, s.propertyEventsOffset], + (propertyEvents, propertyEventsOffset): number | null => + propertyEvents?.previous ? Math.max(propertyEventsOffset - PROPERTY_DEFINITION_EVENTS_LIMIT, 0) : null, + ], breadcrumbs: [ (s) => [s.definition, s.isEvent], (definition, isEvent): Breadcrumb[] => { @@ -183,9 +232,11 @@ export const definitionLogic = kea([ ], }), listeners(({ actions, values }) => ({ - loadDefinitionSuccess: () => { - if (values.isEvent && values.definition.id && values.definition.id !== 'new') { + loadDefinitionSuccess: ({ definition }) => { + if (values.isEvent && definition.id && definition.id !== 'new') { actions.loadPreviews() + } else if (definition.id && definition.id !== 'new') { + actions.loadPropertyEvents(definition.id) } }, createMediaPreviewSuccess: () => { @@ -206,7 +257,9 @@ export const definitionLogic = kea([ actions.setDefinition(createNewDefinition(values.isEvent)) } else { actions.loadDefinition(props.id) - actions.loadMetrics(props.id) + if (values.isEvent) { + actions.loadMetrics(props.id) + } } }), ]) diff --git a/posthog/api/test/test_property_definition.py b/posthog/api/test/test_property_definition.py index 54daa80ba7c4..6406bdefe3c6 100644 --- a/posthog/api/test/test_property_definition.py +++ b/posthog/api/test/test_property_definition.py @@ -1,13 +1,27 @@ import json +from datetime import timedelta from typing import Any, Optional, Union, cast from posthog.test.base import APIBaseTest, BaseTest from unittest.mock import ANY, patch +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.utils import timezone + from parameterized import parameterized from rest_framework import status -from posthog.models import ActivityLog, EventDefinition, EventProperty, Organization, PropertyDefinition, Team +from posthog.models import ( + ActivityLog, + EventDefinition, + EventProperty, + Organization, + PersonalAPIKey, + PropertyDefinition, + Team, +) +from posthog.models.utils import generate_random_token_personal, hash_key_value from posthog.taxonomy.property_definition_api import PropertyDefinitionQuerySerializer, PropertyDefinitionViewSet @@ -756,6 +770,158 @@ def test_seen_together_runs_one_query_per_request(self) -> None: f"{[q['sql'] for q in event_property_queries]}" ) + def test_property_definition_events_lists_events_using_property(self) -> None: + property_definition = PropertyDefinition.objects.create(team=self.team, name="checkout_id") + newer_event = EventDefinition.objects.create( + team=self.team, + name="checkout completed", + last_seen_at=timezone.now(), + ) + older_event = EventDefinition.objects.create( + team=self.team, + name="checkout started", + last_seen_at=timezone.now() - timedelta(days=7), + ) + EventDefinition.objects.create(team=self.team, name="unrelated event") + EventProperty.objects.create(team=self.team, event=newer_event.name, property=property_definition.name) + EventProperty.objects.create(team=self.team, event=older_event.name, property=property_definition.name) + EventProperty.objects.create(team=self.team, event="unrelated event", property="other_property") + + response = self.client.get( + f"/api/projects/{self.team.pk}/property_definitions/{property_definition.id}/events/" + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["count"] == 2 + assert data["source"] == "event_property_metadata" + assert [event["name"] for event in data["results"]] == [newer_event.name, older_event.name] + assert data["results"][0]["id"] == str(newer_event.id) + assert data["results"][0]["last_seen_at"] is not None + + def test_property_definition_events_are_team_scoped(self) -> None: + property_definition = PropertyDefinition.objects.create(team=self.team, name="scoped_plan") + own_event = EventDefinition.objects.create(team=self.team, name="plan changed", last_seen_at=timezone.now()) + EventProperty.objects.create(team=self.team, event=own_event.name, property=property_definition.name) + + other_org = Organization.objects.create(name="Separate Org") + other_team = Team.objects.create(organization=other_org, name="Other Project") + EventDefinition.objects.create(team=other_team, name="plan leaked", last_seen_at=timezone.now()) + EventProperty.objects.create(team=other_team, event="plan leaked", property=property_definition.name) + + response = self.client.get( + f"/api/projects/{self.team.pk}/property_definitions/{property_definition.id}/events/" + ) + + assert response.status_code == status.HTTP_200_OK + assert [event["name"] for event in response.json()["results"]] == [own_event.name] + + def test_property_definition_events_paginate(self) -> None: + property_definition = PropertyDefinition.objects.create(team=self.team, name="page") + + for index in range(3): + event = EventDefinition.objects.create( + team=self.team, + name=f"event {index}", + last_seen_at=timezone.now() - timedelta(days=index), + ) + EventProperty.objects.create(team=self.team, event=event.name, property=property_definition.name) + + response = self.client.get( + f"/api/projects/{self.team.pk}/property_definitions/{property_definition.id}/events/?limit=2" + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["count"] == 3 + assert len(data["results"]) == 2 + assert data["next"] is not None + assert data["previous"] is None + + response = self.client.get(data["next"]) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["results"]) == 1 + assert data["next"] is None + assert data["previous"] is not None + + def test_property_definition_events_omits_deleted_definitions_but_keeps_stale_definitions(self) -> None: + property_definition = PropertyDefinition.objects.create(team=self.team, name="legacy_property") + stale_event = EventDefinition.objects.create( + team=self.team, + name="stale event", + last_seen_at=timezone.now() - timedelta(days=365), + ) + deleted_event = EventDefinition.objects.create(team=self.team, name="deleted event") + EventProperty.objects.create(team=self.team, event=stale_event.name, property=property_definition.name) + EventProperty.objects.create(team=self.team, event=deleted_event.name, property=property_definition.name) + EventProperty.objects.create( + team=self.team, event="missing event definition", property=property_definition.name + ) + deleted_event.delete() + + response = self.client.get( + f"/api/projects/{self.team.pk}/property_definitions/{property_definition.id}/events/" + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["count"] == 1 + assert [event["name"] for event in data["results"]] == [stale_event.name] + assert "deleted event definitions are omitted" in data["freshness"] + + def test_property_definition_events_empty_for_property_without_event_metadata(self) -> None: + property_definition = PropertyDefinition.objects.create(team=self.team, name="unused_property") + + response = self.client.get( + f"/api/projects/{self.team.pk}/property_definitions/{property_definition.id}/events/" + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["count"] == 0 + assert data["results"] == [] + assert "No results means no current event definition is known to use this property" in data["freshness"] + + def test_property_definition_events_requires_property_definition_read_scope(self) -> None: + property_definition = PropertyDefinition.objects.create(team=self.team, name="scoped_property") + token = generate_random_token_personal() + PersonalAPIKey.objects.create( + label="Event definition only", + user=self.user, + secure_value=hash_key_value(token), + scopes=["event_definition:read"], + ) + + self.client.logout() + response = self.client.get( + f"/api/projects/{self.team.pk}/property_definitions/{property_definition.id}/events/", + headers={"authorization": f"Bearer {token}"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_property_definition_events_uses_bounded_metadata_queries(self) -> None: + property_definition = PropertyDefinition.objects.create(team=self.team, name="bounded_property") + for index in range(10): + event = EventDefinition.objects.create(team=self.team, name=f"bounded event {index}") + EventProperty.objects.create(team=self.team, event=event.name, property=property_definition.name) + + with CaptureQueriesContext(connection) as ctx: + response = self.client.get( + f"/api/projects/{self.team.pk}/property_definitions/{property_definition.id}/events/?limit=5" + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 10 + + event_property_queries = [query for query in ctx.captured_queries if "posthog_eventproperty" in query["sql"]] + assert len(event_property_queries) <= 2, ( + f"expected pagination to use bounded event-property metadata queries, got {len(event_property_queries)}: " + f"{[query['sql'] for query in event_property_queries]}" + ) + def test_property_definition_project_id_coalesce(self): # Create legacy property with only team_id (old style) PropertyDefinition.objects.create(team=self.team, name="legacy_team_prop", property_type="String") diff --git a/posthog/taxonomy/property_definition_api.py b/posthog/taxonomy/property_definition_api.py index 50fd52fe430d..0ba569b48d2d 100644 --- a/posthog/taxonomy/property_definition_api.py +++ b/posthog/taxonomy/property_definition_api.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import json import dataclasses +from collections.abc import Sequence from typing import Any, Optional, Self, Union, cast from django.db import connection, models -from django.db.models import Manager, QuerySet +from django.db.models import F, Manager, QuerySet, Subquery from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 @@ -20,7 +23,7 @@ from posthog.constants import GROUP_TYPES_LIMIT from posthog.event_usage import report_user_action from posthog.filters import TermSearchFilterBackend, term_search_filter_sql -from posthog.models import EventProperty, PropertyDefinition, User +from posthog.models import EventDefinition, EventProperty, PropertyDefinition, User from posthog.models.activity_logging.activity_log import Detail, log_activity from posthog.models.utils import UUIDT from posthog.settings import EE_AVAILABLE @@ -43,6 +46,65 @@ class SeenTogetherQuerySerializer(serializers.Serializer): property_name: serializers.CharField = serializers.CharField(required=True) +class PropertyDefinitionEventUsageQuerySerializer(serializers.Serializer): + limit = serializers.IntegerField( + min_value=1, + max_value=100, + default=10, + required=False, + help_text="Maximum number of event definitions to return. Defaults to 10, maximum 100.", + ) + offset = serializers.IntegerField( + min_value=0, + default=0, + required=False, + help_text="Number of matching event definitions to skip before returning results.", + ) + + +class PropertyDefinitionEventUsageSerializer(serializers.Serializer): + id = serializers.UUIDField(help_text="Event definition ID.") + name = serializers.CharField(help_text="Event name.") + last_seen_at = serializers.DateTimeField( + allow_null=True, + help_text=( + "Last time this event definition was seen by ingestion. This is event-level freshness, " + "not a per-property volume or last-seen timestamp." + ), + ) + + +class PropertyDefinitionEventUsageResponseSerializer(serializers.Serializer): + count = serializers.IntegerField( + help_text="Number of current event definitions whose event-property metadata includes this property." + ) + next = serializers.CharField( + allow_null=True, + help_text="URL for the next page of event definitions, or null when there is no next page.", + ) + previous = serializers.CharField( + allow_null=True, + help_text="URL for the previous page of event definitions, or null when there is no previous page.", + ) + results = PropertyDefinitionEventUsageSerializer( + many=True, + help_text="Current event definitions that have been seen with this property.", + ) + source = serializers.CharField( + help_text=( + "Metadata source used for the association. `event_property_metadata` is populated asynchronously " + "during ingestion from distinct event/property pairs." + ) + ) + freshness = serializers.CharField( + help_text=( + "Freshness semantics for the result set. The list is metadata-backed, not a live event-data scan: " + "rows mean the property has been seen on the event at least once, deleted event definitions are omitted, " + "and an empty result means no current event definition is known to use this property." + ) + ) + + class PropertyDefinitionQuerySerializer(serializers.Serializer): search = serializers.CharField( help_text="Searches properties by name", @@ -562,6 +624,11 @@ def paginate_queryset(self, queryset, request, view=None) -> Optional[list[Any]] return list(queryset) +class PropertyDefinitionEventUsagePaginator(LimitOffsetPagination): + default_limit = 10 + max_limit = 100 + + @extend_schema(extensions={"x-product": "core"}) class PropertyDefinitionViewSet( TeamAndOrgViewSetMixin, @@ -912,6 +979,80 @@ def seen_together(self, request: request.Request, *args: Any, **kwargs: Any) -> return response.Response(results) + @extend_schema( + parameters=[PropertyDefinitionEventUsageQuerySerializer], + responses={200: PropertyDefinitionEventUsageResponseSerializer}, + ) + @action(methods=["GET"], detail=True, url_path="events", required_scopes=["property_definition:read"]) + def events(self, request: request.Request, *args: Any, **kwargs: Any) -> response.Response: + property_definition: PropertyDefinition = self.safely_get_object(PropertyDefinition.objects.none()) + self.check_object_permissions(request, property_definition) + + query_serializer = PropertyDefinitionEventUsageQuerySerializer(data=request.GET) + query_serializer.is_valid(raise_exception=True) + + if property_definition.type != PropertyDefinition.Type.EVENT: + return response.Response(self._events_response([], 0, None, None)) + + event_names = ( + EventProperty.objects.alias( + effective_project_id=Coalesce("project_id", "team_id", output_field=models.BigIntegerField()) + ) + .filter( + effective_project_id=self.project_id, + property=property_definition.name, + ) + .values("event") + .distinct() + ) + + event_definitions = ( + EventDefinition.objects.alias( + effective_project_id=Coalesce("project_id", "team_id", output_field=models.BigIntegerField()) + ) + .filter( + effective_project_id=self.project_id, + name__in=Subquery(event_names), + ) + .only("id", "name", "last_seen_at") + .order_by(F("last_seen_at").desc(nulls_last=True), "name") + ) + + paginator = PropertyDefinitionEventUsagePaginator() + page = paginator.paginate_queryset(event_definitions, request, view=self) + objects = page if page is not None else list(event_definitions) + serializer = PropertyDefinitionEventUsageSerializer(objects, many=True) + results = cast(list[dict[str, Any]], serializer.data) + + return response.Response( + self._events_response( + results, + cast(int, paginator.count), + paginator.get_next_link(), + paginator.get_previous_link(), + ) + ) + + @staticmethod + def _events_response( + results: Sequence[dict[str, Any]], + count: int, + next_url: str | None, + previous_url: str | None, + ) -> dict[str, Any]: + return { + "count": count, + "next": next_url, + "previous": previous_url, + "results": results, + "source": "event_property_metadata", + "freshness": ( + "Updated asynchronously from ingestion metadata. Rows mean this property has been seen on the event " + "at least once; deleted event definitions are omitted. No results means no current event definition is " + "known to use this property." + ), + } + def destroy(self, request: request.Request, *args: Any, **kwargs: Any) -> response.Response: instance: PropertyDefinition = self.get_object() instance_id = str(instance.id) diff --git a/services/mcp/definitions/core.yaml b/services/mcp/definitions/core.yaml index 22775684a9ad..526b8551ab9a 100644 --- a/services/mcp/definitions/core.yaml +++ b/services/mcp/definitions/core.yaml @@ -530,6 +530,9 @@ tools: property-definitions-destroy: operation: property_definitions_destroy enabled: false + property-definitions-events-retrieve: + operation: property_definitions_events_retrieve + enabled: false property-definitions-list: operation: property_definitions_list enabled: false diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index d47789fe0433..8efe84c3cf4f 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -40439,6 +40439,39 @@ export namespace Schemas { role?: string | null; } + export interface PropertyDefinitionEventUsage { + /** Event definition ID. */ + id: string; + /** Event name. */ + name: string; + /** + * Last time this event definition was seen by ingestion. This is event-level freshness, not a per-property volume or last-seen timestamp. + * @nullable + */ + last_seen_at: string | null; + } + + export interface PropertyDefinitionEventUsageResponse { + /** Number of current event definitions whose event-property metadata includes this property. */ + count: number; + /** + * URL for the next page of event definitions, or null when there is no next page. + * @nullable + */ + next: string | null; + /** + * URL for the previous page of event definitions, or null when there is no previous page. + * @nullable + */ + previous: string | null; + /** Current event definitions that have been seen with this property. */ + results: PropertyDefinitionEventUsage[]; + /** Metadata source used for the association. `event_property_metadata` is populated asynchronously during ingestion from distinct event/property pairs. */ + source: string; + /** Freshness semantics for the result set. The list is metadata-backed, not a live event-data scan: rows mean the property has been seen on the event at least once, deleted event definitions are omitted, and an empty result means no current event definition is known to use this property. */ + freshness: string; + } + export type PropertyType = typeof PropertyType[keyof typeof PropertyType]; @@ -58846,6 +58879,20 @@ export namespace Schemas { Session: 'session', } as const; + export type PropertyDefinitionsEventsRetrieveParams = { + /** + * Maximum number of event definitions to return. Defaults to 10, maximum 100. + * @minimum 1 + * @maximum 100 + */ + limit?: number; + /** + * Number of matching event definitions to skip before returning results. + * @minimum 0 + */ + offset?: number; + }; + export type QueryLogRetrieve200 = { [key: string]: unknown }; export type QueryCheckAuthForAsyncCreate200 = { [key: string]: unknown };