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,23 +1,19 @@
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 { WebhookAnalyticsTooltip } from '@/analytics/components/WebhookAnalyticsTooltip';
import { ANALYTICS_GRAPH_DESCRIPTION_MAP } from '@/analytics/constants/AnalyticsGraphDescriptionMap';
import { ANALYTICS_GRAPH_TITLE_MAP } from '@/analytics/constants/AnalyticsGraphTitleMap';
import { useGraphData } from '@/analytics/hooks/useGraphData';
import { analyticsGraphDataComponentState } from '@/analytics/states/analyticsGraphDataComponentState';
import { AnalyticsComponentProps as AnalyticsActivityGraphProps } from '@/analytics/types/AnalyticsComponentProps';
import { computeAnalyticsGraphDataFunction } from '@/analytics/utils/computeAnalyticsGraphDataFunction';
import { Select } from '@/ui/input/components/Select';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
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 { useId, useState } from 'react';
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};
@ -33,34 +29,38 @@ const StyledTitleContainer = styled.div`
justify-content: space-between;
`;
type SettingsDevelopersWebhookUsageGraphProps = {
webhookId: string;
};
export const SettingsDevelopersWebhookUsageGraph = ({
webhookId,
}: SettingsDevelopersWebhookUsageGraphProps) => {
const webhookGraphData = useRecoilValue(webhookGraphDataState);
const setWebhookGraphData = useSetRecoilState(webhookGraphDataState);
export const AnalyticsActivityGraph = ({
recordId,
endpointName,
}: AnalyticsActivityGraphProps) => {
const [analyticsGraphData, setAnalyticsGraphData] = useRecoilComponentStateV2(
analyticsGraphDataComponentState,
);
const theme = useTheme();
const [windowLengthGraphOption, setWindowLengthGraphOption] = useState<
'7D' | '1D' | '12H' | '4H'
>('7D');
const { fetchGraphData } = useGraphData(webhookId);
const { fetchGraphData } = useGraphData({
recordId,
endpointName,
});
const transformDataFunction = computeAnalyticsGraphDataFunction(endpointName);
const dropdownId = useId();
return (
<>
{webhookGraphData.length ? (
{analyticsGraphData.length ? (
<Section>
<StyledTitleContainer>
<H2Title
title="Activity"
description="See your webhook activity over time"
title={`${ANALYTICS_GRAPH_TITLE_MAP[endpointName]}`}
description={`${ANALYTICS_GRAPH_DESCRIPTION_MAP[endpointName]}`}
/>
<Select
dropdownId="test-id-webhook-graph"
dropdownId={dropdownId}
value={windowLengthGraphOption}
options={[
{ value: '7D', label: 'This week' },
@ -71,7 +71,7 @@ export const SettingsDevelopersWebhookUsageGraph = ({
onChange={(windowLengthGraphOption) => {
setWindowLengthGraphOption(windowLengthGraphOption);
fetchGraphData(windowLengthGraphOption).then((graphInput) => {
setWebhookGraphData(graphInput);
setAnalyticsGraphData(transformDataFunction(graphInput));
});
}}
/>
@ -79,10 +79,12 @@ export const SettingsDevelopersWebhookUsageGraph = ({
<StyledGraphContainer>
<ResponsiveLine
data={webhookGraphData}
data={analyticsGraphData}
curve={'monotoneX'}
enableArea={true}
colors={(d) => d.color}
colors={{ scheme: 'set1' }}
//it "addapts" to the color scheme of the graph without hardcoding them
//is there a color scheme for graph Data in twenty? Do we always want the gradient?
theme={{
text: {
fill: theme.font.color.light,
@ -149,7 +151,7 @@ export const SettingsDevelopersWebhookUsageGraph = ({
type: 'linear',
}}
axisBottom={{
format: '%b %d, %I:%M %p',
format: '%b %d, %I:%M %p', //TODO: add the user prefered time format for the graph
tickValues: 2,
tickPadding: 5,
tickSize: 6,
@ -167,9 +169,7 @@ export const SettingsDevelopersWebhookUsageGraph = ({
useMesh={true}
enableSlices={false}
enableCrosshair={false}
tooltip={({ point }) => (
<SettingsDevelopersWebhookTooltip point={point} />
)}
tooltip={({ point }) => <WebhookAnalyticsTooltip point={point} />} // later add a condition to get different tooltips
/>
</StyledGraphContainer>
</Section>

View File

@ -0,0 +1,32 @@
import { useGraphData } from '@/analytics/hooks/useGraphData';
import { analyticsGraphDataComponentState } from '@/analytics/states/analyticsGraphDataComponentState';
import { AnalyticsComponentProps as AnalyticsGraphEffectProps } from '@/analytics/types/AnalyticsComponentProps';
import { computeAnalyticsGraphDataFunction } from '@/analytics/utils/computeAnalyticsGraphDataFunction';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useState } from 'react';
export const AnalyticsGraphEffect = ({
recordId,
endpointName,
}: AnalyticsGraphEffectProps) => {
const setAnalyticsGraphData = useSetRecoilComponentStateV2(
analyticsGraphDataComponentState,
);
const transformDataFunction = computeAnalyticsGraphDataFunction(endpointName);
const [isLoaded, setIsLoaded] = useState(false);
const { fetchGraphData } = useGraphData({
recordId,
endpointName,
});
if (!isLoaded) {
fetchGraphData('7D').then((graphInput) => {
setAnalyticsGraphData(transformDataFunction(graphInput));
});
setIsLoaded(true);
}
return <></>;
};

View File

@ -58,12 +58,12 @@ const StyledDataDefinition = styled.div`
const StyledSpan = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
type SettingsDevelopersWebhookTooltipProps = {
type WebhookAnalyticsTooltipProps = {
point: Point;
};
export const SettingsDevelopersWebhookTooltip = ({
export const WebhookAnalyticsTooltip = ({
point,
}: SettingsDevelopersWebhookTooltipProps): ReactElement => {
}: WebhookAnalyticsTooltipProps): ReactElement => {
const { timeFormat, timeZone } = useContext(UserContext);
const windowInterval = new Date(point.data.x);
const windowIntervalDate = formatDateISOStringToDateTimeSimplified(

View File

@ -0,0 +1,10 @@
import { AnalyticsTinybirdJwtMap } from '~/generated-metadata/graphql';
export const ANALYTICS_ENDPOINT_TYPE_MAP: AnalyticsTinybirdJwtMap = {
getWebhookAnalytics: 'webhook',
getPageviewsAnalytics: 'pageviews',
getUsersAnalytics: 'users',
getServerlessFunctionDuration: 'function',
getServerlessFunctionSuccessRate: 'function',
getServerlessFunctionErrorCount: 'function',
};

View File

@ -0,0 +1,10 @@
import { AnalyticsTinybirdJwtMap } from '~/generated-metadata/graphql';
export const ANALYTICS_GRAPH_DESCRIPTION_MAP: AnalyticsTinybirdJwtMap = {
getWebhookAnalytics: 'See your webhook activity over time',
getPageviewsAnalytics: 'See your Page Views activity over time',
getUsersAnalytics: 'See your Users activity over time',
getServerlessFunctionDuration: 'See your function duration over time',
getServerlessFunctionSuccessRate: 'See your function success rate over time',
getServerlessFunctionErrorCount: 'See your function error count over time',
};

View File

@ -0,0 +1,6 @@
export const ANALYTICS_GRAPH_OPTION_MAP = {
'7D': { granularity: 'day' },
'1D': { granularity: 'hour' },
'12H': { granularity: 'hour' },
'4H': { granularity: 'hour' },
};

View File

@ -0,0 +1,10 @@
import { AnalyticsTinybirdJwtMap } from '~/generated-metadata/graphql';
export const ANALYTICS_GRAPH_TITLE_MAP: AnalyticsTinybirdJwtMap = {
getWebhookAnalytics: 'Activity',
getPageviewsAnalytics: 'Page Views',
getUsersAnalytics: 'Users',
getServerlessFunctionDuration: 'Duration (ms)',
getServerlessFunctionSuccessRate: 'Success Rate (%)',
getServerlessFunctionErrorCount: 'Error Count',
};

View File

@ -0,0 +1,87 @@
import { useAnalyticsTinybirdJwts } from '@/analytics/hooks/useAnalyticsTinybirdJwts';
import { CurrentUser, currentUserState } from '@/auth/states/currentUserState';
import { act, renderHook } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useAnalyticsTinybirdJwts', () => {
const JWT_NAME = 'getWebhookAnalytics';
const TEST_JWT_TOKEN = 'test-jwt-token';
it('should return undefined when no user is logged in', () => {
const { result } = renderHook(
() => {
const setCurrentUserState = useSetRecoilState(currentUserState);
return {
hook: useAnalyticsTinybirdJwts(JWT_NAME),
setCurrentUserState,
};
},
{ wrapper: Wrapper },
);
act(() => {
result.current.setCurrentUserState(null);
});
expect(result.current.hook).toBeUndefined();
});
it('should return the correct JWT token when available', () => {
const { result } = renderHook(
() => {
const setCurrentUserState = useSetRecoilState(currentUserState);
return {
hook: useAnalyticsTinybirdJwts(JWT_NAME),
setCurrentUserState,
};
},
{ wrapper: Wrapper },
);
act(() => {
result.current.setCurrentUserState({
id: '1',
email: 'test@test.com',
canImpersonate: false,
userVars: {},
analyticsTinybirdJwts: {
[JWT_NAME]: TEST_JWT_TOKEN,
},
} as CurrentUser);
});
expect(result.current.hook).toBe(TEST_JWT_TOKEN);
});
it('should return undefined when JWT token is not available', () => {
const { result } = renderHook(
() => {
const setCurrentUserState = useSetRecoilState(currentUserState);
return {
hook: useAnalyticsTinybirdJwts(JWT_NAME),
setCurrentUserState,
};
},
{ wrapper: Wrapper },
);
act(() => {
result.current.setCurrentUserState({
id: '1',
email: 'test@test.com',
canImpersonate: false,
userVars: {},
analyticsTinybirdJwts: {
getPageviewsAnalytics: TEST_JWT_TOKEN,
},
} as CurrentUser);
});
expect(result.current.hook).toBeUndefined();
});
});

View File

@ -0,0 +1,87 @@
import { useAnalyticsTinybirdJwts } from '@/analytics/hooks/useAnalyticsTinybirdJwts';
import { useGraphData } from '@/analytics/hooks/useGraphData';
import { fetchGraphDataOrThrow } from '@/analytics/utils/fetchGraphDataOrThrow';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { renderHook } from '@testing-library/react';
jest.mock('@/analytics/hooks/useAnalyticsTinybirdJwts');
jest.mock('@/analytics/utils/fetchGraphDataOrThrow');
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar');
describe('useGraphData', () => {
const mockEnqueueSnackBar = jest.fn();
const mockUseSnackBar = jest.fn().mockReturnValue({
enqueueSnackBar: mockEnqueueSnackBar,
});
const mockUseAnalyticsTinybirdJwts = jest.fn();
const mockFetchGraphDataOrThrow = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useSnackBar as jest.MockedFunction<typeof useSnackBar>).mockImplementation(
mockUseSnackBar,
);
(
useAnalyticsTinybirdJwts as jest.MockedFunction<
typeof useAnalyticsTinybirdJwts
>
).mockImplementation(mockUseAnalyticsTinybirdJwts);
(
fetchGraphDataOrThrow as jest.MockedFunction<typeof fetchGraphDataOrThrow>
).mockImplementation(mockFetchGraphDataOrThrow);
});
it('should fetch graph data successfully', async () => {
const mockJwt = 'mock-jwt';
const mockRecordId = 'mock-record-id';
const mockEndpointName = 'getWebhookAnalytics';
const mockGraphData = [{ x: '2023-01-01', y: 100 }];
mockUseAnalyticsTinybirdJwts.mockReturnValue(mockJwt);
mockFetchGraphDataOrThrow.mockResolvedValue(mockGraphData);
const { result } = renderHook(() =>
useGraphData({ recordId: mockRecordId, endpointName: mockEndpointName }),
);
const { fetchGraphData } = result.current;
const data = await fetchGraphData('7D');
expect(data).toEqual(mockGraphData);
expect(mockFetchGraphDataOrThrow).toHaveBeenCalledWith({
recordId: mockRecordId,
windowLength: '7D',
tinybirdJwt: mockJwt,
endpointName: mockEndpointName,
});
expect(mockEnqueueSnackBar).not.toHaveBeenCalled();
});
it('should handle errors when fetching graph data', async () => {
const mockRecordId = 'mock-record-id';
const mockEndpointName = 'getWebhookAnalytics';
const mockError = new Error('Something went wrong');
mockUseAnalyticsTinybirdJwts.mockReturnValue('');
mockFetchGraphDataOrThrow.mockRejectedValue(mockError);
const { result } = renderHook(() =>
useGraphData({ recordId: mockRecordId, endpointName: mockEndpointName }),
);
const { fetchGraphData } = result.current;
const data = await fetchGraphData('7D');
expect(data).toEqual([]);
expect(mockFetchGraphDataOrThrow).toHaveBeenCalledWith({
recordId: mockRecordId,
windowLength: '7D',
tinybirdJwt: '',
endpointName: mockEndpointName,
});
expect(mockEnqueueSnackBar).toHaveBeenCalledWith(
'Something went wrong while fetching webhook usage: Something went wrong',
{
variant: SnackBarVariant.Error,
},
);
});
});

View File

@ -0,0 +1,16 @@
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { AnalyticsTinybirdJwtMap } from '~/generated-metadata/graphql';
export const useAnalyticsTinybirdJwts = (
jwtName: keyof AnalyticsTinybirdJwtMap,
): string | undefined => {
const currentUser = useRecoilValue(currentUserState);
if (!currentUser) {
return undefined;
}
return currentUser.analyticsTinybirdJwts?.[jwtName];
};

View File

@ -0,0 +1,42 @@
import { useAnalyticsTinybirdJwts } from '@/analytics/hooks/useAnalyticsTinybirdJwts';
import { AnalyticsComponentProps as useGraphDataProps } from '@/analytics/types/AnalyticsComponentProps';
import { fetchGraphDataOrThrow } from '@/analytics/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';
import { useCallback } from 'react';
export const useGraphData = ({ recordId, endpointName }: useGraphDataProps) => {
const { enqueueSnackBar } = useSnackBar();
const tinybirdJwt = useAnalyticsTinybirdJwts(endpointName);
const fetchGraphData = useCallback(
async (windowLengthGraphOption: '7D' | '1D' | '12H' | '4H') => {
try {
if (isUndefined(tinybirdJwt)) {
throw new Error('No jwt associated with this endpoint found');
}
return await fetchGraphDataOrThrow({
recordId,
windowLength: windowLengthGraphOption,
tinybirdJwt,
endpointName,
});
} catch (error) {
if (error instanceof Error) {
enqueueSnackBar(
`Something went wrong while fetching webhook usage: ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
}
return [];
}
},
[tinybirdJwt, recordId, endpointName, enqueueSnackBar],
);
return { fetchGraphData };
};

View File

@ -0,0 +1,10 @@
import { AnalyticsGraphDataInstanceContext } from '@/analytics/states/contexts/AnalyticsGraphDataInstanceContext';
import { NivoLineInput } from '@/analytics/types/NivoLineInput';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const analyticsGraphDataComponentState = createComponentStateV2<
NivoLineInput[]
>({
key: 'analyticsGraphDataComponentState',
defaultValue: [],
componentInstanceContext: AnalyticsGraphDataInstanceContext,
});

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const AnalyticsGraphDataInstanceContext =
createComponentInstanceContext();

View File

@ -0,0 +1,6 @@
import { AnalyticsTinybirdJwtMap } from '~/generated-metadata/graphql';
export type AnalyticsComponentProps = {
recordId: string;
endpointName: keyof AnalyticsTinybirdJwtMap;
};

View File

@ -0,0 +1,8 @@
export type NivoLineInput = {
id: string | number;
color?: string;
data: Array<{
x: number | string | Date;
y: number | string | Date;
}>;
};

View File

@ -0,0 +1,56 @@
import { computeAnalyticsGraphDataFunction } from '@/analytics/utils/computeAnalyticsGraphDataFunction';
import { mapServerlessFunctionDurationToNivoLineInput } from '@/analytics/utils/mapServerlessFunctionDurationToNivoLineInput';
import { mapServerlessFunctionErrorsToNivoLineInput } from '@/analytics/utils/mapServerlessFunctionErrorsToNivoLineInput';
import { mapWebhookAnalyticsResultToNivoLineInput } from '@/analytics/utils/mapWebhookAnalyticsResultToNivoLineInput';
jest.mock('@/analytics/utils/mapServerlessFunctionDurationToNivoLineInput');
jest.mock('@/analytics/utils/mapServerlessFunctionErrorsToNivoLineInput');
jest.mock('@/analytics/utils/mapWebhookAnalyticsResultToNivoLineInput');
describe('computeAnalyticsGraphDataFunction', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should return the mapWebhookAnalyticsResultToNivoLineInput function for "getWebhookAnalytics"', () => {
const result = computeAnalyticsGraphDataFunction('getWebhookAnalytics');
expect(result).toBe(mapWebhookAnalyticsResultToNivoLineInput);
});
it('should return the mapServerlessFunctionDurationToNivoLineInput function for "getServerlessFunctionDuration"', () => {
const result = computeAnalyticsGraphDataFunction(
'getServerlessFunctionDuration',
);
expect(result).toBe(mapServerlessFunctionDurationToNivoLineInput);
});
it('should return a function that calls mapServerlessFunctionErrorsToNivoLineInput with "ErrorCount" for "getServerlessFunctionErrorCount"', () => {
const result = computeAnalyticsGraphDataFunction(
'getServerlessFunctionErrorCount',
);
const data = [{ start: '2023-01-01', error_count: 10 }];
result(data);
expect(mapServerlessFunctionErrorsToNivoLineInput).toHaveBeenCalledWith(
data,
'ErrorCount',
);
});
it('should return a function that calls mapServerlessFunctionErrorsToNivoLineInput with "SuccessRate" for "getServerlessFunctionSuccessRate"', () => {
const result = computeAnalyticsGraphDataFunction(
'getServerlessFunctionSuccessRate',
);
const data = [{ start: '2023-01-01', success_rate: 90 }];
result(data);
expect(mapServerlessFunctionErrorsToNivoLineInput).toHaveBeenCalledWith(
data,
'SuccessRate',
);
});
it('should throw an error for an unknown endpoint', () => {
expect(() => computeAnalyticsGraphDataFunction('unknown')).toThrowError(
'No analytics function found associated with endpoint "unknown"',
);
});
});

View File

@ -0,0 +1,143 @@
import { ANALYTICS_GRAPH_OPTION_MAP } from '@/analytics/constants/AnalyticsGraphOptionMap';
import { computeStartEndDate } from '@/analytics/utils/computeStartEndDate';
import { fetchGraphDataOrThrow } from '@/analytics/utils/fetchGraphDataOrThrow';
// Im going to make this test more contundent later
jest.mock('@/analytics/utils/computeStartEndDate', () => ({
computeStartEndDate: jest.fn(() => ({
start: '2024-01-01',
end: '2024-01-07',
})),
}));
describe('fetchGraphDataOrThrow', () => {
// Setup fetch mock
const mockFetch = jest.fn();
global.fetch = mockFetch;
beforeEach(() => {
jest.clearAllMocks();
});
const mockSuccessResponse = {
data: [
{ timestamp: '2024-01-01', count: 10 },
{ timestamp: '2024-01-02', count: 20 },
],
};
const defaultProps = {
recordId: 'test-123',
windowLength: '7D',
tinybirdJwt: 'test-jwt',
endpointName: 'getWebhookAnalytics',
};
it('should fetch data successfully for webhook type', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSuccessResponse),
});
const result = await fetchGraphDataOrThrow(defaultProps);
// Verify URL construction
const lastCallArgs = mockFetch.mock.calls[0][0];
expect(lastCallArgs).toContain('webhookId=test-123');
expect(lastCallArgs).toContain('getWebhookAnalytics.json');
// Verify headers
const headers = mockFetch.mock.calls[0][1].headers;
expect(headers.Authorization).toBe('Bearer test-jwt');
// Verify response
expect(result).toEqual(mockSuccessResponse.data);
});
it('should handle different window lengths correctly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSuccessResponse),
});
await fetchGraphDataOrThrow({
...defaultProps,
windowLength: '1D',
});
// Verify that correct window length options were used
const lastCallArgs = mockFetch.mock.calls[0][0];
const options = ANALYTICS_GRAPH_OPTION_MAP['1D'];
Object.entries(options).forEach(([key, value]) => {
expect(lastCallArgs).toContain(`${key}=${value}`);
});
});
it('should throw error on failed request', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: 'Failed to fetch' }),
});
await expect(fetchGraphDataOrThrow(defaultProps)).rejects.toThrow(
'Failed to fetch',
);
});
it('should throw error on network failure', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(fetchGraphDataOrThrow(defaultProps)).rejects.toThrow(
'Network error',
);
});
it('should use computed start and end dates', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSuccessResponse),
});
await fetchGraphDataOrThrow(defaultProps);
// Verify computeStartEndDate was called with correct window length
expect(computeStartEndDate).toHaveBeenCalledWith('7D');
// Verify the computed dates are included in the URL
const lastCallArgs = mockFetch.mock.calls[0][0];
expect(lastCallArgs).toContain('start=2024-01-01');
expect(lastCallArgs).toContain('end=2024-01-07');
});
it('should construct URL with all required parameters', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockSuccessResponse),
});
await fetchGraphDataOrThrow(defaultProps);
const lastCallArgs = mockFetch.mock.calls[0][0];
// Check base URL
expect(lastCallArgs).toContain(
'https://api.eu-central-1.aws.tinybird.co/v0/pipes/',
);
// Check endpoint
expect(lastCallArgs).toContain('getWebhookAnalytics.json');
// Check window length options
const options = ANALYTICS_GRAPH_OPTION_MAP['7D'];
Object.entries(options).forEach(([key, value]) => {
expect(lastCallArgs).toContain(`${key}=${value}`);
});
// Check computed dates
expect(lastCallArgs).toContain('start=2024-01-01');
expect(lastCallArgs).toContain('end=2024-01-07');
// Check record ID
expect(lastCallArgs).toContain('webhookId=test-123');
});
});

