feat(Activity-logs): Various enhancements (#38582)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Yasen
2025-09-25 22:42:06 +03:00
committed by GitHub
parent 023c928de6
commit 8f704ea6f8
93 changed files with 539 additions and 138 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 89 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: 92 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -388,13 +388,6 @@ export const getDefaultTreeData = (): FileSystemImport[] => [
iconType: 'data_pipeline_metadata',
href: urls.dataPipelines('destinations'),
} as FileSystemImport,
{
path: `Activity logs`,
category: 'Activity',
iconType: 'team_activity',
href: urls.advancedActivityLogs(),
flag: FEATURE_FLAGS.ADVANCED_ACTIVITY_LOGS,
} as FileSystemImport,
]
export const getDefaultTreeProducts = (): FileSystemImport[] =>

View File

@@ -3,7 +3,7 @@ import { useValues } from 'kea'
import { SkeletonLog } from 'lib/components/ActivityLog/ActivityLog'
import { describerFor } from 'lib/components/ActivityLog/activityLogLogic'
import { humanize } from 'lib/components/ActivityLog/humanizeActivity'
import { WarningHog } from 'lib/components/hedgehogs'
import { DetectiveHog } from 'lib/components/hedgehogs'
import { PaginationControl, usePagination } from 'lib/lemon-ui/PaginationControl'
import { AuditLogTableHeader, AuditLogTableRow } from './AuditLogTable'
@@ -56,7 +56,7 @@ const AdvancedActivityLogsEmptyState = (): JSX.Element => (
data-attr="billing-empty-state"
className="flex flex-col border rounded px-4 py-8 items-center text-center mx-auto"
>
<WarningHog width="100" height="100" className="mb-4" />
<DetectiveHog width="100" height="100" className="mb-4" />
<h2 className="text-xl leading-tight">We couldn't find any activity logs for your current query.</h2>
<p className="text-sm text-balance text-tertiary">
Try adjusting your filters or date range to see more results.

View File

@@ -21,7 +21,7 @@ export const scene: SceneExport = {
}
export function AdvancedActivityLogsScene(): JSX.Element | null {
const { isFeatureFlagEnabled, exports, activeTab } = useValues(advancedActivityLogsLogic)
const { isFeatureFlagEnabled, activeTab } = useValues(advancedActivityLogsLogic)
const { setActiveTab } = useActions(advancedActivityLogsLogic)
if (!isFeatureFlagEnabled) {
@@ -29,12 +29,10 @@ export function AdvancedActivityLogsScene(): JSX.Element | null {
return null
}
const hasExports = exports && exports.length > 0
const tabs = [
{
key: 'logs',
label: 'Activity logs',
label: 'Logs',
content: (
<div className="space-y-4">
<AdvancedActivityLogFiltersPanel />
@@ -42,24 +40,20 @@ export function AdvancedActivityLogsScene(): JSX.Element | null {
</div>
),
},
...(hasExports
? [
{
key: 'exports',
label: 'Exports',
content: <ExportsList />,
},
]
: []),
{
key: 'exports',
label: 'Exports',
content: <ExportsList />,
},
]
return (
<SceneContent>
<SceneTitleSection
name="Advanced activity logs"
description="Track all changes and activities in your organization"
name="Activity logs"
description="Track all changes and activities in your organization with detailed filtering and export capabilities."
resourceType={{
type: 'activity logs',
type: 'team_activity',
forceIcon: <IconActivity />,
}}
/>

View File

@@ -1,7 +1,7 @@
import { useActions, useValues } from 'kea'
import { IconCollapse, IconExpand, IconInfo } from '@posthog/icons'
import { LemonButton, LemonSelect, Tooltip } from '@posthog/lemon-ui'
import { LemonBadge, LemonButton, LemonSelect, Tooltip } from '@posthog/lemon-ui'
import { humanizeActivity, humanizeScope } from 'lib/components/ActivityLog/humanizeActivity'
import { AnimatedCollapsible } from 'lib/components/AnimatedCollapsible'
@@ -14,7 +14,8 @@ import { DetailFilters } from './DetailFilters'
import { advancedActivityLogsLogic } from './advancedActivityLogsLogic'
export const BasicFiltersTab = (): JSX.Element => {
const { filters, availableFilters, showMoreFilters } = useValues(advancedActivityLogsLogic)
const { filters, availableFilters, showMoreFilters, activeAdvancedFiltersCount } =
useValues(advancedActivityLogsLogic)
const { setFilters, setShowMoreFilters } = useActions(advancedActivityLogsLogic)
return (
@@ -30,7 +31,7 @@ export const BasicFiltersTab = (): JSX.Element => {
}}
placeholder="All time"
data-attr="audit-logs-date-filter"
className="h-8 flex items-center"
className="h-[24px] flex items-center"
/>
</div>
@@ -51,7 +52,8 @@ export const BasicFiltersTab = (): JSX.Element => {
placeholder="All users"
allowCustomValues={false}
data-attr="audit-logs-user-filter"
className="min-w-50 min-h-10"
size="small"
className="min-w-50"
/>
</div>
@@ -72,7 +74,8 @@ export const BasicFiltersTab = (): JSX.Element => {
placeholder="All scopes"
allowCustomValues={false}
data-attr="audit-logs-scope-filter"
className="min-w-50 min-h-10"
size="small"
className="min-w-50"
/>
</div>
@@ -93,20 +96,31 @@ export const BasicFiltersTab = (): JSX.Element => {
placeholder="All actions"
allowCustomValues={false}
data-attr="audit-logs-action-filter"
className="min-w-50 min-h-10"
size="small"
className="min-w-50"
/>
</div>
<div className="flex items-end justify-end mb-1">
<LemonButton
type="tertiary"
icon={showMoreFilters ? <IconCollapse /> : <IconExpand />}
onClick={() => setShowMoreFilters(!showMoreFilters)}
data-attr="audit-logs-more-filters-toggle"
className="text-muted-alt hover:text-default"
>
More filters
</LemonButton>
<div className="relative">
<LemonButton
type="tertiary"
size="small"
icon={showMoreFilters ? <IconCollapse /> : <IconExpand />}
onClick={() => setShowMoreFilters(!showMoreFilters)}
data-attr="audit-logs-more-filters-toggle"
className="text-muted-alt hover:text-default"
>
More filters
</LemonButton>
{!showMoreFilters && activeAdvancedFiltersCount > 0 && (
<LemonBadge
content={activeAdvancedFiltersCount.toString()}
size="small"
className="absolute -top-1 -right-1"
/>
)}
</div>
</div>
</div>
@@ -134,7 +148,8 @@ export const BasicFiltersTab = (): JSX.Element => {
]}
placeholder="All"
data-attr="audit-logs-was-impersonated-filter"
className="min-w-50 min-h-10"
size="small"
className="min-w-50"
/>
</div>
@@ -157,7 +172,8 @@ export const BasicFiltersTab = (): JSX.Element => {
]}
placeholder="All"
data-attr="audit-logs-is-system-filter"
className="min-w-50 min-h-10"
size="small"
className="min-w-50"
/>
</div>
@@ -178,7 +194,8 @@ export const BasicFiltersTab = (): JSX.Element => {
placeholder="Enter item IDs"
allowCustomValues={true}
data-attr="audit-logs-item-ids-filter"
className="min-w-50 min-h-10"
size="small"
className="min-w-50"
/>
</div>
</div>

View File

@@ -1,8 +1,15 @@
import { useActions, useValues } from 'kea'
import { useEffect, useMemo, useRef, useState } from 'react'
import { IconPlus, IconTrash } from '@posthog/icons'
import { LemonButton, LemonInput, LemonInputSelect, LemonSearchableSelect, LemonSelect } from '@posthog/lemon-ui'
import { IconInfo, IconPlus, IconTrash } from '@posthog/icons'
import {
LemonButton,
LemonInput,
LemonInputSelect,
LemonSearchableSelect,
LemonSelect,
Tooltip,
} from '@posthog/lemon-ui'
import { ActiveDetailFilter, advancedActivityLogsLogic } from './advancedActivityLogsLogic'
@@ -157,6 +164,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
onChange={handleCustomFieldPathChange}
placeholder="Enter custom field path"
status={fieldPathError ? 'danger' : undefined}
size="small"
/>
{fieldPathError && <div className="text-xs text-danger mt-1">{fieldPathError}</div>}
</div>
@@ -166,6 +174,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
onChange={handleFieldChange}
options={fieldOptionsForRow}
placeholder="Select field"
size="small"
className="min-w-60"
/>
)}
@@ -178,6 +187,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
{ value: 'contains', label: 'contains' },
{ value: 'in', label: 'is one of' },
]}
size="small"
className="min-w-32"
/>
@@ -188,6 +198,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
onChange={handleValueChange}
allowCustomValues
placeholder="Enter values"
size="small"
className="min-w-60"
/>
) : (
@@ -195,6 +206,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
value={localValue as string}
onChange={handleValueChange}
placeholder="Enter value"
size="small"
className="min-w-60"
/>
)}
@@ -271,8 +283,13 @@ export const DetailFilters = (): JSX.Element => {
}
return (
<div className="flex flex-col gap-2">
<label className="block text-sm font-medium">Detail Filters</label>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<label className="block text-sm font-medium">Detail filters</label>
<Tooltip title="Filter by specific fields within the activity log details field. For example, filter by changes to specific dashboard properties, feature flag variations, or other detailed attributes logged with each activity.">
<IconInfo className="w-4 h-4 text-muted-alt cursor-help" />
</Tooltip>
</div>
<div className="flex flex-col gap-2">
{activeFilters.map((filter) => (
@@ -290,9 +307,10 @@ export const DetailFilters = (): JSX.Element => {
}
}}
options={fieldOptions}
placeholder="Add detail filter"
placeholder="Add filter"
searchPlaceholder="Search fields..."
icon={<IconPlus />}
size="small"
className="w-[200px]"
/>
</div>

View File

@@ -3,6 +3,7 @@ import { useValues } from 'kea'
import { IconDownload } from '@posthog/icons'
import { LemonButton, LemonTable, Tooltip } from '@posthog/lemon-ui'
import { DetectiveHog } from 'lib/components/hedgehogs'
import { humanFriendlyDetailedTime } from 'lib/utils'
import {
@@ -17,6 +18,15 @@ import { ExportedAsset, advancedActivityLogsLogic } from './advancedActivityLogs
export function ExportsList(): JSX.Element {
const { exports, exportsLoading } = useValues(advancedActivityLogsLogic)
if (exportsLoading) {
// You could add a skeleton here if desired
return <div>Loading exports...</div>
}
if (!exports || exports.length === 0) {
return <ExportsEmptyState />
}
const columns = [
{
title: 'Filename',
@@ -77,18 +87,31 @@ export function ExportsList(): JSX.Element {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Your exports</h3>
<p className="text-muted text-xs">Refreshed every 5 seconds</p>
</div>
<LemonTable
dataSource={exports || []}
dataSource={exports}
columns={columns}
loading={exportsLoading}
loading={false}
rowKey="id"
emptyState="No exports found"
footer={
<div className="flex items-center justify-end mt-2 mr-2">
<p className="text-muted text-xs">Refreshed every 5 seconds</p>
</div>
}
/>
</div>
)
}
const ExportsEmptyState = (): JSX.Element => (
<div
data-attr="exports-empty-state"
className="flex flex-col border rounded px-4 py-8 items-center text-center mx-auto"
>
<DetectiveHog width="100" height="100" className="mb-4" />
<h2 className="text-xl leading-tight">No exports found</h2>
<p className="text-sm text-balance text-tertiary">
Exports will appear here when you create them from the Logs tab. Start by applying filters and then click
"Export".
</p>
</div>
)

View File

@@ -262,9 +262,31 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
onForward: () => advancedActivityLogsLogic.actions.setPage((filters.page || 1) + 1),
}),
],
activeAdvancedFiltersCount: [
(s) => [s.filters],
(filters: AdvancedActivityLogFilters): number => {
let count = 0
if (filters.was_impersonated !== undefined) {
count++
}
if (filters.is_system !== undefined) {
count++
}
if (filters.item_ids && filters.item_ids.length > 0) {
count++
}
if (filters.detail_filters && Object.keys(filters.detail_filters).length > 0) {
count++
}
return count
},
],
}),
listeners(({ actions, values }) => ({
listeners(({ actions, values, cache }) => ({
setFilters: async (_, breakpoint) => {
await breakpoint(300)
actions.loadAdvancedActivityLogs({})
@@ -273,9 +295,29 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
actions.loadAdvancedActivityLogs({})
},
clearAllFilters: () => {
actions.setActiveFilters([])
actions.setShowMoreFilters(false)
actions.loadAdvancedActivityLogs({})
},
setActiveTab: ({ tab }) => {
if (tab === 'exports') {
// Start polling when switching to exports tab
actions.loadExports()
if (!cache.exportPollingInterval) {
cache.exportPollingInterval = setInterval(() => {
actions.loadExports()
}, 5000)
}
} else {
// Stop polling when switching away from exports tab
if (cache.exportPollingInterval) {
clearInterval(cache.exportPollingInterval)
cache.exportPollingInterval = null
}
}
},
// Detail filter management
addActiveFilter: ({ fieldPath }) => {
if (fieldPath === '__add_custom__') {
@@ -396,7 +438,6 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
})
lemonToast.success(`Export started! Your ${format.toUpperCase()} export is being prepared.`)
actions.loadExports()
actions.setActiveTab('exports')
} catch (error) {
console.error('Export failed:', error)
@@ -429,7 +470,15 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
})),
urlToAction(({ actions }) => ({
'/advanced-activity-logs': (_, searchParams) => {
'/activity-logs': (_, searchParams) => {
const hasUrlParams = Object.keys(searchParams).length > 0
// If just visiting the page, we want to clear all filters in case the page was previously mounted with filters
if (!hasUrlParams) {
actions.clearAllFilters()
return
}
const urlFilters: Partial<AdvancedActivityLogFilters> = {}
if (searchParams.start_date) {
@@ -476,24 +525,19 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
urlFilters.page = parseInt(searchParams.page, 10)
}
if (Object.keys(urlFilters).length > 0) {
actions.setFilters(urlFilters)
}
actions.setFilters(urlFilters)
},
})),
events(({ actions, cache }) => ({
afterMount: () => {
actions.loadAvailableFilters()
actions.loadExports()
cache.exportPollingInterval = setInterval(() => {
actions.loadExports()
}, 5000)
actions.loadAdvancedActivityLogs({})
},
beforeUnmount: () => {
if (cache.exportPollingInterval) {
clearInterval(cache.exportPollingInterval)
cache.exportPollingInterval = null
}
},
})),

View File

@@ -14,7 +14,6 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { capitalizeFirstLetter } from 'lib/utils'
import { Annotations } from 'scenes/annotations'
import { NewAnnotationButton } from 'scenes/annotations/AnnotationModal'
import { AdvancedActivityLogsList } from 'scenes/audit-logs/AdvancedActivityLogsList'
import { Comments } from 'scenes/data-management/comments/Comments'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
@@ -38,7 +37,6 @@ export enum DataManagementTab {
Annotations = 'annotations',
Comments = 'comments',
History = 'history',
ActivityLogs = 'activity-logs',
IngestionWarnings = 'warnings',
Revenue = 'revenue',
MarketingAnalytics = 'marketing-analytics',
@@ -126,12 +124,6 @@ const tabs: Record<DataManagementTab, TabConfig> = {
),
tooltipDocLink: 'https://posthog.com/docs/data#history',
},
[DataManagementTab.ActivityLogs]: {
url: urls.advancedActivityLogs(),
label: 'Activity logs',
content: <AdvancedActivityLogsList />,
flag: FEATURE_FLAGS.ADVANCED_ACTIVITY_LOGS,
},
[DataManagementTab.Revenue]: {
url: urls.revenueSettings(),
label: (

View File

@@ -96,7 +96,7 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
.filter(({ path }) => path.startsWith('Insight/'))
.map((fs) => ({
href: fs.href,
name: 'new ' + fs.path.substring(8),
name: 'New ' + fs.path.substring(8),
icon: getIconForFileSystemItem(fs),
flag: fs.flag,
}))
@@ -114,7 +114,7 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
.filter(({ path }) => !path.startsWith('Insight/') && !path.startsWith('Data/'))
.map((fs) => ({
href: fs.href,
name: 'new ' + fs.path,
name: 'New ' + fs.path,
icon: getIconForFileSystemItem(fs),
flag: fs.flag,
}))
@@ -143,10 +143,10 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
{
category: 'create-new',
types: [
{ name: 'new SQL query', icon: <IconDatabase />, href: '/sql' },
{ name: 'New SQL query', icon: <IconDatabase />, href: '/sql' },
...newInsightItems,
...newOtherItems,
{ name: 'new Hog program', icon: <IconHogQL />, href: '/debug/hog' },
{ name: 'New Hog program', icon: <IconHogQL />, href: '/debug/hog' },
],
},
{

View File

@@ -38,7 +38,7 @@ export const sceneConfigurations: Record<Scene | string, SceneConfig> = {
[Scene.AdvancedActivityLogs]: {
projectBased: true,
organizationBased: false,
name: 'Advanced activity logs',
name: 'Activity logs',
},
[Scene.AsyncMigrations]: { instanceLevel: true },
[Scene.BillingAuthorizationStatus]: {

View File

@@ -567,6 +567,14 @@ export const SETTINGS_MAP: SettingSection[] = [
},
],
},
{
level: 'environment',
id: 'environment-activity-logs',
title: 'Activity logs',
flag: 'ADVANCED_ACTIVITY_LOGS',
to: urls.advancedActivityLogs(),
settings: [],
},
{
level: 'environment',
id: 'mcp-server',

View File

@@ -30,6 +30,7 @@ export type SettingSectionId =
| 'environment-crm'
| 'environment-max'
| 'environment-integrations'
| 'environment-activity-logs'
| 'environment-access-control'
| 'environment-danger-zone'
| 'project-details'

View File

@@ -107,7 +107,7 @@ export const urls = {
`/organization/billing${products && products.length ? `?products=${products.join(',')}` : ''}`,
organizationBillingSection: (section: BillingSectionId = 'overview'): string =>
combineUrl(`/organization/billing/${section}`).url,
advancedActivityLogs: (): string => '/advanced-activity-logs',
advancedActivityLogs: (): string => '/activity-logs',
billingAuthorizationStatus: (): string => `/billing/authorization_status`,
// Self-hosted only
instanceStatus: (): string => '/instance/status',

View File

@@ -63,15 +63,9 @@ class AdvancedActivityLogFilterManager:
# Array fields like changes[].type need special handling
queryset = self._apply_array_field_filter(queryset, field_path, operation, value)
else:
django_field_path = field_path.replace(".", "__")
if operation == "exact":
queryset = queryset.filter(**{f"detail__{django_field_path}": value})
elif operation == "in":
if not isinstance(value, list | tuple):
value = [value]
queryset = queryset.filter(**{f"detail__{django_field_path}__in": value})
elif operation == "contains":
queryset = queryset.filter(**{f"detail__{django_field_path}__icontains": value})
django_field_path = f"detail__{field_path.replace('.', '__')}"
query_condition = self._create_type_insensitive_query(django_field_path, operation, value)
queryset = queryset.filter(query_condition)
return queryset
@@ -93,8 +87,6 @@ class AdvancedActivityLogFilterManager:
def _apply_array_exact_filter(
self, queryset: QuerySet[ActivityLog], field_path: str, operation: str, value: Any
) -> QuerySet[ActivityLog]:
from django.db.models import Q
parts = field_path.split("[].")
if len(parts) < 2:
return self._apply_array_contains_filter(queryset, field_path, value)
@@ -102,53 +94,11 @@ class AdvancedActivityLogFilterManager:
max_indices_to_check = 5 # Check first 5 elements of each array
query_conditions = []
for i in range(max_indices_to_check):
django_path = self._build_indexed_path(parts, [i])
indexed_paths = self._generate_indexed_paths(parts, field_path, max_indices_to_check)
if operation == "exact":
condition = Q(**{f"detail__{django_path}": value})
elif operation == "in":
if not isinstance(value, list | tuple):
value = [value]
condition = Q(**{f"detail__{django_path}__in": value})
else:
continue
query_conditions.append(condition)
# Handle nested arrays like items[].data[].name
if field_path.count("[]") > 1:
for i in range(max_indices_to_check):
for j in range(max_indices_to_check):
django_path = self._build_indexed_path(parts, [i, j])
if operation == "exact":
condition = Q(**{f"detail__{django_path}": value})
elif operation == "in":
if not isinstance(value, list | tuple):
value = [value]
condition = Q(**{f"detail__{django_path}__in": value})
else:
continue
query_conditions.append(condition)
if field_path.count("[]") > 2:
for i in range(max_indices_to_check):
for j in range(max_indices_to_check):
for k in range(max_indices_to_check):
django_path = self._build_indexed_path(parts, [i, j, k])
if operation == "exact":
condition = Q(**{f"detail__{django_path}": value})
elif operation == "in":
if not isinstance(value, list | tuple):
value = [value]
condition = Q(**{f"detail__{django_path}__in": value})
else:
continue
query_conditions.append(condition)
for django_path in indexed_paths:
query_condition = self._create_type_insensitive_query(f"detail__{django_path}", operation, value)
query_conditions.append(query_condition)
# Combine all conditions with OR
if query_conditions:
@@ -159,6 +109,27 @@ class AdvancedActivityLogFilterManager:
return queryset
def _generate_indexed_paths(
self, parts: list[str], field_path: str, max_indices: int, current_indices: list[int] | None = None
) -> list[str]:
"""
Generate all indexed paths for array field filtering based on nesting depth.
"""
if current_indices is None:
return self._generate_indexed_paths(parts, field_path, max_indices, [])
remaining_depth = field_path.count("[]") - len(current_indices)
if remaining_depth == 0:
return [self._build_indexed_path(parts, current_indices)]
paths = []
for i in range(max_indices):
new_indices = [*current_indices, i]
paths.extend(self._generate_indexed_paths(parts, field_path, max_indices, new_indices))
return paths
def _build_indexed_path(self, parts: list[str], indices: list[int]) -> str:
if not parts or not indices:
return ""
@@ -200,3 +171,101 @@ class AdvancedActivityLogFilterManager:
if filters.get("item_ids"):
queryset = queryset.filter(item_id__in=filters["item_ids"])
return queryset
def _get_type_variants(self, value: Any) -> list[Any]:
"""
Convert a value to its possible type variants for type-insensitive matching.
Returns a list of values to try in database queries.
"""
variants = [value] # Always include the original value
# If value is a string, try to convert to other types
if isinstance(value, str) and value.strip():
stripped_value = value.strip()
# Try integer conversion
try:
int_val = int(stripped_value)
if str(int_val) == stripped_value: # Avoid float-to-int conversion artifacts
variants.append(int_val)
except ValueError:
pass
# Try float conversion (only if not already an integer)
try:
float_val = float(stripped_value)
if str(float_val) == stripped_value or (
stripped_value.endswith(".0") and str(float_val) == stripped_value[:-2]
):
variants.append(float_val)
except ValueError:
pass
# Try boolean conversion
lower_val = stripped_value.lower()
if lower_val in ("true", "1"):
variants.append(True)
elif lower_val in ("false", "0"):
variants.append(False)
# If value is boolean, add string and numeric representations
elif isinstance(value, bool):
variants.extend([str(value).lower(), str(value), "1" if value else "0"])
# If value is numeric, add string representation
elif isinstance(value, int | float):
variants.append(str(value))
# Remove duplicates while preserving order
seen = set()
unique_variants = []
for variant in variants:
# Use a tuple representation for hashable comparison
key = (type(variant).__name__, variant)
if key not in seen:
seen.add(key)
unique_variants.append(variant)
return unique_variants
def _expand_values_with_type_variants(self, value: Any) -> list[Any]:
"""
Expand a single value or list of values to include all type variants.
Handles deduplication automatically.
"""
if not isinstance(value, list | tuple):
value = [value]
expanded_values = []
for v in value:
expanded_values.extend(self._get_type_variants(v))
# Remove duplicates while preserving order
seen = set()
unique_values = []
for val in expanded_values:
key = (type(val).__name__, val)
if key not in seen:
seen.add(key)
unique_values.append(val)
return unique_values
def _create_type_insensitive_query(self, field_path: str, operation: str, value: Any) -> Q:
"""
Create a type-insensitive query condition for the given field path and operation.
"""
if operation == "exact":
# Create OR conditions for all type variants
type_variants = self._get_type_variants(value)
conditions = [Q(**{field_path: variant}) for variant in type_variants]
combined_condition = conditions[0]
for condition in conditions[1:]:
combined_condition |= condition
return combined_condition
elif operation == "in":
unique_values = self._expand_values_with_type_variants(value)
return Q(**{f"{field_path}__in": unique_values})
elif operation == "contains":
return Q(**{f"{field_path}__icontains": value})
else:
return Q(**{field_path: value})

View File

@@ -0,0 +1,242 @@
from posthog.test.base import BaseTest
from posthog.models.activity_logging.activity_log import ActivityLog
from .filters import AdvancedActivityLogFilterManager
class TestAdvancedActivityLogFilterManager(BaseTest):
def setUp(self):
super().setUp()
self.filter_manager = AdvancedActivityLogFilterManager()
def test_get_type_variants_string_to_numeric(self):
variants = self.filter_manager._get_type_variants("42")
self.assertIn("42", variants)
self.assertIn(42, variants)
variants = self.filter_manager._get_type_variants("3.14")
self.assertIn("3.14", variants)
self.assertIn(3.14, variants)
variants = self.filter_manager._get_type_variants("42.0")
self.assertIn("42.0", variants)
self.assertIn(42.0, variants)
def test_get_type_variants_numeric_to_string(self):
variants = self.filter_manager._get_type_variants(42)
self.assertIn(42, variants)
self.assertIn("42", variants)
variants = self.filter_manager._get_type_variants(3.14)
self.assertIn(3.14, variants)
self.assertIn("3.14", variants)
def test_get_type_variants_boolean_conversion(self):
variants = self.filter_manager._get_type_variants("true")
self.assertIn("true", variants)
self.assertIn(True, variants)
variants = self.filter_manager._get_type_variants("false")
self.assertIn("false", variants)
self.assertIn(False, variants)
variants = self.filter_manager._get_type_variants("1")
self.assertIn("1", variants)
self.assertIn(1, variants)
self.assertIn(True, variants)
variants = self.filter_manager._get_type_variants("0")
self.assertIn("0", variants)
self.assertIn(0, variants)
self.assertIn(False, variants)
variants = self.filter_manager._get_type_variants(True)
self.assertIn(True, variants)
self.assertIn("true", variants)
self.assertIn("True", variants)
self.assertIn("1", variants)
variants = self.filter_manager._get_type_variants(False)
self.assertIn(False, variants)
self.assertIn("false", variants)
self.assertIn("False", variants)
self.assertIn("0", variants)
def test_get_type_variants_edge_cases(self):
variants = self.filter_manager._get_type_variants("hello")
self.assertEqual(variants, ["hello"])
variants = self.filter_manager._get_type_variants("")
self.assertEqual(variants, [""])
variants = self.filter_manager._get_type_variants(" ")
self.assertEqual(variants, [" "])
variants = self.filter_manager._get_type_variants("abc123")
self.assertEqual(variants, ["abc123"])
def test_get_type_variants_no_duplicates(self):
variants = self.filter_manager._get_type_variants("1")
strings = [v for v in variants if isinstance(v, str)]
integers = [v for v in variants if isinstance(v, int) and not isinstance(v, bool)]
booleans = [v for v in variants if isinstance(v, bool)]
self.assertEqual(len([v for v in strings if v == "1"]), 1)
self.assertEqual(len([v for v in integers if v == 1]), 1)
self.assertEqual(len([v for v in booleans if v is True]), 1)
def _create_activity_log(self, detail: dict) -> ActivityLog:
return ActivityLog.objects.create(
organization_id=self.organization.id,
team_id=self.team.id,
user=self.user,
scope="TestScope",
activity="updated",
item_id="test-item",
detail=detail,
)
def test_apply_detail_filters_exact_type_insensitive(self):
log1 = self._create_activity_log({"count": 42})
log2 = self._create_activity_log({"count": "42"})
log3 = self._create_activity_log({"count": 42.0})
log4 = self._create_activity_log({"count": "other"})
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id, log4.id])
filtered = self.filter_manager._apply_detail_filters(queryset, {"count": {"operation": "exact", "value": "42"}})
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id, log3.id}
self.assertEqual(result_ids, expected_ids)
filtered = self.filter_manager._apply_detail_filters(queryset, {"count": {"operation": "exact", "value": 42}})
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id, log3.id}
self.assertEqual(result_ids, expected_ids)
def test_apply_detail_filters_in_type_insensitive(self):
log1 = self._create_activity_log({"count": "42"})
log2 = self._create_activity_log({"count": 42})
log3 = self._create_activity_log({"count": "other"})
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
filtered = self.filter_manager._apply_detail_filters(queryset, {"count": {"operation": "in", "value": ["42"]}})
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id}
self.assertEqual(result_ids, expected_ids)
def test_apply_detail_filters_contains_unchanged(self):
log1 = self._create_activity_log({"message": "Error code 404"})
log2 = self._create_activity_log({"message": "Success"})
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id])
filtered = self.filter_manager._apply_detail_filters(
queryset, {"message": {"operation": "contains", "value": "Error"}}
)
result_ids = set(filtered.values_list("id", flat=True))
self.assertEqual(result_ids, {log1.id})
def test_nested_object_type_conversion(self):
log1 = self._create_activity_log({"config": {"timeout": 30}})
log2 = self._create_activity_log({"config": {"timeout": "30"}})
log3 = self._create_activity_log({"config": {"timeout": 60}})
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
filtered = self.filter_manager._apply_detail_filters(
queryset, {"config.timeout": {"operation": "exact", "value": "30"}}
)
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id}
self.assertEqual(result_ids, expected_ids)
def test_array_field_type_conversion(self):
log1 = self._create_activity_log({"items": [{"id": 1}, {"id": 2}]})
log2 = self._create_activity_log({"items": [{"id": "1"}, {"id": "3"}]})
log3 = self._create_activity_log({"items": [{"id": "other"}]})
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
filtered = self.filter_manager._apply_array_field_filter(queryset, "items[].id", "exact", "1")
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id}
self.assertEqual(result_ids, expected_ids)
def test_array_field_in_operation_type_conversion(self):
log1 = self._create_activity_log({"tags": [{"priority": 1}, {"priority": 3}]})
log2 = self._create_activity_log({"tags": [{"priority": "2"}, {"priority": "1"}]})
log3 = self._create_activity_log({"tags": [{"priority": "high"}]})
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
filtered = self.filter_manager._apply_array_field_filter(queryset, "tags[].priority", "in", ["1", "2"])
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id}
self.assertEqual(result_ids, expected_ids)
def test_deeply_nested_array_fields(self):
log1 = self._create_activity_log(
{
"changes": [
{"after": [{"field": {"subarray": [{"value": 42}]}}]},
{"after": [{"field": {"subarray": [{"value": "other"}]}}]},
]
}
)
log2 = self._create_activity_log({"changes": [{"after": [{"field": {"subarray": [{"value": "42"}]}}]}]})
log3 = self._create_activity_log({"changes": [{"after": [{"field": {"subarray": [{"value": "different"}]}}]}]})
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
filtered = self.filter_manager._apply_array_field_filter(
queryset, "changes[].after[].field.subarray[].value", "exact", "42"
)
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id}
self.assertEqual(result_ids, expected_ids)
class TestTypeConversionIntegration(BaseTest):
def setUp(self):
super().setUp()
self.filter_manager = AdvancedActivityLogFilterManager()
def test_full_filter_pipeline_with_type_conversion(self):
log1 = ActivityLog.objects.create(
organization_id=self.organization.id,
team_id=self.team.id,
user=self.user,
scope="Dashboard",
activity="updated",
item_id="123",
detail={"version": 2, "active": True, "name": "Test Dashboard"},
)
log2 = ActivityLog.objects.create(
organization_id=self.organization.id,
team_id=self.team.id,
user=self.user,
scope="Dashboard",
activity="created",
item_id="456",
detail={"version": "2", "active": "true", "name": "Another Dashboard"},
)
filters = {
"scopes": ["Dashboard"],
"detail_filters": {
"version": {"operation": "exact", "value": "2"},
"active": {"operation": "exact", "value": "true"},
},
}
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id])
filtered = self.filter_manager.apply_filters(queryset, filters)
result_ids = set(filtered.values_list("id", flat=True))
expected_ids = {log1.id, log2.id}
self.assertEqual(result_ids, expected_ids)