From 18b5e084e64e7f3acce66b34d6d2108c59beb9e6 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Tue, 15 Sep 2026 10:49:29 -0700 Subject: [PATCH] fix(automations): Use workflow stats time range Consume authoritative response bounds for the chart domain while accepting the legacy array response during deployment. Refs ISWF-853 Co-authored-by: Claude --- .../automationStatsChart.timeRange.spec.tsx | 78 +++++++++++++++++ .../components/automationStatsChart.tsx | 85 +++++++++++++++---- 2 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 static/app/views/automations/components/automationStatsChart.timeRange.spec.tsx diff --git a/static/app/views/automations/components/automationStatsChart.timeRange.spec.tsx b/static/app/views/automations/components/automationStatsChart.timeRange.spec.tsx new file mode 100644 index 000000000000..cecdccd7a041 --- /dev/null +++ b/static/app/views/automations/components/automationStatsChart.timeRange.spec.tsx @@ -0,0 +1,78 @@ +import type {EChartsOption} from 'echarts'; +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {AutomationStatsChart} from 'sentry/views/automations/components/automationStatsChart'; + +let mockChartOptions: EChartsOption | undefined; + +jest.mock('echarts-for-react/lib/core', () => { + return function MockEcharts({option}: {option: EChartsOption}) { + mockChartOptions = option; + return null; + }; +}); + +describe('AutomationStatsChart time range', () => { + const organization = OrganizationFixture(); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + mockChartOptions = undefined; + }); + + it('uses the time range returned by the workflow stats endpoint on a time axis', async () => { + const start = Date.parse('2026-01-13T19:00:00Z'); + const end = Date.parse('2026-01-13T20:00:00Z'); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/123/stats/', + body: { + meta: {dataset: 'workflow', start, end}, + timeSeries: [ + { + yAxis: 'count()', + values: [{timestamp: end, value: 1, incomplete: false}], + meta: {interval: 60 * 60 * 1000, valueType: 'integer', valueUnit: null}, + }, + ], + }, + }); + + render( + , + {organization} + ); + + expect(await screen.findByText('1')).toBeInTheDocument(); + expect(mockChartOptions).toMatchObject({ + xAxis: {type: 'time', min: start, max: end}, + }); + }); + + it('continues to accept the legacy response during deployment', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/123/stats/', + body: [{date: '2026-01-13T20:00:00Z', count: 1}], + }); + + render( + , + {organization} + ); + + expect(await screen.findByText('1')).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/automations/components/automationStatsChart.tsx b/static/app/views/automations/components/automationStatsChart.tsx index e47cf6861d0a..4d7a0d4eeaf2 100644 --- a/static/app/views/automations/components/automationStatsChart.tsx +++ b/static/app/views/automations/components/automationStatsChart.tsx @@ -1,5 +1,6 @@ import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; import {Container, Flex} from '@sentry/scraps/layout'; @@ -14,15 +15,53 @@ import {PanelFooter} from 'sentry/components/panels/panelFooter'; import {Placeholder} from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; import type {Automation, AutomationStats} from 'sentry/types/workflowEngine/automations'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {getUtcDateString} from 'sentry/utils/dates'; -import {useApiQuery} from 'sentry/utils/queryClient'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {getUtcDateString, getUtcToLocalDateObject} from 'sentry/utils/dates'; import {useOrganization} from 'sentry/utils/useOrganization'; +import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types'; interface IssueAlertDetailsProps extends DateTimeObject { automationId: Automation['id']; } +type WorkflowStatsResponse = { + meta: { + dataset: string; + end: number; + start: number; + }; + timeSeries: TimeSeries[]; +}; + +type WorkflowStatsApiResponse = AutomationStats[] | WorkflowStatsResponse; + +type WorkflowStats = { + values: TimeSeries['values']; + timeframe?: WorkflowStatsResponse['meta']; +}; + +function normalizeWorkflowStatsResponse( + response: WorkflowStatsApiResponse | undefined +): WorkflowStats | undefined { + if (!response) { + return undefined; + } + + if (Array.isArray(response)) { + return { + values: response.map(({date, count}) => ({ + timestamp: Date.parse(date), + value: count, + })), + }; + } + + return { + timeframe: response.meta, + values: response.timeSeries[0]?.values ?? [], + }; +} + export function AutomationStatsChart({ automationId, period, @@ -34,28 +73,28 @@ export function AutomationStatsChart({ const organization = useOrganization(); const chartZoomProps = useChartZoom({saveOnZoom: true}); const { - data: fireHistory, + data: statsResponse, isPending, isError, - } = useApiQuery( - [ - getApiUrl('/organizations/$organizationIdOrSlug/workflows/$workflowId/stats/', { - path: {organizationIdOrSlug: organization.slug, workflowId: automationId}, - }), + } = useQuery( + apiOptions.as()( + '/organizations/$organizationIdOrSlug/workflows/$workflowId/stats/', { + path: {organizationIdOrSlug: organization.slug, workflowId: automationId}, query: { ...(period && {statsPeriod: period}), start: start ? getUtcDateString(start) : undefined, end: end ? getUtcDateString(end) : undefined, utc: utc ? 'true' : undefined, }, - }, - ], - {staleTime: 30000} + staleTime: 30_000, + } + ) ); + const stats = normalizeWorkflowStatsResponse(statsResponse); const totalAlertsTriggered = - fireHistory?.reduce((acc, curr) => acc + curr.count, 0) ?? 0; + stats?.values.reduce((acc, curr) => acc + (curr.value ?? 0), 0) ?? 0; return ( @@ -65,10 +104,14 @@ export function AutomationStatsChart({ {isPending && } {isError && } - {fireHistory && ( + {stats && ( ({ - name: automation.date, - value: automation.count, + data: stats.values.map(({timestamp, value}) => ({ + name: timestamp, + value: value ?? 0, })), emphasis: { disabled: true,