diff --git a/frontend/src/container/Login/Login.styles.scss b/frontend/src/container/Login/Login.styles.scss new file mode 100644 index 000000000000..badab22b8ad1 --- /dev/null +++ b/frontend/src/container/Login/Login.styles.scss @@ -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); + } + } +} diff --git a/frontend/src/container/Login/__tests__/Login.test.tsx b/frontend/src/container/Login/__tests__/Login.test.tsx index ace087ae3901..bfa5a1744088 100644 --- a/frontend/src/container/Login/__tests__/Login.test.tsx +++ b/frontend/src/container/Login/__tests__/Login.test.tsx @@ -15,98 +15,104 @@ describe('Login Flow', () => { test('Login form is rendered correctly', async () => { render(); - 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(); - 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(); - 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(); 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(); - 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(); - 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(); }); }); }); diff --git a/frontend/src/container/Login/index.tsx b/frontend/src/container/Login/index.tsx index 1239682b154e..3ee204619869 100644 --- a/frontend/src/container/Login/index.tsx +++ b/frontend/src/container/Login/index.tsx @@ -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(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 => { 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 ); @@ -201,48 +202,61 @@ function Login({ return ( - {t('prompt_on_sso_error')}{' '} - {t('login_with_pwd')}. + Are you trying to resolve SSO configuration issue?{' '} + Login with password. ); }; return ( - +
- {t('login_page_title')} +
+ + Sign in to monitor, trace, and troubleshoot your applications + effortlessly. + +
+ - + {precheckComplete && !sso && ( - + - - {t('forgot_password')} - + +
+ + Forgot password? + +
)} } > - {t('button_initiate_login')} + Next )} {precheckComplete && !sso && ( @@ -265,8 +281,10 @@ function Login({ type="primary" htmlType="submit" data-attr="signup" + className="periscope-btn primary next-btn" + icon={} > - {t('button_login')} + Login )} @@ -274,27 +292,28 @@ function Login({ {!precheckComplete && ssoerror && renderOnSsoError()} {!canSelfRegister && ( - - {t('prompt_no_account')} + + Don't have an account? Contact your admin to send you an invite + link. )} {canSelfRegister && ( - {t('prompt_if_admin')}{' '} + If you are admin,{' '} { history.push(ROUTES.SIGN_UP); }} style={{ fontWeight: 700 }} > - {t('create_an_account')} + Create an account )}
- +
); } diff --git a/frontend/src/index.html.ejs b/frontend/src/index.html.ejs index 5eb5e3c13cef..cf6379ace7c6 100644 --- a/frontend/src/index.html.ejs +++ b/frontend/src/index.html.ejs @@ -14,6 +14,11 @@ + + Open source Observability platform | SigNoz @@ -57,7 +62,6 @@ - <% if (htmlWebpackPlugin.options.templateParameters.preloadFonts) { %> <% htmlWebpackPlugin.options.templateParameters.preloadFonts.forEach(function(font) { %> @@ -76,33 +80,36 @@ - + diff --git a/frontend/src/pages/Login/Login.styles.scss b/frontend/src/pages/Login/Login.styles.scss new file mode 100644 index 000000000000..ba4a591ae4a6 --- /dev/null +++ b/frontend/src/pages/Login/Login.styles.scss @@ -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); + } + } +} diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index 40b6087d2898..8a78de4e2901 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -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 ( - - {versionResult.data?.error || t('something_went_wrong')} - - ); - } - - if ( - versionResult.status === 'loading' || - !(versionResult.data && versionResult.data.payload) - ) { - return ; - } - - const { version } = versionResult.data.payload; - return ( - - - +
+
+
+
+ logo + +
SigNoz
+
+ + +
+
); } diff --git a/frontend/src/pages/SignUp/SignUp.styles.scss b/frontend/src/pages/SignUp/SignUp.styles.scss new file mode 100644 index 000000000000..72ba0288761d --- /dev/null +++ b/frontend/src/pages/SignUp/SignUp.styles.scss @@ -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); + } + } + } +} diff --git a/frontend/src/pages/SignUp/SignUp.tsx b/frontend/src/pages/SignUp/SignUp.tsx index e4dcec3221b9..afb8099e6d7a 100644 --- a/frontend/src/pages/SignUp/SignUp.tsx +++ b/frontend/src/pages/SignUp/SignUp.tsx @@ -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({ @@ -167,7 +165,8 @@ function SignUp({ version }: SignUpProps): JSX.Element { const handleSubmitSSO = async (): Promise => { 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 ( - - +
+
+
+
+ logo + +
SigNoz
+
+ - Create your account -
- +
+ + Create your account to monitor, trace, and troubleshoot your applications + effortlessly. + +
+ +
+ {isNameVisible && ( -
- {' '} +
+ {' '} )} -
- {' '} +
+ {' '}
+ {!precheck.sso && ( -
- {' '} - - - -
- )} - {!precheck.sso && ( -
- {' '} - - - - {confirmPasswordError && ( - - {t('failed_confirm_password')} - - )} - {isPasswordPolicyError && ( - - {isPasswordNotValidMessage} - - )} +
+
+ {' '} + + + +
+ +
+ {' '} + + + +
)} + +
+ {confirmPasswordError && ( + + Passwords don’t match. Please try again + + )} + + {isPasswordPolicyError && ( + + {isPasswordNotValidMessage} + + )} +
+ {isSignUp && ( - - This will create an admin account. If you are not an admin, please ask + + * This will create an admin account. If you are not an admin, please ask your admin for an invite link )} - +
- +
- - +
+
); } -interface SignUpProps { - version: string; -} - export default SignUp; diff --git a/frontend/src/pages/SignUp/index.tsx b/frontend/src/pages/SignUp/index.tsx index 7bd27fbf13da..84ee6aebbd14 100644 --- a/frontend/src/pages/SignUp/index.tsx +++ b/frontend/src/pages/SignUp/index.tsx @@ -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 ( - - {versionResponse.data?.error || t('something_went_wrong')} - - ); - } - - if ( - versionResponse.status === 'loading' || - !(versionResponse.data && versionResponse.data.payload) - ) { - return ; - } - - const { version } = versionResponse.data.payload; - - return ; + return ; } export default SignUp; diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 159620df1f0d..d655e16551f4 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -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); diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index bbdd6d76b0fb..9f6de991b01f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,6 +13,7 @@ pytest_plugins = [ "fixtures.zookeeper", "fixtures.signoz", "fixtures.logs", + "fixtures.traces", ] diff --git a/tests/integration/fixtures/logs.py b/tests/integration/fixtures/logs.py index 1b672cce02a2..479189401dd7 100644 --- a/tests/integration/fixtures/logs.py +++ b/tests/integration/fixtures/logs.py @@ -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 diff --git a/tests/integration/fixtures/traces.py b/tests/integration/fixtures/traces.py new file mode 100644 index 000000000000..4d0f025bd4a2 --- /dev/null +++ b/tests/integration/fixtures/traces.py @@ -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" + ) diff --git a/tests/integration/src/querier/a_logs.py b/tests/integration/src/querier/a_logs.py index 8a0b462481e4..f7ddf9167988 100644 --- a/tests/integration/src/querier/a_logs.py +++ b/tests/integration/src/querier/a_logs.py @@ -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()"}], }, diff --git a/tests/integration/src/querier/b_traces.py b/tests/integration/src/querier/b_traces.py new file mode 100644 index 000000000000..bb586fda065d --- /dev/null +++ b/tests/integration/src/querier/b_traces.py @@ -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"])