View File

@ -0,0 +1,65 @@
import { mapServerlessFunctionDurationToNivoLineInput } from '@/analytics/utils/mapServerlessFunctionDurationToNivoLineInput';
describe('mapServerlessFunctionDurationToNivoLineInput', () => {
it('should convert the serverless function duration result to NivoLineInput format', () => {
const serverlessFunctionDurationResult = [
{
start: '2023-01-01T00:00:00.000Z',
minimum: 100,
maximum: 200,
average: 150,
},
{
start: '2023-01-02T00:00:00.000Z',
minimum: 80,
maximum: 160,
average: 120,
},
{
start: '2023-01-03T00:00:00.000Z',
minimum: 90,
maximum: 180,
average: 135,
},
];
const expected = [
{
id: 'Maximum',
data: [
{ x: new Date('2023-01-01T00:00:00.000Z'), y: 200 },
{ x: new Date('2023-01-02T00:00:00.000Z'), y: 160 },
{ x: new Date('2023-01-03T00:00:00.000Z'), y: 180 },
],
},
{
id: 'Minimum',
data: [
{ x: new Date('2023-01-01T00:00:00.000Z'), y: 100 },
{ x: new Date('2023-01-02T00:00:00.000Z'), y: 80 },
{ x: new Date('2023-01-03T00:00:00.000Z'), y: 90 },
],
},
{
id: 'Average',
data: [
{ x: new Date('2023-01-01T00:00:00.000Z'), y: 150 },
{ x: new Date('2023-01-02T00:00:00.000Z'), y: 120 },
{ x: new Date('2023-01-03T00:00:00.000Z'), y: 135 },
],
},
];
const result = mapServerlessFunctionDurationToNivoLineInput(
serverlessFunctionDurationResult,
);
expect(result).toEqual(expected);
});
it('should handle an empty serverless function duration result', () => {
const serverlessFunctionDurationResult = [];
const result = mapServerlessFunctionDurationToNivoLineInput(
serverlessFunctionDurationResult,
);
expect(result).toEqual([]);
});
});

