refactor webhookAnalytics call and enrich analytics module (#8253)

**TLDR**

Refactor WebhoonAnalytics Graph to a more abstract version
AnalyticsGraph (in analytics module). Thus enabling the components to be
used on different instances (ex: new endpoint, new kind of graph).

**In order to test:**

1. Set ANALYTICS_ENABLED to true
2. Set TINYBIRD_JWT_TOKEN to the ADMIN token from the workspace
twenty_analytics_playground
3. Set TINYBIRD_JWT_TOKEN to the datasource or your admin token from the
workspace twenty_analytics_playground
4. Create a Webhook in twenty and set wich events it needs to track
5. Run twenty-worker in order to make the webhooks work.
6. Do your tasks in order to populate the data
7. Enter to settings> webhook>your webhook and the statistics section
should be displayed.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Ana Sofia Marin Alexandre
2024-11-08 06:00:51 -03:00
committed by GitHub
parent f9c076df31
commit f06cdbdfc6
62 changed files with 1429 additions and 539 deletions

View File

@ -1,89 +0,0 @@
import { formatDateISOStringToDateTimeSimplified } from '@/localization/utils/formatDateISOStringToDateTimeSimplified';
import { UserContext } from '@/users/contexts/UserContext';
import styled from '@emotion/styled';
import { Point } from '@nivo/line';
import { ReactElement, useContext } from 'react';
const StyledTooltipContainer = styled.div`
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.md};
border: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
width: 128px;
flex-direction: column;
justify-content: center;
background: ${({ theme }) => theme.background.transparent.secondary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
backdrop-filter: ${({ theme }) => theme.blur.medium};
`;
const StyledTooltipDateContainer = styled.div`
align-items: flex-start;
align-self: stretch;
display: flex;
justify-content: center;
font-weight: ${({ theme }) => theme.font.weight.medium};
font-family: ${({ theme }) => theme.font.family};
gap: ${({ theme }) => theme.spacing(2)};
color: ${({ theme }) => theme.font.color.secondary};
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledTooltipDataRow = styled.div`
align-items: flex-start;
align-self: stretch;
display: flex;
justify-content: space-between;
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledLine = styled.div`
background-color: ${({ theme }) => theme.border.color.medium};
height: 1px;
width: 100%;
`;
const StyledColorPoint = styled.div<{ color: string }>`
background-color: ${({ color }) => color};
border-radius: 50%;
height: 8px;
width: 8px;
display: inline-block;
`;
const StyledDataDefinition = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledSpan = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
type SettingsDevelopersWebhookTooltipProps = {
point: Point;
};
export const SettingsDevelopersWebhookTooltip = ({
point,
}: SettingsDevelopersWebhookTooltipProps): ReactElement => {
const { timeFormat, timeZone } = useContext(UserContext);
const windowInterval = new Date(point.data.x);
const windowIntervalDate = formatDateISOStringToDateTimeSimplified(
windowInterval,
timeZone,
timeFormat,
);
return (
<StyledTooltipContainer>
<StyledTooltipDateContainer>
{windowIntervalDate}
</StyledTooltipDateContainer>
<StyledLine />
<StyledTooltipDataRow>
<StyledDataDefinition>
<StyledColorPoint color={point.serieColor} />
{String(point.serieId)}
</StyledDataDefinition>
<StyledSpan>{String(point.data.y)}</StyledSpan>
</StyledTooltipDataRow>
</StyledTooltipContainer>
);
};

View File

@ -1,181 +0,0 @@
import { SettingsDevelopersWebhookTooltip } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookTooltip';
import { useGraphData } from '@/settings/developers/webhook/hooks/useGraphData';
import { webhookGraphDataState } from '@/settings/developers/webhook/states/webhookGraphDataState';
import { Select } from '@/ui/input/components/Select';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveLine } from '@nivo/line';
import { Section } from '@react-email/components';
import { useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { H2Title } from 'twenty-ui';
export type NivoLineInput = {
id: string | number;
color?: string;
data: Array<{
x: number | string | Date;
y: number | string | Date;
}>;
};
const StyledGraphContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
height: 199px;
padding: ${({ theme }) => theme.spacing(4, 2, 2, 2)};
width: 496px;
`;
const StyledTitleContainer = styled.div`
align-items: flex-start;
display: flex;
justify-content: space-between;
`;
type SettingsDevelopersWebhookUsageGraphProps = {
webhookId: string;
};
export const SettingsDevelopersWebhookUsageGraph = ({
webhookId,
}: SettingsDevelopersWebhookUsageGraphProps) => {
const webhookGraphData = useRecoilValue(webhookGraphDataState);
const setWebhookGraphData = useSetRecoilState(webhookGraphDataState);
const theme = useTheme();
const [windowLengthGraphOption, setWindowLengthGraphOption] = useState<
'7D' | '1D' | '12H' | '4H'
>('7D');
const { fetchGraphData } = useGraphData(webhookId);
return (
<>
{webhookGraphData.length ? (
<Section>
<StyledTitleContainer>
<H2Title
title="Activity"
description="See your webhook activity over time"
/>
<Select
dropdownId="test-id-webhook-graph"
value={windowLengthGraphOption}
options={[
{ value: '7D', label: 'This week' },
{ value: '1D', label: 'Today' },
{ value: '12H', label: 'Last 12 hours' },
{ value: '4H', label: 'Last 4 hours' },
]}
onChange={(windowLengthGraphOption) => {
setWindowLengthGraphOption(windowLengthGraphOption);
fetchGraphData(windowLengthGraphOption).then((graphInput) => {
setWebhookGraphData(graphInput);
});
}}
/>
</StyledTitleContainer>
<StyledGraphContainer>
<ResponsiveLine
data={webhookGraphData}
curve={'monotoneX'}
enableArea={true}
colors={(d) => d.color}
theme={{
text: {
fill: theme.font.color.light,
fontSize: theme.font.size.sm,
fontFamily: theme.font.family,
},
axis: {
domain: {
line: {
stroke: theme.border.color.light,
},
},
ticks: {
line: {
stroke: theme.border.color.light,
},
},
},
grid: {
line: {
stroke: theme.border.color.light,
},
},
crosshair: {
line: {
stroke: theme.font.color.light,
strokeDasharray: '2 2',
},
},
}}
margin={{ top: 20, right: 0, bottom: 30, left: 30 }}
xFormat="time:%Y-%m-%d %H:%M%"
xScale={{
type: 'time',
useUTC: false,
format: '%Y-%m-%d %H:%M:%S',
precision: 'hour',
}}
defs={[
{
colors: [
{
color: 'inherit',
offset: 0,
},
{
color: 'inherit',
offset: 100,
opacity: 0,
},
],
id: 'gradientGraph',
type: 'linearGradient',
},
]}
fill={[
{
id: 'gradientGraph',
match: '*',
},
]}
yScale={{
type: 'linear',
}}
axisBottom={{
format: '%b %d, %I:%M %p',
tickValues: 2,
tickPadding: 5,
tickSize: 6,
}}
axisLeft={{
tickPadding: 5,
tickSize: 6,
tickValues: 4,
}}
enableGridX={false}
lineWidth={1}
gridYValues={4}
enablePoints={false}
isInteractive={true}
useMesh={true}
enableSlices={false}
enableCrosshair={false}
tooltip={({ point }) => (
<SettingsDevelopersWebhookTooltip point={point} />
)}
/>
</StyledGraphContainer>
</Section>
) : (
<></>
)}
</>
);
};

View File

@ -1,28 +0,0 @@
import { useGraphData } from '@/settings/developers/webhook/hooks/useGraphData';
import { webhookGraphDataState } from '@/settings/developers/webhook/states/webhookGraphDataState';
import { useEffect, useState } from 'react';
import { useSetRecoilState } from 'recoil';
type SettingsDevelopersWebhookUsageGraphEffectProps = {
webhookId: string;
};
export const SettingsDevelopersWebhookUsageGraphEffect = ({
webhookId,
}: SettingsDevelopersWebhookUsageGraphEffectProps) => {
const setWebhookGraphData = useSetRecoilState(webhookGraphDataState);
const [isLoaded, setIsLoaded] = useState(false);
const { fetchGraphData } = useGraphData(webhookId);
useEffect(() => {
if (!isLoaded) {
fetchGraphData('7D').then((graphInput) => {
setWebhookGraphData(graphInput);
});
setIsLoaded(true);
}
}, [fetchGraphData, isLoaded, setWebhookGraphData, webhookId]);
return <></>;
};

View File

@ -1,6 +0,0 @@
export const WEBHOOK_GRAPH_API_OPTIONS_MAP = {
'7D': { windowInHours: '168', tickIntervalInMinutes: '420' },
'1D': { windowInHours: '24', tickIntervalInMinutes: '60' },
'12H': { windowInHours: '12', tickIntervalInMinutes: '30' },
'4H': { windowInHours: '4', tickIntervalInMinutes: '10' },
};

View File

@ -1,47 +0,0 @@
import { renderHook } from '@testing-library/react';
import { CurrentUser, currentUserState } from '@/auth/states/currentUserState';
import { useAnalyticsTinybirdJwt } from '@/settings/developers/webhook/hooks/useAnalyticsTinybirdJwt';
import { act } from 'react';
import { useSetRecoilState } from 'recoil';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useAnalyticsTinybirdJwt', () => {
it('should return the analytics jwt token', async () => {
const { result } = renderHook(
() => {
const setCurrentUserState = useSetRecoilState(currentUserState);
return {
useAnalyticsTinybirdJwt: useAnalyticsTinybirdJwt(),
setCurrentUserState,
};
},
{ wrapper: Wrapper },
);
act(() => {
result.current.setCurrentUserState({
analyticsTinybirdJwt: 'jwt',
} as CurrentUser);
});
expect(result.current.useAnalyticsTinybirdJwt).toBe('jwt');
act(() => {
result.current.setCurrentUserState(null);
});
expect(result.current.useAnalyticsTinybirdJwt).toBeUndefined();
act(() => {
result.current.setCurrentUserState({} as CurrentUser);
});
expect(result.current.useAnalyticsTinybirdJwt).toBeUndefined();
});
});

View File

@ -1,18 +0,0 @@
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { isNull } from '@sniptt/guards';
export const useAnalyticsTinybirdJwt = (): string | undefined => {
const currentUser = useRecoilValue(currentUserState);
if (!currentUser) {
return undefined;
}
if (isNull(currentUser.analyticsTinybirdJwt)) {
return undefined;
}
return currentUser.analyticsTinybirdJwt;
};

View File

@ -1,31 +0,0 @@
import { useAnalyticsTinybirdJwt } from '@/settings/developers/webhook/hooks/useAnalyticsTinybirdJwt';
import { fetchGraphDataOrThrow } from '@/settings/developers/webhook/utils/fetchGraphDataOrThrow';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isUndefined } from '@sniptt/guards';
export const useGraphData = (webhookId: string) => {
const { enqueueSnackBar } = useSnackBar();
const analyticsTinybirdJwt = useAnalyticsTinybirdJwt();
const fetchGraphData = async (
windowLengthGraphOption: '7D' | '1D' | '12H' | '4H',
) => {
try {
if (isUndefined(analyticsTinybirdJwt)) {
throw new Error('No analyticsTinybirdJwt found');
}
return await fetchGraphDataOrThrow({
webhookId,
windowLength: windowLengthGraphOption,
tinybirdJwt: analyticsTinybirdJwt,
});
} catch (error) {
enqueueSnackBar('Something went wrong while fetching webhook usage', {
variant: SnackBarVariant.Error,
});
return [];
}
};
return { fetchGraphData };
};

View File

@ -1,7 +0,0 @@
import { NivoLineInput } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraph';
import { createState } from 'twenty-ui';
export const webhookGraphDataState = createState<NivoLineInput[]>({
key: 'webhookGraphData',
defaultValue: [],
});

View File

@ -1,119 +0,0 @@
import { WEBHOOK_GRAPH_API_OPTIONS_MAP } from '@/settings/developers/webhook/constants/WebhookGraphApiOptionsMap';
import { fetchGraphDataOrThrow } from '@/settings/developers/webhook/utils/fetchGraphDataOrThrow';
// Mock the global fetch function
global.fetch = jest.fn();
describe('fetchGraphDataOrThrow', () => {
const mockWebhookId = 'test-webhook-id';
const mockWindowLength = '7D';
const mockTinybirdJwt = 'test-jwt';
beforeEach(() => {
jest.resetAllMocks();
});
it('should fetch and transform data successfully', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({
data: [
{ start_interval: '2023-05-01', failure_count: 2, success_count: 8 },
{ start_interval: '2023-05-02', failure_count: 1, success_count: 9 },
],
}),
};
global.fetch.mockResolvedValue(mockResponse);
const result = await fetchGraphDataOrThrow({
webhookId: mockWebhookId,
windowLength: mockWindowLength,
tinybirdJwt: mockTinybirdJwt,
});
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
`https://api.eu-central-1.aws.tinybird.co/v0/pipes/getWebhooksAnalyticsV2.json?`,
),
expect.objectContaining({
headers: {
Authorization: expect.stringContaining('Bearer '),
},
}),
);
expect(result).toEqual([
{
id: 'Failed',
color: 'red',
data: [
{ x: '2023-05-01', y: 2 },
{ x: '2023-05-02', y: 1 },
],
},
{
id: 'Succeeded',
color: 'blue',
data: [
{ x: '2023-05-01', y: 8 },
{ x: '2023-05-02', y: 9 },
],
},
]);
});
it('should throw an error when the response is not ok', async () => {
const mockResponse = {
ok: false,
json: jest.fn().mockResolvedValue({ error: 'Some error' }),
};
global.fetch.mockResolvedValue(mockResponse);
await expect(
fetchGraphDataOrThrow({
webhookId: mockWebhookId,
windowLength: mockWindowLength,
tinybirdJwt: mockTinybirdJwt,
}),
).rejects.toThrow('Something went wrong while fetching webhook usage');
});
it('should use correct query parameters based on window length', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ data: [] }),
};
global.fetch.mockResolvedValue(mockResponse);
await fetchGraphDataOrThrow({
webhookId: mockWebhookId,
windowLength: '1D',
tinybirdJwt: mockTinybirdJwt,
});
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
new URLSearchParams({
...WEBHOOK_GRAPH_API_OPTIONS_MAP['1D'],
webhookId: mockWebhookId,
}).toString(),
),
expect.any(Object),
);
});
it('should handle empty response data', async () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ data: [] }),
};
global.fetch.mockResolvedValue(mockResponse);
const result = await fetchGraphDataOrThrow({
webhookId: mockWebhookId,
windowLength: mockWindowLength,
});
expect(result).toEqual([]);
});
});

