feat(messaging): Testing area improvements (#37678)

This commit is contained in:
Ben White
2025-09-05 13:54:14 +02:00
committed by GitHub
parent 6d6a756351
commit 3d0314388c
8 changed files with 296 additions and 224 deletions

View File

@@ -163,6 +163,7 @@ import {
UserType,
} from '~/types'
import { HogflowTestResult } from 'products/messaging/frontend/Campaigns/hogflows/steps/types'
import { HogFlow } from 'products/messaging/frontend/Campaigns/hogflows/types'
import { OptOutEntry } from 'products/messaging/frontend/OptOuts/optOutListLogic'
import { MessageTemplate } from 'products/messaging/frontend/TemplateLibrary/messageTemplatesLogic'
@@ -3920,7 +3921,7 @@ const api = {
invocation_id?: string
current_action_id?: string
}
): Promise<any> {
): Promise<HogflowTestResult> {
return await new ApiRequest().hogFlow(hogFlowId).withAction('invocations').create({ data })
},
},

View File

@@ -7,6 +7,7 @@ import {
LemonSelect,
LemonTable,
LemonTableColumn,
LemonTableProps,
LemonTag,
LemonTagProps,
Link,
@@ -59,6 +60,7 @@ export type LogsViewerProps = LogsViewerLogicProps & {
/**
* NOTE: There is a loose attempt to keeep this generic so we can use it as an abstract log component in the future.
*/
export function LogsViewer({
renderColumns = (c) => c,
renderMessage = (m) => m,
@@ -80,49 +82,6 @@ export function LogsViewer({
} = useValues(logic)
const { revealHiddenLogs, loadOlderLogs, setFilters, setRowExpanded, setIsGrouped } = useActions(logic)
const logColumns: LemonTableColumn<LogEntry, keyof LogEntry | undefined>[] = [
{
title: 'Timestamp',
key: 'timestamp',
dataIndex: 'timestamp',
width: 0,
render: (_, { timestamp }) => <TZLabel time={timestamp} />,
},
{
title: 'Level',
key: 'level',
dataIndex: 'level',
width: 0,
render: (_, { level }) => <LemonTag type={tagTypeForLevel(level)}>{level.toUpperCase()}</LemonTag>,
},
{
width: 0,
title: instanceLabel,
dataIndex: 'instanceId',
key: 'instanceId',
render: (_, { instanceId }) => (
<code className="whitespace-nowrap">
<CopyToClipboardInline explicitValue={instanceId} selectable>
<Link
className="font-semibold"
subtle
onClick={() => setRowExpanded(instanceId, !expandedRows[instanceId])}
title={instanceId}
>
{shortInstanceId(instanceId)}
</Link>
</CopyToClipboardInline>
</code>
),
},
{
title: 'Message',
key: 'message',
dataIndex: 'message',
render: (_, { message }) => <code className="whitespace-pre-wrap">{renderMessage(message)}</code>,
},
]
const groupedLogColumns: LemonTableColumn<GroupedLogEntry, keyof GroupedLogEntry | undefined>[] = renderColumns([
{
title: 'Timestamp',
@@ -304,16 +263,18 @@ export function LogsViewer({
onRowCollapse: (record) => setRowExpanded(record.instanceId, false),
expandedRowRender: (record) => {
return (
<LemonTable
<LogsViewerTable
instanceLabel={instanceLabel}
renderMessage={renderMessage}
embedded
dataSource={record.entries}
columns={[
renderColumns={(columns) => [
{
key: 'spacer',
width: 0,
render: () => <div className="w-6" />,
},
...logColumns,
...columns,
]}
/>
)
@@ -321,13 +282,14 @@ export function LogsViewer({
}}
/>
) : (
<LemonTable
<LogsViewerTable
instanceLabel={instanceLabel}
renderMessage={renderMessage}
key="ungrouped"
dataSource={unGroupedLogs}
loading={logsLoading}
className="ph-no-capture overflow-y-auto"
rowKey={(record, index) => `${record.timestamp.toISOString()}-${index}`}
columns={logColumns}
footer={footer}
/>
)}
@@ -336,3 +298,62 @@ export function LogsViewer({
</div>
)
}
export function LogsViewerTable({
instanceLabel,
renderMessage,
renderColumns = (c) => c,
...props
}: Omit<LemonTableProps<LogEntry>, 'columns'> & {
instanceLabel: string
renderMessage: (message: string) => JSX.Element | string
renderColumns?: (
columns: LemonTableColumn<LogEntry, keyof LogEntry | undefined>[]
) => LemonTableColumn<LogEntry, keyof LogEntry | undefined>[]
}): JSX.Element {
let logColumns: LemonTableColumn<LogEntry, keyof LogEntry | undefined>[] = [
{
title: 'Timestamp',
key: 'timestamp',
dataIndex: 'timestamp',
width: 0,
render: (_, { timestamp }) => <TZLabel time={timestamp} />,
},
{
title: 'Level',
key: 'level',
dataIndex: 'level',
width: 0,
render: (_, { level }) => <LemonTag type={tagTypeForLevel(level)}>{level.toUpperCase()}</LemonTag>,
},
{
width: 0,
title: instanceLabel,
dataIndex: 'instanceId',
key: 'instanceId',
render: (_, { instanceId }) => (
<code className="whitespace-nowrap">
<CopyToClipboardInline explicitValue={instanceId} selectable>
<Link className="font-semibold" subtle title={instanceId}>
{shortInstanceId(instanceId)}
</Link>
</CopyToClipboardInline>
</code>
),
},
{
title: 'Message',
key: 'message',
dataIndex: 'message',
render: (_, { message }) => <code className="whitespace-pre-wrap">{renderMessage(message)}</code>,
},
]
return (
<LemonTable
{...props}
rowKey={(record, index) => `${record.timestamp.toISOString()}-${index}`}
columns={renderColumns(logColumns)}
/>
)
}

View File

@@ -480,7 +480,7 @@ export class CdpApi {
const result = await this.hogFlowExecutor.executeCurrentAction(invocation)
res.json({
result,
nextActionId: result.invocation.state.currentAction?.id,
status: result.error ? 'error' : 'success',
errors: result.error ? [result.error] : [],
logs: result.logs,

View File

@@ -223,7 +223,7 @@ describe('Hogflow Executor', () => {
{
level: 'info',
timestamp: expect.any(DateTime),
message: "Workflow moved to action 'exit (exit)'",
message: 'Workflow moved to action [Action:exit]',
},
{
level: 'info',
@@ -297,7 +297,7 @@ describe('Hogflow Executor', () => {
"[Action:function_id_1] Fetch 3, 200",
"[Action:function_id_1] All fetches done!",
"[Action:function_id_1] Function completed in REPLACEDms. Sync: 0ms. Mem: 0.099kb. Ops: 32. Event: 'http://localhost:8000/events/1'",
"Workflow moved to action 'exit (exit)'",
"Workflow moved to action [Action:exit]",
"Workflow completed",
]
`)
@@ -379,7 +379,7 @@ describe('Hogflow Executor', () => {
expect(result1.finished).toBe(false)
expect(result1.invocation.state.currentAction?.id).toBe('function_id_1')
expect(result1.logs.map((log) => log.message)).toEqual([
"Workflow moved to action 'function (function_id_1)'",
'Workflow moved to action [Action:function_id_1]',
])
// Second step: should process function_id_1 and move to exit, but not complete
@@ -390,7 +390,7 @@ describe('Hogflow Executor', () => {
'[Action:function_id_1] Hello, Mr Debug User!',
'[Action:function_id_1] Fetch 1, 200',
expect.stringContaining('[Action:function_id_1] Function completed in'),
"Workflow moved to action 'exit (exit)'",
'Workflow moved to action [Action:exit]',
'Workflow completed',
])
})

View File

@@ -31,6 +31,11 @@ import { ensureCurrentAction, findContinueAction, shouldSkipAction } from './hog
export const MAX_ACTION_STEPS_HARD_LIMIT = 1000
// Special format which the frontend understands and can render as a link
const actionIdForLogging = (action: HogFlowAction) => {
return `[Action:${action.id}]`
}
export class HogFlowExecutorService {
private readonly actionHandlers: Record<HogFlowAction['type'], ActionHandler>
@@ -324,7 +329,7 @@ export class HogFlowExecutorService {
result.logs.push({
level: 'info',
timestamp: DateTime.now(),
message: `Workflow moved to action '${nextAction.name} (${nextAction.id})'`,
message: `Workflow moved to action ${actionIdForLogging(nextAction)}`,
})
this.trackActionMetric(result, currentAction, reason === 'filtered' ? 'filtered' : 'succeeded')
@@ -357,7 +362,7 @@ export class HogFlowExecutorService {
level: LogEntryLevel,
message: string
): void {
this.log(result, level, `[Action:${action.id}] ${message}`)
this.log(result, level, `${actionIdForLogging(action)} ${message}`)
}
private log(

View File

@@ -1,25 +1,29 @@
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { useEffect } from 'react'
import { IconInfo, IconPlay, IconPlayFilled, IconRedo } from '@posthog/icons'
import {
LemonBanner,
LemonButton,
LemonCollapse,
LemonDivider,
LemonLabel,
LemonSwitch,
LemonTable,
Link,
ProfilePicture,
Spinner,
Tooltip,
} from '@posthog/lemon-ui'
import { TZLabel } from 'lib/components/TZLabel'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LogsViewerTable } from 'scenes/hog-functions/logs/LogsViewer'
import { asDisplay } from 'scenes/persons/person-utils'
import { urls } from 'scenes/urls'
import { campaignLogic } from '../../../campaignLogic'
import { renderWorkflowLogMessage } from '../../../logs/log-utils'
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
import { hogFlowEditorTestLogic } from './hogFlowEditorTestLogic'
@@ -34,20 +38,26 @@ export function HogFlowTestPanelNonSelected(): JSX.Element {
}
export function HogFlowEditorPanelTest(): JSX.Element | null {
const { selectedNode } = useValues(hogFlowEditorLogic)
const { campaign, selectedNode } = useValues(hogFlowEditorLogic)
const { setSelectedNodeId } = useActions(hogFlowEditorLogic)
const { logicProps } = useValues(campaignLogic)
const { sampleGlobals, isTestInvocationSubmitting, testResult, shouldLoadSampleGlobals } = useValues(
hogFlowEditorTestLogic(logicProps)
)
const {
sampleGlobals,
sampleGlobalsLoading,
isTestInvocationSubmitting,
testResult,
shouldLoadSampleGlobals,
nextActionId,
} = useValues(hogFlowEditorTestLogic(logicProps))
const { submitTestInvocation, setTestResult, loadSampleGlobals } = useActions(hogFlowEditorTestLogic(logicProps))
const display = asDisplay(sampleGlobals?.person)
const url = urls.personByDistinctId(sampleGlobals?.event?.distinct_id || '')
if (!selectedNode) {
// NOTE: This shouldn't ever happen as the parent checks it
return null
}
useEffect(() => {
setTestResult(null)
}, [selectedNode?.id, setTestResult])
return (
<Form
@@ -57,178 +67,194 @@ export function HogFlowEditorPanelTest(): JSX.Element | null {
enableFormOnSubmit
className="flex overflow-hidden flex-col flex-1"
>
{/* Body */}
<div className="flex overflow-y-auto flex-col flex-1 gap-2 p-2">
{/* Event Information */}
{sampleGlobals?.event && (
<div className="p-3 rounded border bg-surface-secondary">
<div className="flex flex-wrap gap-1 items-center">
{sampleGlobals.person && (
<Link to={url} className="flex gap-2 items-center">
<ProfilePicture name={display} /> <span className="font-semibold">{display}</span>
</Link>
)}
<span className="text-muted">performed</span>
<div className="space-y-1 font-semibold text-md">{sampleGlobals.event.event}</div>{' '}
<div>
<TZLabel time={sampleGlobals.event.timestamp} />
</div>
</div>
{/* Event Properties */}
{sampleGlobals.event.properties && Object.keys(sampleGlobals.event.properties).length > 0 && (
<div className="mt-3">
<div className="mb-2 text-sm">Event properties</div>
<div className="overflow-auto max-h-32 rounded border bg-surface-primary">
<pre className="p-2 text-xs whitespace-pre-wrap text-muted">
{JSON.stringify(sampleGlobals.event.properties, null, 2)}
</pre>
</div>
</div>
)}
</div>
)}
{/* Test Results */}
{testResult && (
<div data-attr="test-results" className="flex flex-col gap-2">
<h2 className="mb-0">Test results</h2>
<LemonBanner
type={
testResult.status === 'success'
? 'success'
: testResult.status === 'skipped'
? 'warning'
: 'error'
<div className="flex gap-2 items-center p-2">
<LemonField name="mock_async_functions" className="flex-1">
{({ value, onChange }) => (
<LemonSwitch
onChange={(v) => onChange(!v)}
checked={!value}
data-attr="toggle-workflow-test-panel-new-mocking"
className="whitespace-nowrap"
size="small"
bordered
label={
<Tooltip
title={
<>
When disabled, message deliveries and other async actions will not be
called. Instead they will be mocked out and logged.
</>
}
>
<span className="flex gap-2">
Make real HTTP requests
<IconInfo className="text-lg" />
</span>
</Tooltip>
}
>
{testResult.status === 'success'
? 'Success'
: testResult.status === 'skipped'
? 'Workflow was skipped because the event did not match the filter criteria'
: 'Error'}
</LemonBanner>
<div className="flex flex-col gap-2">
<LemonLabel>Test invocation logs</LemonLabel>
<LemonTable
dataSource={testResult.logs ?? []}
columns={[
{
title: 'Timestamp',
key: 'timestamp',
dataIndex: 'timestamp',
render: (timestamp) => <TZLabel time={timestamp as string} />,
width: 0,
},
{
width: 100,
title: 'Level',
key: 'level',
dataIndex: 'level',
},
{
title: 'Message',
key: 'message',
dataIndex: 'message',
render: (message) => <code className="whitespace-pre-wrap">{message}</code>,
},
]}
className="ph-no-capture"
rowKey="timestamp"
/>
</div>
</div>
)}
</div>
<LemonDivider className="my-0" />
{/* footer */}
<div className="p-2">
/>
)}
</LemonField>
{testResult ? (
<div className="flex justify-end gap-2">
<>
<div className="flex-1" />
<LemonButton
type="secondary"
onClick={() => setTestResult(null)}
loading={isTestInvocationSubmitting}
size="small"
data-attr="clear-workflow-test-panel-new-result"
>
Clear test result
</LemonButton>
{selectedNode?.data?.type !== 'exit' && (
{nextActionId && (
<LemonButton
type="primary"
onClick={() => submitTestInvocation()}
onClick={() => setSelectedNodeId(nextActionId)}
icon={<IconPlayFilled />}
loading={isTestInvocationSubmitting}
size="small"
data-attr="continue-workflow-test-panel-new"
>
Continue
Go to next step
</LemonButton>
)}
</div>
</>
) : (
<>
<div className="flex flex-col gap-2">
<div className="text-sm text-muted">
Note: Delays will be logged to indicate when they would have been executed.
</div>
<div className="flex-1" />
<div className="flex gap-2 items-center">
<LemonField name="mock_async_functions" className="flex-1">
{({ value, onChange }) => (
<LemonSwitch
onChange={(v) => onChange(!v)}
checked={!value}
data-attr="toggle-workflow-test-panel-new-mocking"
label={
<Tooltip
title={
<>
When disabled, message deliveries and other async actions
will not be called. Instead they will be mocked out and
logged.
</>
}
>
<span className="flex gap-2">
Make real HTTP requests
<IconInfo className="text-lg" />
</span>
</Tooltip>
}
/>
)}
</LemonField>
<LemonButton
type="secondary"
onClick={() => loadSampleGlobals()}
tooltip="Find the last event matching the trigger event filters, and use it to populate the globals for a test run."
disabledReason={
!shouldLoadSampleGlobals ? 'Must configure trigger event' : undefined
}
icon={<IconRedo />}
>
Load new event
</LemonButton>
<LemonButton
type="primary"
data-attr="test-workflow-panel-new"
onClick={() => submitTestInvocation()}
icon={<IconPlay />}
loading={isTestInvocationSubmitting}
disabledReason={sampleGlobals ? undefined : 'Must load event to run test'}
>
Run test
</LemonButton>
</div>
</div>
<LemonButton
type="primary"
data-attr="test-workflow-panel-new"
onClick={() => submitTestInvocation()}
icon={<IconPlay />}
loading={isTestInvocationSubmitting}
disabledReason={sampleGlobals ? undefined : 'Must load event to run test'}
size="small"
>
Run test
</LemonButton>
</>
)}
</div>
<LemonDivider className="my-0" />
<div className="flex flex-col flex-1 overflow-y-auto">
{/* Event Information */}
<div className="flex-0">
<LemonCollapse
embedded
panels={[
{
key: 'event',
header: {
children: sampleGlobalsLoading ? (
<>
Loading test event... <Spinner />
</>
) : (
<>Test event: {sampleGlobals?.event?.event} </>
),
},
className: 'bg-surface-secondary',
content: (
<div>
<div className="bg-surface-secondary">
<div className="flex gap-2 items-center">
<ProfilePicture name={display} />
<div className="flex-1">
{sampleGlobals?.person ? (
<Link to={url} className="flex gap-2 items-center">
<span className="font-semibold">{display}</span>
</Link>
) : (
<span className="text-muted">Loading...</span>
)}{' '}
<span className="text-muted">performed</span>{' '}
<span className="space-y-1 font-semibold text-md">
{sampleGlobals?.event.event}
</span>{' '}
{sampleGlobals?.event.timestamp && (
<TZLabel time={sampleGlobals.event.timestamp} />
)}
</div>
<LemonButton
type="secondary"
onClick={() => loadSampleGlobals()}
tooltip="Find the last event matching the trigger event filters, and use it to populate the globals for a test run."
disabledReason={
!shouldLoadSampleGlobals
? 'Must configure trigger event'
: undefined
}
icon={<IconRedo />}
size="small"
>
Load new event
</LemonButton>
</div>
{/* Event Properties */}
{sampleGlobals?.event.properties &&
Object.keys(sampleGlobals.event.properties).length > 0 && (
<div className="mt-3">
<div className="mb-2 text-sm">Event properties</div>
<div className="overflow-auto max-h-32 rounded border bg-surface-primary">
<pre className="p-2 text-xs whitespace-pre-wrap text-muted">
{JSON.stringify(
sampleGlobals.event.properties,
null,
2
)}
</pre>
</div>
</div>
)}
</div>
</div>
),
},
]}
/>
</div>
<LemonDivider className="my-0" />
<div className="flex flex-col flex-1 gap-2 p-2">
<h3 className="mb-0">Test results</h3>
{!testResult ? (
<div className="text-muted text-sm">No tests run yet</div>
) : (
<>
<LemonBanner
type={
testResult.status === 'success'
? 'success'
: testResult.status === 'skipped'
? 'warning'
: 'error'
}
>
{testResult.status === 'success'
? 'Success'
: testResult.status === 'skipped'
? 'Workflow was skipped because the event did not match the filter criteria'
: 'Error: ' + testResult.errors?.join(', ')}
</LemonBanner>
<div className="flex flex-col gap-2">
<LemonLabel>Logs</LemonLabel>
<LogsViewerTable
instanceLabel="workflow run"
renderMessage={(m) => renderWorkflowLogMessage(campaign, m)}
dataSource={testResult.logs ?? []}
renderColumns={(columns) => columns.filter((column) => column.key !== 'instanceId')}
/>
</div>
</>
)}
</div>
</div>
</Form>
)
}

View File

@@ -23,6 +23,7 @@ import {
import { CampaignLogicProps, campaignLogic } from '../../../campaignLogic'
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
import { HogflowTestResult } from '../../steps/types'
import { HogFlow } from '../../types'
import type { hogFlowEditorTestLogicType } from './hogFlowEditorTestLogicType'
@@ -31,16 +32,6 @@ export interface HogflowTestInvocation {
mock_async_functions: boolean
}
export interface HogflowTestResult {
status: 'success' | 'error' | 'skipped'
result?: any
logs?: Array<{
timestamp: string
level: string
message: string
}>
}
export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
path((key) => ['products', 'messaging', 'frontend', 'Campaigns', 'hogflows', 'actions', 'workflowTestLogic', key]),
props({} as CampaignLogicProps),
@@ -56,6 +47,7 @@ export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
setSampleGlobalsError: (error: string | null) => ({ error }),
cancelSampleGlobalsLoading: true,
receiveExampleGlobals: (globals: object | null) => ({ globals }),
setNextActionId: (nextActionId: string | null) => ({ nextActionId }),
}),
reducers({
testResult: [
@@ -84,6 +76,12 @@ export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
cancelSampleGlobalsLoading: () => true,
},
],
nextActionId: [
null as string | null,
{
setNextActionId: (_, { nextActionId }) => nextActionId,
},
],
}),
loaders(({ actions, values }) => ({
@@ -254,8 +252,20 @@ export const hogFlowEditorTestLogic = kea<hogFlowEditorTestLogicType>([
current_action_id: values.selectedNodeId ?? undefined,
})
actions.setTestResult(apiResponse)
actions.setSelectedNodeId(values.testResult?.result?.invocation?.state?.currentAction?.id)
const result: HogflowTestResult = {
...apiResponse,
logs: apiResponse.logs?.map((log) => ({
...log,
instanceId: 'test',
timestamp: dayjs(log.timestamp),
})),
}
actions.setTestResult(result)
const nextActionId = result.nextActionId
if (nextActionId && nextActionId !== values.selectedNodeId) {
actions.setNextActionId(nextActionId)
}
return values.testInvocation
} catch (error: any) {

View File

@@ -1,6 +1,8 @@
import { Handle, NodeProps } from '@xyflow/react'
import { z } from 'zod'
import { LogEntry } from 'scenes/hog-functions/logs/logsViewerLogic'
import { Optional } from '~/types'
import { HogFlowAction } from '../types'
@@ -159,3 +161,10 @@ export const isFunctionAction = (
): action is Extract<HogFlowAction, { type: 'function' | 'function_sms' | 'function_email' }> => {
return ['function', 'function_sms', 'function_email'].includes(action.type)
}
export interface HogflowTestResult {
status: 'success' | 'error' | 'skipped'
logs?: LogEntry[]
nextActionId: string | null
errors?: string[]
}