View File

@ -0,0 +1,68 @@
import { mapServerlessFunctionErrorsToNivoLineInput } from '@/analytics/utils/mapServerlessFunctionErrorsToNivoLineInput';
describe('mapServerlessFunctionErrorsToNivoLineInput', () => {
it('should map the serverless function result to Nivo line input format for error count', () => {
const serverlessFunctionResult = [
{ start: '2023-01-01', error_count: 10, success_rate: 0.66 },
{ start: '2023-01-02', error_count: 5, success_rate: 0.75 },
{ start: '2023-01-03', error_count: 8, success_rate: 0.69 },
];
const expected = [
{
id: 'Error',
data: [
{ x: new Date('2023-01-01'), y: 10 },
{ x: new Date('2023-01-02'), y: 5 },
{ x: new Date('2023-01-03'), y: 8 },
],
},
];
const result = mapServerlessFunctionErrorsToNivoLineInput(
serverlessFunctionResult,
'ErrorCount',
);
expect(result).toEqual(expected);
});
it('should map the serverless function result to Nivo line input format for success rate', () => {
const serverlessFunctionResult = [
{ start: '2023-01-01', error_count: 10, success_rate: 0.66 },
{ start: '2023-01-02', error_count: 5, success_rate: 0.75 },
{ start: '2023-01-03', error_count: 8, success_rate: 0.69 },
];
const expected = [
{
id: 'Success Rate',
data: [
{ x: new Date('2023-01-01'), y: 0.66 },
{ x: new Date('2023-01-02'), y: 0.75 },
{ x: new Date('2023-01-03'), y: 0.69 },
],
},
];
const result = mapServerlessFunctionErrorsToNivoLineInput(
serverlessFunctionResult,
'SuccessRate',
);
expect(result).toEqual(expected);
});
it('should handle empty input', () => {
const serverlessFunctionResult = [];
const expected = [
{
id: 'Error',
data: [],
},
];
const result = mapServerlessFunctionErrorsToNivoLineInput(
serverlessFunctionResult,
'ErrorCount',
);
expect(result).toEqual(expected);
});
});

