Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
77a0a79
feat(workflows): add transactional email delivery stats and what peop…
dmarchuk Nov 2, 2026
a1f76fd
chore(workflows): add the transactional email performance url to the …
dmarchuk Nov 2, 2026
3456bde
fix(workflows): drop stale transactional email stats and show failed …
dmarchuk Nov 2, 2026
dada439
chore(workflows): type the transactional email results formatters
dmarchuk Nov 2, 2026
c46ac5b
chore(workflows): rerun ci
dmarchuk Nov 2, 2026
b0c3aed
fix(workflows): show an error with a retry when performance stats fai…
dmarchuk Nov 3, 2026
1b09606
feat(workflows): add last hour and last 24 hours to the transactional…
dmarchuk Nov 3, 2026
aa5c708
fix(workflows): let the performance results retry when the email list…
dmarchuk Nov 3, 2026
6068324
chore(workflows): split the performance results stats into their own …
dmarchuk Nov 3, 2026
bb67c76
feat(workflows): call the transactional email performance tab metrics…
dmarchuk Nov 3, 2026
0e7bbc1
fix(workflows): ignore superseded and empty-window results requests o…
dmarchuk Nov 3, 2026
9d795fb
chore(workflows): update the mcp app url snapshot for the metrics route
dmarchuk Nov 4, 2026
9fc775e
chore(workflows): merge master into transactional email performance
dmarchuk Nov 5, 2026
abbe34e
chore(workflows): merge the lower layers into transactional email per…
dmarchuk Nov 5, 2026
b1e666d
chore(workflows): merge master into email-performance
dmarchuk Nov 5, 2026
296bcec
chore(workflows): merge the lower layers into transactional email per…
dmarchuk Nov 5, 2026
ae56c1a
chore(workflows): merge the lower layers into transactional email per…
dmarchuk Nov 5, 2026
ee4db39
chore(workflows): merge master into transactional email performance
dmarchuk Nov 5, 2026
9794661
chore(workflows): merge master into transactional email performance
dmarchuk Nov 5, 2026
78716c1
chore(workflows): merge the log api into transactional email performance
dmarchuk Nov 5, 2026
b74f103
chore(workflows): merge master into transactional email performance
dmarchuk Nov 5, 2026
09e6fb8
chore(workflows): merge master into transactional email performance
dmarchuk Nov 5, 2026
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
2 changes: 2 additions & 0 deletions frontend/src/products.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions products/workflows/frontend/Transactional/TransactionalScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,18 @@
isMessagingSetupTab,
} from '../messagingTabs'
import { TransactionalEmailLog } from './log/TransactionalEmailLog'
import { TransactionalEmailMetrics } from './metrics/TransactionalEmailMetrics'
import { TransactionalEmailsTable } from './TransactionalEmailsTable'
import { TransactionalEmailWizard } from './wizard/TransactionalEmailWizard'

type TransactionalTab = 'transactional' | 'log'
type TransactionalTab = 'transactional' | 'log' | 'metrics'

export const scene: SceneExport = {
component: TransactionalScene,
productKey: ProductKey.WORKFLOWS,
}

export function TransactionalScene(): JSX.Element {

Check warning on line 40 in products/workflows/frontend/Transactional/TransactionalScene.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`TransactionalScene` has cyclomatic complexity 14 (warn >10)
const { location } = useValues(router)
const showTransactional = useFeatureFlag('WORKFLOWS_TRANSACTIONAL_EMAIL_API')
const todayRailNav = useFeatureFlag('TODAY_RAIL_NAV')
Expand All @@ -46,10 +47,10 @@
lastSegment as MessagingNavTabKey
)
? (lastSegment as MessagingNavTabKey)
: lastSegment === 'log'
: lastSegment === 'log' || lastSegment === 'metrics'
? lastSegment
: 'transactional'
const isTransactionalTab = currentTab === 'transactional' || currentTab === 'log'
const isTransactionalTab = currentTab === 'transactional' || currentTab === 'log' || currentTab === 'metrics'

