mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
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:
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
4014
frontend/src/lib/posthog-typed.ts
Normal file
4014
frontend/src/lib/posthog-typed.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
10
posthog.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
30
posthog/migrations/0901_add_object_property_type.py
Normal file
30
posthog/migrations/0901_add_object_property_type.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
0900_team_receive_org_level_activity_logs
|
||||
0901_add_object_property_type
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user