View File

@ -0,0 +1,187 @@
import { mapWebhookAnalyticsResultToNivoLineInput } from '@/analytics/utils/mapWebhookAnalyticsResultToNivoLineInput';
describe('mapWebhookAnalyticsResultToNivoLineInput', () => {
it('should correctly map empty array', () => {
const result = mapWebhookAnalyticsResultToNivoLineInput([]);
expect(result).toEqual([]);
});
it('should correctly map single data point', () => {
const input = [
{
start: '2024-01-01T00:00:00Z',
success_count: 10,
failure_count: 5,
},
];
const expected = [
{
id: 'Failed',
data: [
{
x: new Date('2024-01-01T00:00:00Z'),
y: 5,
},
],
},
{
id: 'Succeeded',
data: [
{
x: new Date('2024-01-01T00:00:00Z'),
y: 10,
},
],
},
];
const result = mapWebhookAnalyticsResultToNivoLineInput(input);
expect(result).toEqual(expected);
});
it('should correctly map multiple data points', () => {
const input = [
{
start: '2024-01-01T00:00:00Z',
success_count: 10,
failure_count: 5,
},
{
start: '2024-01-02T00:00:00Z',
success_count: 15,
failure_count: 3,
},
];
const expected = [
{
id: 'Failed',
data: [
{
x: new Date('2024-01-01T00:00:00Z'),
y: 5,
},
{
x: new Date('2024-01-02T00:00:00Z'),
y: 3,
},
],
},
{
id: 'Succeeded',
data: [
{
x: new Date('2024-01-01T00:00:00Z'),
y: 10,
},
{
x: new Date('2024-01-02T00:00:00Z'),
y: 15,
},
],
},
];
const result = mapWebhookAnalyticsResultToNivoLineInput(input);
expect(result).toEqual(expected);
});
it('should handle zero counts', () => {
const input = [
{
start: '2024-01-01T00:00:00Z',
success_count: 0,
failure_count: 0,
},
];
const expected = [
{
id: 'Failed',
data: [
{
x: new Date('2024-01-01T00:00:00Z'),
y: 0,
},
],
},
{
id: 'Succeeded',
data: [
{
x: new Date('2024-01-01T00:00:00Z'),
y: 0,
},
],
},
];
const result = mapWebhookAnalyticsResultToNivoLineInput(input);
expect(result).toEqual(expected);
});
it('should preserve data point order', () => {
const input = [
{
start: '2024-01-02T00:00:00Z',
success_count: 15,
failure_count: 3,
},
{
start: '2024-01-01T00:00:00Z',
success_count: 10,
failure_count: 5,
},
];
const result = mapWebhookAnalyticsResultToNivoLineInput(input);
// Check that dates in data arrays maintain input order
expect(result[0].data[0].x).toEqual(new Date('2024-01-02T00:00:00Z'));
expect(result[0].data[1].x).toEqual(new Date('2024-01-01T00:00:00Z'));
expect(result[1].data[0].x).toEqual(new Date('2024-01-02T00:00:00Z'));
expect(result[1].data[1].x).toEqual(new Date('2024-01-01T00:00:00Z'));
});
it('should handle malformed dates by creating invalid Date objects', () => {
const input = [
{
start: 'invalid-date',
success_count: 10,
failure_count: 5,
},
];
const result = mapWebhookAnalyticsResultToNivoLineInput(input);
expect(result[0].data[0].x.toString()).toBe('Invalid Date');
expect(result[1].data[0].x.toString()).toBe('Invalid Date');
});
it('should maintain consistent structure with mixed data', () => {
const input = [
{
start: '2024-01-01T00:00:00Z',
success_count: 10,
failure_count: 0,
},
{
start: '2024-01-02T00:00:00Z',
success_count: 0,
failure_count: 5,
},
];
const result = mapWebhookAnalyticsResultToNivoLineInput(input);
// Check both lines exist even when one has zero values
expect(result.length).toBe(2);
expect(result[0].data.length).toBe(2);
expect(result[1].data.length).toBe(2);
});
});