View File

@ -1,82 +0,0 @@
import { NivoLineInput } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraph';
import { WEBHOOK_GRAPH_API_OPTIONS_MAP } from '@/settings/developers/webhook/constants/WebhookGraphApiOptionsMap';
type fetchGraphDataOrThrowProps = {
webhookId: string;
windowLength: '7D' | '1D' | '12H' | '4H';
tinybirdJwt: string;
};
export const fetchGraphDataOrThrow = async ({
webhookId,
windowLength,
tinybirdJwt,
}: fetchGraphDataOrThrowProps) => {
const queryString = new URLSearchParams({
...WEBHOOK_GRAPH_API_OPTIONS_MAP[windowLength],
webhookId,
}).toString();
const response = await fetch(
`https://api.eu-central-1.aws.tinybird.co/v0/pipes/getWebhooksAnalyticsV2.json?${queryString}`,
{
headers: {
Authorization: 'Bearer ' + tinybirdJwt,
},
},
);
const result = await response.json();
if (!response.ok) {
throw new Error('Something went wrong while fetching webhook usage');
}
// Next steps: separate the map logic to a different component (response.data, {id:str, color:str}[])=>NivoLineInput[]
const graphInput = result.data
.flatMap(
(dataRow: {
start_interval: string;
failure_count: number;
success_count: number;
}) => [
{
x: dataRow.start_interval,
y: dataRow.failure_count,
id: 'Failed',
color: 'red', // need to refacto this
},
{
x: dataRow.start_interval,
y: dataRow.success_count,
id: 'Succeeded',
color: 'blue',
},
],
)
.reduce(
(
acc: NivoLineInput[],
{
id,
x,
y,
color,
}: { id: string; x: string; y: number; color: string },
) => {
const existingGroupIndex = acc.findIndex((group) => group.id === id);
const isExistingGroup = existingGroupIndex !== -1;
if (isExistingGroup) {
return acc.map((group, index) =>
index === existingGroupIndex
? { ...group, data: [...group.data, { x, y }] }
: group,
);
} else {
return [...acc, { id, color, data: [{ x, y }] }];
}
},
[],
);
return graphInput;
};