0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-12-01 12:21:02 +01:00

feat: merging groups (#23966)

This commit is contained in:
David Newell 2024-07-25 16:23:28 +01:00 committed by GitHub
parent 9a5ab8fc0e
commit 49983a6bda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 350 additions and 20 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 223 KiB

View File

@ -673,6 +673,10 @@ class ApiRequest {
return this.errorTracking(teamId).addPathComponent(fingerprint)
}
public errorTrackingMerge(fingerprint: ErrorTrackingGroup['fingerprint']): ApiRequest {
return this.errorTrackingGroup(fingerprint).addPathComponent('merge')
}
// # Warehouse
public dataWarehouseTables(teamId?: TeamType['id']): ApiRequest {
return this.projectsDetail(teamId).addPathComponent('warehouse_tables')
@ -1720,6 +1724,15 @@ const api = {
): Promise<ErrorTrackingGroup> {
return await new ApiRequest().errorTrackingGroup(fingerprint).update({ data })
},
async merge(
primaryFingerprint: ErrorTrackingGroup['fingerprint'],
mergingFingerprints: ErrorTrackingGroup['fingerprint'][]
): Promise<{ content: string }> {
return await new ApiRequest()
.errorTrackingMerge(primaryFingerprint)
.create({ data: { merging_fingerprints: mergingFingerprints } })
},
},
recordings: {

View File

@ -1,17 +1,19 @@
import { LemonSelect } from '@posthog/lemon-ui'
import { LemonButton, LemonSelect } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
import { errorTrackingDataLogic } from './errorTrackingDataLogic'
import { errorTrackingLogic } from './errorTrackingLogic'
import { errorTrackingSceneLogic } from './errorTrackingSceneLogic'
export const ErrorTrackingActions = ({ showOrder = true }: { showOrder?: boolean }): JSX.Element => {
const { dateRange } = useValues(errorTrackingLogic)
const { setDateRange } = useActions(errorTrackingLogic)
const { order } = useValues(errorTrackingSceneLogic)
const { setOrder } = useActions(errorTrackingSceneLogic)
const { order, selectedRows } = useValues(errorTrackingSceneLogic)
const { setOrder, setSelectedRows } = useActions(errorTrackingSceneLogic)
const { mergeGroups } = useActions(errorTrackingDataLogic)
return (
return selectedRows.length === 0 ? (
<div className="flex gap-4">
<div className="flex items-center gap-1">
<span>Date range:</span>
@ -58,5 +60,23 @@ export const ErrorTrackingActions = ({ showOrder = true }: { showOrder?: boolean
</div>
)}
</div>
) : (
<div className="flex space-x-1">
<LemonButton type="secondary" size="small" onClick={() => setSelectedRows([])}>
Unselect all
</LemonButton>
{selectedRows.length > 1 && (
<LemonButton
type="secondary"
size="small"
onClick={() => {
mergeGroups(selectedRows)
setSelectedRows([])
}}
>
Merge
</LemonButton>
)}
</div>
)
}

View File

@ -1,6 +1,7 @@
import { TZLabel } from '@posthog/apps-common'
import { IconPerson } from '@posthog/icons'
import { LemonButton, LemonDivider, LemonSegmentedButton, ProfilePicture } from '@posthog/lemon-ui'
import { LemonButton, LemonCheckbox, LemonDivider, LemonSegmentedButton, ProfilePicture } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { BindLogic, useActions, useValues } from 'kea'
import { MemberSelect } from 'lib/components/MemberSelect'
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
@ -80,10 +81,26 @@ const CustomVolumeColumnHeader: QueryContextColumnTitleComponent = ({ columnName
}
const CustomGroupTitleColumn: QueryContextColumnComponent = (props) => {
const { selectedRows } = useValues(errorTrackingSceneLogic)
const { setSelectedRows } = useActions(errorTrackingSceneLogic)
const record = props.record as ErrorTrackingGroup
const checked = selectedRows.includes(record.fingerprint)
return (
<div className="flex items-start space-x-1.5 group">
<LemonCheckbox
className={clsx('pt-1 group-hover:visible', !checked && 'invisible')}
checked={checked}
onChange={(checked) => {
setSelectedRows(
checked
? [...selectedRows, record.fingerprint]
: selectedRows.filter((r) => r != record.fingerprint)
)
}}
/>
<LemonTableLink
title={record.fingerprint}
description={

View File

@ -5,6 +5,7 @@ import { dataNodeLogic, DataNodeLogicProps } from '~/queries/nodes/DataNode/data
import { ErrorTrackingGroup } from '~/queries/schema'
import type { errorTrackingDataLogicType } from './errorTrackingDataLogicType'
import { mergeGroups } from './utils'
export interface ErrorTrackingDataLogicProps {
query: DataNodeLogicProps['query']
@ -21,6 +22,7 @@ export const errorTrackingDataLogic = kea<errorTrackingDataLogicType>([
})),
actions({
mergeGroups: (fingerprints: string[]) => ({ fingerprints }),
assignGroup: (recordIndex: number, assigneeId: number | null) => ({
recordIndex,
assigneeId,
@ -28,6 +30,30 @@ export const errorTrackingDataLogic = kea<errorTrackingDataLogicType>([
}),
listeners(({ values, actions }) => ({
mergeGroups: async ({ fingerprints }) => {
const results = values.response?.results as ErrorTrackingGroup[]
const groups = results.filter((g) => fingerprints.includes(g.fingerprint))
const primaryGroup = groups.shift()
if (primaryGroup && groups.length > 0) {
const mergingFingerprints = groups.map((g) => g.fingerprint)
const mergedGroup = mergeGroups(primaryGroup, groups)
// optimistically update local results
actions.setResponse({
...values.response,
results: results
// remove merged groups
.filter((group) => !mergingFingerprints.includes(group.fingerprint))
.map((group) =>
// replace primary group
mergedGroup.fingerprint === group.fingerprint ? mergedGroup : group
),
})
await api.errorTracking.merge(primaryGroup?.fingerprint, mergingFingerprints)
}
},
assignGroup: async ({ recordIndex, assigneeId }) => {
const response = values.response
if (response) {

View File

@ -1,4 +1,5 @@
import { actions, connect, kea, path, reducers, selectors } from 'kea'
import { subscriptions } from 'kea-subscriptions'
import { DataTableNode, ErrorTrackingQuery } from '~/queries/schema'
@ -15,6 +16,7 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
actions({
setOrder: (order: ErrorTrackingQuery['order']) => ({ order }),
setSelectedRows: (selectedRows: string[]) => ({ selectedRows }),
}),
reducers({
order: [
@ -24,6 +26,12 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
setOrder: (_, { order }) => order,
},
],
selectedRows: [
[] as string[],
{
setSelectedRows: (_, { selectedRows }) => selectedRows,
},
],
}),
selectors({
@ -39,4 +47,8 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
}),
],
}),
subscriptions(({ actions }) => ({
query: () => actions.setSelectedRows([]),
})),
])

View File

@ -0,0 +1,157 @@
import { ErrorTrackingGroup } from '~/queries/schema'
import { mergeGroups } from './utils'
describe('mergeGroups', () => {
it('arbitrary values', async () => {
const primaryGroup: ErrorTrackingGroup = {
assignee: 400,
description: 'This is the original description',
fingerprint: 'Fingerprint',
first_seen: '2024-07-22T13:15:07.074000Z',
last_seen: '2024-07-20T13:15:50.186000Z',
merged_fingerprints: ['ExistingFingerprint'],
occurrences: 250,
sessions: 100,
status: 'active',
users: 50,
volume: [
'__hx_tag',
'Sparkline',
'data',
[10, 5, 10, 20, 50],
'labels',
[
'25 Jun, 2024 00:00 (UTC)',
'26 Jun, 2024 00:00 (UTC)',
'27 Jun, 2024 00:00 (UTC)',
'28 Jun, 2024 00:00 (UTC)',
'29 Jun, 2024 00:00 (UTC)',
],
],
}
const mergingGroups: ErrorTrackingGroup[] = [
{
assignee: 100,
description: 'This is another description',
fingerprint: 'Fingerprint2',
first_seen: '2024-07-21T13:15:07.074000Z',
last_seen: '2024-07-20T13:15:50.186000Z',
merged_fingerprints: ['NestedFingerprint'],
occurrences: 10,
sessions: 5,
status: 'active',
users: 1,
volume: [
'__hx_tag',
'Sparkline',
'data',
[1, 1, 2, 1, 2],
'labels',
[
'25 Jun, 2024 00:00 (UTC)',
'26 Jun, 2024 00:00 (UTC)',
'27 Jun, 2024 00:00 (UTC)',
'28 Jun, 2024 00:00 (UTC)',
'29 Jun, 2024 00:00 (UTC)',
],
],
},
{
assignee: 400,
description: 'This is another description',
fingerprint: 'Fingerprint3',
first_seen: '2024-07-21T13:15:07.074000Z',
last_seen: '2024-07-22T13:15:50.186000Z',
merged_fingerprints: [],
occurrences: 1,
sessions: 1,
status: 'active',
users: 1,
volume: [
'__hx_tag',
'Sparkline',
'data',
[5, 10, 2, 3, 5],
'labels',
[
'25 Jun, 2024 00:00 (UTC)',
'26 Jun, 2024 00:00 (UTC)',
'27 Jun, 2024 00:00 (UTC)',
'28 Jun, 2024 00:00 (UTC)',
'29 Jun, 2024 00:00 (UTC)',
],
],
},
{
assignee: null,
description: 'This is another description',
fingerprint: 'Fingerprint4',
first_seen: '2023-07-22T13:15:07.074000Z',
last_seen: '2024-07-22T13:15:50.186000Z',
merged_fingerprints: [],
occurrences: 1000,
sessions: 500,
status: 'active',
users: 50,
volume: [
'__hx_tag',
'Sparkline',
'data',
[10, 100, 200, 300, 700],
'labels',
[
'25 Jun, 2024 00:00 (UTC)',
'26 Jun, 2024 00:00 (UTC)',
'27 Jun, 2024 00:00 (UTC)',
'28 Jun, 2024 00:00 (UTC)',
'29 Jun, 2024 00:00 (UTC)',
],
],
},
]
const mergedGroup = mergeGroups(primaryGroup, mergingGroups)
expect(mergedGroup).toEqual({
// retains values from primary group
assignee: 400,
description: 'This is the original description',
fingerprint: 'Fingerprint',
status: 'active',
// earliest first_seen
first_seen: '2023-07-22T13:15:07.074Z',
// latest last_seen
last_seen: '2024-07-22T13:15:50.186Z',
// retains previously merged_fingerprints
// adds new fingerprints AND their nested fingerprints
merged_fingerprints: [
'ExistingFingerprint',
'Fingerprint2',
'NestedFingerprint',
'Fingerprint3',
'Fingerprint4',
],
// sums counts
occurrences: 1261,
sessions: 606,
users: 102,
// sums volumes
volume: [
'__hx_tag',
'Sparkline',
'data',
[26, 116, 214, 324, 757],
'labels',
[
'25 Jun, 2024 00:00 (UTC)',
'26 Jun, 2024 00:00 (UTC)',
'27 Jun, 2024 00:00 (UTC)',
'28 Jun, 2024 00:00 (UTC)',
'29 Jun, 2024 00:00 (UTC)',
],
],
})
})
})

View File

@ -0,0 +1,48 @@
import { dayjs } from 'lib/dayjs'
import { ErrorTrackingGroup } from '~/queries/schema'
export const mergeGroups = (
primaryGroup: ErrorTrackingGroup,
mergingGroups: ErrorTrackingGroup[]
): ErrorTrackingGroup => {
const mergingFingerprints = mergingGroups.flatMap((g) => [g.fingerprint, ...g.merged_fingerprints])
const mergedFingerprints = [...primaryGroup.merged_fingerprints]
mergedFingerprints.push(...mergingFingerprints)
const sum = (value: 'occurrences' | 'users' | 'sessions'): number => {
return mergingGroups.reduce((sum, g) => sum + g[value], primaryGroup[value])
}
const [firstSeen, lastSeen] = mergingGroups.reduce(
(res, g) => {
const firstSeen = dayjs(g.first_seen)
const lastSeen = dayjs(g.last_seen)
return [res[0].isAfter(firstSeen) ? firstSeen : res[0], res[1].isBefore(lastSeen) ? lastSeen : res[1]]
},
[dayjs(primaryGroup.first_seen), dayjs(primaryGroup.last_seen)]
)
const volume = primaryGroup.volume
if (volume) {
const dataIndex = 3
const data = mergingGroups.reduce(
(sum: number[], g) => g.volume[dataIndex].map((num: number, idx: number) => num + sum[idx]),
primaryGroup.volume[dataIndex]
)
volume.splice(dataIndex, 1, data)
}
return {
...primaryGroup,
merged_fingerprints: mergedFingerprints,
occurrences: sum('occurrences'),
sessions: sum('sessions'),
users: sum('users'),
first_seen: firstSeen.toISOString(),
last_seen: lastSeen.toISOString(),
volume: volume,
}
}

View File

@ -4,6 +4,8 @@ from rest_framework import serializers, viewsets
from posthog.api.forbid_destroy_model import ForbidDestroyModel
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.models.error_tracking import ErrorTrackingGroup
from rest_framework.decorators import action
from rest_framework.response import Response
class ErrorTrackingGroupSerializer(serializers.ModelSerializer):
@ -21,3 +23,10 @@ class ErrorTrackingGroupViewSet(TeamAndOrgViewSetMixin, ForbidDestroyModel, view
fingerprint = self.kwargs["pk"]
group, _ = queryset.get_or_create(fingerprint=fingerprint, team=self.team)
return group
@action(methods=["POST"], detail=True)
def merge(self, request, **kwargs):
group: ErrorTrackingGroup = self.get_object()
merging_fingerprints: list[str] = request.data.get("merging_fingerprints", [])
group.merge(merging_fingerprints)
return Response({"success": True})

View File

@ -37,3 +37,29 @@ class TestErrorTracking(APIBaseTest):
)
group.refresh_from_db()
self.assertEqual(group.fingerprint, "CustomFingerprint")
def test_merging_of_an_existing_group(self):
fingerprint = "CustomFingerprint"
merging_fingerprints = ["NewFingerprint"]
group = ErrorTrackingGroup.objects.create(fingerprint=fingerprint, team=self.team)
self.client.post(
f"/api/projects/{self.team.id}/error_tracking/{fingerprint}/merge",
data={"merging_fingerprints": merging_fingerprints},
)
group.refresh_from_db()
self.assertEqual(group.merged_fingerprints, merging_fingerprints)
def test_merging_when_no_group_exists(self):
fingerprint = "CustomFingerprint"
merging_fingerprints = ["NewFingerprint"]
self.assertEqual(ErrorTrackingGroup.objects.count(), 0)
self.client.post(
f"/api/projects/{self.team.id}/error_tracking/{fingerprint}/merge",
data={"merging_fingerprints": merging_fingerprints},
)
self.assertEqual(ErrorTrackingGroup.objects.count(), 1)
groups = ErrorTrackingGroup.objects.only("merged_fingerprints")
self.assertEqual(groups[0].merged_fingerprints, merging_fingerprints)

View File

@ -36,17 +36,17 @@ class ErrorTrackingGroup(UUIDModel):
return queryset.filter(query)
@transaction.atomic
def merge(self, groups: list["ErrorTrackingGroup"]) -> None:
if not groups:
def merge(self, fingerprints: list[str]) -> None:
if not fingerprints:
return
merged_fingerprints = set(self.merged_fingerprints)
for group in groups:
fingerprints = [group.fingerprint, *group.merged_fingerprints]
merged_fingerprints |= set(fingerprints)
merged_fingerprints.update(fingerprints)
merging_groups = ErrorTrackingGroup.objects.filter(team=self.team, fingerprint__in=fingerprints)
for group in merging_groups:
merged_fingerprints |= set(group.merged_fingerprints)
merging_groups.delete()
self.merged_fingerprints = list(merged_fingerprints)
self.save()
for group in groups:
group.delete()

View File

@ -22,18 +22,18 @@ class TestErrorTracking(BaseTest):
)
matching_groups = ErrorTrackingGroup.objects.filter(fingerprint__in=["first_error", "second_error"])
assert len(matching_groups) == 2
assert matching_groups.count() == 2
matching_groups = ErrorTrackingGroup.objects.filter(merged_fingerprints__contains=["previously_merged"])
assert len(matching_groups) == 1
assert matching_groups.count() == 1
matching_groups = ErrorTrackingGroup.filter_fingerprints(
queryset=ErrorTrackingGroup.objects, fingerprints=["first_error", "previously_merged"]
)
assert len(matching_groups) == 2
assert matching_groups.count() == 2
def test_merge(self):
root_group = ErrorTrackingGroup.objects.create(
primary_group = ErrorTrackingGroup.objects.create(
status="active",
team=self.team,
fingerprint="a_fingerprint",
@ -49,14 +49,16 @@ class TestErrorTracking(BaseTest):
merged_fingerprints=["merged_fingerprint"],
)
root_group.merge([merge_group_1, merge_group_2])
merging_fingerprints = [merge_group_1.fingerprint, merge_group_2.fingerprint, "no_group_fingerprint"]
primary_group.merge(merging_fingerprints)
assert sorted(root_group.merged_fingerprints) == [
assert sorted(primary_group.merged_fingerprints) == [
"already_merged_fingerprint",
"another_fingerprint",
"merged_fingerprint",
"new_fingerprint",
"no_group_fingerprint",
]
# deletes the old groups
assert len(ErrorTrackingGroup.objects.filter(fingerprint="new_fingerprint")) == 0
assert ErrorTrackingGroup.objects.count() == 1