View File

@ -0,0 +1,25 @@
import { mapServerlessFunctionDurationToNivoLineInput } from '@/analytics/utils/mapServerlessFunctionDurationToNivoLineInput';
import { mapServerlessFunctionErrorsToNivoLineInput } from '@/analytics/utils/mapServerlessFunctionErrorsToNivoLineInput';
import { mapWebhookAnalyticsResultToNivoLineInput } from '@/analytics/utils/mapWebhookAnalyticsResultToNivoLineInput';
import { AnalyticsTinybirdJwtMap } from '~/generated-metadata/graphql';
export const computeAnalyticsGraphDataFunction = (
endpointName: keyof AnalyticsTinybirdJwtMap,
) => {
switch (endpointName) {
case 'getWebhookAnalytics':
return mapWebhookAnalyticsResultToNivoLineInput;
case 'getServerlessFunctionDuration':
return mapServerlessFunctionDurationToNivoLineInput;
case 'getServerlessFunctionErrorCount':
return (data: { start: string; error_count: number }[]) =>
mapServerlessFunctionErrorsToNivoLineInput(data, 'ErrorCount');
case 'getServerlessFunctionSuccessRate':
return (data: { start: string; success_rate: number }[]) =>
mapServerlessFunctionErrorsToNivoLineInput(data, 'SuccessRate');
default:
throw new Error(
`No analytics function found associated with endpoint "${endpointName}"`,
);
}
};

