From 48fe87d3bd5019d360a765e1c754911308a59765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20Br=C3=A4nn?= Date: Fri, 31 Oct 2025 11:53:31 +0100 Subject: [PATCH 1/4] feat(events): Add external_id and parent_event_id to Events This will allow us to store events hierarchically and introduce a span-type structure where different events are related to each other and can be bundled up. --- ...add_external_id_and_parent_id_to_events.py | 49 ++++++ server/polar/event/endpoints.py | 5 + server/polar/event/repository.py | 21 ++- server/polar/event/schemas.py | 17 ++ server/polar/event/service.py | 151 +++++++++++++++--- server/polar/models/event.py | 12 ++ server/tests/event/test_endpoints.py | 2 +- server/tests/fixtures/random_objects.py | 4 + 8 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 server/migrations/versions/2025-11-03-1514_add_external_id_and_parent_id_to_events.py diff --git a/server/migrations/versions/2025-11-03-1514_add_external_id_and_parent_id_to_events.py b/server/migrations/versions/2025-11-03-1514_add_external_id_and_parent_id_to_events.py new file mode 100644 index 0000000000..8bab085329 --- /dev/null +++ b/server/migrations/versions/2025-11-03-1514_add_external_id_and_parent_id_to_events.py @@ -0,0 +1,49 @@ +"""add external_id and parent_id to events + +Revision ID: cd0255e61066 +Revises: f3cbc3937937 +Create Date: 2025-11-03 15:14:55.787471 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "cd0255e61066" +down_revision = "f3cbc3937937" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("events", sa.Column("external_id", sa.String(), nullable=True)) + op.add_column("events", sa.Column("parent_id", sa.Uuid(), nullable=True)) + op.create_index( + op.f("ix_events_external_id"), + "events", + ["external_id"], + unique=True, + ) + op.create_index(op.f("ix_events_parent_id"), "events", ["parent_id"], unique=False) + op.create_foreign_key( + op.f("events_parent_id_fkey"), + "events", + "events", + ["parent_id"], + ["id"], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f("events_parent_id_fkey"), "events", type_="foreignkey") + op.drop_index(op.f("ix_events_parent_id"), table_name="events") + op.drop_index(op.f("ix_events_external_id"), table_name="events") + op.drop_column("events", "parent_id") + op.drop_column("events", "external_id") + # ### end Alembic commands ### diff --git a/server/polar/event/endpoints.py b/server/polar/event/endpoints.py index f3c678feab..89c2c931b5 100644 --- a/server/polar/event/endpoints.py +++ b/server/polar/event/endpoints.py @@ -80,6 +80,10 @@ async def list( query: str | None = Query( None, title="Query", description="Query to filter events." ), + parent_id: EventID | None = Query( + None, + description="Filter events by parent event ID. When not specified, returns root events only.", + ), session: AsyncSession = Depends(get_db_session), ) -> ListResource[EventSchema]: """List events.""" @@ -119,6 +123,7 @@ async def list( pagination=pagination, sorting=sorting, query=query, + parent_id=parent_id, ) return ListResource.from_paginated_results( diff --git a/server/polar/event/repository.py b/server/polar/event/repository.py index 840afe346d..9c703a81da 100644 --- a/server/polar/event/repository.py +++ b/server/polar/event/repository.py @@ -9,10 +9,10 @@ Select, and_, func, - insert, or_, select, ) +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import joinedload from polar.auth.models import AuthSubject, Organization, User, is_organization, is_user @@ -38,12 +38,23 @@ async def get_all_by_organization(self, organization_id: UUID) -> Sequence[Event ) return await self.get_all(statement) - async def insert_batch(self, events: Sequence[dict[str, Any]]) -> Sequence[UUID]: + async def insert_batch( + self, events: Sequence[dict[str, Any]] + ) -> tuple[Sequence[UUID], int]: if not events: - return [] - statement = insert(Event).returning(Event.id) + return [], 0 + + statement = ( + insert(Event) + .on_conflict_do_nothing(index_elements=["external_id"]) + .returning(Event.id) + ) result = await self.session.execute(statement, events) - return result.scalars().all() + inserted_ids = result.scalars().all() + + duplicates_count = len(events) - len(inserted_ids) + + return inserted_ids, duplicates_count async def get_latest_meter_reset( self, customer: Customer, meter_id: UUID diff --git a/server/polar/event/schemas.py b/server/polar/event/schemas.py index 4238da1cac..64e5a5a9a9 100644 --- a/server/polar/event/schemas.py +++ b/server/polar/event/schemas.py @@ -137,6 +137,20 @@ class EventCreateBase(Schema): "**Required unless you use an organization token.**" ), ) + external_id: str | None = Field( + default=None, + description=( + "Your unique identifier for this event. " + "Useful for deduplication and parent-child relationships." + ), + ) + parent_id: str | None = Field( + default=None, + description=( + "The ID of the parent event. " + "Can be either a Polar event ID (UUID) or an external event ID." + ), + ) metadata: EventMetadataInput = Field( description=METADATA_DESCRIPTION.format( heading=( @@ -172,6 +186,9 @@ class EventsIngest(Schema): class EventsIngestResponse(Schema): inserted: int = Field(description="Number of events inserted.") + duplicates: int = Field( + default=0, description="Number of duplicate events skipped." + ) class BaseEvent(IDSchema): diff --git a/server/polar/event/service.py b/server/polar/event/service.py index 419098eb7e..308e0cea38 100644 --- a/server/polar/event/service.py +++ b/server/polar/event/service.py @@ -4,7 +4,18 @@ from typing import Any import structlog -from sqlalchemy import String, UnaryExpression, asc, cast, desc, func, or_, select, text +from sqlalchemy import ( + Select, + String, + UnaryExpression, + asc, + cast, + desc, + func, + or_, + select, + text, +) from polar.auth.models import AuthSubject, is_organization, is_user from polar.customer.repository import CustomerRepository @@ -21,7 +32,12 @@ from polar.worker import enqueue_events from .repository import EventRepository -from .schemas import EventCreateCustomer, EventName, EventsIngest, EventsIngestResponse +from .schemas import ( + EventCreateCustomer, + EventName, + EventsIngest, + EventsIngestResponse, +) from .sorting import EventNamesSortProperty, EventSortProperty log: Logger = structlog.get_logger() @@ -37,10 +53,11 @@ def __init__(self, errors: list[ValidationError]) -> None: class EventService: - async def list( + async def _build_filtered_statement( self, session: AsyncSession, auth_subject: AuthSubject[User | Organization], + repository: EventRepository, *, filter: Filter | None = None, start_timestamp: datetime | None = None, @@ -52,13 +69,11 @@ async def list( name: Sequence[str] | None = None, source: Sequence[EventSource] | None = None, metadata: MetadataQuery | None = None, - pagination: PaginationParams, - sorting: list[Sorting[EventSortProperty]] = [ - (EventSortProperty.timestamp, True) - ], + sorting: Sequence[Sorting[EventSortProperty]] = ( + (EventSortProperty.timestamp, True), + ), query: str | None = None, - ) -> tuple[Sequence[Event], int]: - repository = EventRepository.from_session(session) + ) -> Select[tuple[Event]]: statement = repository.get_readable_statement(auth_subject).options( *repository.get_eager_options() ) @@ -139,6 +154,54 @@ async def list( order_by_clauses.append(clause_function(Event.timestamp)) statement = statement.order_by(*order_by_clauses) + return statement + + async def list( + self, + session: AsyncSession, + auth_subject: AuthSubject[User | Organization], + *, + filter: Filter | None = None, + start_timestamp: datetime | None = None, + end_timestamp: datetime | None = None, + organization_id: Sequence[uuid.UUID] | None = None, + customer_id: Sequence[uuid.UUID] | None = None, + external_customer_id: Sequence[str] | None = None, + meter_id: uuid.UUID | None = None, + name: Sequence[str] | None = None, + source: Sequence[EventSource] | None = None, + metadata: MetadataQuery | None = None, + pagination: PaginationParams, + sorting: Sequence[Sorting[EventSortProperty]] = ( + (EventSortProperty.timestamp, True), + ), + query: str | None = None, + parent_id: uuid.UUID | None = None, + ) -> tuple[Sequence[Event], int]: + repository = EventRepository.from_session(session) + statement = await self._build_filtered_statement( + session, + auth_subject, + repository, + filter=filter, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + organization_id=organization_id, + customer_id=customer_id, + external_customer_id=external_customer_id, + meter_id=meter_id, + name=name, + source=source, + metadata=metadata, + sorting=sorting, + query=query, + ) + + if parent_id is not None: + statement = statement.where(Event.parent_id == parent_id) + else: + statement = statement.where(Event.parent_id.is_(None)) + return await repository.paginate( statement, limit=pagination.limit, page=pagination.page ) @@ -246,28 +309,36 @@ async def ingest( ) if isinstance(event_create, EventCreateCustomer): validate_customer_id(index, event_create.customer_id) + + parent_id: uuid.UUID | None = None + if event_create.parent_id is not None: + parent_id = await self._resolve_parent_id( + session, index, event_create.parent_id, organization_id + ) except EventIngestValidationError as e: errors.extend(e.errors) continue else: - events.append( - { - "source": EventSource.user, - "organization_id": organization_id, - **event_create.model_dump( - exclude={"organization_id"}, by_alias=True - ), - } + event_dict = event_create.model_dump( + exclude={"organization_id", "parent_id"}, by_alias=True ) + event_dict["source"] = EventSource.user + event_dict["organization_id"] = organization_id + if parent_id is not None: + event_dict["parent_id"] = parent_id + + events.append(event_dict) if len(errors) > 0: raise PolarRequestValidationError(errors) repository = EventRepository.from_session(session) - event_ids = await repository.insert_batch(events) + event_ids, duplicates_count = await repository.insert_batch(events) enqueue_events(*event_ids) - return EventsIngestResponse(inserted=len(events)) + return EventsIngestResponse( + inserted=len(event_ids), duplicates=duplicates_count + ) async def create_event(self, session: AsyncSession, event: Event) -> Event: repository = EventRepository.from_session(session) @@ -404,5 +475,47 @@ def _validate_customer_id(index: int, customer_id: uuid.UUID) -> uuid.UUID: return _validate_customer_id + async def _resolve_parent_id( + self, + session: AsyncSession, + index: int, + parent_id: str, + organization_id: uuid.UUID, + ) -> uuid.UUID: + try: + parent_uuid = uuid.UUID(parent_id) + statement = select(Event.id).where( + Event.id == parent_uuid, Event.organization_id == organization_id + ) + result = await session.execute(statement) + found_id = result.scalar_one_or_none() + + if found_id is not None: + return found_id + + except ValueError: + pass + + statement = select(Event.id).where( + Event.external_id == parent_id, + Event.organization_id == organization_id, + ) + result = await session.execute(statement) + found_id = result.scalar_one_or_none() + + if found_id is not None: + return found_id + + raise EventIngestValidationError( + [ + { + "type": "parent_id", + "msg": "Parent event not found.", + "loc": ("body", "events", index, "parent_id"), + "input": parent_id, + } + ] + ) + event = EventService() diff --git a/server/polar/models/event.py b/server/polar/models/event.py index 3354a9334c..d47fa40661 100644 --- a/server/polar/models/event.py +++ b/server/polar/models/event.py @@ -127,6 +127,18 @@ class Event(Model, MetadataMixin): String, nullable=True, index=True ) + external_id: Mapped[str | None] = mapped_column( + String, nullable=True, index=True, unique=True + ) + + parent_id: Mapped[UUID | None] = mapped_column( + Uuid, ForeignKey("events.id"), nullable=True, index=True + ) + + @declared_attr + def parent(cls) -> Mapped["Event | None"]: + return relationship("Event", remote_side="Event.id", lazy="raise") + @declared_attr def customer(cls) -> Mapped[Customer | None]: return relationship( diff --git a/server/tests/event/test_endpoints.py b/server/tests/event/test_endpoints.py index 253602fca4..fdf1dcdb0a 100644 --- a/server/tests/event/test_endpoints.py +++ b/server/tests/event/test_endpoints.py @@ -110,4 +110,4 @@ async def test_valid( assert response.status_code == 200 json = response.json() - assert json == {"inserted": len(events)} + assert json == {"inserted": len(events), "duplicates": 0} diff --git a/server/tests/fixtures/random_objects.py b/server/tests/fixtures/random_objects.py index eb52d662d9..8d3fa3856c 100644 --- a/server/tests/fixtures/random_objects.py +++ b/server/tests/fixtures/random_objects.py @@ -1725,6 +1725,8 @@ async def create_event( timestamp: datetime | None = None, customer: Customer | None = None, external_customer_id: str | None = None, + external_id: str | None = None, + parent_id: uuid.UUID | None = None, metadata: dict[str, str | int | bool | float | Any] | None = None, ) -> Event: event = Event( @@ -1733,6 +1735,8 @@ async def create_event( name=name, customer_id=customer.id if customer else None, external_customer_id=external_customer_id, + external_id=external_id, + parent_id=parent_id, organization=organization, user_metadata=metadata or {}, ) From da08ca7fb7f23b8dfdd563eee085fac531aae19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20Br=C3=A4nn?= Date: Mon, 3 Nov 2025 14:33:59 +0100 Subject: [PATCH 2/4] feat(events): Add children_count to Events --- clients/packages/client/src/v1.ts | 120 +++++++++++++++++++++++++- server/polar/event/schemas.py | 3 + server/polar/models/event.py | 14 +++ server/tests/event/test_endpoints.py | 124 +++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) diff --git a/clients/packages/client/src/v1.ts b/clients/packages/client/src/v1.ts index 2794330759..278a63f5f9 100644 --- a/clients/packages/client/src/v1.ts +++ b/clients/packages/client/src/v1.ts @@ -5602,6 +5602,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -6900,6 +6906,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -7452,6 +7464,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -7523,6 +7541,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -11578,6 +11602,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -11745,6 +11775,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -13541,6 +13577,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -14821,6 +14863,16 @@ export interface components { * @description The ID of the organization owning the event. **Required unless you use an organization token.** */ organization_id?: string | null + /** + * External Id + * @description Your unique identifier for this event. Useful for deduplication and parent-child relationships. + */ + external_id?: string | null + /** + * Parent Id + * @description The ID of the parent event. Can be either a Polar event ID (UUID) or an external event ID. + */ + parent_id?: string | null /** * @description Key-value object allowing you to store additional information about the event. Some keys like `_llm` are structured data that are handled specially by Polar. * @@ -14860,6 +14912,16 @@ export interface components { * @description The ID of the organization owning the event. **Required unless you use an organization token.** */ organization_id?: string | null + /** + * External Id + * @description Your unique identifier for this event. Useful for deduplication and parent-child relationships. + */ + external_id?: string | null + /** + * Parent Id + * @description The ID of the parent event. Can be either a Polar event ID (UUID) or an external event ID. + */ + parent_id?: string | null /** * @description Key-value object allowing you to store additional information about the event. Some keys like `_llm` are structured data that are handled specially by Polar. * @@ -14962,6 +15024,12 @@ export interface components { * @description Number of events inserted. */ inserted: number + /** + * Duplicates + * @description Number of duplicate events skipped. + * @default 0 + */ + duplicates: number } /** * ExistingProductPrice @@ -16383,6 +16451,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -16467,6 +16541,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -17528,6 +17608,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -17654,6 +17740,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -20802,6 +20894,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -20917,6 +21015,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -21029,6 +21133,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Source * @description The source of the event. `system` events are created by Polar. `user` events are the one you create through our ingestion API. @@ -21643,6 +21753,12 @@ export interface components { * @description ID of the customer in your system associated with the event. */ external_customer_id: string | null + /** + * Child Count + * @description Number of direct child events linked to this event. + * @default 0 + */ + child_count: number /** * Name * @description The name of the event. @@ -26912,7 +27028,6 @@ export interface operations { | 'America/Coral_Harbour' | 'America/Cordoba' | 'America/Costa_Rica' - | 'America/Coyhaique' | 'America/Creston' | 'America/Cuiaba' | 'America/Curacao' @@ -31358,6 +31473,8 @@ export interface operations { | null /** @description Query to filter events. */ query?: string | null + /** @description Filter events by parent event ID. When not specified, returns root events only. */ + parent_id?: string | null /** @description Page number, defaults to 1. */ page?: number /** @description Size of a page, defaults to 10. Maximum is 100. */ @@ -33502,7 +33619,6 @@ export const pathsV1MetricsGetParametersQueryTimezoneValues: ReadonlyArray< 'America/Coral_Harbour', 'America/Cordoba', 'America/Costa_Rica', - 'America/Coyhaique', 'America/Creston', 'America/Cuiaba', 'America/Curacao', diff --git a/server/polar/event/schemas.py b/server/polar/event/schemas.py index 64e5a5a9a9..ad86ee09c9 100644 --- a/server/polar/event/schemas.py +++ b/server/polar/event/schemas.py @@ -207,6 +207,9 @@ class BaseEvent(IDSchema): external_customer_id: str | None = Field( description="ID of the customer in your system associated with the event." ) + child_count: int = Field( + default=0, description="Number of direct child events linked to this event." + ) class SystemEventBase(BaseEvent): diff --git a/server/polar/models/event.py b/server/polar/models/event.py index d47fa40661..95fa4ebe96 100644 --- a/server/polar/models/event.py +++ b/server/polar/models/event.py @@ -12,10 +12,13 @@ Uuid, and_, case, + column, exists, extract, + func, or_, select, + table, ) from sqlalchemy import ( cast as sql_cast, @@ -164,6 +167,17 @@ def customer(cls) -> Mapped[Customer | None]: ) ) + @declared_attr + def child_count(cls) -> Mapped[int]: + child_events = table("events", column("parent_id")).alias("child_events") + return column_property( + select(func.count()) + .select_from(child_events) + .where(child_events.c.parent_id == cls.id) + .correlate_except(child_events) + .scalar_subquery() + ) + organization_id: Mapped[UUID] = mapped_column( Uuid, ForeignKey("organizations.id", ondelete="cascade"), diff --git a/server/tests/event/test_endpoints.py b/server/tests/event/test_endpoints.py index fdf1dcdb0a..fb3142ed20 100644 --- a/server/tests/event/test_endpoints.py +++ b/server/tests/event/test_endpoints.py @@ -82,6 +82,130 @@ async def test_filter( assert json["pagination"]["total_count"] == 1 assert json["items"][0]["id"] == str(event2.id) + @pytest.mark.auth + async def test_children_sorting( + self, + save_fixture: SaveFixture, + client: AsyncClient, + organization: Organization, + user_organization: UserOrganization, + ) -> None: + """Test that children are sorted according to sorting parameter.""" + base_time = utc_now() + + root_event1 = await create_event( + save_fixture, + organization=organization, + name="root1", + timestamp=base_time - timedelta(hours=10), + ) + + root_event2 = await create_event( + save_fixture, + organization=organization, + name="root2", + timestamp=base_time - timedelta(hours=5), + ) + + child1 = await create_event( + save_fixture, + organization=organization, + name="child1", + parent_id=root_event1.id, + timestamp=base_time - timedelta(hours=3), + ) + + child2 = await create_event( + save_fixture, + organization=organization, + name="child2", + parent_id=root_event1.id, + timestamp=base_time - timedelta(hours=1), + ) + + child3 = await create_event( + save_fixture, + organization=organization, + name="child3", + parent_id=root_event1.id, + timestamp=base_time - timedelta(hours=2), + ) + + # Test descending sort (newest first) + response = await client.get( + "/v1/events/", + params={ + "organization_id": str(organization.id), + "sorting": "-timestamp", + }, + ) + + assert response.status_code == 200 + json = response.json() + + items = json["items"] + assert len(items) == 2 + + # Root events should be sorted newest to oldest + assert items[0]["id"] == str(root_event2.id) # 5 hours ago + assert items[1]["id"] == str(root_event1.id) # 10 hours ago + assert items[1]["child_count"] == 3 + + # Query children with descending sort + response = await client.get( + "/v1/events/", + params={ + "organization_id": str(organization.id), + "parent_id": str(root_event1.id), + "sorting": "-timestamp", + }, + ) + + assert response.status_code == 200 + json = response.json() + children = json["items"] + assert len(children) == 3 + assert children[0]["id"] == str(child2.id) # 1 hour ago + assert children[1]["id"] == str(child3.id) # 2 hours ago + assert children[2]["id"] == str(child1.id) # 3 hours ago + + # Test ascending sort (oldest first) + response = await client.get( + "/v1/events/", + params={ + "organization_id": str(organization.id), + "sorting": "timestamp", + }, + ) + + assert response.status_code == 200 + json = response.json() + + items = json["items"] + assert len(items) == 2 + + # Root events should be sorted oldest to newest + assert items[0]["id"] == str(root_event1.id) # 10 hours ago + assert items[1]["id"] == str(root_event2.id) # 5 hours ago + + # Query children with ascending sort + response = await client.get( + "/v1/events/", + params={ + "organization_id": str(organization.id), + "parent_id": str(root_event1.id), + "sorting": "timestamp", + }, + ) + + assert response.status_code == 200 + json = response.json() + children = json["items"] + assert len(children) == 3 + assert children[0]["id"] == str(child1.id) # 3 hours ago + assert children[1]["id"] == str(child3.id) # 2 hours ago + assert children[2]["id"] == str(child2.id) # 1 hour ago + @pytest.mark.asyncio class TestIngest: From 457728ebd0a5a1314313202fb3b8077b0f1fe100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20Br=C3=A4nn?= Date: Mon, 3 Nov 2025 14:56:19 +0100 Subject: [PATCH 3/4] feat(dashboard/events): Load + list child events --- .../web/src/components/Events/EventRow.tsx | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/clients/apps/web/src/components/Events/EventRow.tsx b/clients/apps/web/src/components/Events/EventRow.tsx index ec00a99a27..4364a7c756 100644 --- a/clients/apps/web/src/components/Events/EventRow.tsx +++ b/clients/apps/web/src/components/Events/EventRow.tsx @@ -1,3 +1,5 @@ +import Pagination from '@/components/Pagination/Pagination' +import { useEvents } from '@/hooks/queries/events' import KeyboardArrowDownOutlined from '@mui/icons-material/KeyboardArrowDownOutlined' import KeyboardArrowRightOutlined from '@mui/icons-material/KeyboardArrowRightOutlined' import { schemas } from '@polar-sh/client' @@ -8,19 +10,39 @@ import { TooltipTrigger, } from '@polar-sh/ui/components/ui/tooltip' import Link from 'next/link' +import { useSearchParams } from 'next/navigation' import { useMemo, useState } from 'react' import { EventCustomer } from './EventCustomer' import { EventSourceBadge } from './EventSourceBadge' import { useEventCard, useEventCostBadge, useEventDisplayName } from './utils' +const PAGE_SIZE = 100 + export const EventRow = ({ event, organization, + depth = 0, }: { event: schemas['Event'] organization: schemas['Organization'] + depth?: number }) => { const [isExpanded, setIsExpanded] = useState(false) + const [childrenPage, setChildrenPage] = useState(1) + const searchParams = useSearchParams() + + const { data: childrenData } = useEvents( + organization.id, + { + parent_id: event.id, + page: childrenPage, + limit: PAGE_SIZE, + }, + isExpanded && event.child_count > 0, + ) + + const hasChildren = event.child_count > 0 + const children = childrenData?.items ?? [] const handleToggleExpand = () => { setIsExpanded(!isExpanded) @@ -52,8 +74,13 @@ export const EventRow = ({ const eventCard = useEventCard(event) const eventCostBadge = useEventCostBadge(event) + const leftMargin = depth > 0 ? `${depth * 16}px` : '0px' + return ( -
+
{eventDisplayName} + {hasChildren && ( + + children: {event.child_count} + + )}
{formattedTimestamp} @@ -112,6 +144,29 @@ export const EventRow = ({
{isExpanded ? eventCard : null} {isExpanded ? : null} + {isExpanded && hasChildren && ( +
+ {children.map((child) => ( + + ))} + {childrenData && childrenData.pagination.total_count > PAGE_SIZE && ( +
+ +
+ )} +
+ )}
) } From 37da6b43e48414e2c2cba22da429f0065c6ceee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20Br=C3=A4nn?= Date: Mon, 3 Nov 2025 15:24:40 +0100 Subject: [PATCH 4/4] fix(dashboard/events): Only do query out for one level of nesting --- clients/apps/web/src/components/Events/EventRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/apps/web/src/components/Events/EventRow.tsx b/clients/apps/web/src/components/Events/EventRow.tsx index 4364a7c756..6e43058dd7 100644 --- a/clients/apps/web/src/components/Events/EventRow.tsx +++ b/clients/apps/web/src/components/Events/EventRow.tsx @@ -38,7 +38,7 @@ export const EventRow = ({ page: childrenPage, limit: PAGE_SIZE, }, - isExpanded && event.child_count > 0, + isExpanded && event.child_count > 0 && depth < 1, ) const hasChildren = event.child_count > 0