Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/scenes/inbox/SourcesList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useActions, useValues } from 'kea'
import posthog from 'posthog-js'
import { useState } from 'react'

import { IconArrowRight, IconBell, IconGithub, IconHeartPlus, IconLinear } from '@posthog/icons'
Expand All @@ -12,6 +11,7 @@ import { iconForType } from '~/layout/panel-layout/ProjectTree/defaultTree'

import iconZendesk from 'public/services/zendesk.svg'

import { captureSignalSourceInterest } from './inboxAnalytics'
import { PgAnalyzeIcon as IconPgAnalyze } from './PgAnalyzeIcon'
import { signalSourcesLogic } from './signalSourcesLogic'
import { SignalSourceConfigStatus } from './types'
Expand Down Expand Up @@ -51,7 +51,7 @@ function NotifyMeButton({ source }: { source: string }): JSX.Element {
size="xsmall"
disabledReason={notified ? "We'll let you know!" : undefined}
onClick={() => {
posthog.capture('signals source interest', { source })
captureSignalSourceInterest(source)
setNotified(true)
}}
icon={<IconBell />}
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/scenes/inbox/components/InboxReportList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ComponentType, JSX, useEffect, useRef } from 'react'

import { LemonBanner } from '@posthog/lemon-ui'

import { captureInboxViewed } from '../inboxAnalytics'
import { inboxFiltersLogic } from '../logics/inboxFiltersLogic'
import { reportListLogic, ReportListLogicProps } from '../logics/reportListLogic'
import { InboxFlatListTabKey, SignalReport } from '../types'
Expand Down Expand Up @@ -60,8 +61,26 @@ function ActiveFiltersBanner(): JSX.Element | null {
function InboxReportListInner({ tabKey, Card, emptyState }: InboxReportListProps): JSX.Element {
const { reports, count, hasMore, reportsResponseLoading, isLoaded } = useValues(reportListLogic)
const { ensureLoaded, loadMore, archiveReport, restoreReport, refresh } = useActions(reportListLogic)
const { hasActiveFilters, sourceProductFilter, priorityFilter, scope } = useValues(inboxFiltersLogic)
const sentinelRef = useRef<HTMLDivElement>(null)

// Fire `Inbox viewed` once per tab mount, the first time its list settles (loaded with a known count).
const viewedFiredRef = useRef(false)
useEffect(() => {
if (isLoaded && count !== null && !viewedFiredRef.current) {
viewedFiredRef.current = true
captureInboxViewed({
Comment thread
posthog-bot-comment-resolver[bot] marked this conversation as resolved.
Outdated
tab: tabKey,
reports,
totalCount: count,
hasActiveFilters,
sourceProductFilter,
priorityFilter,
scope,
})
}
}, [isLoaded, count, reports, tabKey, hasActiveFilters, sourceProductFilter, priorityFilter, scope])

// Read fresh state at intersection time via refs so the observer is created once and not
// rebuilt twice per page fetch (`hasMore`/`reportsResponseLoading` both flip during a load).
const hasMoreRef = useRef(hasMore)
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/scenes/inbox/components/cards/ReportCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,13 @@ export function ReportCard({
const headline = deriveHeadline(report.summary)
const detailUrl = urls.inboxReport(tabKey, report.id)

const { isArchiving, onArchiveClick } = useReportArchive({ reportId: report.id, cardTitle, onArchive })
const { isArchiving, onArchiveClick } = useReportArchive({
reportId: report.id,
cardTitle,
report,
surface: 'list_row',
onArchive,
})

// On the Archive tab, surface why it was dismissed (reason tag + note tooltip) when we have it.
const dismissalLabel = isArchived ? dismissalReasonLabel(report.dismissal_reason) : null
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/scenes/inbox/components/cards/useReportArchive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@ import { useState } from 'react'

import api from 'lib/api'

import { captureInboxReportAction, InboxReportActionSurface } from '../../inboxAnalytics'
import { SignalReport } from '../../types'
import { DismissalReasonValue } from '../../utils/dismissalReasons'
import { openDismissReportDialog } from '../shell/DismissReportDialog'

/**
* Shared archive handler for the inbox cards (Report / Pull request). Opens the dismissal
* dialog and either delegates to the bound list logic via `onArchive` (optimistic) or, when
* used standalone (e.g. stories), falls back to a direct `signalReports.setState` call.
* used standalone (e.g. stories), falls back to a direct `signalReports.setState` call. The
* single-report `dismiss` analytics fire here so both the list card and the detail pane are
* covered from one place.
*/
export function useReportArchive({
reportId,
cardTitle,
report,
surface,
onArchive,
onArchived,
}: {
reportId: string
cardTitle: string
/** The report being archived, used to enrich the `dismiss` analytics. */
report?: SignalReport | null
/** Which surface the archive was triggered from, for the `dismiss` analytics. */
surface?: InboxReportActionSurface
onArchive?: (reason: DismissalReasonValue, note: string) => void
/** Fired once the report is archived (after `onArchive`, or after the fallback API call succeeds). */
onArchived?: () => void
Expand All @@ -31,6 +41,12 @@ export function useReportArchive({
openDismissReportDialog({
reportTitle: cardTitle,
onConfirm: async ({ reason, note }) => {
captureInboxReportAction({
report: report ?? null,
actionType: 'dismiss',
surface: surface ?? 'list_row',
extra: { dismissal_reason: reason, ...(note ? { dismissal_note: note } : {}) },
Comment thread
posthog-bot-comment-resolver[bot] marked this conversation as resolved.
Outdated
})
if (onArchive) {
onArchive(reason, note)
onArchived?.()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useActions, useValues } from 'kea'
import { router } from 'kea-router'
import posthog from 'posthog-js'
import { useState } from 'react'

import { IconArchive, IconPullRequest, IconUndo } from '@posthog/icons'
Expand All @@ -9,25 +8,13 @@ import { LemonButton, lemonToast } from '@posthog/lemon-ui'
import api from 'lib/api'
import { urls } from 'scenes/urls'

import { captureInboxReportAction } from '../../inboxAnalytics'
import { inboxSceneLogic } from '../../inboxSceneLogic'
import { inboxTaskKickoffLogic } from '../../inboxTaskKickoffLogic'
import { INBOX_FLAT_TAB_LIST_PARAMS, reportListLogic } from '../../logics/reportListLogic'
import { ACTIONABLE_ACTIONABILITY_VALUES, SignalReport, SignalReportStatus } from '../../types'
import { useReportArchive } from '../cards/useReportArchive'

/** Mirror desktop's `Inbox report action` analytics for detail-pane actions. */
function fireReportAction(report: SignalReport, action: 'create_pr', extras: Record<string, unknown> = {}): void {
posthog.capture('Inbox report action', {
report_id: report.id,
report_title: report.title ?? null,
priority: report.priority ?? null,
actionability: report.actionability ?? null,
action_type: action,
surface: 'detail_pane',
...extras,
})
}

/**
* Should the Create PR action be offered? Mirrors desktop `canCreateImplementationPr` /
* the server-side autostart rules: only when ready & actionable, or blocked on user input.
Expand Down Expand Up @@ -65,6 +52,8 @@ export function ReportDetailActions({ report }: { report: SignalReport }): JSX.E
const { isArchiving, onArchiveClick } = useReportArchive({
reportId: report.id,
cardTitle: report.title ?? 'Untitled report',
report,
surface: 'detail_pane',
// Back to the list once archived – the suppressed report drops out on the list's refetch.
onArchived: () => router.actions.push(urls.inbox(activeTab)),
})
Expand All @@ -77,6 +66,7 @@ export function ReportDetailActions({ report }: { report: SignalReport }): JSX.E
listParams: INBOX_FLAT_TAB_LIST_PARAMS.archived,
})
if (archivedList) {
// The list logic fires the `restore` analytics; just drive navigation here.
archivedList.actions.restoreReport(report.id)
router.actions.push(urls.inbox(activeTab))
return
Expand All @@ -85,6 +75,7 @@ export function ReportDetailActions({ report }: { report: SignalReport }): JSX.E
setIsRestoring(true)
try {
await api.signalReports.setState(report.id, { state: 'potential' })
captureInboxReportAction({ report, actionType: 'restore', surface: 'detail_pane' })
lemonToast.success('Report restored to inbox')
router.actions.push(urls.inbox(activeTab))
} catch (error: any) {
Expand Down Expand Up @@ -131,7 +122,7 @@ export function ReportDetailActions({ report }: { report: SignalReport }): JSX.E
loading={isCreatingPr}
tooltip="Have Self-driving open a pull request for this report"
onClick={() => {
fireReportAction(report, 'create_pr')
captureInboxReportAction({ report, actionType: 'create_pr', surface: 'detail_pane' })
createPrFromReport(report)
}}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useActions, useValues } from 'kea'
import posthog from 'posthog-js'
import { useMemo, useState } from 'react'

import { IconCheck, IconPeople, IconPlus, IconX } from '@posthog/icons'
Expand All @@ -8,6 +7,7 @@ import { LemonButton, LemonInput, Link, Spinner, Tooltip } from '@posthog/lemon-
import { LemonDropdown } from 'lib/lemon-ui/LemonDropdown'
import { PersonDisplay } from 'scenes/persons/PersonDisplay'

import { captureInboxReportAction } from '../../inboxAnalytics'
import { inboxReportDetailLogic } from '../../logics/inboxReportDetailLogic'
import { EnrichedReviewer, SignalReport } from '../../types'
import { RightColumnSection } from './DetailSection'
Expand Down Expand Up @@ -64,14 +64,11 @@ export function SuggestedReviewersSection({ report }: { report: SignalReport }):
action: 'add_suggested_reviewer' | 'remove_suggested_reviewer',
login?: string | null
): void => {
posthog.capture('Inbox report action', {
report_id: report.id,
report_title: report.title ?? null,
priority: report.priority ?? null,
actionability: report.actionability ?? null,
action_type: action,
captureInboxReportAction({
report,
actionType: action,
surface: 'detail_pane',
suggested_reviewer_login: login || undefined,
extra: { suggested_reviewer_login: login || undefined },
})
}

Expand Down
132 changes: 132 additions & 0 deletions frontend/src/scenes/inbox/inboxAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import posthog from 'posthog-js'

import {
captureInboxReportAction,
captureInboxViewed,
captureSignalSourceConnected,
INBOX_EVENTS,
} from './inboxAnalytics'
import { SignalReport, SignalReportStatus } from './types'

jest.mock('posthog-js')

function lastCapture(event: string): Record<string, any> | undefined {
const calls = (posthog.capture as jest.Mock).mock.calls.filter(([name]) => name === event)
return calls.length > 0 ? calls[calls.length - 1][1] : undefined
}

function makeReport(overrides: Partial<SignalReport> = {}): SignalReport {
return {
id: 'r1',
title: 'Something broke',
summary: null,
status: SignalReportStatus.READY,
total_weight: 1,
signal_count: 1,
relevant_user_count: null,
created_at: '2026-06-20T00:00:00Z',
updated_at: '2026-06-20T00:00:00Z',
artefact_count: 0,
is_suggested_reviewer: false,
priority: 'P1',
actionability: 'immediately_actionable',
...overrides,
}
}

describe('inboxAnalytics', () => {
beforeEach(() => {
;(posthog.capture as jest.Mock).mockClear()
})

it('stamps every event with the cloud client discriminator', () => {
captureInboxViewed({
tab: 'reports',
reports: [],
totalCount: 0,
hasActiveFilters: false,
sourceProductFilter: [],
priorityFilter: [],
scope: 'for-you',
})
expect(lastCapture(INBOX_EVENTS.VIEWED)?.inbox_client).toBe('cloud')
})

it('breaks the visible reports down by priority and actionability', () => {
captureInboxViewed({
tab: 'reports',
reports: [
makeReport({ id: 'a', priority: 'P0', actionability: 'immediately_actionable' }),
makeReport({ id: 'b', priority: 'P1', actionability: 'requires_human_input' }),
makeReport({ id: 'c', priority: null, actionability: null }),
],
totalCount: 3,
hasActiveFilters: true,
sourceProductFilter: ['error_tracking'],
priorityFilter: ['P0'],
scope: 'entire-project',
})
const props = lastCapture(INBOX_EVENTS.VIEWED)
expect(props).toMatchObject({
report_count: 3,
total_count: 3,
is_empty: false,
has_active_filters: true,
source_product_filter: ['error_tracking'],
priority_p0_count: 1,
priority_p1_count: 1,
priority_unknown_count: 1,
actionability_immediately_actionable_count: 1,
actionability_requires_human_input_count: 1,
actionability_unknown_count: 1,
})
})

it('emits a single-report action with the report context', () => {
captureInboxReportAction({
report: makeReport(),
actionType: 'create_pr',
surface: 'detail_pane',
})
expect(lastCapture(INBOX_EVENTS.REPORT_ACTION)).toMatchObject({
report_id: 'r1',
action_type: 'create_pr',
surface: 'detail_pane',
is_bulk: false,
bulk_size: 1,
})
})

it('emits a bulk action with a null report and the selection size', () => {
captureInboxReportAction({
actionType: 'dismiss',
surface: 'bulk_bar',
isBulk: true,
bulkSize: 4,
extra: { dismissal_reason: 'wontfix_irrelevant' },
})
expect(lastCapture(INBOX_EVENTS.REPORT_ACTION)).toMatchObject({
report_id: null,
action_type: 'dismiss',
surface: 'bulk_bar',
is_bulk: true,
bulk_size: 4,
dismissal_reason: 'wontfix_irrelevant',
})
})

it('records a connected source with first-connection and wizard flags', () => {
captureSignalSourceConnected({
sourceProduct: 'github',
sourceType: 'issue',
isFirstConnection: true,
viaSetupWizard: true,
})
expect(lastCapture(INBOX_EVENTS.SOURCE_CONNECTED)).toMatchObject({
source_product: 'github',
source_type: 'issue',
is_first_connection: true,
via_setup_wizard: true,
})
})
})
Loading
Loading