View File

@ -0,0 +1,30 @@
import { subDays, subHours } from 'date-fns';
export const computeStartEndDate = (
windowLength: '7D' | '1D' | '12H' | '4H',
) => {
const now = new Date(Date.now());
const end = now.toISOString();
switch (windowLength) {
case '7D':
return {
start: subDays(now, 7).toISOString(),
end,
};
case '1D':
return {
start: subDays(now, 1).toISOString(),
end,
};
case '12H':
return {
start: subHours(now, 12).toISOString(),
end,
};
case '4H':
return {
start: subHours(now, 4).toISOString(),
end,
};
}
};

View File

@ -0,0 +1,38 @@
import { ANALYTICS_ENDPOINT_TYPE_MAP } from '@/analytics/constants/AnalyticsEndpointTypeMap';
import { ANALYTICS_GRAPH_OPTION_MAP } from '@/analytics/constants/AnalyticsGraphOptionMap';
import { AnalyticsComponentProps } from '@/analytics/types/AnalyticsComponentProps';
import { computeStartEndDate } from '@/analytics/utils/computeStartEndDate';
type fetchGraphDataOrThrowProps = AnalyticsComponentProps & {
windowLength: '7D' | '1D' | '12H' | '4H';
tinybirdJwt: string;
};
export const fetchGraphDataOrThrow = async ({
recordId,
windowLength,
tinybirdJwt,
endpointName,
}: fetchGraphDataOrThrowProps) => {
const recordType = ANALYTICS_ENDPOINT_TYPE_MAP[endpointName];
const queryString = new URLSearchParams({
...ANALYTICS_GRAPH_OPTION_MAP[windowLength],
...computeStartEndDate(windowLength),
...{ [`${recordType}Id`]: recordId },
}).toString();
const response = await fetch(
`https://api.eu-central-1.aws.tinybird.co/v0/pipes/${endpointName}.json?${queryString}`,
{
headers: {
Authorization: 'Bearer ' + tinybirdJwt,
},
},
);
const result = await response.json();
if (!response.ok) {
throw new Error(result.error);
}
return result.data;
};

