Merge branch 'main' into feat/telemetry-meter

This commit is contained in:
Vikrant Gupta 2025-07-31 16:04:24 +05:30 committed by GitHub
commit 73bc95a56a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1703 additions and 257 deletions

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

View File

@ -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();
});
});
});

View File

@ -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&apos;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>
);
}

View File

@ -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>

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

View File

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

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

View File

@ -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 dont 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;

View File

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

View File

@ -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);

View File

@ -13,6 +13,7 @@ pytest_plugins = [
"fixtures.zookeeper",
"fixtures.signoz",
"fixtures.logs",
"fixtures.traces",
]

View File

@ -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

View 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"
)

View File

@ -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()"}],
},

View 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"])