if (!showTransactional) {
return <NotFound object="page" />
Expand Down Expand Up @@ -111,6 +112,12 @@
link: urls.transactionalLog(),
content: <TransactionalEmailLog />,
},
{
label: 'Metrics',
key: 'metrics',
link: urls.transactionalMetrics(),
content: <TransactionalEmailMetrics />,
},
{
label: 'Templates',
key: 'library',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useActions, useValues } from 'kea'

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

import { urls } from 'scenes/urls'

import { SceneDivider } from '~/layout/scenes/components/SceneDivider'
import { SceneSection } from '~/layout/scenes/components/SceneSection'

import { METRICS_DATE_RANGES, transactionalEmailMetricsLogic } from './transactionalEmailMetricsLogic'
import { TransactionalEmailResults } from './TransactionalEmailResults'
import { TransactionalEmailStatsTable } from './TransactionalEmailStatsTable'

export function TransactionalEmailMetrics(): JSX.Element {
const { hours, templateStats, templateStatsLoading, domainStats, domainStatsLoading, statsFailed } =
useValues(transactionalEmailMetricsLogic)
const { setHours, loadTemplateStats, loadDomainStats } = useActions(transactionalEmailMetricsLogic)
const emptyState = statsFailed ? "The numbers couldn't load" : 'No transactional emails were sent in this period'

return (
<div className="flex flex-col gap-4">
{statsFailed && !templateStatsLoading && !domainStatsLoading && (
<LemonBanner
type="error"
action={{
children: 'Try again',
onClick: () => {
loadTemplateStats()
loadDomainStats()
},
'data-attr': 'transactional-email-metrics-retry',
}}
>
The numbers couldn't load. Check your connection and try again.
</LemonBanner>
)}
<SceneSection
title="By email"
description="Delivered and bounced are shares of what was sent. Marked as spam, opened and clicked are shares of what was delivered."
actions={
<LemonSelect
size="small"
value={hours}
onChange={setHours}
options={METRICS_DATE_RANGES.map((range) => ({ value: range.value, label: range.label }))}
data-attr="transactional-email-metrics-date-filter"
/>
}
>
<TransactionalEmailStatsTable
rows={templateStats}
loading={templateStatsLoading}
groupTitle="Email"
linkFor={(row) => (row.label ? urls.transactionalTemplate(row.key) : null)}
emptyState={emptyState}
dataAttr="transactional-email-metrics-by-email"
/>
</SceneSection>
<SceneDivider />
<SceneSection title="By sending domain" description="The same counts for each domain the emails came from.">
<TransactionalEmailStatsTable
rows={domainStats}
loading={domainStatsLoading}
groupTitle="Sending domain"
emptyState={emptyState}
dataAttr="transactional-email-metrics-by-domain"
/>
</SceneSection>
<SceneDivider />
<SceneSection
title="What happened after"
description="Of the people an email was sent to, how many did an event afterwards, and the revenue that came with it."
>
<TransactionalEmailResults />
</SceneSection>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useActions, useValues } from 'kea'

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

import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { TaxonomicPopover } from 'lib/components/TaxonomicPopover/TaxonomicPopover'

import { messageTemplatesLogic } from '../../TemplateLibrary/messageTemplatesLogic'
import { transactionalEmailMetricsLogic } from './transactionalEmailMetricsLogic'
import { TransactionalEmailResultsStats } from './TransactionalEmailResultsStats'

export function TransactionalEmailResults(): JSX.Element {
const { resultsQuery, results, resultsLoading, resultsFailed, canLoadResults, templatesFailed } =
useValues(transactionalEmailMetricsLogic)
const { setResultsQuery, loadResults, loadTemplates } = useActions(transactionalEmailMetricsLogic)
const { transactionalTemplates, templatesLoading } = useValues(messageTemplatesLogic)

const emailOptions = transactionalTemplates
.filter((template) => template.transactional_hog_flow_id)
.map((template) => ({
value: template.transactional_hog_flow_id as string,
label: template.name || 'Unnamed email',
}))

return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span>Of everyone who got</span>
<LemonSelect
size="small"
placeholder="Choose an email"
value={resultsQuery.workflowId}
onChange={(workflowId) => setResultsQuery({ workflowId })}
options={emailOptions}
data-attr="transactional-email-results-email"
/>
<span>how many did</span>
<TaxonomicPopover
groupType={TaxonomicFilterGroupType.Events}
value={resultsQuery.event ?? undefined}
onChange={(event) => setResultsQuery({ event: event ? String(event) : null })}
placeholder="Choose an event"
size="small"
data-attr="transactional-email-results-event"
/>
<span>within</span>
<LemonInput
type="number"
size="small"
min={1}
max={30}
value={resultsQuery.windowDays}
onChange={(windowDays) => {
// Clearing the field gives NaN, which the API rejects.
if (windowDays !== undefined && Number.isFinite(windowDays)) {
setResultsQuery({ windowDays: Math.min(Math.max(Math.round(windowDays), 1), 30) })
}
}}
className="w-16"
data-attr="transactional-email-results-window"
/>
<span>days, with revenue from</span>
<TaxonomicPopover
groupType={TaxonomicFilterGroupType.EventProperties}
value={resultsQuery.revenueProperty ?? undefined}
onChange={(property) => setResultsQuery({ revenueProperty: property ? String(property) : null })}
placeholder="No revenue property"
allowClear
size="small"
data-attr="transactional-email-results-revenue"
/>
</div>
{templatesFailed && !templatesLoading ? (
<LemonBanner
type="error"
action={{
children: 'Try again',
onClick: () => loadTemplates(),
'data-attr': 'transactional-email-results-templates-retry',
}}
>
Your emails couldn't load, so there's nothing to pick yet. Check your connection and try again.
</LemonBanner>
) : !canLoadResults ? (
<p className="text-secondary text-sm m-0">
Choose an email and the event that counts as a conversion, such as a signup or a purchase.
</p>
) : resultsFailed && !resultsLoading ? (
<LemonBanner
type="error"
action={{
children: 'Try again',
onClick: () => loadResults(),
'data-attr': 'transactional-email-results-retry',
}}
>
These results couldn't load. Try again, or pick a shorter window.
</LemonBanner>
) : !resultsLoading && results?.recipients === 0 ? (
<p className="text-secondary text-sm m-0">
Nobody was sent this email in this period, or the sends named no distinct ID.
</p>
) : (
<TransactionalEmailResultsStats />
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useValues } from 'kea'

import { AnalyticsMetricCard } from 'lib/components/AnalyticsMetricCard/AnalyticsMetricCard'
import { humanFriendlyDuration } from 'lib/utils/durations'
import { humanFriendlyNumber, percentage } from 'lib/utils/numbers'

import { transactionalEmailMetricsLogic } from './transactionalEmailMetricsLogic'

export function TransactionalEmailResultsStats(): JSX.Element {
const { resultsQuery, results, resultsLoading } = useValues(transactionalEmailMetricsLogic)

return (
<div
className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]"
data-attr="transactional-email-results-stats"
>
<AnalyticsMetricCard
title="People sent the email"
loading={resultsLoading || !results}
value={results?.recipients}
formatValue={(value: number) => humanFriendlyNumber(value)}
subtitle="First send per person"
/>
<AnalyticsMetricCard
title={`Did ${resultsQuery.event} after`}
loading={resultsLoading || !results}
value={results?.converted}
formatValue={(value: number) => humanFriendlyNumber(value)}
subtitle={
results ? `${percentage(results.converted / results.recipients, 1, true)} of them` : undefined
}
/>
<AnalyticsMetricCard
title="Median time to convert"
loading={resultsLoading || !results}
value={results?.median_hours_to_convert ?? undefined}
formatValue={(hours: number) => humanFriendlyDuration(hours * 3600, { maxUnits: 2 })}
subtitle="From the send to the event"
emptyMessage="Nobody converted yet"
/>
{resultsQuery.revenueProperty && (
<AnalyticsMetricCard
title="Revenue"
loading={resultsLoading || !results}
value={results?.revenue}
formatValue={(value: number) => humanFriendlyNumber(value, 2)}
subtitle={`From ${resultsQuery.revenueProperty}`}
/>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Tooltip } from '@posthog/lemon-ui'

import { LemonTable } from 'lib/lemon-ui/LemonTable'
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
import { humanFriendlyNumber, percentage } from 'lib/utils/numbers'

import type { TransactionalEmailStatsRowApi } from 'products/messaging/frontend/generated/api.schemas'

type Rate = 'delivered' | 'bounced' | 'complained' | 'opened' | 'clicked'

// Bounces divide by what was sent, the rest by what arrived, since only a delivered email can be opened or reported.
const RATE_BASE: Record<Rate, 'sent' | 'delivered'> = {
delivered: 'sent',
bounced: 'sent',
complained: 'delivered',
opened: 'delivered',
clicked: 'delivered',
}

const RATE_TITLE: Record<Rate, string> = {
delivered: 'Delivered',
bounced: 'Bounced',
complained: 'Marked as spam',
opened: 'Opened',
clicked: 'Clicked',
}

function RateCell({ row, rate }: { row: TransactionalEmailStatsRowApi; rate: Rate }): JSX.Element {
const base = row[RATE_BASE[rate]]
const count = row[rate]
if (base === 0) {
return <span className="text-secondary">–</span>
}
return (
<Tooltip title={`${humanFriendlyNumber(count)} of ${humanFriendlyNumber(base)} ${RATE_BASE[rate]}`}>
<span className="tabular-nums">{percentage(count / base, 1, true)}</span>
</Tooltip>
)
}

export interface TransactionalEmailStatsTableProps {
rows: TransactionalEmailStatsRowApi[] | null
loading: boolean
groupTitle: string
// Where the group's name links to, when it has a page.
linkFor?: (row: TransactionalEmailStatsRowApi) => string | null
emptyState: string
dataAttr: string
}

export function TransactionalEmailStatsTable({
rows,
loading,
groupTitle,
linkFor,
emptyState,
dataAttr,
}: TransactionalEmailStatsTableProps): JSX.Element {
return (
<LemonTable<TransactionalEmailStatsRowApi>
dataSource={rows ?? []}
loading={loading}
rowKey="key"
data-attr={dataAttr}
columns={[
{
title: groupTitle,
key: 'label',
render: (_, row) => {
const name = row.label || <span className="text-secondary">Deleted email</span>
const to = linkFor?.(row)
return to ? <LemonTableLink to={to} title={name} /> : name
},
},
{
title: 'Sent',
key: 'sent',
align: 'right',
sorter: (a, b) => a.sent - b.sent,
render: (_, row) => <span className="tabular-nums">{humanFriendlyNumber(row.sent)}</span>,
},
...(['delivered', 'bounced', 'complained', 'opened', 'clicked'] as const).map((rate) => ({
title: RATE_TITLE[rate],
key: rate,
align: 'right' as const,
render: (_: unknown, row: TransactionalEmailStatsRowApi) => <RateCell row={row} rate={rate} />,
})),
{
title: 'Failed',
key: 'failed',
align: 'right',
render: (_, row) => (
<span className={row.failed > 0 ? 'tabular-nums text-danger' : 'tabular-nums text-secondary'}>
{humanFriendlyNumber(row.failed)}
</span>
),
},
]}
defaultSorting={{ columnKey: 'sent', order: -1 }}
emptyState={emptyState}
/>
)
}
Loading
Loading