View File

@ -0,0 +1,49 @@
import { NivoLineInput } from '@/analytics/types/NivoLineInput';
//DOING: Adding the servelessFunctionDurationGraph to twenty
export const mapServerlessFunctionDurationToNivoLineInput = (
serverlessFunctionDurationResult: {
start: string;
minimum: number;
maximum: number;
average: number;
}[],
): NivoLineInput[] => {
return serverlessFunctionDurationResult
.flatMap((dataRow) => [
{
x: new Date(dataRow.start),
y: dataRow.maximum,
id: 'Maximum',
},
{
x: new Date(dataRow.start),
y: dataRow.minimum,
id: 'Minimum',
},
{
x: new Date(dataRow.start),
y: dataRow.average,
id: 'Average',
},
])
.reduce(
(
acc: NivoLineInput[],
{ id, x, y }: { id: string; x: Date; y: number },
) => {
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, data: [{ x, y }] }];
}
},
[],
);
};

View File

@ -0,0 +1,26 @@
import { NivoLineInput } from '@/analytics/types/NivoLineInput';
export const mapServerlessFunctionErrorsToNivoLineInput = <
T extends { start: string },
>(
serverlessFunctionResult: (T & {
error_count?: number;
success_rate?: number;
})[],
type: 'ErrorCount' | 'SuccessRate',
): NivoLineInput[] => {
return [
{
id: type === 'ErrorCount' ? 'Error' : 'Success Rate',
data: serverlessFunctionResult.flatMap((dataRow) => [
{
x: new Date(dataRow.start),
y:
type === 'ErrorCount'
? (dataRow.error_count ?? 0)
: (dataRow.success_rate ?? 0),
},
]),
},
];
};

