feat: event schema, push posthog schema, fix some things (#40846)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Sandy Spicer
2025-11-05 12:35:23 -08:00
committed by GitHub
parent 2653b2f5b4
commit 6ad0f94755
13 changed files with 4152 additions and 40 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -79,24 +79,30 @@ describe('query', () => {
})
it('emits an event when a query is run', async () => {
jest.spyOn(posthog, 'capture')
const captureSpy = jest.spyOn(posthog, 'capture')
const q: EventsQuery = setLatestVersionsOnQuery({
kind: NodeKind.EventsQuery,
select: ['timestamp'],
limit: 100,
})
captureSpy.mockClear()
await performQuery(q)
expect(posthog.capture).toHaveBeenCalledWith('query completed', { query: q, duration: expect.any(Number) })
const queryCompletedCalls = captureSpy.mock.calls.filter((call) => call[0] === 'query completed')
expect(queryCompletedCalls).toHaveLength(1)
expect(queryCompletedCalls[0][1]).toMatchObject({ query: q, duration: expect.any(Number) })
})
it('emits a specific event on a HogQLQuery', async () => {
jest.spyOn(posthog, 'capture')
const captureSpy = jest.spyOn(posthog, 'capture')
const q: HogQLQuery = setLatestVersionsOnQuery({
kind: NodeKind.HogQLQuery,
query: 'select * from events',
})
captureSpy.mockClear()
await performQuery(q)
expect(posthog.capture).toHaveBeenCalledWith('query completed', {
const queryCompletedCalls = captureSpy.mock.calls.filter((call) => call[0] === 'query completed')
expect(queryCompletedCalls).toHaveLength(1)
expect(queryCompletedCalls[0][1]).toMatchObject({
query: q,
duration: expect.any(Number),
clickhouse_sql: expect.any(String),
@@ -105,16 +111,19 @@ describe('query', () => {
})
it('emits an event when a query errors', async () => {
jest.spyOn(posthog, 'capture')
const captureSpy = jest.spyOn(posthog, 'capture')
const q: EventsQuery = setLatestVersionsOnQuery({
kind: NodeKind.EventsQuery,
select: ['error'],
limit: 100,
})
captureSpy.mockClear()
await expect(async () => {
await performQuery(q)
}).rejects.toThrow(ApiError)
expect(posthog.capture).toHaveBeenCalledWith('query failed', { query: q, duration: expect.any(Number) })
const queryFailedCalls = captureSpy.mock.calls.filter((call) => call[0] === 'query failed')
expect(queryFailedCalls).toHaveLength(1)
expect(queryFailedCalls[0][1]).toMatchObject({ query: q, duration: expect.any(Number) })
})
})

View File

@@ -1,6 +1,5 @@
import posthog from 'posthog-js'
import api, { ApiMethodOptions } from 'lib/api'
import posthog from 'lib/posthog-typed'
import { delay } from 'lib/utils'
import {

View File

@@ -7,15 +7,12 @@ import { LemonButton, LemonCheckbox, LemonInput, LemonModal, LemonSelect, LemonT
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonTable, LemonTableColumns } from 'lib/lemon-ui/LemonTable'
import { PropertyType, SchemaPropertyGroupProperty, schemaManagementLogic } from './schemaManagementLogic'
const PROPERTY_TYPE_OPTIONS: { value: PropertyType; label: string }[] = [
{ value: 'String', label: 'String' },
{ value: 'Numeric', label: 'Numeric' },
{ value: 'Boolean', label: 'Boolean' },
{ value: 'DateTime', label: 'DateTime' },
{ value: 'Duration', label: 'Duration' },
]
import {
PROPERTY_TYPE_OPTIONS,
PropertyType,
SchemaPropertyGroupProperty,
schemaManagementLogic,
} from './schemaManagementLogic'
function isValidPropertyName(name: string): boolean {
if (!name || !name.trim()) {

View File

@@ -17,18 +17,33 @@ export function PropertyTypeTag({ propertyName, schemaPropertyType }: PropertyTy
const { getPropertyDefinition } = useValues(propertyDefinitionsModel)
const propertyDefinition = getPropertyDefinition(propertyName, PropertyDefinitionType.Event)
// Special case: 'Object' from schema matches 'String' from property definitions
// posthog-js automatically uses JSON.stringify()
const hasTypeMismatch =
propertyDefinition &&
propertyDefinition.property_type &&
propertyDefinition.property_type !== schemaPropertyType
propertyDefinition.property_type !== schemaPropertyType &&
!(schemaPropertyType === 'Object' && propertyDefinition.property_type === 'String')
const getTooltipMessage = (): string => {
if (!propertyDefinition?.property_type) {
return ''
}
const baseMessage = `Type mismatch: Property management defines this as ${propertyDefinition.property_type}.`
if (schemaPropertyType === 'Object') {
return `${baseMessage} Objects expect String type in property management, as they are stored with JSON.stringify`
}
return baseMessage
}
return (
<div className="flex items-center gap-1">
<LemonTag type="muted">{schemaPropertyType}</LemonTag>
{hasTypeMismatch && (
<Tooltip
title={`Type mismatch: Property management defines this as ${propertyDefinition.property_type}`}
>
<Tooltip title={getTooltipMessage()}>
<IconWarning className="text-warning text-base" />
</Tooltip>
)}

View File

@@ -8,7 +8,15 @@ import { teamLogic } from 'scenes/teamLogic'
import type { schemaManagementLogicType } from './schemaManagementLogicType'
export type PropertyType = 'String' | 'Numeric' | 'Boolean' | 'DateTime' | 'Duration'
export type PropertyType = 'String' | 'Numeric' | 'Boolean' | 'DateTime' | 'Object'
export const PROPERTY_TYPE_OPTIONS: { value: PropertyType; label: string }[] = [
{ value: 'String', label: 'String' },
{ value: 'Numeric', label: 'Numeric' },
{ value: 'Boolean', label: 'Boolean' },
{ value: 'DateTime', label: 'DateTime' },
{ value: 'Object', label: 'Object' },
]
function getErrorMessage(error: any, defaultMessage: string): string {
// Handle field-specific errors from DRF serializer

10
posthog.json Normal file
View File

@@ -0,0 +1,10 @@
{
"languages": {
"typescript": {
"output_path": "frontend/src/lib/posthog-typed.ts",
"schema_hash": "7a0ce391306a233ff52edddcb7b3dcc5",
"updated_at": "2025-11-04T07:37:13.518309+00:00",
"event_count": 32
}
}
}

View File

@@ -1,4 +1,3 @@
import json
import hashlib
from datetime import datetime
from typing import Any, Literal, Optional, cast
@@ -6,6 +5,7 @@ from typing import Any, Literal, Optional, cast
from django.core.cache import cache
from django.db.models import Manager, Q
import orjson
from loginas.utils import is_impersonated_session
from rest_framework import mixins, request, response, serializers, status, viewsets
@@ -173,7 +173,7 @@ class EventDefinitionViewSet(
excluded_properties = self.request.GET.get("excluded_properties")
if excluded_properties:
excluded_list = list(set(json.loads(excluded_properties)))
excluded_list = list(set(orjson.loads(excluded_properties)))
search_query = search_query + f" AND NOT name = ANY(ARRAY{excluded_list})"
sql = create_event_definitions_sql(
@@ -316,7 +316,7 @@ class EventDefinitionViewSet(
"version": self.TYPESCRIPT_GENERATOR_VERSION,
"schemas": schema_data,
}
schema_hash = hashlib.sha256(json.dumps(hash_input, sort_keys=True).encode()).hexdigest()[:32]
schema_hash = hashlib.sha256(orjson.dumps(hash_input, option=orjson.OPT_SORT_KEYS)).hexdigest()[:32]
# Generate TypeScript definitions
ts_content = self._generate_typescript(event_definitions, schema_map)
@@ -354,19 +354,18 @@ import type {{ CaptureOptions, CaptureResult, PostHog as OriginalPostHog, Proper
for event_def in event_definitions:
properties = schema_map.get(str(event_def.id), [])
# Escape event name for use as object key
event_name = event_def.name.replace("'", "\\'")
event_name_json = orjson.dumps(event_def.name).decode("utf-8")
if not properties:
event_schemas_lines.append(f" '{event_name}': Record<string, any>")
event_schemas_lines.append(f" {event_name_json}: Record<string, any>")
else:
event_schemas_lines.append(f" '{event_name}': {{")
event_schemas_lines.append(f" {event_name_json}: {{")
for prop in properties:
ts_type = self._map_property_type(prop.property_type)
optional_marker = "" if prop.is_required else "?"
# Always quote property names (simpler and handles all edge cases)
prop_name = f"'{prop.name.replace("'", "\\'")}'"
event_schemas_lines.append(f" {prop_name}{optional_marker}: {ts_type}")
# Use orjson.dumps() for proper escaping of property names
prop_name_json = orjson.dumps(prop.name).decode("utf-8")
event_schemas_lines.append(f" {prop_name_json}{optional_marker}: {ts_type}")
event_schemas_lines.append(" }")
event_schemas_lines.append("}")
@@ -490,7 +489,6 @@ const createTypedPostHog = (original: OriginalPostHog): TypedPostHog => {
const posthog = createTypedPostHog(originalPostHog as OriginalPostHog)
export default posthog
export { posthog }
export type { EventSchemas, TypedPostHog }
// Re-export everything else from posthog-js

View File

@@ -78,6 +78,24 @@ class TestEventDefinitionTypeScriptGeneration(APIBaseTest):
team=self.team, project=self.project, name="untyped_event"
)
# Create event with special characters to test escaping
self.special_chars_event = EventDefinition.objects.create(
team=self.team, project=self.project, name="a'a\\'b\"c>?>%}}%%>c<[[?${{%}}cake'"
)
special_property_group = SchemaPropertyGroup.objects.create(
team=self.team, project=self.project, name="Special Properties"
)
SchemaPropertyGroupProperty.objects.create(
property_group=special_property_group,
name="prop'with\\'quotes\"\\slash",
property_type="String",
is_required=True,
)
EventSchema.objects.create(event_definition=self.special_chars_event, property_group=special_property_group)
def _generate_typescript(self) -> str:
"""Generate TypeScript definitions by calling the actual API endpoint"""
response = self.client.get(f"/api/projects/{self.project.id}/event_definitions/typescript/")
@@ -234,6 +252,15 @@ posthog.captureRaw('test_event', {
// ✅ Should compile: string variables work
posthog.captureRaw(stringVar, { any: 'data' })
// ========================================
// TEST 8: Special characters are escaped
// ========================================
// ✅ Should compile: event and property names with special chars
posthog.capture("a'a\\\\'b\\"c>?>%}}%%>c<[[?${{%}}cake'", {
"prop'with\\\\'quotes\\"\\\\slash": 'value'
})
"""
)

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.22 on 2025-11-04 06:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("posthog", "0900_team_receive_org_level_activity_logs"),
]
operations = [
migrations.RemoveConstraint(
model_name="schemapropertygroupproperty",
name="property_type_is_valid_schema",
),
migrations.AlterField(
model_name="schemapropertygroupproperty",
name="property_type",
field=models.CharField(
choices=[
("DateTime", "DateTime"),
("String", "String"),
("Numeric", "Numeric"),
("Boolean", "Boolean"),
("Object", "Object"),
],
max_length=50,
),
),
]

View File

@@ -1 +1 @@
0900_team_receive_org_level_activity_logs
0901_add_object_property_type

View File

@@ -2,11 +2,20 @@ from django.db import models
from django.utils import timezone
from posthog.models.event_definition import EventDefinition
from posthog.models.property_definition import PropertyType
from posthog.models.team import Team
from posthog.models.utils import UUIDTModel
class SchemaPropertyType(models.TextChoices):
"""Property types supported in schema definitions (includes Object for TypeScript generation)"""
Datetime = "DateTime", "DateTime"
String = "String", "String"
Numeric = "Numeric", "Numeric"
Boolean = "Boolean", "Boolean"
Object = "Object", "Object"
class SchemaPropertyGroup(UUIDTModel):
"""
A reusable group of properties that defines a schema.
@@ -60,7 +69,7 @@ class SchemaPropertyGroupProperty(UUIDTModel):
related_query_name="property",
)
name = models.CharField(max_length=400)
property_type = models.CharField(max_length=50, choices=PropertyType.choices)
property_type = models.CharField(max_length=50, choices=SchemaPropertyType.choices)
is_required = models.BooleanField(default=False)
description = models.TextField(blank=True)
created_at = models.DateTimeField(default=timezone.now)
@@ -78,10 +87,6 @@ class SchemaPropertyGroupProperty(UUIDTModel):
fields=["property_group", "name"],
name="unique_property_group_property_name",
),
models.CheckConstraint(
name="property_type_is_valid_schema",
check=models.Q(property_type__in=PropertyType.values),
),
]
ordering = ["name"]