mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-24 02:46:27 +00:00
Merge branch 'main' into feat/telemetry-meter
This commit is contained in:
commit
73bc95a56a
44
frontend/src/container/Login/Login.styles.scss
Normal file
44
frontend/src/container/Login/Login.styles.scss
Normal file
@ -0,0 +1,44 @@
|
||||
.login-form-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
|
||||
.login-form-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-form-header-text {
|
||||
color: var(--text-vanilla-300);
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
padding: 0px 16px;
|
||||
}
|
||||
|
||||
.login-form-input {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.no-acccount {
|
||||
color: var(--text-vanilla-300);
|
||||
font-size: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.login-form-container {
|
||||
.login-form-header {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
.login-form-header-text {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
.no-acccount {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,98 +15,104 @@ describe('Login Flow', () => {
|
||||
test('Login form is rendered correctly', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
|
||||
const headingElement = screen.getByRole('heading', {
|
||||
name: 'login_page_title',
|
||||
});
|
||||
expect(headingElement).toBeInTheDocument();
|
||||
// Check for the main description
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Sign in to monitor, trace, and troubleshoot your applications effortlessly.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const textboxElement = screen.getByRole('textbox');
|
||||
expect(textboxElement).toBeInTheDocument();
|
||||
// Email input
|
||||
const emailInput = screen.getByTestId('email');
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).toHaveAttribute('type', 'email');
|
||||
|
||||
const buttonElement = screen.getByRole('button', {
|
||||
name: 'button_initiate_login',
|
||||
});
|
||||
expect(buttonElement).toBeInTheDocument();
|
||||
// Next button
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(nextButton).toBeInTheDocument();
|
||||
|
||||
const noAccountPromptElement = screen.getByText('prompt_no_account');
|
||||
expect(noAccountPromptElement).toBeInTheDocument();
|
||||
// No account prompt (default: canSelfRegister is false)
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Don't have an account? Contact your admin to send you an invite link.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test(`Display "invalid_email" if email is not provided`, async () => {
|
||||
test('Display error if email is not provided', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
|
||||
const buttonElement = screen.getByText('button_initiate_login');
|
||||
fireEvent.click(buttonElement);
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'invalid_email',
|
||||
message: 'Please enter a valid email address',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Display invalid_config if invalid email is provided and next clicked', async () => {
|
||||
test('Display error if invalid email is provided and next clicked', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
|
||||
const textboxElement = screen.getByRole('textbox');
|
||||
fireEvent.change(textboxElement, {
|
||||
const emailInput = screen.getByTestId('email');
|
||||
fireEvent.change(emailInput, {
|
||||
target: { value: 'failEmail@signoz.io' },
|
||||
});
|
||||
|
||||
const buttonElement = screen.getByRole('button', {
|
||||
name: 'button_initiate_login',
|
||||
});
|
||||
fireEvent.click(buttonElement);
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'invalid_config',
|
||||
message:
|
||||
'Invalid configuration detected, please contact your administrator',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('providing shaheer@signoz.io as email and pressing next, should make the login_with_sso button visible', async () => {
|
||||
test('providing shaheer@signoz.io as email and pressing next, should make the Login with SSO button visible', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="" />);
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByTestId('email'), {
|
||||
target: { value: 'shaheer@signoz.io' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('initiate_login'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('login_with_sso')).toBeInTheDocument();
|
||||
expect(screen.getByText(/login with sso/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('Display email, password, forgot password if password=Y', () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="Y" />);
|
||||
|
||||
const emailTextBox = screen.getByTestId('email');
|
||||
expect(emailTextBox).toBeInTheDocument();
|
||||
const emailInput = screen.getByTestId('email');
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
|
||||
const passwordTextBox = screen.getByTestId('password');
|
||||
expect(passwordTextBox).toBeInTheDocument();
|
||||
const passwordInput = screen.getByTestId('password');
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
|
||||
const forgotPasswordLink = screen.getByText('forgot_password');
|
||||
const forgotPasswordLink = screen.getByText('Forgot password?');
|
||||
expect(forgotPasswordLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Display tooltip with "prompt_forgot_password" if forgot password is clicked while password=Y', async () => {
|
||||
test('Display tooltip with correct message if forgot password is hovered while password=Y', async () => {
|
||||
render(<Login ssoerror="" jwt="" refreshjwt="" userId="" withPassword="Y" />);
|
||||
const forgotPasswordLink = screen.getByText('forgot_password');
|
||||
const forgotPasswordLink = screen.getByText('Forgot password?');
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseOver(forgotPasswordLink);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const forgotPasswordTooltip = screen.getByRole('tooltip', {
|
||||
name: 'prompt_forgot_password',
|
||||
});
|
||||
expect(forgotPasswordLink).toBeInTheDocument();
|
||||
expect(forgotPasswordTooltip).toBeInTheDocument();
|
||||
// Tooltip text is static in the new UI
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Ask your admin to reset your password and send you a new invite link',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import './Login.styles.scss';
|
||||
|
||||
import { Button, Form, Input, Space, Tooltip, Typography } from 'antd';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
@ -9,16 +11,14 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import APIError from 'types/api/error';
|
||||
import { PayloadProps as PrecheckResultType } from 'types/api/user/loginPrecheck';
|
||||
|
||||
import { FormContainer, FormWrapper, Label, ParentContainer } from './styles';
|
||||
|
||||
const { Title } = Typography;
|
||||
import { FormContainer, Label, ParentContainer } from './styles';
|
||||
|
||||
interface LoginProps {
|
||||
jwt: string;
|
||||
@ -37,7 +37,6 @@ function Login({
|
||||
ssoerror = '',
|
||||
withPassword = '0',
|
||||
}: LoginProps): JSX.Element {
|
||||
const { t } = useTranslation(['login']);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { user } = useAppContext();
|
||||
|
||||
@ -104,16 +103,16 @@ function Login({
|
||||
useEffect(() => {
|
||||
if (ssoerror !== '') {
|
||||
notifications.error({
|
||||
message: t('failed_to_login'),
|
||||
message: 'sorry, failed to login',
|
||||
});
|
||||
}
|
||||
}, [ssoerror, t, notifications]);
|
||||
}, [ssoerror, notifications]);
|
||||
|
||||
const onNextHandler = async (): Promise<void> => {
|
||||
const email = form.getFieldValue('email');
|
||||
if (!email) {
|
||||
notifications.error({
|
||||
message: t('invalid_email'),
|
||||
message: 'Please enter a valid email address',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -131,17 +130,19 @@ function Login({
|
||||
setPrecheckComplete(true);
|
||||
} else {
|
||||
notifications.error({
|
||||
message: t('invalid_account'),
|
||||
message:
|
||||
'This account does not exist. To create a new account, contact your admin to get an invite link',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
notifications.error({
|
||||
message: t('invalid_config'),
|
||||
message:
|
||||
'Invalid configuration detected, please contact your administrator',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('failed to call precheck Api', e);
|
||||
notifications.error({ message: t('unexpected_error') });
|
||||
notifications.error({ message: 'Sorry, something went wrong' });
|
||||
}
|
||||
setPrecheckInProcess(false);
|
||||
};
|
||||
@ -190,7 +191,7 @@ function Login({
|
||||
disabled={isLoading}
|
||||
href={precheckResult.ssoUrl}
|
||||
>
|
||||
{t('login_with_sso')}
|
||||
Login with SSO
|
||||
</Button>
|
||||
);
|
||||
|
||||
@ -201,48 +202,61 @@ function Login({
|
||||
|
||||
return (
|
||||
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
|
||||
{t('prompt_on_sso_error')}{' '}
|
||||
<a href="/login?password=Y">{t('login_with_pwd')}</a>.
|
||||
Are you trying to resolve SSO configuration issue?{' '}
|
||||
<a href="/login?password=Y">Login with password</a>.
|
||||
</Typography.Paragraph>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormWrapper>
|
||||
<div className="login-form-container">
|
||||
<FormContainer form={form} onFinish={onSubmitHandler}>
|
||||
<Title level={4}>{t('login_page_title')}</Title>
|
||||
<div className="login-form-header">
|
||||
<Typography.Paragraph className="login-form-header-text">
|
||||
Sign in to monitor, trace, and troubleshoot your applications
|
||||
effortlessly.
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<ParentContainer>
|
||||
<Label htmlFor="signupEmail">{t('label_email')}</Label>
|
||||
<Label htmlFor="signupEmail" style={{ marginTop: 0 }}>
|
||||
Email
|
||||
</Label>
|
||||
<FormContainer.Item name="email">
|
||||
<Input
|
||||
type="email"
|
||||
id="loginEmail"
|
||||
data-testid="email"
|
||||
required
|
||||
placeholder={t('placeholder_email')}
|
||||
placeholder="name@yourcompany.com"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
className="login-form-input"
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
</ParentContainer>
|
||||
{precheckComplete && !sso && (
|
||||
<ParentContainer>
|
||||
<Label htmlFor="Password">{t('label_password')}</Label>
|
||||
<Label htmlFor="Password">Password</Label>
|
||||
<FormContainer.Item name="password">
|
||||
<Input.Password
|
||||
required
|
||||
id="currentPassword"
|
||||
data-testid="password"
|
||||
disabled={isLoading}
|
||||
className="login-form-input"
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
<Tooltip title={t('prompt_forgot_password')}>
|
||||
<Typography.Link>{t('forgot_password')}</Typography.Link>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tooltip title="Ask your admin to reset your password and send you a new invite link">
|
||||
<Typography.Link>Forgot password?</Typography.Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ParentContainer>
|
||||
)}
|
||||
<Space
|
||||
style={{ marginTop: '1.3125rem' }}
|
||||
style={{ marginTop: 16 }}
|
||||
align="start"
|
||||
direction="vertical"
|
||||
size={20}
|
||||
@ -254,8 +268,10 @@ function Login({
|
||||
type="primary"
|
||||
onClick={onNextHandler}
|
||||
data-testid="initiate_login"
|
||||
className="periscope-btn primary next-btn"
|
||||
icon={<ArrowRight size={12} />}
|
||||
>
|
||||
{t('button_initiate_login')}
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{precheckComplete && !sso && (
|
||||
@ -265,8 +281,10 @@ function Login({
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
data-attr="signup"
|
||||
className="periscope-btn primary next-btn"
|
||||
icon={<ArrowRight size={12} />}
|
||||
>
|
||||
{t('button_login')}
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -274,27 +292,28 @@ function Login({
|
||||
{!precheckComplete && ssoerror && renderOnSsoError()}
|
||||
|
||||
{!canSelfRegister && (
|
||||
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
|
||||
{t('prompt_no_account')}
|
||||
<Typography.Paragraph className="no-acccount">
|
||||
Don't have an account? Contact your admin to send you an invite
|
||||
link.
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{canSelfRegister && (
|
||||
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
|
||||
{t('prompt_if_admin')}{' '}
|
||||
If you are admin,{' '}
|
||||
<Typography.Link
|
||||
onClick={(): void => {
|
||||
history.push(ROUTES.SIGN_UP);
|
||||
}}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{t('create_an_account')}
|
||||
Create an account
|
||||
</Typography.Link>
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</Space>
|
||||
</FormContainer>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,11 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.vercel.com" crossorigin />
|
||||
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<title data-react-helmet="true">
|
||||
Open source Observability platform | SigNoz
|
||||
</title>
|
||||
@ -57,7 +62,6 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
|
||||
|
||||
<link rel="stylesheet" href="/css/uPlot.min.css" />
|
||||
<% if (htmlWebpackPlugin.options.templateParameters.preloadFonts) { %> <%
|
||||
htmlWebpackPlugin.options.templateParameters.preloadFonts.forEach(function(font)
|
||||
{ %>
|
||||
@ -76,33 +80,36 @@
|
||||
|
||||
<script>
|
||||
const PYLON_APP_ID = '<%= htmlWebpackPlugin.options.PYLON_APP_ID %>';
|
||||
(function() {
|
||||
(function () {
|
||||
var e = window;
|
||||
var t = document;
|
||||
var n = function() {
|
||||
var n = function () {
|
||||
n.e(arguments);
|
||||
};
|
||||
n.q = [];
|
||||
n.e = function(e) {
|
||||
n.e = function (e) {
|
||||
n.q.push(e);
|
||||
};
|
||||
e.Pylon = n;
|
||||
var r = function() {
|
||||
var e = t.createElement("script");
|
||||
e.setAttribute("type", "text/javascript");
|
||||
e.setAttribute("async", "true");
|
||||
e.setAttribute("src", "https://widget.usepylon.com/widget/" + PYLON_APP_ID);
|
||||
var n = t.getElementsByTagName("script")[0];
|
||||
var r = function () {
|
||||
var e = t.createElement('script');
|
||||
e.setAttribute('type', 'text/javascript');
|
||||
e.setAttribute('async', 'true');
|
||||
e.setAttribute(
|
||||
'src',
|
||||
'https://widget.usepylon.com/widget/' + PYLON_APP_ID,
|
||||
);
|
||||
var n = t.getElementsByTagName('script')[0];
|
||||
n.parentNode.insertBefore(e, n);
|
||||
};
|
||||
if (t.readyState === "complete") {
|
||||
if (t.readyState === 'complete') {
|
||||
r();
|
||||
} else if (e.addEventListener) {
|
||||
e.addEventListener("load", r, false);
|
||||
e.addEventListener('load', r, false);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
<script type="text/javascript">
|
||||
window.AppcuesSettings = { enableURLDetection: true };
|
||||
</script>
|
||||
<script>
|
||||
@ -114,7 +121,7 @@
|
||||
var s = d.getElementsByTagName(t)[0];
|
||||
s.parentNode.insertBefore(a, s);
|
||||
})(document, 'script');
|
||||
|
||||
</script>
|
||||
<link rel="stylesheet" href="/css/uPlot.min.css" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
116
frontend/src/pages/Login/Login.styles.scss
Normal file
116
frontend/src/pages/Login/Login.styles.scss
Normal file
@ -0,0 +1,116 @@
|
||||
.login-page-container {
|
||||
height: 100vh;
|
||||
gap: 32px;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.brand-container {
|
||||
width: 100%;
|
||||
padding: 16px 0px;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
|
||||
color: var(--text-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.perilin-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: radial-gradient(circle, #fff 10%, transparent 0);
|
||||
background-size: 12px 12px;
|
||||
opacity: 1;
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-page-content {
|
||||
width: 480px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
|
||||
background: rgb(18 19 23);
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.login-page-container {
|
||||
.brand-container {
|
||||
.brand-title {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.perilin-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: radial-gradient(circle, #000000 10%, transparent 0);
|
||||
background-size: 12px 12px;
|
||||
opacity: 1;
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-page-content {
|
||||
background: rgb(255 255 255);
|
||||
border: 1px solid var(--border-vanilla-200);
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,9 @@
|
||||
import { Typography } from 'antd';
|
||||
import getUserVersion from 'api/v1/version/getVersion';
|
||||
import Spinner from 'components/Spinner';
|
||||
import WelcomeLeftContainer from 'components/WelcomeLeftContainer';
|
||||
import './Login.styles.scss';
|
||||
|
||||
import LoginContainer from 'container/Login';
|
||||
import useURLQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
function Login(): JSX.Element {
|
||||
const { isLoggedIn } = useAppContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const urlQueryParams = useURLQuery();
|
||||
const jwt = urlQueryParams.get('jwt') || '';
|
||||
const refreshJwt = urlQueryParams.get('refreshjwt') || '';
|
||||
@ -19,42 +11,29 @@ function Login(): JSX.Element {
|
||||
const ssoerror = urlQueryParams.get('ssoerror') || '';
|
||||
const withPassword = urlQueryParams.get('password') || '';
|
||||
|
||||
const versionResult = useQuery({
|
||||
queryFn: getUserVersion,
|
||||
queryKey: ['getUserVersion', jwt],
|
||||
enabled: !isLoggedIn,
|
||||
});
|
||||
|
||||
if (
|
||||
versionResult.status === 'error' ||
|
||||
(versionResult.status === 'success' && versionResult?.data.statusCode !== 200)
|
||||
) {
|
||||
return (
|
||||
<Typography>
|
||||
{versionResult.data?.error || t('something_went_wrong')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
versionResult.status === 'loading' ||
|
||||
!(versionResult.data && versionResult.data.payload)
|
||||
) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
const { version } = versionResult.data.payload;
|
||||
|
||||
return (
|
||||
<WelcomeLeftContainer version={version}>
|
||||
<LoginContainer
|
||||
ssoerror={ssoerror}
|
||||
jwt={jwt}
|
||||
refreshjwt={refreshJwt}
|
||||
userId={userId}
|
||||
withPassword={withPassword}
|
||||
/>
|
||||
</WelcomeLeftContainer>
|
||||
<div className="login-page-container">
|
||||
<div className="perilin-bg" />
|
||||
<div className="login-page-content">
|
||||
<div className="brand-container">
|
||||
<img
|
||||
src="/Logos/signoz-brand-logo.svg"
|
||||
alt="logo"
|
||||
className="brand-logo"
|
||||
/>
|
||||
|
||||
<div className="brand-title">SigNoz</div>
|
||||
</div>
|
||||
|
||||
<LoginContainer
|
||||
ssoerror={ssoerror}
|
||||
jwt={jwt}
|
||||
refreshjwt={refreshJwt}
|
||||
userId={userId}
|
||||
withPassword={withPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
213
frontend/src/pages/SignUp/SignUp.styles.scss
Normal file
213
frontend/src/pages/SignUp/SignUp.styles.scss
Normal file
@ -0,0 +1,213 @@
|
||||
.signup-page-container {
|
||||
height: 100vh;
|
||||
gap: 32px;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.brand-container {
|
||||
width: 100%;
|
||||
padding: 16px 0px;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
|
||||
color: var(--text-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.perilin-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: radial-gradient(circle, #fff 10%, transparent 0);
|
||||
background-size: 12px 12px;
|
||||
opacity: 1;
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.signup-page-content {
|
||||
width: 720px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
|
||||
background: rgb(18 19 23);
|
||||
|
||||
z-index: 1;
|
||||
|
||||
.signup-form {
|
||||
width: 100%;
|
||||
|
||||
.ant-input {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.ant-input-affix-wrapper {
|
||||
height: 40px;
|
||||
|
||||
.ant-input {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signup-form-header {
|
||||
.signup-form-header-text {
|
||||
color: var(--text-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.email-container,
|
||||
.first-name-container,
|
||||
.org-name-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-input {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.password-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
|
||||
margin-top: 16px;
|
||||
|
||||
.password-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.password-error-container {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.password-error-message {
|
||||
color: var(--text-amber-400);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0px;
|
||||
text-align: left;
|
||||
text-underline-position: from-font;
|
||||
text-decoration-skip-ink: none;
|
||||
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-info-message {
|
||||
color: var(--text-vanilla-300);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
.signup-button-container {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.signup-page-container {
|
||||
.brand-container {
|
||||
.brand-title {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-form-header {
|
||||
.signup-form-header-text {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.perilin-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: radial-gradient(circle, #000000 10%, transparent 0);
|
||||
background-size: 12px 12px;
|
||||
opacity: 1;
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.signup-page-content {
|
||||
background: rgb(255 255 255);
|
||||
border: 1px solid var(--border-vanilla-200);
|
||||
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
|
||||
color: var(--text-ink-500);
|
||||
|
||||
.password-error-container {
|
||||
.password-error-message {
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
}
|
||||
|
||||
.signup-info-message {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import './SignUp.styles.scss';
|
||||
|
||||
import { Button, Form, Input, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import accept from 'api/v1/invite/id/accept';
|
||||
@ -5,12 +7,11 @@ import getInviteDetails from 'api/v1/invite/id/get';
|
||||
import loginApi from 'api/v1/login/login';
|
||||
import signUpApi from 'api/v1/register/signup';
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
import WelcomeLeftContainer from 'components/WelcomeLeftContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
@ -18,11 +19,9 @@ import APIError from 'types/api/error';
|
||||
import { InviteDetails } from 'types/api/user/getInviteDetails';
|
||||
import { PayloadProps as LoginPrecheckPayloadProps } from 'types/api/user/loginPrecheck';
|
||||
|
||||
import { ButtonContainer, FormContainer, FormWrapper, Label } from './styles';
|
||||
import { FormContainer, Label } from './styles';
|
||||
import { isPasswordNotValidMessage, isPasswordValid } from './utils';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
type FormValues = {
|
||||
firstName: string;
|
||||
email: string;
|
||||
@ -33,8 +32,7 @@ type FormValues = {
|
||||
isAnonymous: boolean;
|
||||
};
|
||||
|
||||
function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
const { t } = useTranslation(['signup']);
|
||||
function SignUp(): JSX.Element {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [precheck, setPrecheck] = useState<LoginPrecheckPayloadProps>({
|
||||
@ -167,7 +165,8 @@ function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
const handleSubmitSSO = async (): Promise<void> => {
|
||||
if (!params.get('token')) {
|
||||
notifications.error({
|
||||
message: t('token_required'),
|
||||
message:
|
||||
'Invite token is required for signup, please request one from your admin',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -184,7 +183,7 @@ function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
window.location.href = response.data?.ssoUrl;
|
||||
} else {
|
||||
notifications.error({
|
||||
message: t('failed_to_initiate_login'),
|
||||
message: 'Signup completed but failed to initiate login',
|
||||
});
|
||||
// take user to login page as there is nothing to do here
|
||||
history.push(ROUTES.LOGIN);
|
||||
@ -192,7 +191,7 @@ function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: t('unexpected_error'),
|
||||
message: 'Something went wrong',
|
||||
});
|
||||
}
|
||||
|
||||
@ -229,7 +228,7 @@ function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: t('unexpected_error'),
|
||||
message: 'Something went wrong',
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
@ -268,19 +267,37 @@ function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<WelcomeLeftContainer version={version}>
|
||||
<FormWrapper>
|
||||
<div className="signup-page-container">
|
||||
<div className="perilin-bg" />
|
||||
<div className="signup-page-content">
|
||||
<div className="brand-container">
|
||||
<img
|
||||
src="/Logos/signoz-brand-logo.svg"
|
||||
alt="logo"
|
||||
className="brand-logo"
|
||||
/>
|
||||
|
||||
<div className="brand-title">SigNoz</div>
|
||||
</div>
|
||||
|
||||
<FormContainer
|
||||
onFinish={!precheck.sso ? handleSubmit : handleSubmitSSO}
|
||||
onValuesChange={handleValuesChange}
|
||||
form={form}
|
||||
className="signup-form"
|
||||
>
|
||||
<Title level={4}>Create your account</Title>
|
||||
<div>
|
||||
<Label htmlFor="signupEmail">{t('label_email')}</Label>
|
||||
<div className="signup-form-header">
|
||||
<Typography.Paragraph className="signup-form-header-text">
|
||||
Create your account to monitor, trace, and troubleshoot your applications
|
||||
effortlessly.
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<div className="email-container">
|
||||
<Label htmlFor="signupEmail">Email</Label>
|
||||
<FormContainer.Item noStyle name="email">
|
||||
<Input
|
||||
placeholder={t('placeholder_email')}
|
||||
placeholder="name@yourcompany.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
required
|
||||
@ -291,11 +308,11 @@ function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
</div>
|
||||
|
||||
{isNameVisible && (
|
||||
<div>
|
||||
<Label htmlFor="signupFirstName">{t('label_firstname')}</Label>{' '}
|
||||
<div className="first-name-container">
|
||||
<Label htmlFor="signupFirstName">Name</Label>{' '}
|
||||
<FormContainer.Item noStyle name="firstName">
|
||||
<Input
|
||||
placeholder={t('placeholder_firstname')}
|
||||
placeholder="Your Name"
|
||||
required
|
||||
id="signupFirstName"
|
||||
disabled={isDetailsDisable && form.getFieldValue('firstName')}
|
||||
@ -304,87 +321,76 @@ function SignUp({ version }: SignUpProps): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="organizationName">{t('label_orgname')}</Label>{' '}
|
||||
<div className="org-name-container">
|
||||
<Label htmlFor="organizationName">Organization Name</Label>{' '}
|
||||
<FormContainer.Item noStyle name="organizationName">
|
||||
<Input
|
||||
placeholder={t('placeholder_orgname')}
|
||||
placeholder="Your Company"
|
||||
id="organizationName"
|
||||
disabled={isDetailsDisable}
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
</div>
|
||||
|
||||
{!precheck.sso && (
|
||||
<div>
|
||||
<Label htmlFor="Password">{t('label_password')}</Label>{' '}
|
||||
<FormContainer.Item noStyle name="password">
|
||||
<Input.Password required id="currentPassword" />
|
||||
</FormContainer.Item>
|
||||
</div>
|
||||
)}
|
||||
{!precheck.sso && (
|
||||
<div>
|
||||
<Label htmlFor="ConfirmPassword">{t('label_confirm_password')}</Label>{' '}
|
||||
<FormContainer.Item noStyle name="confirmPassword">
|
||||
<Input.Password required id="confirmPassword" />
|
||||
</FormContainer.Item>
|
||||
{confirmPasswordError && (
|
||||
<Typography.Paragraph
|
||||
italic
|
||||
id="password-confirm-error"
|
||||
style={{
|
||||
color: '#D89614',
|
||||
marginTop: '0.50rem',
|
||||
}}
|
||||
>
|
||||
{t('failed_confirm_password')}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{isPasswordPolicyError && (
|
||||
<Typography.Paragraph
|
||||
italic
|
||||
style={{
|
||||
color: '#D89614',
|
||||
marginTop: '0.50rem',
|
||||
}}
|
||||
>
|
||||
{isPasswordNotValidMessage}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
<div className="password-section">
|
||||
<div className="password-container">
|
||||
<label htmlFor="Password">Password</label>{' '}
|
||||
<FormContainer.Item noStyle name="password">
|
||||
<Input.Password required id="currentPassword" />
|
||||
</FormContainer.Item>
|
||||
</div>
|
||||
|
||||
<div className="password-container">
|
||||
<label htmlFor="ConfirmPassword">Confirm Password</label>{' '}
|
||||
<FormContainer.Item noStyle name="confirmPassword">
|
||||
<Input.Password required id="confirmPassword" />
|
||||
</FormContainer.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="password-error-container">
|
||||
{confirmPasswordError && (
|
||||
<Typography.Paragraph
|
||||
id="password-confirm-error"
|
||||
className="password-error-message"
|
||||
>
|
||||
Passwords don’t match. Please try again
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{isPasswordPolicyError && (
|
||||
<Typography.Paragraph className="password-error-message">
|
||||
{isPasswordNotValidMessage}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSignUp && (
|
||||
<Typography.Paragraph
|
||||
italic
|
||||
style={{
|
||||
color: '#D89614',
|
||||
marginTop: '0.50rem',
|
||||
}}
|
||||
>
|
||||
This will create an admin account. If you are not an admin, please ask
|
||||
<Typography.Paragraph className="signup-info-message">
|
||||
* This will create an admin account. If you are not an admin, please ask
|
||||
your admin for an invite link
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
<ButtonContainer>
|
||||
<div className="signup-button-container">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
data-attr="signup"
|
||||
loading={loading}
|
||||
disabled={isValidForm()}
|
||||
className="periscope-btn primary next-btn"
|
||||
icon={<ArrowRight size={12} />}
|
||||
>
|
||||
{t('button_get_started')}
|
||||
Sign Up
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</div>
|
||||
</FormContainer>
|
||||
</FormWrapper>
|
||||
</WelcomeLeftContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SignUpProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export default SignUp;
|
||||
|
||||
@ -1,47 +1,7 @@
|
||||
import { Typography } from 'antd';
|
||||
import getUserVersion from 'api/v1/version/getVersion';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueries } from 'react-query';
|
||||
|
||||
import SignUpComponent from './SignUp';
|
||||
|
||||
function SignUp(): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { isLoggedIn, user } = useAppContext();
|
||||
|
||||
const [versionResponse] = useQueries([
|
||||
{
|
||||
queryFn: getUserVersion,
|
||||
queryKey: ['getUserVersion', user?.accessJwt],
|
||||
enabled: !isLoggedIn,
|
||||
},
|
||||
]);
|
||||
|
||||
if (
|
||||
versionResponse.status === 'error' ||
|
||||
(versionResponse.status === 'success' &&
|
||||
versionResponse.data?.statusCode !== 200)
|
||||
) {
|
||||
return (
|
||||
<Typography>
|
||||
{versionResponse.data?.error || t('something_went_wrong')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
versionResponse.status === 'loading' ||
|
||||
!(versionResponse.data && versionResponse.data.payload)
|
||||
) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
const { version } = versionResponse.data.payload;
|
||||
|
||||
return <SignUpComponent version={version} />;
|
||||
return <SignUpComponent />;
|
||||
}
|
||||
|
||||
export default SignUp;
|
||||
|
||||
@ -3,11 +3,21 @@
|
||||
|
||||
@import './periscope.scss';
|
||||
|
||||
/* Import fonts from CDN */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Work+Sans&wght@500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Mono&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono&wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
#root,
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
@ -690,15 +700,6 @@ notifications - 2050
|
||||
|
||||
*/
|
||||
|
||||
/* Import fonts from CDN */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@500&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Mono&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100;200;300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
/* Remove the old Geist Mono font-face declarations since we're using Google Fonts */
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@ -13,6 +13,7 @@ pytest_plugins = [
|
||||
"fixtures.zookeeper",
|
||||
"fixtures.signoz",
|
||||
"fixtures.logs",
|
||||
"fixtures.traces",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -22,7 +22,9 @@ class LogsResource(ABC):
|
||||
fingerprint: str,
|
||||
seen_at_ts_bucket_start: np.int64,
|
||||
) -> None:
|
||||
self.labels = json.dumps(labels, separators=(',', ':')) # clickhouse treats {"a": "b"} differently from {"a":"b"}. In the first case it is not able to run json functions
|
||||
self.labels = json.dumps(
|
||||
labels, separators=(",", ":")
|
||||
) # clickhouse treats {"a": "b"} differently from {"a":"b"}. In the first case it is not able to run json functions
|
||||
self.fingerprint = fingerprint
|
||||
self.seen_at_ts_bucket_start = seen_at_ts_bucket_start
|
||||
|
||||
|
||||
716
tests/integration/fixtures/traces.py
Normal file
716
tests/integration/fixtures/traces.py
Normal file
@ -0,0 +1,716 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import uuid
|
||||
from abc import ABC
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Generator, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.fingerprint import LogsOrTracesFingerprint
|
||||
|
||||
|
||||
class TracesKind(Enum):
|
||||
SPAN_KIND_UNSPECIFIED = 0
|
||||
SPAN_KIND_INTERNAL = 1
|
||||
SPAN_KIND_SERVER = 2
|
||||
SPAN_KIND_CLIENT = 3
|
||||
SPAN_KIND_PRODUCER = 4
|
||||
SPAN_KIND_CONSUMER = 5
|
||||
|
||||
|
||||
class TracesStatusCode(Enum):
|
||||
STATUS_CODE_UNSET = 0
|
||||
STATUS_CODE_OK = 1
|
||||
STATUS_CODE_ERROR = 2
|
||||
|
||||
|
||||
class TracesRefType(Enum):
|
||||
REF_TYPE_CHILD_OF = "CHILD_OF"
|
||||
REF_TYPE_FOLLOWS_FROM = "FOLLOWS_FROM"
|
||||
|
||||
|
||||
class TraceIdGenerator(ABC):
|
||||
@staticmethod
|
||||
def trace_id() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
@staticmethod
|
||||
def span_id() -> str:
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
class TracesResource(ABC):
|
||||
labels: str
|
||||
fingerprint: str
|
||||
seen_at_ts_bucket_start: np.int64
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
labels: dict[str, str],
|
||||
fingerprint: str,
|
||||
seen_at_ts_bucket_start: np.int64,
|
||||
) -> None:
|
||||
self.labels = json.dumps(
|
||||
labels, separators=(",", ":")
|
||||
) # clickhouse treats {"a": "b"} differently from {"a":"b"}. In the first case it is not able to run json functions
|
||||
self.fingerprint = fingerprint
|
||||
self.seen_at_ts_bucket_start = seen_at_ts_bucket_start
|
||||
|
||||
def np_arr(self) -> np.array:
|
||||
return np.array([self.labels, self.fingerprint, self.seen_at_ts_bucket_start])
|
||||
|
||||
|
||||
class TracesResourceOrAttributeKeys(ABC):
|
||||
name: str
|
||||
datatype: str
|
||||
tag_type: str
|
||||
is_column: bool
|
||||
|
||||
def __init__(
|
||||
self, name: str, datatype: str, tag_type: str, is_column: bool = False
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.datatype = datatype
|
||||
self.tag_type = tag_type
|
||||
self.is_column = is_column
|
||||
|
||||
def np_arr(self) -> np.array:
|
||||
return np.array([self.name, self.tag_type, self.datatype, self.is_column])
|
||||
|
||||
|
||||
class TracesTagAttributes(ABC):
|
||||
unix_milli: np.int64
|
||||
tag_key: str
|
||||
tag_type: str
|
||||
tag_data_type: str
|
||||
string_value: str
|
||||
number_value: np.float64
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timestamp: datetime.datetime,
|
||||
tag_key: str,
|
||||
tag_type: str,
|
||||
tag_data_type: str,
|
||||
string_value: str,
|
||||
number_value: np.float64,
|
||||
) -> None:
|
||||
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
|
||||
self.tag_key = tag_key
|
||||
self.tag_type = tag_type
|
||||
self.tag_data_type = tag_data_type
|
||||
self.string_value = string_value or ""
|
||||
self.number_value = number_value
|
||||
|
||||
def np_arr(self) -> np.array:
|
||||
return np.array(
|
||||
[
|
||||
self.unix_milli,
|
||||
self.tag_key,
|
||||
self.tag_type,
|
||||
self.tag_data_type,
|
||||
self.string_value,
|
||||
self.number_value,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TracesEvent(ABC):
|
||||
name: str
|
||||
time_unix_nano: np.uint64
|
||||
attribute_map: dict[str, str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
timestamp: datetime.datetime,
|
||||
attribute_map: dict[str, str] = {},
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.time_unix_nano = np.uint64(int(timestamp.timestamp() * 1e9))
|
||||
self.attribute_map = attribute_map
|
||||
|
||||
def np_arr(self) -> np.array:
|
||||
return np.array(
|
||||
[self.name, self.time_unix_nano, json.dumps(self.attribute_map)]
|
||||
)
|
||||
|
||||
|
||||
class TracesErrorEvent(ABC):
|
||||
event: TracesEvent
|
||||
error_id: str
|
||||
error_group_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event: TracesEvent,
|
||||
error_id: str = "",
|
||||
error_group_id: str = "",
|
||||
) -> None:
|
||||
self.event = event
|
||||
self.error_id = error_id
|
||||
self.error_group_id = error_group_id
|
||||
|
||||
def np_arr(self) -> np.array:
|
||||
return np.array(
|
||||
[
|
||||
self.event.time_unix_nano,
|
||||
self.error_id,
|
||||
self.error_group_id,
|
||||
self.event.name,
|
||||
json.dumps(self.event.attribute_map),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TracesSpanAttribute(ABC):
|
||||
key: str
|
||||
tag_type: str
|
||||
data_type: str
|
||||
string_value: str
|
||||
number_value: np.float64
|
||||
is_column: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
tag_type: str,
|
||||
data_type: str,
|
||||
string_value: str = "",
|
||||
number_value: np.float64 = None,
|
||||
is_column: bool = False,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.tag_type = tag_type
|
||||
self.data_type = data_type
|
||||
self.string_value = string_value
|
||||
self.number_value = number_value
|
||||
self.is_column = is_column
|
||||
|
||||
|
||||
class TracesLink(ABC):
|
||||
trace_id: str
|
||||
span_id: str
|
||||
ref_type: TracesRefType
|
||||
|
||||
def __init__(self, trace_id: str, span_id: str, ref_type: TracesRefType) -> None:
|
||||
self.trace_id = trace_id
|
||||
self.span_id = span_id
|
||||
self.ref_type = ref_type
|
||||
|
||||
def __dict__(self) -> dict[str, Any]:
|
||||
return {
|
||||
"traceId": self.trace_id,
|
||||
"spanId": self.span_id,
|
||||
"refType": self.ref_type.value,
|
||||
}
|
||||
|
||||
|
||||
class Traces(ABC):
|
||||
ts_bucket_start: np.uint64
|
||||
resource_fingerprint: str
|
||||
timestamp: np.datetime64
|
||||
trace_id: str
|
||||
span_id: str
|
||||
trace_state: str
|
||||
parent_span_id: str
|
||||
flags: np.uint32
|
||||
name: str
|
||||
kind: np.int8
|
||||
kind_string: str
|
||||
duration_nano: np.uint64
|
||||
status_code: np.int16
|
||||
status_message: str
|
||||
status_code_string: str
|
||||
attribute_string: dict[str, str]
|
||||
attributes_number: dict[str, np.float64]
|
||||
attributes_bool: dict[str, bool]
|
||||
resources_string: dict[str, str]
|
||||
events: List[str]
|
||||
links: str
|
||||
response_status_code: str
|
||||
external_http_url: str
|
||||
http_url: str
|
||||
external_http_method: str
|
||||
http_method: str
|
||||
http_host: str
|
||||
db_name: str
|
||||
db_operation: str
|
||||
has_error: bool
|
||||
is_remote: str
|
||||
|
||||
resource: List[TracesResource]
|
||||
tag_attributes: List[TracesTagAttributes]
|
||||
resource_keys: List[TracesResourceOrAttributeKeys]
|
||||
attribute_keys: List[TracesResourceOrAttributeKeys]
|
||||
span_attributes: List[TracesSpanAttribute]
|
||||
error_events: List[TracesErrorEvent]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timestamp: datetime.datetime = datetime.datetime.now(),
|
||||
duration: datetime.timedelta = datetime.timedelta(seconds=1),
|
||||
trace_id: str = "",
|
||||
span_id: str = "",
|
||||
parent_span_id: str = "",
|
||||
name: str = "default span",
|
||||
kind: TracesKind = TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code: TracesStatusCode = TracesStatusCode.STATUS_CODE_UNSET,
|
||||
status_message: str = "",
|
||||
resources: dict[str, Any] = {},
|
||||
attributes: dict[str, Any] = {},
|
||||
events: List[TracesEvent] = [],
|
||||
links: List[TracesLink] = [],
|
||||
trace_state: str = "",
|
||||
flags: np.uint32 = 0,
|
||||
) -> None:
|
||||
self.tag_attributes = []
|
||||
self.attribute_keys = []
|
||||
self.resource_keys = []
|
||||
self.span_attributes = []
|
||||
self.error_events = []
|
||||
|
||||
# Calculate ts_bucket_start (30mins bucket)
|
||||
# Round down to nearest 30-minute interval
|
||||
minute = timestamp.minute
|
||||
if minute < 30:
|
||||
bucket_minute = 0
|
||||
else:
|
||||
bucket_minute = 30
|
||||
|
||||
bucket_start = timestamp.replace(minute=bucket_minute, second=0, microsecond=0)
|
||||
self.ts_bucket_start = np.uint64(int(bucket_start.timestamp()))
|
||||
|
||||
self.timestamp = timestamp
|
||||
|
||||
self.duration_nano = np.uint64(int(duration.total_seconds() * 1e9))
|
||||
|
||||
# Initialize trace fields
|
||||
self.trace_id = trace_id
|
||||
self.span_id = span_id
|
||||
self.parent_span_id = parent_span_id
|
||||
self.trace_state = trace_state
|
||||
self.flags = flags
|
||||
self.name = name
|
||||
self.kind = kind.value
|
||||
self.kind_string = kind.name
|
||||
self.status_code = status_code.value
|
||||
self.status_message = status_message
|
||||
self.status_code_string = status_code.name
|
||||
self.has_error = status_code == TracesStatusCode.STATUS_CODE_ERROR
|
||||
self.is_remote = self._determine_is_remote(flags)
|
||||
|
||||
# Initialize custom fields to empty values
|
||||
self.response_status_code = ""
|
||||
self.external_http_url = ""
|
||||
self.http_url = ""
|
||||
self.external_http_method = ""
|
||||
self.http_method = ""
|
||||
self.http_host = ""
|
||||
self.db_name = ""
|
||||
self.db_operation = ""
|
||||
|
||||
# Process resources and derive service_name
|
||||
self.resources_string = {k: str(v) for k, v in resources.items()}
|
||||
self.service_name = self.resources_string.get("service.name", "default-service")
|
||||
|
||||
for k, v in self.resources_string.items():
|
||||
self.tag_attributes.append(
|
||||
TracesTagAttributes(
|
||||
timestamp=timestamp,
|
||||
tag_key=k,
|
||||
tag_type="resource",
|
||||
tag_data_type="string",
|
||||
string_value=v,
|
||||
number_value=None,
|
||||
)
|
||||
)
|
||||
self.resource_keys.append(
|
||||
TracesResourceOrAttributeKeys(
|
||||
name=k, datatype="string", tag_type="resource"
|
||||
)
|
||||
)
|
||||
self.span_attributes.append(
|
||||
TracesSpanAttribute(
|
||||
key=k,
|
||||
tag_type="resource",
|
||||
data_type="string",
|
||||
string_value=v,
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate resource fingerprint
|
||||
self.resource_fingerprint = LogsOrTracesFingerprint(
|
||||
self.resources_string
|
||||
).calculate()
|
||||
|
||||
# Process attributes by type and populate custom fields
|
||||
self.attribute_string = {}
|
||||
self.attributes_number = {}
|
||||
self.attributes_bool = {}
|
||||
|
||||
for k, v in attributes.items():
|
||||
# Populate custom fields based on attribute keys (following Go exporter logic)
|
||||
self._populate_custom_attrs(k, v)
|
||||
|
||||
if isinstance(v, bool):
|
||||
self.attributes_bool[k] = v
|
||||
self.tag_attributes.append(
|
||||
TracesTagAttributes(
|
||||
timestamp=timestamp,
|
||||
tag_key=k,
|
||||
tag_type="tag",
|
||||
tag_data_type="bool",
|
||||
string_value=None,
|
||||
number_value=None,
|
||||
)
|
||||
)
|
||||
self.attribute_keys.append(
|
||||
TracesResourceOrAttributeKeys(
|
||||
name=k, datatype="bool", tag_type="tag"
|
||||
)
|
||||
)
|
||||
self.span_attributes.append(
|
||||
TracesSpanAttribute(
|
||||
key=k,
|
||||
tag_type="tag",
|
||||
data_type="bool",
|
||||
number_value=None,
|
||||
)
|
||||
)
|
||||
elif isinstance(v, (int, float)):
|
||||
self.attributes_number[k] = np.float64(v)
|
||||
self.tag_attributes.append(
|
||||
TracesTagAttributes(
|
||||
timestamp=timestamp,
|
||||
tag_key=k,
|
||||
tag_type="tag",
|
||||
tag_data_type="float64",
|
||||
string_value=None,
|
||||
number_value=np.float64(v),
|
||||
)
|
||||
)
|
||||
self.attribute_keys.append(
|
||||
TracesResourceOrAttributeKeys(
|
||||
name=k, datatype="float64", tag_type="tag"
|
||||
)
|
||||
)
|
||||
self.span_attributes.append(
|
||||
TracesSpanAttribute(
|
||||
key=k,
|
||||
tag_type="tag",
|
||||
data_type="float64",
|
||||
number_value=np.float64(v),
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.attribute_string[k] = str(v)
|
||||
self.tag_attributes.append(
|
||||
TracesTagAttributes(
|
||||
timestamp=timestamp,
|
||||
tag_key=k,
|
||||
tag_type="tag",
|
||||
tag_data_type="string",
|
||||
string_value=str(v),
|
||||
number_value=None,
|
||||
)
|
||||
)
|
||||
self.attribute_keys.append(
|
||||
TracesResourceOrAttributeKeys(
|
||||
name=k, datatype="string", tag_type="tag"
|
||||
)
|
||||
)
|
||||
self.span_attributes.append(
|
||||
TracesSpanAttribute(
|
||||
key=k,
|
||||
tag_type="tag",
|
||||
data_type="string",
|
||||
string_value=str(v),
|
||||
)
|
||||
)
|
||||
|
||||
# Process events and derive error events
|
||||
self.events = []
|
||||
for event in events:
|
||||
self.events.append(
|
||||
json.dumps([event.name, event.time_unix_nano, event.attribute_map])
|
||||
)
|
||||
|
||||
# Create error events for exception events (following Go exporter logic)
|
||||
if event.name == "exception":
|
||||
error_event = self._create_error_event(event)
|
||||
self.error_events.append(error_event)
|
||||
|
||||
# In Python, when you define a function with a mutable default argument (like a list []), that default object is created once when the function is defined,
|
||||
# not each time the function is called.
|
||||
# This means all calls to the function share the same default object.
|
||||
# https://stackoverflow.com/questions/1132941/least-astonishment-in-python-the-mutable-default-argument
|
||||
links_copy = links.copy() if links else []
|
||||
if self.parent_span_id != "":
|
||||
links_copy.insert(
|
||||
0,
|
||||
TracesLink(
|
||||
trace_id=self.trace_id,
|
||||
span_id=self.parent_span_id,
|
||||
ref_type=TracesRefType.REF_TYPE_CHILD_OF,
|
||||
),
|
||||
)
|
||||
|
||||
self.links = json.dumps(
|
||||
[link.__dict__() for link in links_copy], separators=(",", ":")
|
||||
)
|
||||
|
||||
# Initialize resource
|
||||
self.resource = []
|
||||
self.resource.append(
|
||||
TracesResource(
|
||||
labels=self.resources_string,
|
||||
fingerprint=self.resource_fingerprint,
|
||||
seen_at_ts_bucket_start=self.ts_bucket_start,
|
||||
)
|
||||
)
|
||||
|
||||
def _create_error_event(self, event: TracesEvent) -> TracesErrorEvent:
|
||||
"""Create error event from exception event (following Go exporter logic)"""
|
||||
error_id = str(uuid.uuid4()).replace("-", "")
|
||||
|
||||
# Create error group ID based on exception type and message
|
||||
exception_type = event.attribute_map.get("exception.type", "")
|
||||
exception_message = event.attribute_map.get("exception.message", "")
|
||||
|
||||
error_group_content = self.service_name + exception_type + exception_message
|
||||
|
||||
error_group_id = hashlib.md5(error_group_content.encode()).hexdigest()
|
||||
|
||||
return TracesErrorEvent(
|
||||
event=event,
|
||||
error_id=error_id,
|
||||
error_group_id=error_group_id,
|
||||
)
|
||||
|
||||
def _determine_is_remote(self, flags: np.uint32) -> str:
|
||||
"""Determine if span is remote based on flags (following Go exporter logic)"""
|
||||
has_is_remote_mask = 0x00000100
|
||||
is_remote_mask = 0x00000200
|
||||
|
||||
if flags & has_is_remote_mask != 0:
|
||||
if flags & is_remote_mask != 0:
|
||||
return "yes"
|
||||
|
||||
return "no"
|
||||
return "unknown"
|
||||
|
||||
def _populate_custom_attrs( # pylint: disable=too-many-branches
|
||||
self, key: str, value: Any
|
||||
) -> None:
|
||||
"""Populate custom attributes based on attribute keys (following Go exporter logic)"""
|
||||
str_value = str(value)
|
||||
|
||||
if key in ["http.status_code", "http.response.status_code"]:
|
||||
# Handle both string/int http status codes
|
||||
try:
|
||||
status_int = int(str_value)
|
||||
self.response_status_code = str(status_int)
|
||||
except ValueError:
|
||||
self.response_status_code = str_value
|
||||
elif key in ["http.url", "url.full"] and self.kind == 3: # SPAN_KIND_CLIENT
|
||||
# For client spans, extract hostname for external URL
|
||||
try:
|
||||
parsed = urlparse(str_value)
|
||||
self.external_http_url = parsed.hostname or str_value
|
||||
self.http_url = str_value
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
self.external_http_url = str_value
|
||||
self.http_url = str_value
|
||||
elif (
|
||||
key in ["http.method", "http.request.method"] and self.kind == 3
|
||||
): # SPAN_KIND_CLIENT
|
||||
self.external_http_method = str_value
|
||||
self.http_method = str_value
|
||||
elif key in ["http.url", "url.full"] and self.kind != 3:
|
||||
self.http_url = str_value
|
||||
elif key in ["http.method", "http.request.method"] and self.kind != 3:
|
||||
self.http_method = str_value
|
||||
elif key in [
|
||||
"http.host",
|
||||
"server.address",
|
||||
"client.address",
|
||||
"http.request.header.host",
|
||||
]:
|
||||
self.http_host = str_value
|
||||
elif key in ["db.name", "db.namespace"]:
|
||||
self.db_name = str_value
|
||||
elif key in ["db.operation", "db.operation.name"]:
|
||||
self.db_operation = str_value
|
||||
elif key == "rpc.grpc.status_code":
|
||||
# Handle both string/int status code in GRPC spans
|
||||
try:
|
||||
status_int = int(str_value)
|
||||
self.response_status_code = str(status_int)
|
||||
except ValueError:
|
||||
self.response_status_code = str_value
|
||||
elif key == "rpc.jsonrpc.error_code":
|
||||
self.response_status_code = str_value
|
||||
|
||||
def np_arr(self) -> np.array:
|
||||
"""Return span data as numpy array for database insertion"""
|
||||
return np.array(
|
||||
[
|
||||
self.ts_bucket_start,
|
||||
self.resource_fingerprint,
|
||||
self.timestamp,
|
||||
self.trace_id,
|
||||
self.span_id,
|
||||
self.trace_state,
|
||||
self.parent_span_id,
|
||||
self.flags,
|
||||
self.name,
|
||||
self.kind,
|
||||
self.kind_string,
|
||||
self.duration_nano,
|
||||
self.status_code,
|
||||
self.status_message,
|
||||
self.status_code_string,
|
||||
self.attribute_string,
|
||||
self.attributes_number,
|
||||
self.attributes_bool,
|
||||
self.resources_string,
|
||||
self.events,
|
||||
self.links,
|
||||
self.response_status_code,
|
||||
self.external_http_url,
|
||||
self.http_url,
|
||||
self.external_http_method,
|
||||
self.http_method,
|
||||
self.http_host,
|
||||
self.db_name,
|
||||
self.db_operation,
|
||||
self.has_error,
|
||||
self.is_remote,
|
||||
],
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="insert_traces", scope="function")
|
||||
def insert_traces(
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
) -> Generator[Callable[[List[Traces]], None], Any, None]:
|
||||
def _insert_traces(traces: List[Traces]) -> None:
|
||||
"""
|
||||
Insert traces into ClickHouse tables following the same logic as the Go exporter.
|
||||
This function handles insertion into multiple tables:
|
||||
- distributed_signoz_index_v3 (main traces table)
|
||||
- distributed_traces_v3_resource (resource fingerprints)
|
||||
- distributed_tag_attributes_v2 (tag attributes)
|
||||
- distributed_span_attributes_keys (attribute keys)
|
||||
- distributed_signoz_error_index_v2 (error events)
|
||||
"""
|
||||
resources: List[TracesResource] = []
|
||||
for trace in traces:
|
||||
resources.extend(trace.resource)
|
||||
|
||||
if len(resources) > 0:
|
||||
clickhouse.conn.insert(
|
||||
database="signoz_traces",
|
||||
table="distributed_traces_v3_resource",
|
||||
data=[resource.np_arr() for resource in resources],
|
||||
)
|
||||
|
||||
tag_attributes: List[TracesTagAttributes] = []
|
||||
for trace in traces:
|
||||
tag_attributes.extend(trace.tag_attributes)
|
||||
|
||||
if len(tag_attributes) > 0:
|
||||
clickhouse.conn.insert(
|
||||
database="signoz_traces",
|
||||
table="distributed_tag_attributes_v2",
|
||||
data=[tag_attribute.np_arr() for tag_attribute in tag_attributes],
|
||||
)
|
||||
|
||||
attribute_keys: List[TracesResourceOrAttributeKeys] = []
|
||||
for trace in traces:
|
||||
attribute_keys.extend(trace.attribute_keys)
|
||||
|
||||
if len(attribute_keys) > 0:
|
||||
clickhouse.conn.insert(
|
||||
database="signoz_traces",
|
||||
table="distributed_span_attributes_keys",
|
||||
data=[attribute_key.np_arr() for attribute_key in attribute_keys],
|
||||
)
|
||||
|
||||
# Insert main traces
|
||||
clickhouse.conn.insert(
|
||||
database="signoz_traces",
|
||||
table="distributed_signoz_index_v3",
|
||||
column_names=[
|
||||
"ts_bucket_start",
|
||||
"resource_fingerprint",
|
||||
"timestamp",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"trace_state",
|
||||
"parent_span_id",
|
||||
"flags",
|
||||
"name",
|
||||
"kind",
|
||||
"kind_string",
|
||||
"duration_nano",
|
||||
"status_code",
|
||||
"status_message",
|
||||
"status_code_string",
|
||||
"attributes_string",
|
||||
"attributes_number",
|
||||
"attributes_bool",
|
||||
"resources_string",
|
||||
"events",
|
||||
"links",
|
||||
"response_status_code",
|
||||
"external_http_url",
|
||||
"http_url",
|
||||
"external_http_method",
|
||||
"http_method",
|
||||
"http_host",
|
||||
"db_name",
|
||||
"db_operation",
|
||||
"has_error",
|
||||
"is_remote",
|
||||
],
|
||||
data=[trace.np_arr() for trace in traces],
|
||||
)
|
||||
|
||||
# Insert error events
|
||||
error_events: List[TracesErrorEvent] = []
|
||||
for trace in traces:
|
||||
error_events.extend(trace.error_events)
|
||||
|
||||
if len(error_events) > 0:
|
||||
clickhouse.conn.insert(
|
||||
database="signoz_traces",
|
||||
table="distributed_signoz_error_index_v2",
|
||||
data=[error_event.np_arr() for error_event in error_events],
|
||||
)
|
||||
|
||||
yield _insert_traces
|
||||
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_traces.signoz_index_v3 ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
|
||||
)
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_traces.traces_v3_resource ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
|
||||
)
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_traces.tag_attributes_v2 ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
|
||||
)
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_traces.span_attributes_keys ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
|
||||
)
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_traces.signoz_error_index_v2 ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
|
||||
)
|
||||
@ -3,7 +3,6 @@ from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
|
||||
import requests
|
||||
import time
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
@ -636,7 +635,9 @@ def test_logs_time_series_count(
|
||||
"signal": "logs",
|
||||
"stepInterval": 60,
|
||||
"disabled": False,
|
||||
"filter": {"expression": "service.name = 'erlang' OR cloud.account.id = '000'"},
|
||||
"filter": {
|
||||
"expression": "service.name = 'erlang' OR cloud.account.id = '000'"
|
||||
},
|
||||
"having": {"expression": ""},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
|
||||
375
tests/integration/src/querier/b_traces.py
Normal file
375
tests/integration/src/querier/b_traces.py
Normal file
@ -0,0 +1,375 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, List
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def test_traces_list(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_jwt_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[List[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert 4 traces with different attributes.
|
||||
http-service: POST /integration -> SELECT, HTTP PATCH
|
||||
topic-service: topic publish
|
||||
|
||||
Tests:
|
||||
1. Query traces for the last 5 minutes and check if the spans are returned in the correct order
|
||||
2. Query root spans for the last 5 minutes and check if the spans are returned in the correct order
|
||||
3. Query values of http.request.method attribute from the autocomplete API
|
||||
4. Query values of http.request.method attribute from the fields API
|
||||
"""
|
||||
http_service_trace_id = TraceIdGenerator.trace_id()
|
||||
http_service_span_id = TraceIdGenerator.span_id()
|
||||
http_service_db_span_id = TraceIdGenerator.span_id()
|
||||
http_service_patch_span_id = TraceIdGenerator.span_id()
|
||||
topic_service_trace_id = TraceIdGenerator.trace_id()
|
||||
topic_service_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=3),
|
||||
trace_id=http_service_trace_id,
|
||||
span_id=http_service_span_id,
|
||||
parent_span_id="",
|
||||
name="POST /integration",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "http-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-000",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
},
|
||||
attributes={
|
||||
"net.transport": "IP.TCP",
|
||||
"http.scheme": "http",
|
||||
"http.user_agent": "Integration Test",
|
||||
"http.request.method": "POST",
|
||||
"http.response.status_code": "200",
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3.5),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=http_service_trace_id,
|
||||
span_id=http_service_db_span_id,
|
||||
parent_span_id=http_service_span_id,
|
||||
name="SELECT",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "http-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-000",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
},
|
||||
attributes={
|
||||
"db.name": "integration",
|
||||
"db.operation": "SELECT",
|
||||
"db.statement": "SELECT * FROM integration",
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=http_service_trace_id,
|
||||
span_id=http_service_patch_span_id,
|
||||
parent_span_id=http_service_span_id,
|
||||
name="HTTP PATCH",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "http-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-000",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
},
|
||||
attributes={
|
||||
"http.request.method": "PATCH",
|
||||
"http.status_code": "404",
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
duration=timedelta(seconds=4),
|
||||
trace_id=topic_service_trace_id,
|
||||
span_id=topic_service_span_id,
|
||||
parent_span_id="",
|
||||
name="topic publish",
|
||||
kind=TracesKind.SPAN_KIND_PRODUCER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources={
|
||||
"deployment.environment": "production",
|
||||
"service.name": "topic-service",
|
||||
"os.type": "linux",
|
||||
"host.name": "linux-001",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "001",
|
||||
},
|
||||
attributes={
|
||||
"message.type": "SENT",
|
||||
"messaging.operation": "publish",
|
||||
"messaging.message.id": "001",
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_jwt_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
|
||||
|
||||
# Query all traces for the past 5 minutes
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=2,
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
json={
|
||||
"schemaVersion": "v1",
|
||||
"start": int(
|
||||
(datetime.now(tz=timezone.utc) - timedelta(minutes=5)).timestamp()
|
||||
* 1000
|
||||
),
|
||||
"end": int(datetime.now(tz=timezone.utc).timestamp() * 1000),
|
||||
"requestType": "raw",
|
||||
"compositeQuery": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": False,
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
],
|
||||
"selectFields": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "resource",
|
||||
"signal": "traces",
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "span",
|
||||
"signal": "traces",
|
||||
},
|
||||
{
|
||||
"name": "duration_nano",
|
||||
"fieldDataType": "",
|
||||
"fieldContext": "span",
|
||||
"signal": "traces",
|
||||
},
|
||||
{
|
||||
"name": "http_method",
|
||||
"fieldDataType": "",
|
||||
"fieldContext": "span",
|
||||
"signal": "traces",
|
||||
},
|
||||
{
|
||||
"name": "response_status_code",
|
||||
"fieldDataType": "",
|
||||
"fieldContext": "span",
|
||||
"signal": "traces",
|
||||
},
|
||||
],
|
||||
"having": {"expression": ""},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
|
||||
rows = results[0]["rows"]
|
||||
assert len(rows) == 4
|
||||
|
||||
# Care about the order of the rows
|
||||
row_0 = dict(rows[0]["data"])
|
||||
assert row_0.pop("timestamp") is not None
|
||||
assert row_0 == {
|
||||
"duration_nano": 4 * 1e9,
|
||||
"http_method": "",
|
||||
"name": "topic publish",
|
||||
"response_status_code": "",
|
||||
"service.name": "topic-service",
|
||||
"span_id": topic_service_span_id,
|
||||
"trace_id": topic_service_trace_id,
|
||||
}
|
||||
|
||||
row_2 = dict(rows[1]["data"])
|
||||
assert row_2.pop("timestamp") is not None
|
||||
assert row_2 == {
|
||||
"duration_nano": 1 * 1e9,
|
||||
"http_method": "PATCH",
|
||||
"name": "HTTP PATCH",
|
||||
"response_status_code": "404",
|
||||
"service.name": "http-service",
|
||||
"span_id": http_service_patch_span_id,
|
||||
"trace_id": http_service_trace_id,
|
||||
}
|
||||
|
||||
row_3 = dict(rows[2]["data"])
|
||||
assert row_3.pop("timestamp") is not None
|
||||
assert row_3 == {
|
||||
"duration_nano": 0.5 * 1e9,
|
||||
"http_method": "",
|
||||
"name": "SELECT",
|
||||
"response_status_code": "",
|
||||
"service.name": "http-service",
|
||||
"span_id": http_service_db_span_id,
|
||||
"trace_id": http_service_trace_id,
|
||||
}
|
||||
|
||||
row_1 = dict(rows[3]["data"])
|
||||
assert row_1.pop("timestamp") is not None
|
||||
assert row_1 == {
|
||||
"duration_nano": 3 * 1e9,
|
||||
"http_method": "POST",
|
||||
"name": "POST /integration",
|
||||
"response_status_code": "200",
|
||||
"service.name": "http-service",
|
||||
"span_id": http_service_span_id,
|
||||
"trace_id": http_service_trace_id,
|
||||
}
|
||||
|
||||
# Query root spans for the last 5 minutes and check if the spans are returned in the correct order
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=2,
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
json={
|
||||
"schemaVersion": "v1",
|
||||
"start": int(
|
||||
(datetime.now(tz=timezone.utc) - timedelta(minutes=5)).timestamp()
|
||||
* 1000
|
||||
),
|
||||
"end": int(datetime.now(tz=timezone.utc).timestamp() * 1000),
|
||||
"requestType": "raw",
|
||||
"compositeQuery": {
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": False,
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"filter": {"expression": "isRoot = 'true'"},
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
],
|
||||
"selectFields": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "resource",
|
||||
}
|
||||
],
|
||||
"having": {"expression": ""},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
assert len(results) == 1
|
||||
|
||||
rows = results[0]["rows"]
|
||||
assert len(rows) == 2
|
||||
|
||||
assert rows[0]["data"]["service.name"] == "topic-service"
|
||||
assert rows[1]["data"]["service.name"] == "http-service"
|
||||
|
||||
# Query values of http.request.method attribute from the autocomplete API
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v3/autocomplete/attribute_values"),
|
||||
timeout=2,
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
params={
|
||||
"aggregateOperator": "noop",
|
||||
"dataSource": "traces",
|
||||
"aggregateAttribute": "",
|
||||
"attributeKey": "http.request.method",
|
||||
"searchText": "",
|
||||
"filterAttributeKeyDataType": "string",
|
||||
"tagType": "tag",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
values = response.json()["data"]["stringAttributeValues"]
|
||||
assert len(values) == 2
|
||||
|
||||
assert set(values) == set(["POST", "PATCH"])
|
||||
|
||||
# Query values of http.request.method attribute from the fields API
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
|
||||
timeout=2,
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
},
|
||||
params={
|
||||
"signal": "traces",
|
||||
"name": "http.request.method",
|
||||
"searchText": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
values = response.json()["data"]["values"]["stringValues"]
|
||||
assert len(values) == 2
|
||||
|
||||
assert set(values) == set(["POST", "PATCH"])
|
||||
Loading…
x
Reference in New Issue
Block a user