mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-10 23:10:47 +01:00
Compare commits
34 Commits
proto/logi
...
ns/scope
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883e9492d6 | ||
|
|
cd8346a91a | ||
|
|
db5f4b4cd5 | ||
|
|
3bd9b2a96a | ||
|
|
ab3b88966e | ||
|
|
08ebc37109 | ||
|
|
aa5a1c5e62 | ||
|
|
7b34a47ac5 | ||
|
|
b4b2d7bb66 | ||
|
|
e16416475b | ||
|
|
0ea7c1ae6e | ||
|
|
a023c8ed4a | ||
|
|
a73ae62cd1 | ||
|
|
ec6fb58052 | ||
|
|
d3d13eb7ff | ||
|
|
782de2b210 | ||
|
|
d3c38693f3 | ||
|
|
8791df3697 | ||
|
|
eb719c3d0d | ||
|
|
f10435c210 | ||
|
|
f3f1e9cb59 | ||
|
|
d0370ce3ef | ||
|
|
d169761e65 | ||
|
|
87864ef5d4 | ||
|
|
2e0bc8998e | ||
|
|
7e1f4aa50d | ||
|
|
35da39247c | ||
|
|
ceccc47a34 | ||
|
|
23da5e22ec | ||
|
|
4c1b479149 | ||
|
|
f72204a8b2 | ||
|
|
deb3f385fa | ||
|
|
77ce5f86b1 | ||
|
|
ff211de441 |
@@ -12198,9 +12198,9 @@ paths:
|
||||
- dashboard
|
||||
/api/v1/resetPassword:
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint resets the password by token
|
||||
operationId: ResetPassword
|
||||
operationId: ResetPasswordDeprecated
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -15567,6 +15567,41 @@ paths:
|
||||
summary: Forgot password
|
||||
tags:
|
||||
- users
|
||||
/api/v2/factor_password/reset:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint resets the password using a single use reset password
|
||||
token
|
||||
operationId: ResetPassword
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesPostableResetPassword'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Reset password
|
||||
tags:
|
||||
- users
|
||||
/api/v2/features:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -255,9 +255,10 @@ export const useCreateInvite = <
|
||||
};
|
||||
/**
|
||||
* This endpoint resets the password by token
|
||||
* @deprecated
|
||||
* @summary Reset password
|
||||
*/
|
||||
export const resetPassword = (
|
||||
export const resetPasswordDeprecated = (
|
||||
typesPostableResetPasswordDTO?: BodyType<TypesPostableResetPasswordDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
@@ -270,23 +271,23 @@ export const resetPassword = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getResetPasswordMutationOptions = <
|
||||
export const getResetPasswordDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['resetPassword'];
|
||||
const mutationKey = ['resetPasswordDeprecated'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -296,45 +297,47 @@ export const getResetPasswordMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return resetPassword(data);
|
||||
return resetPasswordDeprecated(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ResetPasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof resetPassword>>
|
||||
export type ResetPasswordDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>
|
||||
>;
|
||||
export type ResetPasswordMutationBody =
|
||||
export type ResetPasswordDeprecatedMutationBody =
|
||||
| BodyType<TypesPostableResetPasswordDTO>
|
||||
| undefined;
|
||||
export type ResetPasswordMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
export type ResetPasswordDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Reset password
|
||||
*/
|
||||
export const useResetPassword = <
|
||||
export const useResetPasswordDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
Awaited<ReturnType<typeof resetPasswordDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getResetPasswordMutationOptions(options));
|
||||
return useMutation(getResetPasswordDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all users
|
||||
@@ -593,6 +596,89 @@ export const useForgotPassword = <
|
||||
> => {
|
||||
return useMutation(getForgotPasswordMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint resets the password using a single use reset password token
|
||||
* @summary Reset password
|
||||
*/
|
||||
export const resetPassword = (
|
||||
typesPostableResetPasswordDTO?: BodyType<TypesPostableResetPasswordDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/factor_password/reset`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableResetPasswordDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getResetPasswordMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['resetPassword'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return resetPassword(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ResetPasswordMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof resetPassword>>
|
||||
>;
|
||||
export type ResetPasswordMutationBody =
|
||||
| BodyType<TypesPostableResetPasswordDTO>
|
||||
| undefined;
|
||||
export type ResetPasswordMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Reset password
|
||||
*/
|
||||
export const useResetPassword = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof resetPassword>>,
|
||||
TError,
|
||||
{ data?: BodyType<TypesPostableResetPasswordDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getResetPasswordMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint verifies whether a reset password token exists and is not expired
|
||||
* @summary Verify a reset password token
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
|
||||
.dropdownContent {
|
||||
--dropdown-menu-content-z-index: 2100;
|
||||
}
|
||||
|
||||
.leftSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l1-foreground);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.arrows {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { aggregateAttributesResourcesToString } from 'container/LogDetailedView/utils';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Compass,
|
||||
Copy,
|
||||
Ellipsis,
|
||||
Link,
|
||||
} from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { MouseEvent, MouseEventHandler } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import styles from './LogDetailsHeader.module.scss';
|
||||
|
||||
const TOOLTIP_CONTENT_PROPS = { className: styles.tooltipContent };
|
||||
|
||||
interface LogDetailsHeaderProps {
|
||||
log: ILog;
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
isPrevDisabled: boolean;
|
||||
isNextDisabled: boolean;
|
||||
showOpenInExplorer?: boolean;
|
||||
onOpenInExplorer?: MouseEventHandler;
|
||||
}
|
||||
|
||||
function LogDetailsHeader({
|
||||
log,
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
isPrevDisabled,
|
||||
isNextDisabled,
|
||||
showOpenInExplorer = false,
|
||||
onOpenInExplorer,
|
||||
}: LogDetailsHeaderProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { onLogCopy } = useCopyLogLink(log?.id);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleCopyLog = (): void => {
|
||||
copyToClipboard(aggregateAttributesResourcesToString(log));
|
||||
toast.success('Copied to clipboard', { position: 'bottom-right' });
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'copy-log',
|
||||
label: 'Copy log',
|
||||
icon: <Copy size={14} />,
|
||||
onClick: handleCopyLog,
|
||||
},
|
||||
{
|
||||
key: 'copy-link',
|
||||
label: 'Copy link to log',
|
||||
icon: <Link size={14} />,
|
||||
onClick: (): void => onLogCopy(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.header} data-log-detail-ignore="true">
|
||||
<div className={styles.leftSection}>
|
||||
<Divider type="vertical" className={styles.divider} />
|
||||
<Typography.Text
|
||||
className={styles.timestamp}
|
||||
data-testid="log-details-header-timestamp"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
log.date ?? log.timestamp,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{showOpenInExplorer && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<Compass size={16} />}
|
||||
onClick={onOpenInExplorer}
|
||||
>
|
||||
Open in Explorer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
align="end"
|
||||
className={styles.dropdownContent}
|
||||
onClick={(e: MouseEvent): void => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
prefix={<Ellipsis size={16} />}
|
||||
data-testid="log-details-header-menu"
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
<div className={styles.arrows}>
|
||||
<TooltipSimple
|
||||
title="Move to previous log"
|
||||
side="top"
|
||||
open={isPrevDisabled ? false : undefined}
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronUp size={14} />}
|
||||
disabled={isPrevDisabled}
|
||||
onClick={onNavigatePrev}
|
||||
data-testid="log-details-header-prev"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
title="Move to next log"
|
||||
side="top"
|
||||
open={isNextDisabled ? false : undefined}
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronDown size={14} />}
|
||||
disabled={isNextDisabled}
|
||||
onClick={onNavigateNext}
|
||||
data-testid="log-details-header-next"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LogDetailsHeader.defaultProps = {
|
||||
showOpenInExplorer: false,
|
||||
onOpenInExplorer: undefined,
|
||||
};
|
||||
|
||||
export default LogDetailsHeader;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
interface UseLogNavigationParams {
|
||||
logs?: ILog[];
|
||||
activeLogId: string;
|
||||
onNavigateLog?: (log: ILog) => void;
|
||||
onScrollToLog?: (id: string) => void;
|
||||
}
|
||||
|
||||
interface UseLogNavigationReturn {
|
||||
goToPrev: () => void;
|
||||
goToNext: () => void;
|
||||
isPrevDisabled: boolean;
|
||||
isNextDisabled: boolean;
|
||||
}
|
||||
|
||||
export function useLogNavigation({
|
||||
logs,
|
||||
activeLogId,
|
||||
onNavigateLog,
|
||||
onScrollToLog,
|
||||
}: UseLogNavigationParams): UseLogNavigationReturn {
|
||||
const currentIndex = useMemo(
|
||||
() => logs?.findIndex((l) => l.id === activeLogId) ?? -1,
|
||||
[logs, activeLogId],
|
||||
);
|
||||
|
||||
const canNavigate = !!logs?.length && !!onNavigateLog && currentIndex !== -1;
|
||||
const isPrevDisabled = !canNavigate || currentIndex <= 0;
|
||||
const isNextDisabled = !canNavigate || currentIndex >= (logs?.length ?? 0) - 1;
|
||||
|
||||
const goToPrev = useCallback((): void => {
|
||||
if (isPrevDisabled || !logs) {
|
||||
return;
|
||||
}
|
||||
const prev = logs[currentIndex - 1];
|
||||
onNavigateLog?.(prev);
|
||||
onScrollToLog?.(prev.id);
|
||||
}, [isPrevDisabled, logs, currentIndex, onNavigateLog, onScrollToLog]);
|
||||
|
||||
const goToNext = useCallback((): void => {
|
||||
if (isNextDisabled || !logs) {
|
||||
return;
|
||||
}
|
||||
const next = logs[currentIndex + 1];
|
||||
onNavigateLog?.(next);
|
||||
onScrollToLog?.(next.id);
|
||||
}, [isNextDisabled, logs, currentIndex, onNavigateLog, onScrollToLog]);
|
||||
|
||||
return { goToPrev, goToNext, isPrevDisabled, isNextDisabled };
|
||||
}
|
||||
161
frontend/src/components/LogDetail/__tests__/LogDetail.test.tsx
Normal file
161
frontend/src/components/LogDetail/__tests__/LogDetail.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import LogDetail from '..';
|
||||
import { VIEW_TYPES } from '../constants';
|
||||
import { LogDetailProps } from '../LogDetail.interfaces';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// The flag to be removed later
|
||||
jest.mock('../constants', () => ({
|
||||
...jest.requireActual('../constants'),
|
||||
isLogDetailsV2: true,
|
||||
}));
|
||||
|
||||
const mockLog: ILog = {
|
||||
id: 'log-1',
|
||||
timestamp: '2024-01-15T09:45:30Z',
|
||||
date: '2024-01-15T09:45:30Z',
|
||||
body: 'test log body',
|
||||
severityText: 'INFO',
|
||||
severityNumber: 9,
|
||||
traceFlags: 0,
|
||||
traceId: '',
|
||||
spanID: '',
|
||||
attributesString: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributes_string: {},
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
};
|
||||
|
||||
const makeLog = (id: string): ILog => ({ ...mockLog, id });
|
||||
|
||||
function renderDrawer(props: Partial<LogDetailProps> = {}): void {
|
||||
render(
|
||||
<LogDetail
|
||||
log={mockLog}
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
onAddToQuery={jest.fn()}
|
||||
onClickActionItem={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders the revamped header when a log is provided', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.getByTestId('log-details-header-menu')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('log-details-header-prev')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('log-details-header-next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the log timestamp formatted (DASH_DATETIME) in the header', () => {
|
||||
// Pin the timezone to UTC so the formatted output is deterministic across
|
||||
// machines/CI (Jest doesn't fix a TZ).
|
||||
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
|
||||
|
||||
renderDrawer();
|
||||
|
||||
// mockLog date is 2024-01-15T09:45:30Z → DASH_DATETIME in UTC.
|
||||
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
|
||||
'Jan 15, 2024 ⎯ 09:45:30',
|
||||
);
|
||||
});
|
||||
|
||||
it('copies the log link from the ⋯ menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByTestId('log-details-header-menu'));
|
||||
await user.click(await screen.findByText('Copy link to log'));
|
||||
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies the log from the ⋯ menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByTestId('log-details-header-menu'));
|
||||
await user.click(await screen.findByText('Copy log'));
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('Copied to clipboard', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Open in Explorer" when a handleOpenInExplorer handler is provided', () => {
|
||||
renderDrawer({ handleOpenInExplorer: jest.fn() });
|
||||
|
||||
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "Open in Explorer" when no handleOpenInExplorer handler is provided', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.queryByText('Open in Explorer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the next / previous log with the Down / Up arrow keys', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const logs = [makeLog('log-0'), makeLog('log-1'), makeLog('log-2')];
|
||||
const onNavigateLog = jest.fn();
|
||||
const onScrollToLog = jest.fn();
|
||||
|
||||
// Active log is the middle one so both directions are available.
|
||||
renderDrawer({ log: logs[1], logs, onNavigateLog, onScrollToLog });
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[2]);
|
||||
expect(onScrollToLog).toHaveBeenLastCalledWith('log-2');
|
||||
|
||||
await user.keyboard('{ArrowUp}');
|
||||
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[0]);
|
||||
expect(onScrollToLog).toHaveBeenLastCalledWith('log-0');
|
||||
});
|
||||
|
||||
it('does not navigate past the first log on ArrowUp', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const logs = [makeLog('log-0'), makeLog('log-1')];
|
||||
const onNavigateLog = jest.fn();
|
||||
|
||||
renderDrawer({ log: logs[0], logs, onNavigateLog });
|
||||
|
||||
await user.keyboard('{ArrowUp}');
|
||||
expect(onNavigateLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates via the header up / down buttons and disables them at boundaries', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const logs = [makeLog('log-0'), makeLog('log-1')];
|
||||
const onNavigateLog = jest.fn();
|
||||
|
||||
// Active log is the first one.
|
||||
renderDrawer({ log: logs[0], logs, onNavigateLog });
|
||||
|
||||
expect(screen.getByTestId('log-details-header-prev')).toBeDisabled();
|
||||
expect(screen.getByTestId('log-details-header-next')).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByTestId('log-details-header-next'));
|
||||
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[1]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,10 @@
|
||||
import getLocalStorage from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
// Temp feature flag before actual roll-out
|
||||
export const isLogDetailsV2 =
|
||||
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
OVERVIEW: 'OVERVIEW',
|
||||
JSON: 'JSON',
|
||||
|
||||
@@ -8,7 +8,9 @@ import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import LogStateIndicator, {
|
||||
LogType,
|
||||
} from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -48,8 +51,10 @@ import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -96,7 +101,8 @@ function LogDetailInner({
|
||||
target.closest('[data-log-detail-ignore="true"]') ||
|
||||
target.closest('.cm-tooltip-autocomplete') ||
|
||||
target.closest('.drawer-popover') ||
|
||||
target.closest('.query-status-popover')
|
||||
target.closest('.query-status-popover') ||
|
||||
target.closest('[data-radix-popper-content-wrapper]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -112,49 +118,30 @@ function LogDetailInner({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Keyboard navigation - handle up/down arrow keys
|
||||
// Only listen when in OVERVIEW tab
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const { goToPrev, goToNext, isPrevDisabled, isNextDisabled } =
|
||||
useLogNavigation({
|
||||
logs,
|
||||
activeLogId: log.id,
|
||||
onNavigateLog,
|
||||
onScrollToLog,
|
||||
});
|
||||
|
||||
// Keyboard navigation - handle up/down arrow keys. Only listen in the OVERVIEW
|
||||
// tab so we don't hijack arrow keys from the JSON editor / context view.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!logs ||
|
||||
!onNavigateLog ||
|
||||
logs.length === 0 ||
|
||||
selectedView !== VIEW_TYPES.OVERVIEW
|
||||
) {
|
||||
return;
|
||||
if (selectedView !== VIEW_TYPES.OVERVIEW) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const currentIndex = logs.findIndex((l) => l.id === log.id);
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Navigate to previous log
|
||||
if (currentIndex > 0) {
|
||||
const prevLog = logs[currentIndex - 1];
|
||||
onNavigateLog(prevLog);
|
||||
// Trigger scroll to the log element
|
||||
if (onScrollToLog) {
|
||||
onScrollToLog(prevLog.id);
|
||||
}
|
||||
}
|
||||
goToPrev();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Navigate to next log
|
||||
if (currentIndex < logs.length - 1) {
|
||||
const nextLog = logs[currentIndex + 1];
|
||||
onNavigateLog(nextLog);
|
||||
// Trigger scroll to the log element
|
||||
if (onScrollToLog) {
|
||||
onScrollToLog(nextLog.id);
|
||||
}
|
||||
}
|
||||
goToNext();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,7 +149,7 @@ function LogDetailInner({
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [log.id, logs, onNavigateLog, onScrollToLog, selectedView]);
|
||||
}, [selectedView, goToPrev, goToNext]);
|
||||
|
||||
const listQuery = useMemo(() => {
|
||||
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) {
|
||||
@@ -303,33 +290,6 @@ function LogDetailInner({
|
||||
};
|
||||
|
||||
const logType = log?.attributes_string?.log_level || LogType.INFO;
|
||||
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
|
||||
const isPrevDisabled =
|
||||
!logs || !onNavigateLog || logs.length === 0 || currentLogIndex <= 0;
|
||||
const isNextDisabled =
|
||||
!logs ||
|
||||
!onNavigateLog ||
|
||||
logs.length === 0 ||
|
||||
currentLogIndex === logs.length - 1;
|
||||
|
||||
type HandleNavigateLogParams = {
|
||||
direction: 'next' | 'previous';
|
||||
};
|
||||
|
||||
const handleNavigateLog = ({ direction }: HandleNavigateLogParams): void => {
|
||||
if (!logs || !onNavigateLog || currentLogIndex === -1) {
|
||||
return;
|
||||
}
|
||||
if (direction === 'previous' && !isPrevDisabled) {
|
||||
const prevLog = logs[currentLogIndex - 1];
|
||||
onNavigateLog(prevLog);
|
||||
onScrollToLog?.(prevLog.id);
|
||||
} else if (direction === 'next' && !isNextDisabled) {
|
||||
const nextLog = logs[currentLogIndex + 1];
|
||||
onNavigateLog(nextLog);
|
||||
onScrollToLog?.(nextLog.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -338,57 +298,69 @@ function LogDetailInner({
|
||||
maskClosable={false}
|
||||
getContainer={getContainer}
|
||||
title={
|
||||
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__title-left">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
|
||||
<Typography.Text className="title">Log details</Typography.Text>
|
||||
</div>
|
||||
<div className="log-detail-drawer__title-right">
|
||||
<div className="log-arrows">
|
||||
<Tooltip
|
||||
title={isPrevDisabled ? '' : 'Move to previous log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronUp size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-up"
|
||||
disabled={isPrevDisabled}
|
||||
onClick={(): void => handleNavigateLog({ direction: 'previous' })}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={isNextDisabled ? '' : 'Move to next log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronDown size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-down"
|
||||
disabled={isNextDisabled}
|
||||
onClick={(): void => handleNavigateLog({ direction: 'next' })}
|
||||
/>
|
||||
</Tooltip>
|
||||
isLogDetailsV2 ? (
|
||||
<LogDetailsHeader
|
||||
log={log}
|
||||
onNavigatePrev={goToPrev}
|
||||
onNavigateNext={goToNext}
|
||||
isPrevDisabled={isPrevDisabled}
|
||||
isNextDisabled={isNextDisabled}
|
||||
showOpenInExplorer={!!handleOpenInExplorer}
|
||||
onOpenInExplorer={handleOpenInExplorer}
|
||||
/>
|
||||
) : (
|
||||
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__title-left">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
|
||||
<Typography.Text className="title">Log details</Typography.Text>
|
||||
</div>
|
||||
{handleOpenInExplorer && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<Compass size={16} />}
|
||||
className="open-in-explorer-btn"
|
||||
onClick={handleOpenInExplorer}
|
||||
<div className="log-detail-drawer__title-right">
|
||||
<div className="log-arrows">
|
||||
<Tooltip
|
||||
title={isPrevDisabled ? '' : 'Move to previous log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
Open in Explorer
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronUp size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-up"
|
||||
disabled={isPrevDisabled}
|
||||
onClick={goToPrev}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={isNextDisabled ? '' : 'Move to next log'}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronDown size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-down"
|
||||
disabled={isNextDisabled}
|
||||
onClick={goToNext}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{handleOpenInExplorer && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<Compass size={16} />}
|
||||
className="open-in-explorer-btn"
|
||||
onClick={handleOpenInExplorer}
|
||||
>
|
||||
Open in Explorer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
placement="right"
|
||||
onClose={drawerCloseHandler}
|
||||
@@ -407,7 +379,15 @@ function LogDetailInner({
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
{isLogDetailsV2 ? (
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
) : (
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
)}
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
placement="left"
|
||||
@@ -483,22 +463,25 @@ function LogDetailInner({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
|
||||
placement="topLeft"
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Copy size={12} />}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* V2 moves copy actions into the header ⋯ menu */}
|
||||
{!isLogDetailsV2 && (
|
||||
<Tooltip
|
||||
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
|
||||
placement="topLeft"
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Copy size={12} />}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
|
||||
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler } from 'react';
|
||||
import { MouseEvent } from 'react';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -11,7 +11,7 @@ export type UseCopyLogLink = {
|
||||
isHighlighted: boolean;
|
||||
isLogsExplorerPage: boolean;
|
||||
activeLogId: string | null;
|
||||
onLogCopy: MouseEventHandler<HTMLElement>;
|
||||
onLogCopy: (event?: MouseEvent<HTMLElement>) => void;
|
||||
onClearActiveLog: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
@@ -46,14 +40,14 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const onLogCopy: MouseEventHandler<HTMLElement> = useCallback(
|
||||
(event) => {
|
||||
const onLogCopy = useCallback(
|
||||
(event?: MouseEvent<HTMLElement>): void => {
|
||||
if (!logId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
urlQuery.delete(QueryParams.activeLogId);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
@@ -66,7 +60,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
|
||||
|
||||
setCopy(link);
|
||||
|
||||
toast.success('Copied to clipboard', { position: 'top-right' });
|
||||
toast.success('Copied to clipboard', { position: 'bottom-right' });
|
||||
},
|
||||
[logId, urlQuery, minTime, maxTime, pathname, setCopy],
|
||||
);
|
||||
|
||||
@@ -249,7 +249,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/resetPassword", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
|
||||
ID: "ResetPassword",
|
||||
ID: "ResetPasswordDeprecated",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Reset password",
|
||||
Description: "This endpoint resets the password by token",
|
||||
@@ -259,7 +259,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
@@ -299,6 +299,23 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/factor_password/reset", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
|
||||
ID: "ResetPassword",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Reset password",
|
||||
Description: "This endpoint resets the password using a single use reset password token",
|
||||
Request: new(types.PostableResetPassword),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
|
||||
ID: "GetRolesByUserID",
|
||||
Tags: []string{"users"},
|
||||
|
||||
@@ -391,7 +391,7 @@ func (handler *handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
|
||||
req := new(types.PostableResetPassword)
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,6 +56,17 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
|
||||
// https://github.com/SigNoz/signoz/issues/11374
|
||||
if key.FieldContext == telemetrytypes.FieldContextScope {
|
||||
keys = append(keys, &telemetrytypes.FieldKeySelector{
|
||||
Name: key.FieldContext.StringValue() + "." + key.Name,
|
||||
Signal: key.Signal,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,23 @@ func TestQueryToKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `scope.version = '1.0.0'`,
|
||||
expectedKeys: []telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
{
|
||||
Name: "scope.version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -373,6 +373,94 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.name filter and group by",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.name = 'opentelemetry-io'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`scope.name`) GLOBAL IN (SELECT `scope.name` FROM __limit_cte) GROUP BY ts, `scope.name`",
|
||||
Args: []any{"opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.version filter with scope.name group by",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`scope.name`) GLOBAL IN (SELECT `scope.name` FROM __limit_cte) GROUP BY ts, `scope.name`",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.version filter only (no scope field in group by)",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
@@ -799,6 +887,52 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "List query with scope filter only (no scope in select or group by)",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.version": {
|
||||
{
|
||||
Name: "scope.version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression test: scope.version in selectFields with no metadata (isColumn=true filters it out)
|
||||
// must still produce scope.version::String, not scope.attributes.version::String
|
||||
name: "scope.version in selectFields only, no metadata (intrinsic field fallback)",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "scope.version", FieldContext: telemetrytypes.FieldContextUnspecified},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, scope.version::String AS `scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
`CASE
|
||||
// WHEN tagType = 'spanfield' THEN 1
|
||||
WHEN tagType = 'resource' THEN 2
|
||||
// WHEN tagType = 'scope' THEN 3
|
||||
WHEN tagType = 'scope' THEN 3
|
||||
WHEN tagType = 'tag' THEN 4
|
||||
ELSE 5
|
||||
END as priority`,
|
||||
|
||||
@@ -121,6 +121,20 @@ var (
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.name": {
|
||||
Name: "scope.name",
|
||||
Description: "Instrumentation scope name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.version": {
|
||||
Name: "scope.version",
|
||||
Description: "Instrumentation scope version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
"traceID": {
|
||||
|
||||
@@ -52,6 +52,7 @@ var (
|
||||
ValueType: schema.ColumnTypeString,
|
||||
}},
|
||||
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
|
||||
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
|
||||
|
||||
"events": {Name: "events", Type: schema.ArrayColumnType{
|
||||
ElementType: schema.ColumnTypeString,
|
||||
@@ -176,7 +177,7 @@ func (m *fieldMapper) getColumn(
|
||||
case telemetrytypes.FieldContextResource:
|
||||
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
|
||||
case telemetrytypes.FieldContextScope:
|
||||
return []*schema.Column{}, qbtypes.ErrColumnNotFound
|
||||
return []*schema.Column{indexV3Columns["scope"]}, nil
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
switch key.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeString:
|
||||
@@ -287,14 +288,24 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
|
||||
switch column.Type.GetType() {
|
||||
case schema.ColumnTypeEnumJSON:
|
||||
// json is only supported for resource context as of now
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case telemetrytypes.FieldContextScope:
|
||||
switch key.Name {
|
||||
case "scope.name", "scope.version":
|
||||
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s IS NOT NULL", key.Name))
|
||||
default:
|
||||
exprs = append(exprs, fmt.Sprintf("%s.attributes.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.`%s` IS NOT NULL", columnName, key.Name))
|
||||
}
|
||||
default:
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
|
||||
@@ -83,6 +83,33 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - scope.name",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.name::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - scope.version",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.version::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - custom attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "custom.attr",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.attributes.`custom.attr`::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
|
||||
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",
|
||||
|
||||
@@ -113,6 +113,20 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
},
|
||||
"scope.name": {
|
||||
{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"scope.version": {
|
||||
{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, keys := range keysMap {
|
||||
for _, key := range keys {
|
||||
|
||||
@@ -24,6 +24,8 @@ pytest_plugins = [
|
||||
"fixtures.browser",
|
||||
"fixtures.keycloak",
|
||||
"fixtures.idp",
|
||||
"fixtures.googleidp",
|
||||
"fixtures.tls",
|
||||
"fixtures.notification_channel",
|
||||
"fixtures.maildev",
|
||||
"fixtures.alerts",
|
||||
|
||||
231
tests/fixtures/googleidp.py
vendored
Normal file
231
tests/fixtures/googleidp.py
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
import functools
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
import pytest
|
||||
import requests
|
||||
from jwcrypto import jwk, jwt
|
||||
from testcontainers.core.container import Network
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
from wiremock.testing.testcontainer import WireMockContainer
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.tls import CA_ID_LABEL, KEYSTORE_PASSWORD, ca_id, issue_server_keystore
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# The google callback authn hardcodes Google's issuer, so the mock must be
|
||||
# reachable as accounts.google.com over TLS from the signoz container: the
|
||||
# wiremock container joins the network under that alias and serves HTTPS on 443
|
||||
# with a certificate issued by the integration CA that signoz trusts.
|
||||
ISSUER = "https://accounts.google.com"
|
||||
ISSUER_HOST = "accounts.google.com"
|
||||
|
||||
GOOGLE_DOMAIN = "google.integration.test"
|
||||
|
||||
|
||||
# One signing key for the whole session: the token and JWKS stubs are always
|
||||
# installed together, so per-call keys would only add RSA keygen latency.
|
||||
@functools.cache
|
||||
def signing_key() -> jwk.JWK:
|
||||
return jwk.JWK.generate(kty="RSA", size=2048, kid="googleidp-integration", use="sig", alg="RS256")
|
||||
|
||||
|
||||
def perform_google_login(
|
||||
signoz: types.SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
get_session_context: Callable[[str], dict],
|
||||
email: str,
|
||||
) -> str:
|
||||
"""Drive the google login flow for email and return the final redirect URL.
|
||||
|
||||
The authorize URL points at https://accounts.google.com (resolvable only
|
||||
inside the docker network), so it is rewritten to the mock's host-mapped
|
||||
port, mirroring how the oidc suite rewrites keycloak URLs.
|
||||
"""
|
||||
session_context = get_session_context(email)
|
||||
|
||||
assert len(session_context["orgs"]) == 1
|
||||
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
|
||||
|
||||
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
|
||||
assert url.startswith(f"{ISSUER}/")
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
authorize_url = googleidp.host_configs["8080"].get(f"{parsed_url.path}?{parsed_url.query}")
|
||||
|
||||
response = requests.get(authorize_url, allow_redirects=False, timeout=5)
|
||||
assert response.status_code == HTTPStatus.FOUND
|
||||
|
||||
callback_url = response.headers["Location"]
|
||||
assert "/api/v1/complete/google" in callback_url
|
||||
|
||||
response = requests.get(callback_url, allow_redirects=False, timeout=30)
|
||||
assert response.status_code == HTTPStatus.SEE_OTHER
|
||||
|
||||
return response.headers["Location"]
|
||||
|
||||
|
||||
def get_google_domain(signoz: types.SigNoz, admin_token: str) -> dict:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
return next(
|
||||
(domain for domain in response.json()["data"] if domain["name"] == GOOGLE_DOMAIN),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def google_oidc_mappings(email: str, name: str, hd: str, audience: str, email_verified: bool = True) -> list[Mapping]:
|
||||
"""Wiremock mappings for one Google OIDC login: discovery, an auto-approving
|
||||
authorize redirect, a token response with an RS256 id_token for the given
|
||||
identity, and the JWKS the signoz container verifies it against."""
|
||||
now = int(time.time())
|
||||
token = jwt.JWT(
|
||||
header={"alg": "RS256", "kid": signing_key()["kid"], "typ": "JWT"},
|
||||
claims={
|
||||
"iss": ISSUER,
|
||||
"aud": audience,
|
||||
"sub": f"google-oauth2|{email}",
|
||||
"email": email,
|
||||
"email_verified": email_verified,
|
||||
"name": name,
|
||||
"hd": hd,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
},
|
||||
)
|
||||
token.make_signed_token(signing_key())
|
||||
id_token = token.serialize()
|
||||
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path="/.well-known/openid-configuration"),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": f"{ISSUER}/o/oauth2/v2/auth",
|
||||
"token_endpoint": f"{ISSUER}/token",
|
||||
"jwks_uri": f"{ISSUER}/jwks",
|
||||
"response_types_supported": ["code"],
|
||||
"subject_types_supported": ["public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"scopes_supported": ["openid", "email", "profile"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
|
||||
},
|
||||
),
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path="/o/oauth2/v2/auth"),
|
||||
response=MappingResponse(
|
||||
status=302,
|
||||
headers={
|
||||
# Triple-stache: redirect_uri and state are URLs; handlebars
|
||||
# would otherwise HTML-escape their special characters.
|
||||
# request.query values arrive URL-decoded, so the state is
|
||||
# re-encoded into the redirect exactly as google does.
|
||||
"Location": "{{{request.query.redirect_uri}}}?code=integration-test-code&state={{{urlEncode request.query.state}}}",
|
||||
},
|
||||
transformers=["response-template"],
|
||||
),
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path="/token"),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={
|
||||
"access_token": "integration-test-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"id_token": id_token,
|
||||
},
|
||||
),
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path="/jwks"),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={"keys": [signing_key().export_public(as_dict=True)]},
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="googleidp", scope="package")
|
||||
def googleidp( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
tls: types.TLS,
|
||||
tmpfs: Callable[[str], Path],
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerDocker:
|
||||
"""Wiremock impersonating Google's OIDC provider. Stubs are installed per
|
||||
test via make_http_mocks with google_oidc_mappings; port 8080 serves the
|
||||
admin API and the authorize redirect to the test process."""
|
||||
|
||||
def create() -> types.TestContainerDocker:
|
||||
keystore_path = issue_server_keystore(tls, tmpfs("googleidp-certs"), ISSUER_HOST)
|
||||
|
||||
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
|
||||
container.with_volume_mapping(str(keystore_path.parent), "/certs", "ro")
|
||||
container.with_network(network)
|
||||
container.with_network_aliases(ISSUER_HOST)
|
||||
container.with_kwargs(labels={CA_ID_LABEL: ca_id(tls)})
|
||||
|
||||
try:
|
||||
container.start(f"--port 8080 --https-port 443 --https-keystore /certs/keystore.p12 --keystore-type PKCS12 --keystore-password {KEYSTORE_PASSWORD} --local-response-templating")
|
||||
except Exception:
|
||||
# Ryuk is disabled: a started-but-unready container would survive
|
||||
# and keep squatting on the accounts.google.com alias, poisoning
|
||||
# DNS for any replacement on the shared network.
|
||||
container.stop()
|
||||
raise
|
||||
|
||||
return types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
host_configs={
|
||||
"8080": types.TestContainerUrlConfig("http", container.get_container_host_ip(), container.get_exposed_port(8080)),
|
||||
},
|
||||
container_configs={
|
||||
"443": types.TestContainerUrlConfig("https", ISSUER_HOST, 443),
|
||||
},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker) -> None:
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.containers.get(container_id=container.id).stop()
|
||||
client.containers.get(container_id=container.id).remove(v=True)
|
||||
except docker.errors.NotFound:
|
||||
logger.info("googleidp container %s already gone", container.id)
|
||||
|
||||
def restore(cache: dict) -> types.TestContainerDocker:
|
||||
return types.TestContainerDocker.from_cache(cache)
|
||||
|
||||
def stale(container: types.TestContainerDocker) -> bool:
|
||||
client = docker.from_env()
|
||||
try:
|
||||
labels = client.containers.get(container_id=container.id).attrs["Config"]["Labels"]
|
||||
except docker.errors.NotFound:
|
||||
return True
|
||||
return labels.get(CA_ID_LABEL) != ca_id(tls)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"googleidp",
|
||||
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
stale=stale,
|
||||
)
|
||||
24
tests/fixtures/querier.py
vendored
24
tests/fixtures/querier.py
vendored
@@ -999,6 +999,8 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"trace_id": "corrupt_data",
|
||||
"scope_name": "corrupt_data",
|
||||
"scope.scope.name": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"net.transport": "IP.TCP",
|
||||
@@ -1007,7 +1009,10 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"http.request.method": "POST",
|
||||
"http.response.status_code": "200",
|
||||
"timestamp": "corrupt_data",
|
||||
"version": "1.0.0",
|
||||
"scope.scope.version": "1.0.0",
|
||||
},
|
||||
scope={"name": "io.signoz.http.server", "version": "2.0.0"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3.5),
|
||||
@@ -1027,12 +1032,24 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"timestamp": "corrupt_data",
|
||||
"scope.attributes.name": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"db.name": "integration",
|
||||
"db.operation": "SELECT",
|
||||
"db.statement": "SELECT * FROM integration",
|
||||
"trace_d": "corrupt_data",
|
||||
"scope.attributes.version": "corrupt_data",
|
||||
},
|
||||
scope={
|
||||
"name": "io.opentelemetry.contrib.http",
|
||||
"version": "1.0.0",
|
||||
"attributes": {
|
||||
"telemetry.sdk.language": "cpp",
|
||||
"name": "not-the-real-name",
|
||||
"version": "not-the-real-version",
|
||||
"attributes": "literally-a-key-named-attributes",
|
||||
},
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
@@ -1053,12 +1070,15 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"duration_nano": "corrupt_data",
|
||||
"scope.scope.attributes.version": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"http.request.method": "PATCH",
|
||||
"http.status_code": "404",
|
||||
"id": "1",
|
||||
"scope.scope.version": "corrupt_data",
|
||||
},
|
||||
scope={"name": "io.signoz.http.client", "version": "2.0.0"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
@@ -1077,6 +1097,7 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"host.name": "linux-001",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "001",
|
||||
"scope.scope.version": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"message.type": "SENT",
|
||||
@@ -1084,7 +1105,10 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"messaging.message.id": "001",
|
||||
"duration_nano": "corrupt_data",
|
||||
"id": 1,
|
||||
"scope": "corrupt_data",
|
||||
"scope.attributes.name": "corrupt_data",
|
||||
},
|
||||
scope={"name": "io.signoz.messaging", "version": "3.0.0"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
24
tests/fixtures/reuse.py
vendored
24
tests/fixtures/reuse.py
vendored
@@ -39,6 +39,7 @@ def wrap( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
delete: Callable[[T], None],
|
||||
restore: Callable[[dict], T],
|
||||
rebuild: bool = False,
|
||||
stale: Callable[[T], bool] | None = None,
|
||||
) -> T:
|
||||
"""
|
||||
Wraps a resource creation and cleanup process with reuse and teardown options.
|
||||
@@ -50,6 +51,7 @@ def wrap( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
- delete: function to delete the resource
|
||||
- restore: function to restore resource from cache
|
||||
- rebuild: under --reuse, delete the cached resource and recreate it instead of restoring it
|
||||
- stale: under --reuse, decides whether a restored resource is still usable; a stale resource is deleted and recreated
|
||||
"""
|
||||
resource = empty()
|
||||
|
||||
@@ -62,8 +64,14 @@ def wrap( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
delete(restore(existing_resource))
|
||||
pytestconfig.cache.set(key, None)
|
||||
else:
|
||||
logger.info("Reusing existing %s(%s)", key, existing_resource)
|
||||
return restore(existing_resource)
|
||||
restored = restore(existing_resource)
|
||||
if stale is not None and stale(restored):
|
||||
logger.info("Recreating stale %s(%s)", key, existing_resource)
|
||||
delete(restored)
|
||||
pytestconfig.cache.set(key, None)
|
||||
else:
|
||||
logger.info("Reusing existing %s(%s)", key, existing_resource)
|
||||
return restored
|
||||
|
||||
if not teardown(request):
|
||||
resource = create()
|
||||
@@ -88,15 +96,23 @@ def wrap( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
return
|
||||
|
||||
resource = restore(existing_resource)
|
||||
logger.info(
|
||||
"Removing %s",
|
||||
resource.__log__() if hasattr(resource, "__log__") else resource,
|
||||
)
|
||||
delete(resource)
|
||||
pytestconfig.cache.set(key, None)
|
||||
return
|
||||
|
||||
# A run without --reuse owns only what it created this session: the
|
||||
# cache key (and whatever a parked --reuse stack has under it) is left
|
||||
# untouched.
|
||||
logger.info(
|
||||
"Removing %s",
|
||||
resource.__log__() if hasattr(resource, "__log__") else resource,
|
||||
)
|
||||
delete(resource)
|
||||
|
||||
pytestconfig.cache.set(key, None)
|
||||
|
||||
request.addfinalizer(finalizer)
|
||||
|
||||
if reuse(request):
|
||||
|
||||
25
tests/fixtures/signoz.py
vendored
25
tests/fixtures/signoz.py
vendored
@@ -13,6 +13,7 @@ from testcontainers.core.container import DockerContainer, Network
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.tls import CA_CONTAINER_PATH, CA_ID_LABEL, ca_id
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -27,10 +28,12 @@ def create_signoz(
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "signoz",
|
||||
env_overrides: dict | None = None,
|
||||
tls: types.TLS | None = None,
|
||||
) -> types.SigNoz:
|
||||
"""
|
||||
Factory function for creating a SigNoz container.
|
||||
Accepts optional env_overrides to customize the container environment.
|
||||
Accepts optional env_overrides to customize the container environment, and
|
||||
an optional integration CA (tls) to trust in addition to the system roots.
|
||||
"""
|
||||
|
||||
def create() -> types.SigNoz:
|
||||
@@ -115,6 +118,13 @@ def create_signoz(
|
||||
"rw",
|
||||
)
|
||||
|
||||
# The CA lands in the directory Go scans for system roots, so tests can
|
||||
# stand in for real TLS hosts (e.g. the fake accounts.google.com) while
|
||||
# the bundled roots keep working for everything else.
|
||||
if tls:
|
||||
container.with_volume_mapping(tls.ca_cert_path, CA_CONTAINER_PATH, "ro")
|
||||
container.with_kwargs(labels={CA_ID_LABEL: ca_id(tls)})
|
||||
|
||||
container.start()
|
||||
|
||||
def ready(container: DockerContainer) -> None:
|
||||
@@ -193,6 +203,16 @@ def create_signoz(
|
||||
gateway=gateway,
|
||||
)
|
||||
|
||||
def stale(container: types.SigNoz) -> bool:
|
||||
if not tls:
|
||||
return False
|
||||
client = docker.from_env()
|
||||
try:
|
||||
labels = client.containers.get(container_id=container.self.id).attrs["Config"]["Labels"]
|
||||
except docker.errors.NotFound:
|
||||
return True
|
||||
return labels.get(CA_ID_LABEL) != ca_id(tls)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
@@ -212,6 +232,7 @@ def create_signoz(
|
||||
delete=delete,
|
||||
restore=restore,
|
||||
rebuild=pytestconfig.getoption("--rebuild"),
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
|
||||
@@ -222,6 +243,7 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
tls: types.TLS,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
@@ -233,4 +255,5 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
tls=tls,
|
||||
)
|
||||
|
||||
143
tests/fixtures/tls.py
vendored
Normal file
143
tests/fixtures/tls.py
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# The integration CA is mounted into the directory Go scans for system roots,
|
||||
# so signoz containers trust it in addition to the bundled Debian roots (not
|
||||
# instead of them, which is what SSL_CERT_FILE would do) and mocks that must be
|
||||
# reached over TLS under a real hostname (e.g. accounts.google.com) can serve
|
||||
# certificates issued by it via issue_server_keystore.
|
||||
CA_CONTAINER_PATH = "/etc/ssl/certs/signoz-integration-ca.pem"
|
||||
KEYSTORE_PASSWORD = "password" # noqa: S105
|
||||
|
||||
# Containers chained to the CA carry its id as this label; comparing it against
|
||||
# the current CA spots reused containers built against a rotated CA (or before
|
||||
# the CA existed) so they can be recreated instead of failing TLS opaquely.
|
||||
CA_ID_LABEL = "signoz.integration.ca"
|
||||
|
||||
|
||||
def ca_id(tls: types.TLS) -> str:
|
||||
return hashlib.sha256(Path(tls.ca_cert_path).read_bytes()).hexdigest()[:12]
|
||||
|
||||
|
||||
@pytest.fixture(name="tls", scope="package")
|
||||
def tls(
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TLS:
|
||||
"""The integration CA. Server certificates for mocks are issued from it
|
||||
with issue_server_keystore.
|
||||
|
||||
The CA cannot live in tmpfs: pytest wipes basetemp at every session start,
|
||||
while reused containers (and the keystores issued for them in later
|
||||
sessions) must keep chaining to the same CA. The pytest cache directory is
|
||||
the cross-session store, like the reuse metadata itself."""
|
||||
|
||||
def create() -> types.TLS:
|
||||
# Each CA gets a fresh directory: a run without --reuse must never
|
||||
# rotate the files that a parked stack's containers bind-mount.
|
||||
ca_dir = pytestconfig.cache.mkdir("tls") / uuid.uuid4().hex
|
||||
ca_dir.mkdir()
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "signoz-integration-ca")])
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(ca_name)
|
||||
.issuer_name(ca_name)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
(ca_dir / "ca.pem").write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||
(ca_dir / "ca.key").write_bytes(
|
||||
ca_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
|
||||
return types.TLS(ca_cert_path=str(ca_dir / "ca.pem"), ca_key_path=str(ca_dir / "ca.key"))
|
||||
|
||||
def delete(tls: types.TLS) -> None:
|
||||
for path in (tls.ca_cert_path, tls.ca_key_path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
logger.info("CA file %s already gone", path)
|
||||
with contextlib.suppress(OSError):
|
||||
os.rmdir(Path(tls.ca_cert_path).parent)
|
||||
|
||||
def restore(cache: dict) -> types.TLS:
|
||||
return types.TLS.from_cache(cache)
|
||||
|
||||
def stale(tls: types.TLS) -> bool:
|
||||
return not (Path(tls.ca_cert_path).is_file() and Path(tls.ca_key_path).is_file())
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"tls",
|
||||
lambda: types.TLS(ca_cert_path="", ca_key_path=""),
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
|
||||
def issue_server_keystore(tls: types.TLS, directory: Path, hostname: str) -> Path:
|
||||
"""Write a PKCS12 keystore (keystore.p12, password KEYSTORE_PASSWORD) into
|
||||
directory, holding a certificate for hostname issued by the integration CA.
|
||||
Mount it into a mock container that must serve TLS as hostname."""
|
||||
ca_cert = x509.load_pem_x509_certificate(Path(tls.ca_cert_path).read_bytes())
|
||||
ca_key = serialization.load_pem_private_key(Path(tls.ca_key_path).read_bytes(), password=None)
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
leaf_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
|
||||
.issuer_name(ca_cert.subject)
|
||||
.public_key(leaf_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False)
|
||||
.add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
keystore_path = directory / "keystore.p12"
|
||||
keystore_path.write_bytes(
|
||||
pkcs12.serialize_key_and_certificates(
|
||||
name=hostname.encode(),
|
||||
key=leaf_key,
|
||||
cert=leaf_cert,
|
||||
cas=[ca_cert],
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(KEYSTORE_PASSWORD.encode()),
|
||||
)
|
||||
)
|
||||
|
||||
return keystore_path
|
||||
32
tests/fixtures/traces.py
vendored
32
tests/fixtures/traces.py
vendored
@@ -302,6 +302,7 @@ class Traces(ABC):
|
||||
db_operation: str
|
||||
has_error: bool
|
||||
is_remote: str
|
||||
scope_json: dict[str, Any]
|
||||
|
||||
resource: list[TracesResource]
|
||||
tag_attributes: list[TracesTagAttributes]
|
||||
@@ -327,6 +328,7 @@ class Traces(ABC):
|
||||
links: list[TracesLink] = [],
|
||||
trace_state: str = "",
|
||||
flags: np.uint32 = 0,
|
||||
scope: dict[str, Any] = {},
|
||||
resource_write_mode: Literal["legacy_only", "dual_write"] = "dual_write",
|
||||
) -> None:
|
||||
if timestamp is None:
|
||||
@@ -408,6 +410,33 @@ class Traces(ABC):
|
||||
# Calculate resource fingerprint
|
||||
self.resource_fingerprint = LogsOrTracesFingerprint(self.resources_string).calculate()
|
||||
|
||||
# Process scope mirroring the InstrumentationScope on the OTLP span.
|
||||
scope_name = scope.get("name", "")
|
||||
scope_version = scope.get("version", "")
|
||||
scope_string = {k: str(v) for k, v in scope.get("attributes", {}).items()}
|
||||
self.scope_json = {
|
||||
"name": scope_name,
|
||||
"version": scope_version,
|
||||
"attributes": scope_string,
|
||||
}
|
||||
|
||||
scope_keys = {"scope.name": scope_name, "scope.version": scope_version}
|
||||
scope_keys.update(scope_string)
|
||||
for k, v in scope_keys.items():
|
||||
if v == "":
|
||||
continue
|
||||
self.tag_attributes.append(
|
||||
TracesTagAttributes(
|
||||
timestamp=timestamp,
|
||||
tag_key=k,
|
||||
tag_type="scope",
|
||||
tag_data_type="string",
|
||||
string_value=v,
|
||||
number_value=None,
|
||||
)
|
||||
)
|
||||
self.attribute_keys.append(TracesResourceOrAttributeKeys(name=k, datatype="string", tag_type="scope"))
|
||||
|
||||
# Process attributes by type and populate custom fields
|
||||
self.attribute_string = {}
|
||||
self.attributes_number = {}
|
||||
@@ -659,6 +688,7 @@ class Traces(ABC):
|
||||
self.has_error,
|
||||
self.is_remote,
|
||||
self.resource_json,
|
||||
self.scope_json,
|
||||
],
|
||||
dtype=object,
|
||||
)
|
||||
@@ -689,6 +719,7 @@ class Traces(ABC):
|
||||
attributes=data.get("attributes", {}),
|
||||
trace_state=data.get("trace_state", ""),
|
||||
flags=data.get("flags", 0),
|
||||
scope=data.get("scope", {}),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -828,6 +859,7 @@ def insert_traces_to_clickhouse(conn, traces: list[Traces]) -> None:
|
||||
"has_error",
|
||||
"is_remote",
|
||||
"resource",
|
||||
"scope",
|
||||
],
|
||||
data=[trace.np_arr() for trace in traces],
|
||||
)
|
||||
|
||||
20
tests/fixtures/types.py
vendored
20
tests/fixtures/types.py
vendored
@@ -158,6 +158,26 @@ class Network:
|
||||
return f"Network(id={self.id}, name={self.name})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TLS:
|
||||
__test__ = False
|
||||
ca_cert_path: str
|
||||
ca_key_path: str
|
||||
|
||||
@staticmethod
|
||||
def from_cache(cache: dict) -> "TLS":
|
||||
return TLS(ca_cert_path=cache["ca_cert_path"], ca_key_path=cache["ca_key_path"])
|
||||
|
||||
def __cache__(self) -> dict:
|
||||
return {
|
||||
"ca_cert_path": self.ca_cert_path,
|
||||
"ca_key_path": self.ca_key_path,
|
||||
}
|
||||
|
||||
def __log__(self) -> str:
|
||||
return f"TLS(ca_cert_path={self.ca_cert_path}, ca_key_path={self.ca_key_path})"
|
||||
|
||||
|
||||
# Alerts related types
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ def test_telemetry_databases_exist(signoz: types.SigNoz) -> None:
|
||||
def test_teardown(
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
idp: types.TestContainerIDP, # pylint: disable=unused-argument
|
||||
googleidp: types.TestContainerDocker, # pylint: disable=unused-argument
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
migrator: types.Operation, # pylint: disable=unused-argument
|
||||
maildev: types.TestContainerDocker, # pylint: disable=unused-argument
|
||||
|
||||
185
tests/integration/tests/callbackauthn/04_google.py
Normal file
185
tests/integration/tests/callbackauthn/04_google.py
Normal file
@@ -0,0 +1,185 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
assert_user_has_role,
|
||||
find_user_with_roles_by_email,
|
||||
)
|
||||
from fixtures.googleidp import GOOGLE_DOMAIN, get_google_domain, google_oidc_mappings, perform_google_login
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
GOOGLE_CLIENT_ID = "google-client-id.apps.googleusercontent.com"
|
||||
GOOGLE_CLIENT_SECRET = "google-client-secret"
|
||||
|
||||
|
||||
def test_create_auth_domain(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Reruns against a reused stack find the domain from the previous run;
|
||||
# drop it so creation always starts from a clean slate.
|
||||
domain = get_google_domain(signoz, admin_token)
|
||||
if domain:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
json={
|
||||
"name": GOOGLE_DOMAIN,
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "google_auth",
|
||||
"googleAuthConfig": {
|
||||
"clientId": GOOGLE_CLIENT_ID,
|
||||
"clientSecret": GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
|
||||
def test_google_authn(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
email = "viewer@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Google Viewer", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "accessToken=" in redirect_url
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
|
||||
|
||||
assert found_user["displayName"] == "Google Viewer"
|
||||
assert_user_has_role(found_user, "signoz-viewer")
|
||||
|
||||
|
||||
def test_google_authn_hd_mismatch(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
# The id_token carries a hosted-domain claim for a different workspace than
|
||||
# the auth domain; the callback must reject it and provision no user.
|
||||
email = "intruder@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Intruder", hd="other.workspace.test", audience=GOOGLE_CLIENT_ID))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "callbackauthnerr" in redirect_url
|
||||
assert "accessToken=" not in redirect_url
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert not any(user["email"] == email for user in response.json()["data"])
|
||||
|
||||
|
||||
def test_google_authn_unverified_email(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
email = "unverified@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Unverified", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID, email_verified=False))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "callbackauthnerr" in redirect_url
|
||||
|
||||
# Opting the domain into insecureSkipEmailVerified must let the same
|
||||
# unverified identity through.
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
domain = get_google_domain(signoz, admin_token)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
|
||||
json={
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "google_auth",
|
||||
"googleAuthConfig": {
|
||||
"clientId": GOOGLE_CLIENT_ID,
|
||||
"clientSecret": GOOGLE_CLIENT_SECRET,
|
||||
"insecureSkipEmailVerified": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "accessToken=" in redirect_url
|
||||
|
||||
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
|
||||
assert_user_has_role(found_user, "signoz-viewer")
|
||||
|
||||
|
||||
def test_google_role_mapping_default_role(
|
||||
signoz: SigNoz,
|
||||
googleidp: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
domain = get_google_domain(signoz, admin_token)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/domains/{domain['id']}"),
|
||||
json={
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "google_auth",
|
||||
"googleAuthConfig": {
|
||||
"clientId": GOOGLE_CLIENT_ID,
|
||||
"clientSecret": GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
"roleMapping": {
|
||||
"defaultRole": "EDITOR",
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
email = "editor@google.integration.test"
|
||||
make_http_mocks(googleidp, google_oidc_mappings(email=email, name="Google Editor", hd=GOOGLE_DOMAIN, audience=GOOGLE_CLIENT_ID))
|
||||
|
||||
redirect_url = perform_google_login(signoz, googleidp, get_session_context, email)
|
||||
assert "accessToken=" in redirect_url
|
||||
|
||||
found_user = find_user_with_roles_by_email(signoz, admin_token, email)
|
||||
assert_user_has_role(found_user, "signoz-editor")
|
||||
@@ -130,6 +130,45 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
|
||||
assert token is not None
|
||||
|
||||
|
||||
def test_reset_password_v2(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
found_user = find_user_by_email(signoz, admin_token, PASSWORD_USER_EMAIL)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
token = response.json()["data"]["token"]
|
||||
|
||||
# A password failing the strength policy is rejected without consuming the token
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
|
||||
json={"password": "password", "token": token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
|
||||
json={"password": "resetV2Password123Z$", "token": token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
assert get_token(PASSWORD_USER_EMAIL, "resetV2Password123Z$") is not None
|
||||
|
||||
# The token is single use, so replaying it no longer resolves
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
|
||||
json={"password": "resetV2Password456Z$", "token": token},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
|
||||
|
||||
def test_reset_password_with_no_password(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
|
||||
@@ -1240,6 +1240,13 @@ def test_traces_list_span_scope(
|
||||
lambda x: {"duration_nano": int(x[1].duration_nano), "span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
|
||||
id="select_attribute_duration_order_intrinsic",
|
||||
),
|
||||
# Case 9: filter on the intrinsic scope.version. Only x[1] should match.
|
||||
pytest.param(
|
||||
BuilderQuery(signal="traces", name="A", select_fields=[TelemetryFieldKey("timestamp")], filter_expression="scope.version = '1.0.0'", limit=1),
|
||||
HTTPStatus.OK,
|
||||
lambda x: {"span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
|
||||
id="filter_scope_version",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_traces_list_with_corrupt_data(
|
||||
@@ -1283,6 +1290,156 @@ def test_traces_list_with_corrupt_data(
|
||||
assert get_rows(response)[0]["data"] == expected(traces)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_expression,expected_indices",
|
||||
[
|
||||
# Intrinsic scope.name / scope.version resolve to the JSON sub-columns.
|
||||
pytest.param("scope.name = 'io.signoz.payment'", [1], id="intrinsic_scope_name"),
|
||||
pytest.param("scope.version = '2.3.1'", [0], id="intrinsic_scope_version"),
|
||||
# A scope attribute resolves against the scope JSON column's attributes.
|
||||
pytest.param("scope.telemetry.sdk.language = 'python'", [1], id="scope_attribute"),
|
||||
# `env.tier` is a span attribute on span 0 and a scope attribute on
|
||||
# span 1. Unprefixed -> no explicit context, so it is checked in every
|
||||
# applicable context (attribute OR scope) and both spans match.
|
||||
pytest.param("env.tier = 'gold'", [0, 1], id="bare_cross_context"),
|
||||
# The explicit `scope.` prefix forces scope context only, so span 0's
|
||||
# span attribute is ignored — only span 1 matches.
|
||||
pytest.param("scope.env.tier = 'gold'", [1], id="scope_prefixed_cross_context"),
|
||||
# `scope.name` matches BOTH the intrinsic scope.name field (span 0) and a
|
||||
# scope attribute literally named `name` (span 1's scope attribute
|
||||
# name='io.signoz.checkout').
|
||||
pytest.param("scope.name = 'io.signoz.checkout'", [0, 1], id="scope_name_collision"),
|
||||
# `scope.name` also matches a span attribute literally named `scope.name`
|
||||
# (attribute context) — span 2 carries attribute scope.name='attr-scope-name'.
|
||||
pytest.param("scope.name = 'attr-scope-name'", [2], id="scope_name_attribute_collision"),
|
||||
# An unprefixed `name` resolves to the intrinsic span `name` column and a
|
||||
# `name` scope attribute, but NOT the scope.name field. Span 2's span
|
||||
# name and span 1's scope attribute `name` both equal 'io.signoz.checkout';
|
||||
# span 0's scope.name field equals it too but is NOT matched.
|
||||
pytest.param("name = 'io.signoz.checkout'", [1, 2], id="bare_name_excludes_scope_name_field"),
|
||||
# A value that no resolvable key holds (scope.name/scope.version field,
|
||||
# a `name`/`version` scope attribute, or a same-named attribute/resource)
|
||||
# returns nothing.
|
||||
pytest.param("scope.version = 'corrupt_data'", [], id="scope_version_no_match"),
|
||||
pytest.param("scope.name = 'corrupt_data'", [], id="scope_name_no_match"),
|
||||
],
|
||||
)
|
||||
def test_traces_list_with_scope_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
filter_expression: str,
|
||||
expected_indices: list[int],
|
||||
) -> None:
|
||||
"""
|
||||
Setup three spans with different scope key resolution:
|
||||
- x[0]: scope.name/version 'io.signoz.checkout'/'2.3.1'; span attribute
|
||||
env.tier='gold'.
|
||||
- x[1]: scope.name/version 'io.signoz.payment'/'4.5.6'; scope attributes
|
||||
telemetry.sdk.language='python', env.tier='gold', and a `name` scope
|
||||
attribute colliding with x[0]'s scope.name value.
|
||||
- x[2]: span name 'io.signoz.checkout' (colliding with x[0]'s scope.name
|
||||
value) and a span attribute literally named `scope.name`.
|
||||
|
||||
Tests:
|
||||
- Filtering on scope.name / scope.version / a scope attribute.
|
||||
- An unprefixed key is resolved across contexts (scope checked alongside
|
||||
attribute / intrinsic), while a `scope.`-prefixed key is scope-only.
|
||||
- `scope.name` hits the intrinsic field, a `name` scope attribute, and a
|
||||
span attribute `scope.name` (cross-context), while a bare `name` hits
|
||||
the span name column (and a `name` scope attribute) but never the
|
||||
scope.name field.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
span_ids = [TraceIdGenerator.span_id() for _ in range(3)]
|
||||
|
||||
traces = [
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=2),
|
||||
trace_id=trace_id,
|
||||
span_id=span_ids[0],
|
||||
parent_span_id="",
|
||||
name="GET /checkout",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "checkout"},
|
||||
attributes={"http.request.method": "GET", "env.tier": "gold"},
|
||||
scope={
|
||||
"name": "io.signoz.checkout",
|
||||
"version": "2.3.1",
|
||||
"attributes": {"telemetry.sdk.language": "go"},
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=span_ids[1],
|
||||
parent_span_id="",
|
||||
name="POST /pay",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "payment"},
|
||||
attributes={"http.request.method": "POST"},
|
||||
# env.tier is a scope attribute here (cross-context with span 0);
|
||||
# `name` is a scope attribute colliding with span 0's scope.name.
|
||||
scope={
|
||||
"name": "io.signoz.payment",
|
||||
"version": "4.5.6",
|
||||
"attributes": {
|
||||
"telemetry.sdk.language": "python",
|
||||
"env.tier": "gold",
|
||||
"name": "io.signoz.checkout",
|
||||
},
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=span_ids[2],
|
||||
parent_span_id="",
|
||||
# span name collides with span 0's scope.name value
|
||||
name="io.signoz.checkout",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "probe"},
|
||||
# a span attribute named `scope.name`
|
||||
attributes={"scope.name": "attr-scope-name"},
|
||||
scope={"name": "span-gamma", "version": "9.9.9"},
|
||||
),
|
||||
]
|
||||
insert_traces(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = _query_window(now)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("timestamp")],
|
||||
filter_expression=filter_expression,
|
||||
limit=10,
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
got_span_ids = {row["data"]["span_id"] for row in get_rows(response)}
|
||||
expected_span_ids = {traces[i].span_id for i in expected_indices}
|
||||
assert got_span_ids == expected_span_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ["filter", "select", "order"])
|
||||
def test_traces_list_unknown_span_context_synthesizes(
|
||||
signoz: types.SigNoz,
|
||||
|
||||
@@ -19,6 +19,8 @@ dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.34",
|
||||
"py>=1.11",
|
||||
"cryptography>=50.0.0",
|
||||
"jwcrypto>=1.5.8",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
4
tests/uv.lock
generated
4
tests/uv.lock
generated
@@ -1070,8 +1070,10 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "clickhouse-connect" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "isodate" },
|
||||
{ name = "jwcrypto" },
|
||||
{ name = "numpy" },
|
||||
{ name = "psycopg2" },
|
||||
{ name = "py" },
|
||||
@@ -1093,8 +1095,10 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "clickhouse-connect", specifier = ">=0.8.18" },
|
||||
{ name = "cryptography", specifier = ">=50.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "isodate", specifier = ">=0.7.2" },
|
||||
{ name = "jwcrypto", specifier = ">=1.5.8" },
|
||||
{ name = "numpy", specifier = ">=2.3.2" },
|
||||
{ name = "psycopg2", specifier = ">=2.9.10" },
|
||||
{ name = "py", specifier = ">=1.11" },
|
||||
|
||||
Reference in New Issue
Block a user