View File

@ -0,0 +1,43 @@
import { NivoLineInput } from '@/analytics/types/NivoLineInput';
export const mapWebhookAnalyticsResultToNivoLineInput = (
webhookAnalyticsResult: {
start: string;
failure_count: number;
success_count: number;
}[],
): NivoLineInput[] => {
return webhookAnalyticsResult
.flatMap((dataRow) => [
{
x: new Date(dataRow.start),
y: dataRow.failure_count,
id: 'Failed',
},
{
x: new Date(dataRow.start),
y: dataRow.success_count,
id: 'Succeeded',
},
])
.reduce(
(
acc: NivoLineInput[],
{ id, x, y }: { id: string; x: Date; y: number },
) => {
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, data: [{ x, y }] }];
}
},
[],
);
};

View File

@ -7,7 +7,7 @@ export type CurrentUser = Pick<
| 'id'
| 'email'
| 'supportUserHash'
| 'analyticsTinybirdJwt'
| 'analyticsTinybirdJwts'
| 'canImpersonate'
| 'onboardingStatus'
| 'userVars'

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;
};

View File

@ -0,0 +1,75 @@
import { AnalyticsActivityGraph } from '@/analytics/components/AnalyticsActivityGraph';
import { AnalyticsGraphEffect } from '@/analytics/components/AnalyticsGraphEffect';
import { AnalyticsGraphDataInstanceContext } from '@/analytics/states/contexts/AnalyticsGraphDataInstanceContext';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useNavigate } from 'react-router-dom';
import { Key } from 'ts-key-enum';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
export const SettingsServerlessFunctionMonitoringTab = ({
serverlessFunctionId,
}: {
serverlessFunctionId: string;
}) => {
const navigate = useNavigate();
useHotkeyScopeOnMount(
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionSettingsTab,
);
useScopedHotkeys(
[Key.Escape],
() => {
navigate(getSettingsPagePath(SettingsPath.ServerlessFunctions));
},
SettingsServerlessFunctionHotkeyScope.ServerlessFunctionSettingsTab,
);
return (
<>
<AnalyticsGraphDataInstanceContext.Provider
value={{
instanceId: `function-${serverlessFunctionId}-errorCount`,
}}
>
<AnalyticsGraphEffect
recordId={serverlessFunctionId}
endpointName="getServerlessFunctionErrorCount"
/>
<AnalyticsActivityGraph
recordId={serverlessFunctionId}
endpointName="getServerlessFunctionErrorCount"
/>
</AnalyticsGraphDataInstanceContext.Provider>
<AnalyticsGraphDataInstanceContext.Provider
value={{ instanceId: `function-${serverlessFunctionId}-duration` }}
>
<AnalyticsGraphEffect
recordId={serverlessFunctionId}
endpointName="getServerlessFunctionDuration"
/>
<AnalyticsActivityGraph
recordId={serverlessFunctionId}
endpointName="getServerlessFunctionDuration"
/>
</AnalyticsGraphDataInstanceContext.Provider>
<AnalyticsGraphDataInstanceContext.Provider
value={{ instanceId: `function-${serverlessFunctionId}-successRate` }}
>
<AnalyticsGraphEffect
recordId={serverlessFunctionId}
endpointName="getServerlessFunctionSuccessRate"
/>
<AnalyticsActivityGraph
recordId={serverlessFunctionId}
endpointName="getServerlessFunctionSuccessRate"
/>
</AnalyticsGraphDataInstanceContext.Provider>
</>
);
};

View File

@ -4,6 +4,7 @@ import TextareaAutosize from 'react-textarea-autosize';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { RGBA } from 'twenty-ui';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
@ -51,6 +52,10 @@ const StyledTextArea = styled(TextareaAutosize)`
&:focus {
outline: none;
${({ theme }) => {
return `box-shadow: 0px 0px 0px 3px ${RGBA(theme.color.blue, 0.1)};
border-color: ${theme.color.blue};`;
}};
}
&::placeholder {

View File

@ -8,7 +8,14 @@ export const USER_QUERY_FRAGMENT = gql`
email
canImpersonate
supportUserHash
analyticsTinybirdJwt
analyticsTinybirdJwts {
getWebhookAnalytics
getPageviewsAnalytics
getUsersAnalytics
getServerlessFunctionDuration
getServerlessFunctionSuccessRate
getServerlessFunctionErrorCount
}
onboardingStatus
workspaceMember {
...WorkspaceMemberQueryFragment