Skip to content

Commit

Permalink
feat(cdp): App metrics (#23893)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjackwhite authored Jul 23, 2024
1 parent fc835c6 commit 1ff75d7
Show file tree
Hide file tree
Showing 20 changed files with 851 additions and 95 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
ActionType,
ActivityScope,
AlertType,
AppMetricsTotalsV2Response,
AppMetricsV2RequestParams,
AppMetricsV2Response,
BatchExportConfiguration,
BatchExportRun,
CohortType,
Expand Down Expand Up @@ -1626,6 +1629,18 @@ const api = {
): Promise<PaginatedResponse<LogEntry>> {
return await new ApiRequest().hogFunction(id).withAction('logs').withQueryString(params).get()
},
async metrics(
id: HogFunctionType['id'],
params: AppMetricsV2RequestParams = {}
): Promise<AppMetricsV2Response> {
return await new ApiRequest().hogFunction(id).withAction('metrics').withQueryString(params).get()
},
async metricsTotals(
id: HogFunctionType['id'],
params: Partial<AppMetricsV2RequestParams> = {}
): Promise<AppMetricsTotalsV2Response> {
return await new ApiRequest().hogFunction(id).withAction('metrics/totals').withQueryString(params).get()
},

async listTemplates(): Promise<PaginatedResponse<HogFunctionTemplateType>> {
return await new ApiRequest().hogFunctionTemplates().get()
Expand Down
29 changes: 28 additions & 1 deletion frontend/src/scenes/pipeline/AppMetricSparkLine.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useValues } from 'kea'
import { useActions, useValues } from 'kea'
import { Sparkline, SparklineTimeSeries } from 'lib/components/Sparkline'
import { useEffect } from 'react'

import { pipelineNodeMetricsLogic } from './pipelineNodeMetricsLogic'
import { pipelineNodeMetricsV2Logic } from './pipelineNodeMetricsV2Logic'
import { PipelineBackend, PipelineNode } from './types'

export function AppMetricSparkLine({ pipelineNode }: { pipelineNode: PipelineNode }): JSX.Element {
Expand Down Expand Up @@ -34,3 +36,28 @@ export function AppMetricSparkLine({ pipelineNode }: { pipelineNode: PipelineNod
}
return <Sparkline loading={appMetricsResponse === null} labels={dates} data={displayData} className="max-w-24" />
}

export function AppMetricSparkLineV2({ pipelineNode }: { pipelineNode: PipelineNode }): JSX.Element {
const logic = pipelineNodeMetricsV2Logic({ id: `${pipelineNode.id}`.replace('hog-', '') })
const { appMetrics, appMetricsLoading } = useValues(logic)
const { loadMetrics } = useActions(logic)

useEffect(() => {
loadMetrics()
}, [])

const displayData: SparklineTimeSeries[] = [
{
color: 'success',
name: 'Success',
values: appMetrics?.series.find((s) => s.name === 'succeeded')?.values || [],
},
{
color: 'danger',
name: 'Failures',
values: appMetrics?.series.find((s) => s.name === 'failed')?.values || [],
},
]

return <Sparkline loading={appMetricsLoading} labels={appMetrics?.labels} data={displayData} className="max-w-24" />
}
7 changes: 3 additions & 4 deletions frontend/src/scenes/pipeline/PipelineNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BatchExportRuns } from './BatchExportRuns'
import { PipelineNodeConfiguration } from './PipelineNodeConfiguration'
import { pipelineNodeLogic, PipelineNodeLogicProps } from './pipelineNodeLogic'
import { PipelineNodeMetrics } from './PipelineNodeMetrics'
import { PipelineNodeMetricsV2 } from './PipelineNodeMetricsV2'
import { PipelineBackend } from './types'

export const PIPELINE_TAB_TO_NODE_STAGE: Partial<Record<PipelineTab, PipelineStage>> = {
Expand Down Expand Up @@ -58,10 +59,8 @@ export function PipelineNode(params: { stage?: string; id?: string } = {}): JSX.
[PipelineNodeTab.Configuration]: <PipelineNodeConfiguration />,
}

if ([PipelineBackend.Plugin, PipelineBackend.BatchExport].includes(node.backend)) {
tabToContent[PipelineNodeTab.Metrics] = <PipelineNodeMetrics id={id} />
}

tabToContent[PipelineNodeTab.Metrics] =
node.backend === PipelineBackend.HogFunction ? <PipelineNodeMetricsV2 /> : <PipelineNodeMetrics id={id} />
tabToContent[PipelineNodeTab.Logs] = <PipelineNodeLogs id={id} stage={stage} />

if (node.backend === PipelineBackend.BatchExport) {
Expand Down
251 changes: 251 additions & 0 deletions frontend/src/scenes/pipeline/PipelineNodeMetricsV2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { IconCalendar } from '@posthog/icons'
import { LemonSelect, LemonSkeleton, Popover, SpinnerOverlay, Tooltip } from '@posthog/lemon-ui'
import { BindLogic, useActions, useValues } from 'kea'
import { Chart, ChartDataset, ChartItem } from 'lib/Chart'
import { getColorVar } from 'lib/colors'
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
import { humanFriendlyNumber, inStorybookTestRunner } from 'lib/utils'
import { useEffect, useRef, useState } from 'react'
import { InsightTooltip } from 'scenes/insights/InsightTooltip/InsightTooltip'

import { pipelineNodeLogic } from './pipelineNodeLogic'
import { pipelineNodeMetricsV2Logic } from './pipelineNodeMetricsV2Logic'
import { PipelineBackend } from './types'

const METRICS_INFO = {
succeeded: 'Total number of events processed successfully',
failed: 'Total number of events that had errors during processing',
filtered: 'Total number of events that were filtered out',
disabled_temporarily:
'Total number of events that were skipped due to the destination being temporarily disabled (due to issues such as the destination being down or rate-limited)',
disabled_permanently:
'Total number of events that were skipped due to the destination being permanently disabled (due to prolonged issues with the destination)',
}

export function PipelineNodeMetricsV2(): JSX.Element {
const { node } = useValues(pipelineNodeLogic)

const logic = pipelineNodeMetricsV2Logic({ id: `${node.id}` })

const { filters } = useValues(logic)
const { setFilters, loadMetrics, loadMetricsTotals } = useActions(logic)

useEffect(() => {
loadMetrics()
loadMetricsTotals()
}, [])

if (node.backend !== PipelineBackend.HogFunction) {
return <div>Metrics not available for this node</div>
}

return (
<BindLogic logic={pipelineNodeMetricsV2Logic} props={{ id: node.id }}>
<div className="space-y-4">
<AppMetricsTotals />

<div className="flex items-center gap-2">
<h2 className="mb-0">Delivery trends</h2>
<div className="flex-1" />
<LemonSelect
options={[
{ label: 'Hourly', value: 'hour' },
{ label: 'Daily', value: 'day' },
{ label: 'Weekly', value: 'week' },
]}
size="small"
value={filters.interval}
onChange={(value) => setFilters({ interval: value })}
/>
<DateFilter
dateTo={filters.before}
dateFrom={filters.after}
onChange={(from, to) => setFilters({ after: from || undefined, before: to || undefined })}
allowedRollingDateOptions={['days', 'weeks', 'months', 'years']}
makeLabel={(key) => (
<>
<IconCalendar /> {key}
</>
)}
/>
</div>

<AppMetricsGraph />
</div>
</BindLogic>
)
}

function AppMetricBigNumber({
label,
value,
tooltip,
}: {
label: string
value: number | undefined
tooltip: JSX.Element | string
}): JSX.Element {
return (
<Tooltip title={tooltip}>
<div className="border p-2 rounded bg-bg-light flex-1 flex flex-col gap-2 items-center">
<div className="uppercase font-bold text-xs">{label.replace(/_/g, ' ')}</div>
<div className="text-2xl flex-1 mb-2 flex items-center">{humanFriendlyNumber(value ?? 0)}</div>
</div>
</Tooltip>
)
}

function AppMetricsTotals(): JSX.Element {
const { appMetricsTotals, appMetricsTotalsLoading } = useValues(pipelineNodeMetricsV2Logic)

return (
<div className="space-y-4">
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(METRICS_INFO).map(([key, value]) => (
<div key={key} className="flex flex-col h-30 min-w-30 flex-1 max-w-100">
{appMetricsTotalsLoading ? (
<LemonSkeleton className="h-full w-full" />
) : (
<AppMetricBigNumber label={key} value={appMetricsTotals?.totals?.[key]} tooltip={value} />
)}
</div>
))}
</div>
</div>
)
}

function AppMetricsGraph(): JSX.Element {
const { appMetrics, appMetricsLoading } = useValues(pipelineNodeMetricsV2Logic)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const [popoverContent, setPopoverContent] = useState<JSX.Element | null>(null)
const [tooltipState, setTooltipState] = useState({ x: 0, y: 0, visible: false })

useEffect(() => {
let chart: Chart
if (canvasRef.current && appMetrics && !inStorybookTestRunner()) {
chart = new Chart(canvasRef.current?.getContext('2d') as ChartItem, {
type: 'line',
data: {
labels: appMetrics.labels,
datasets: [
...appMetrics.series.map((series) => ({
label: series.name,
data: series.values,
borderColor: '',
...colorConfig(series.name),
})),
],
},
options: {
scales: {
x: {
ticks: {
maxRotation: 0,
},
grid: {
display: false,
},
},
y: {
beginAtZero: true,
},
},
plugins: {
// @ts-expect-error Types of library are out of date
crosshair: false,
legend: {
display: false,
},
tooltip: {
enabled: false, // Using external tooltip
external({ tooltip, chart }) {
setPopoverContent(
<InsightTooltip
embedded
hideInspectActorsSection
// showHeader={!!labels}
altTitle={tooltip.dataPoints[0].label}
seriesData={tooltip.dataPoints.map((dp, i) => ({
id: i,
dataIndex: 0,
datasetIndex: 0,
label: dp.dataset.label,
color: dp.dataset.borderColor as string,
count: (dp.dataset.data?.[dp.dataIndex] as number) || 0,
}))}
renderSeries={(value) => value}
renderCount={(count) => humanFriendlyNumber(count)}
/>
)

const position = chart.canvas.getBoundingClientRect()
setTooltipState({
x: position.left + tooltip.caretX,
y: position.top + tooltip.caretY,
visible: tooltip.opacity > 0,
})
},
},
},
maintainAspectRatio: false,
interaction: {
mode: 'index',
axis: 'x',
intersect: false,
},
},
})

return () => {
chart?.destroy()
}
}
}, [appMetrics])

return (
<div className="relative border rounded p-6 bg-bg-light h-[50vh]">
{appMetricsLoading && <SpinnerOverlay />}
{!!appMetrics && <canvas ref={canvasRef} />}
<Popover
visible={tooltipState.visible}
overlay={popoverContent}
placement="top"
padded={false}
className="pointer-events-none"
>
<div
className="fixed"
// eslint-disable-next-line react/forbid-dom-props
style={{ left: tooltipState.x, top: tooltipState.y }}
/>
</Popover>
</div>
)
}

function colorConfig(name: string): Partial<ChartDataset<'line', any>> {
let color = ''

switch (name) {
case 'succeeded':
color = getColorVar('success')
break
case 'failed':
color = getColorVar('danger')
break
default:
color = getColorVar('data-color-1')
break
}

return {
borderColor: color,
hoverBorderColor: color,
hoverBackgroundColor: color,
backgroundColor: color,
fill: false,
borderWidth: 2,
pointRadius: 0,
}
}
8 changes: 6 additions & 2 deletions frontend/src/scenes/pipeline/destinations/Destinations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { urls } from 'scenes/urls'

import { AvailableFeature, PipelineNodeTab, PipelineStage, ProductKey } from '~/types'

import { AppMetricSparkLine } from '../AppMetricSparkLine'
import { AppMetricSparkLine, AppMetricSparkLineV2 } from '../AppMetricSparkLine'
import { HogFunctionIcon } from '../hogfunctions/HogFunctionIcon'
import { NewButton } from '../NewButton'
import { pipelineAccessLogic } from '../pipelineAccessLogic'
Expand Down Expand Up @@ -161,7 +161,11 @@ export function DestinationsTable(props: PipelineDestinationsLogicProps): JSX.El
PipelineNodeTab.Metrics
)}
>
<AppMetricSparkLine pipelineNode={destination} />
{destination.backend === PipelineBackend.HogFunction ? (
<AppMetricSparkLineV2 pipelineNode={destination} />
) : (
<AppMetricSparkLine pipelineNode={destination} />
)}
</Link>
)
},
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/scenes/pipeline/pipelineNodeLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ export const pipelineNodeLogic = kea<pipelineNodeLogicType>([
setCurrentTab: () => [urls.pipelineNode(props.stage as PipelineStage, props.id, values.currentTab)],
}
}),
urlToAction(({ actions, values }) => ({
'/pipeline/:stage/:id/:nodeTab': ({ nodeTab }) => {
urlToAction(({ props, actions, values }) => ({
[urls.pipelineNode(props.stage as PipelineStage, props.id, ':nodeTab')]: ({ nodeTab }) => {
if (nodeTab !== values.currentTab && Object.values(PipelineNodeTab).includes(nodeTab as PipelineNodeTab)) {
actions.setCurrentTab(nodeTab as PipelineNodeTab)
}
Expand Down
Loading

0 comments on commit 1ff75d7

Please sign in to comment.