mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-08 11:30:32 +01:00
Compare commits
1 Commits
no-auth-fe
...
tvats-pkg-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411d3e64a4 |
@@ -165,8 +165,6 @@ function createMockAppContext(
|
||||
orgPreferences: createMockOrgPreferences(),
|
||||
userPreferences: [],
|
||||
isLoggedIn: true,
|
||||
isNoAuthMode: false,
|
||||
isPreflightLoading: false,
|
||||
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
|
||||
@@ -58,7 +58,6 @@ function App(): JSX.Element {
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
isPreflightLoading,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
|
||||
@@ -351,10 +350,6 @@ function App(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCloudUser, isEnterpriseSelfHostedUser]);
|
||||
|
||||
if (isPreflightLoading) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
// if the user is in logged in state
|
||||
if (isLoggedInState) {
|
||||
// if the setup calls are loading then return a spinner
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { interceptorRejected } from '../index';
|
||||
|
||||
jest.mock('api/v2/sessions/rotate/post', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('AppRoutes/utils', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
Logout: jest.fn(),
|
||||
}));
|
||||
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const post = require('api/v2/sessions/rotate/post').default;
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const { Logout } = require('../utils');
|
||||
|
||||
describe('interceptorRejected — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('does NOT call rotate or Logout when IS_NO_AUTH_MODE=true on 401', async () => {
|
||||
localStorage.setItem(LOCALSTORAGE.IS_NO_AUTH_MODE, 'true');
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES attempt rotate when IS_NO_AUTH_MODE=false on 401', async () => {
|
||||
localStorage.setItem(LOCALSTORAGE.IS_NO_AUTH_MODE, 'false');
|
||||
(post as jest.Mock).mockResolvedValue({
|
||||
data: { accessToken: 'a', refreshToken: 'b' },
|
||||
});
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -108,11 +108,7 @@ export const interceptorRejected = async (
|
||||
if (axios.isAxiosError(value) && value.response) {
|
||||
const { response } = value;
|
||||
|
||||
const isNoAuthMode =
|
||||
getLocalStorageApi(LOCALSTORAGE.IS_NO_AUTH_MODE) === 'true';
|
||||
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
response.config.url !== '/sessions/rotate' &&
|
||||
@@ -144,20 +140,16 @@ export const interceptorRejected = async (
|
||||
return await Promise.resolve(reResponse);
|
||||
} catch (error) {
|
||||
if ((error as AxiosError)?.response?.status === 401) {
|
||||
void Logout();
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
void Logout();
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
response.config.url === '/sessions/rotate'
|
||||
) {
|
||||
void Logout();
|
||||
if (response.status === 401 && response.config.url === '/sessions/rotate') {
|
||||
Logout();
|
||||
}
|
||||
}
|
||||
return await Promise.reject(value);
|
||||
|
||||
@@ -39,5 +39,4 @@ export enum LOCALSTORAGE {
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
IS_NO_AUTH_MODE = 'IS_NO_AUTH_MODE',
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toISOString } from 'utils/app';
|
||||
|
||||
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
|
||||
@@ -21,7 +20,6 @@ const PAGE_SIZE = 20;
|
||||
function MembersSettings(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
const { isNoAuthMode } = useAppContext();
|
||||
|
||||
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
|
||||
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
|
||||
@@ -147,7 +145,7 @@ function MembersSettings(): JSX.Element {
|
||||
: `Deleted ⎯ ${deletedCount}`;
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
void refetchUsers();
|
||||
refetchUsers();
|
||||
}, [refetchUsers]);
|
||||
|
||||
const handleRowClick = useCallback((member: MemberRow): void => {
|
||||
@@ -159,7 +157,7 @@ function MembersSettings(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const handleMemberEditComplete = useCallback((): void => {
|
||||
void refetchUsers();
|
||||
refetchUsers();
|
||||
}, [refetchUsers]);
|
||||
|
||||
return (
|
||||
@@ -202,16 +200,14 @@ function MembersSettings(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isNoAuthMode && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => setIsInviteModalOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Invite member
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => setIsInviteModalOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Invite member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<MembersTable
|
||||
@@ -225,13 +221,11 @@ function MembersSettings(): JSX.Element {
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
{!isNoAuthMode && (
|
||||
<InviteMembersModal
|
||||
open={isInviteModalOpen}
|
||||
onClose={(): void => setIsInviteModalOpen(false)}
|
||||
onComplete={handleInviteComplete}
|
||||
/>
|
||||
)}
|
||||
<InviteMembersModal
|
||||
open={isInviteModalOpen}
|
||||
onClose={(): void => setIsInviteModalOpen(false)}
|
||||
onComplete={handleInviteComplete}
|
||||
/>
|
||||
|
||||
<EditMemberDrawer
|
||||
member={selectedMember}
|
||||
|
||||
@@ -15,7 +15,7 @@ import '../MySettings.styles.scss';
|
||||
import './UserInfo.styles.scss';
|
||||
|
||||
function UserInfo(): JSX.Element {
|
||||
const { user, org, updateUser, isNoAuthMode } = useAppContext();
|
||||
const { user, org, updateUser } = useAppContext();
|
||||
const { t } = useTranslation(['routes', 'settings', 'common']);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
@@ -79,10 +79,10 @@ function UserInfo(): JSX.Element {
|
||||
currentPassword === updatePassword;
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
void logEvent('Account Settings: Name Updated', {
|
||||
logEvent('Account Settings: Name Updated', {
|
||||
name: changedName,
|
||||
});
|
||||
void logEvent(
|
||||
logEvent(
|
||||
'Account Settings: Name Updated',
|
||||
{
|
||||
name: changedName,
|
||||
@@ -143,16 +143,14 @@ function UserInfo(): JSX.Element {
|
||||
Update name
|
||||
</Button>
|
||||
|
||||
{!isNoAuthMode && (
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<FileTerminal size={16} />}
|
||||
onClick={(): void => setIsResetPasswordModalOpen(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<FileTerminal size={16} />}
|
||||
onClick={(): void => setIsResetPasswordModalOpen(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@@ -184,64 +182,62 @@ function UserInfo(): JSX.Element {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{!isNoAuthMode && (
|
||||
<Modal
|
||||
className="reset-password-modal"
|
||||
title={<span className="title">Reset password</span>}
|
||||
open={isResetPasswordModalOpen}
|
||||
closable
|
||||
onCancel={hideResetPasswordModal}
|
||||
footer={[
|
||||
<Button
|
||||
key="submit"
|
||||
className={`periscope-btn ${
|
||||
isResetPasswordDisabled ? 'secondary' : 'primary'
|
||||
}`}
|
||||
icon={<Check size={16} />}
|
||||
onClick={onChangePasswordClickHandler}
|
||||
disabled={isLoading || isResetPasswordDisabled}
|
||||
data-testid="reset-password-btn"
|
||||
>
|
||||
Reset password
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="reset-password-container">
|
||||
<div className="current-password-input">
|
||||
<Typography.Text>Current password</Typography.Text>
|
||||
<Input.Password
|
||||
data-testid="current-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
setCurrentPassword(event.target.value);
|
||||
}}
|
||||
value={currentPassword}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
visibilityToggle
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="new-password-input">
|
||||
<Typography.Text>New password</Typography.Text>
|
||||
<Input.Password
|
||||
data-testid="new-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
const updatedValue = event.target.value;
|
||||
setUpdatePassword(updatedValue);
|
||||
}}
|
||||
value={updatePassword}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
visibilityToggle={false}
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
className="reset-password-modal"
|
||||
title={<span className="title">Reset password</span>}
|
||||
open={isResetPasswordModalOpen}
|
||||
closable
|
||||
onCancel={hideResetPasswordModal}
|
||||
footer={[
|
||||
<Button
|
||||
key="submit"
|
||||
className={`periscope-btn ${
|
||||
isResetPasswordDisabled ? 'secondary' : 'primary'
|
||||
}`}
|
||||
icon={<Check size={16} />}
|
||||
onClick={onChangePasswordClickHandler}
|
||||
disabled={isLoading || isResetPasswordDisabled}
|
||||
data-testid="reset-password-btn"
|
||||
>
|
||||
Reset password
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="reset-password-container">
|
||||
<div className="current-password-input">
|
||||
<Typography.Text>Current password</Typography.Text>
|
||||
<Input.Password
|
||||
data-testid="current-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
setCurrentPassword(event.target.value);
|
||||
}}
|
||||
value={currentPassword}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
visibilityToggle
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<div className="new-password-input">
|
||||
<Typography.Text>New password</Typography.Text>
|
||||
<Input.Password
|
||||
data-testid="new-password-textbox"
|
||||
disabled={isLoading}
|
||||
placeholder={defaultPlaceHolder}
|
||||
onChange={(event): void => {
|
||||
const updatedValue = event.target.value;
|
||||
setUpdatePassword(updatedValue);
|
||||
}}
|
||||
value={updatePassword}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
visibilityToggle={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,8 +100,6 @@ export function getAppContextMockState(
|
||||
orgPreferences: null,
|
||||
userPreferences: null,
|
||||
isLoggedIn: false,
|
||||
isNoAuthMode: false,
|
||||
isPreflightLoading: false,
|
||||
org: null,
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
|
||||
@@ -131,7 +131,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
featureFlags,
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
isNoAuthMode,
|
||||
userPreferences,
|
||||
changelog,
|
||||
toggleChangelogModal,
|
||||
@@ -402,7 +401,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
);
|
||||
|
||||
const handleReorderShortcutNavItems = (): void => {
|
||||
void logEvent('Sidebar V2: Save shortcuts clicked', {
|
||||
logEvent('Sidebar V2: Save shortcuts clicked', {
|
||||
shortcuts: tempPinnedMenuItems.map((item) => item.key),
|
||||
});
|
||||
setPinnedMenuItems(tempPinnedMenuItems);
|
||||
@@ -430,7 +429,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
|
||||
|
||||
const onClickGetStarted = (event: MouseEvent): void => {
|
||||
void logEvent('Sidebar: Menu clicked', {
|
||||
logEvent('Sidebar: Menu clicked', {
|
||||
menuRoute: '/get-started',
|
||||
menuLabel: 'Get Started',
|
||||
});
|
||||
@@ -483,14 +482,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
isNoAuthMode,
|
||||
}),
|
||||
[
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
user.email,
|
||||
isWorkspaceBlocked,
|
||||
isNoAuthMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -512,7 +509,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || isNoAuthMode) {
|
||||
if (!isAdmin) {
|
||||
setHelpSupportDropdownMenuItems((prevState) =>
|
||||
prevState.filter(
|
||||
(item) => !('key' in item) || item.key !== 'invite-collaborators',
|
||||
@@ -603,7 +600,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isAdmin,
|
||||
isNoAuthMode,
|
||||
isChatSupportEnabled,
|
||||
isPremiumSupportEnabled,
|
||||
isCloudUser,
|
||||
@@ -632,7 +628,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
} else if (item) {
|
||||
onClickHandler(item?.key as string, event);
|
||||
}
|
||||
void logEvent('Sidebar V2: Menu clicked', {
|
||||
logEvent('Sidebar V2: Menu clicked', {
|
||||
menuRoute: item?.key,
|
||||
menuLabel: item?.label,
|
||||
});
|
||||
@@ -775,7 +771,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
onTogglePin={
|
||||
allowPin
|
||||
? (item): void => {
|
||||
void logEvent(
|
||||
logEvent(
|
||||
`Sidebar V2: Menu item ${item.isPinned ? 'unpinned' : 'pinned'}`,
|
||||
{
|
||||
menuRoute: item.key,
|
||||
@@ -822,7 +818,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const event = (info as SidebarItem & { domEvent?: MouseEvent }).domEvent;
|
||||
|
||||
if (item && !('type' in item)) {
|
||||
void logEvent('Help Popover: Item clicked', {
|
||||
logEvent('Help Popover: Item clicked', {
|
||||
menuRoute: item.key,
|
||||
menuLabel: String(item.label),
|
||||
});
|
||||
@@ -871,7 +867,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
menuLabel = item.label;
|
||||
}
|
||||
|
||||
void logEvent('Settings Popover: Item clicked', {
|
||||
logEvent('Settings Popover: Item clicked', {
|
||||
menuRoute: item?.key,
|
||||
menuLabel,
|
||||
});
|
||||
@@ -908,7 +904,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
}
|
||||
break;
|
||||
case 'logout':
|
||||
void Logout();
|
||||
Logout();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
@@ -1062,7 +1058,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
<div
|
||||
className="nav-section-title-icon reorder"
|
||||
onClick={(): void => {
|
||||
void logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
setIsReorderShortcutNavItemsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -1109,7 +1105,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
return;
|
||||
}
|
||||
const newCollapsedState = !isMoreMenuCollapsed;
|
||||
void logEvent('Sidebar V2: More menu clicked', {
|
||||
logEvent('Sidebar V2: More menu clicked', {
|
||||
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
|
||||
});
|
||||
setIsMoreMenuCollapsed(newCollapsedState);
|
||||
@@ -1213,14 +1209,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
open={isReorderShortcutNavItemsModalOpen}
|
||||
closable
|
||||
onCancel={(): void => {
|
||||
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
hideReorderShortcutNavItemsModal();
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => {
|
||||
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
hideReorderShortcutNavItemsModal();
|
||||
}}
|
||||
className="periscope-btn cancel-btn secondary-btn"
|
||||
|
||||
@@ -5,7 +5,6 @@ const BASE_PARAMS = {
|
||||
isWorkspaceBlocked: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
isNoAuthMode: false,
|
||||
};
|
||||
|
||||
describe('getUserSettingsDropdownMenuItems', () => {
|
||||
@@ -72,15 +71,4 @@ describe('getUserSettingsDropdownMenuItems', () => {
|
||||
expect(keys[3]).toBe('account');
|
||||
expect(keys[keys.length - 1]).toBe('logout');
|
||||
});
|
||||
|
||||
it('omits sign out and its preceding divider when isNoAuthMode=true', () => {
|
||||
const items =
|
||||
getUserSettingsDropdownMenuItems({ ...BASE_PARAMS, isNoAuthMode: true }) ??
|
||||
[];
|
||||
const keys = items.map((item: any) => item.key ?? item.type);
|
||||
|
||||
expect(keys).not.toContain('logout');
|
||||
// the trailing divider before logout should also be gone
|
||||
expect(keys[keys.length - 1]).toBe('keyboard-shortcuts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -470,7 +470,6 @@ export interface UserSettingsMenuItemsParams {
|
||||
isWorkspaceBlocked: boolean;
|
||||
isEnterpriseSelfHostedUser: boolean;
|
||||
isCommunityEnterpriseUser: boolean;
|
||||
isNoAuthMode: boolean;
|
||||
}
|
||||
|
||||
export const getUserSettingsDropdownMenuItems = ({
|
||||
@@ -478,7 +477,6 @@ export const getUserSettingsDropdownMenuItems = ({
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
isNoAuthMode,
|
||||
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
|
||||
[
|
||||
{
|
||||
@@ -522,25 +520,21 @@ export const getUserSettingsDropdownMenuItems = ({
|
||||
icon: <Keyboard size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'keyboard-shortcuts-nav-item',
|
||||
},
|
||||
...(isNoAuthMode
|
||||
? []
|
||||
: [
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
icon: (
|
||||
<LogOut
|
||||
size={14}
|
||||
className="user-settings-dropdown-logout-section"
|
||||
color={Style.DANGER_BACKGROUND}
|
||||
/>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
]),
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
icon: (
|
||||
<LogOut
|
||||
size={14}
|
||||
className="user-settings-dropdown-logout-section"
|
||||
color={Style.DANGER_BACKGROUND}
|
||||
/>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
/** Mapping of some newly added routes and their corresponding active sidebar menu key */
|
||||
|
||||
@@ -26,13 +26,8 @@ import './Settings.styles.scss';
|
||||
function SettingsPage(): JSX.Element {
|
||||
const { pathname, search } = useLocation();
|
||||
|
||||
const {
|
||||
user,
|
||||
featureFlags,
|
||||
trialInfo,
|
||||
isFetchingActiveLicense,
|
||||
isNoAuthMode,
|
||||
} = useAppContext();
|
||||
const { user, featureFlags, trialInfo, isFetchingActiveLicense } =
|
||||
useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const [settingsMenuItems, setSettingsMenuItems] = useState<SidebarItem[]>(
|
||||
@@ -181,14 +176,6 @@ function SettingsPage(): JSX.Element {
|
||||
}));
|
||||
}
|
||||
|
||||
// In no-auth mode, hide the Members page from the sidebar
|
||||
if (isNoAuthMode) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled: item.key === ROUTES.MEMBERS_SETTINGS ? false : item.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
return updatedItems;
|
||||
});
|
||||
}, [
|
||||
@@ -198,7 +185,6 @@ function SettingsPage(): JSX.Element {
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isFetchingActiveLicense,
|
||||
isNoAuthMode,
|
||||
trialInfo?.workSpaceBlock,
|
||||
pathname,
|
||||
]);
|
||||
@@ -213,7 +199,6 @@ function SettingsPage(): JSX.Element {
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
t,
|
||||
isNoAuthMode,
|
||||
),
|
||||
[
|
||||
user.role,
|
||||
@@ -223,7 +208,6 @@ function SettingsPage(): JSX.Element {
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
t,
|
||||
isNoAuthMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -314,7 +298,7 @@ function SettingsPage(): JSX.Element {
|
||||
isDisabled={false}
|
||||
showIcon={false}
|
||||
onClick={(event): void => {
|
||||
void logEvent('Settings V2: Menu clicked', {
|
||||
logEvent('Settings V2: Menu clicked', {
|
||||
menuLabel: item.label,
|
||||
menuRoute: item.key,
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ export const getRoutes = (
|
||||
isCloudUser: boolean,
|
||||
isEnterpriseSelfHostedUser: boolean,
|
||||
t: TFunction,
|
||||
isNoAuthMode = false,
|
||||
): RouteTabProps['routes'] => {
|
||||
const settings = [];
|
||||
|
||||
@@ -38,7 +37,7 @@ export const getRoutes = (
|
||||
if (isWorkspaceBlocked && isAdmin) {
|
||||
settings.push(
|
||||
...organizationSettings(t),
|
||||
...(isNoAuthMode ? [] : membersSettings(t)),
|
||||
...membersSettings(t),
|
||||
...mySettings(t),
|
||||
...billingSettings(t),
|
||||
...keyboardShortcuts(t),
|
||||
@@ -65,7 +64,7 @@ export const getRoutes = (
|
||||
|
||||
if (isAdmin) {
|
||||
settings.push(
|
||||
...(isNoAuthMode ? [] : membersSettings(t)),
|
||||
...membersSettings(t),
|
||||
...serviceAccountsSettings(t),
|
||||
...rolesSettings(t),
|
||||
...roleDetails(t),
|
||||
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
import { useQuery } from 'react-query';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { useGetGlobalConfig } from 'api/generated/services/global';
|
||||
import { useGetMyUser } from 'api/generated/services/users';
|
||||
import listOrgPreferences from 'api/v1/org/preferences/list';
|
||||
import { clearAuthStorage } from 'utils/clearAuthStorage';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -70,50 +68,11 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(
|
||||
(): boolean => getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true',
|
||||
);
|
||||
const [isNoAuthMode, setIsNoAuthMode] = useState<boolean>(false);
|
||||
const [isPreflightLoading, setIsPreflightLoading] = useState<boolean>(true);
|
||||
const [org, setOrg] = useState<Organization[] | null>(null);
|
||||
const [changelog, setChangelog] = useState<ChangelogSchema | null>(null);
|
||||
|
||||
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
|
||||
|
||||
// Pre-flight: discover auth mode from public global config.
|
||||
// On success: in impersonation mode → clear stale tokens, force isLoggedIn=true,
|
||||
// persist IS_NO_AUTH_MODE flag so the axios interceptor (outside React)
|
||||
// can skip the rotate-logout chain.
|
||||
// On failure: fail-safe to normal auth flow (treat as not no-auth).
|
||||
const { data: globalConfigData, isLoading: isFetchingGlobalConfig } =
|
||||
useGetGlobalConfig({
|
||||
query: {
|
||||
retry: 2,
|
||||
retryDelay: 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingGlobalConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const impersonationEnabled =
|
||||
globalConfigData?.data?.identN?.impersonation?.enabled === true;
|
||||
|
||||
if (impersonationEnabled) {
|
||||
clearAuthStorage();
|
||||
setLocalStorageApi(LOCALSTORAGE.IS_NO_AUTH_MODE, 'true');
|
||||
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
|
||||
setIsNoAuthMode(true);
|
||||
setIsLoggedIn(true);
|
||||
} else {
|
||||
setLocalStorageApi(LOCALSTORAGE.IS_NO_AUTH_MODE, 'false');
|
||||
setIsNoAuthMode(false);
|
||||
}
|
||||
|
||||
setIsPreflightLoading(false);
|
||||
}, [globalConfigData, isFetchingGlobalConfig]);
|
||||
|
||||
// fetcher for current user
|
||||
// user will only be fetched if the user id and token is present
|
||||
// if logged out and trying to hit any route none of these calls will trigger
|
||||
@@ -383,9 +342,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
|
||||
// global event listener for LOGOUT event to clean the app context state
|
||||
useGlobalEventListener('LOGOUT', () => {
|
||||
if (isNoAuthMode) {
|
||||
return;
|
||||
} // logout is meaningless in no-auth; defensively no-op
|
||||
setIsLoggedIn(false);
|
||||
setDefaultUser(getUserDefaults());
|
||||
setActiveLicense(null);
|
||||
@@ -404,8 +360,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
trialInfo,
|
||||
orgPreferences,
|
||||
isLoggedIn,
|
||||
isNoAuthMode,
|
||||
isPreflightLoading,
|
||||
org,
|
||||
isFetchingUser,
|
||||
isFetchingActiveLicense,
|
||||
@@ -441,8 +395,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUser,
|
||||
isLoggedIn,
|
||||
isNoAuthMode,
|
||||
isPreflightLoading,
|
||||
org,
|
||||
orgPreferences,
|
||||
activeLicenseRefetch,
|
||||
|
||||
@@ -18,8 +18,6 @@ export interface IAppContext {
|
||||
orgPreferences: OrgPreference[] | null;
|
||||
userPreferences: UserPreference[] | null;
|
||||
isLoggedIn: boolean;
|
||||
isNoAuthMode: boolean;
|
||||
isPreflightLoading: boolean;
|
||||
org: Organization[] | null;
|
||||
isFetchingUser: boolean;
|
||||
isFetchingActiveLicense: boolean;
|
||||
|
||||
@@ -237,8 +237,6 @@ export function getAppContextMock(
|
||||
isFetchingOrgPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isNoAuthMode: false,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: jest.fn(),
|
||||
updateOrg: jest.fn(),
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { clearAuthStorage } from '../clearAuthStorage';
|
||||
|
||||
describe('clearAuthStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('removes all auth-related localStorage keys', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
|
||||
localStorage.setItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN, 'refresh');
|
||||
localStorage.setItem(LOCALSTORAGE.IS_LOGGED_IN, 'true');
|
||||
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, 'old@example.com');
|
||||
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_NAME, 'old');
|
||||
localStorage.setItem(LOCALSTORAGE.IS_IDENTIFIED_USER, 'true');
|
||||
localStorage.setItem(LOCALSTORAGE.USER_ID, 'abc');
|
||||
|
||||
clearAuthStorage();
|
||||
|
||||
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.IS_LOGGED_IN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_NAME)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.IS_IDENTIFIED_USER)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.USER_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves non-auth localStorage keys', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.THEME, 'dark');
|
||||
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
|
||||
|
||||
clearAuthStorage();
|
||||
|
||||
expect(localStorage.getItem(LOCALSTORAGE.THEME)).toBe('dark');
|
||||
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
const AUTH_KEYS: LOCALSTORAGE[] = [
|
||||
LOCALSTORAGE.AUTH_TOKEN,
|
||||
LOCALSTORAGE.REFRESH_AUTH_TOKEN,
|
||||
LOCALSTORAGE.IS_LOGGED_IN,
|
||||
LOCALSTORAGE.LOGGED_IN_USER_EMAIL,
|
||||
LOCALSTORAGE.LOGGED_IN_USER_NAME,
|
||||
LOCALSTORAGE.IS_IDENTIFIED_USER,
|
||||
LOCALSTORAGE.USER_ID,
|
||||
];
|
||||
|
||||
export const clearAuthStorage = (): void => {
|
||||
AUTH_KEYS.forEach((key) => deleteLocalStorageKey(key));
|
||||
};
|
||||
99
pkg/errors/v2/code.go
Normal file
99
pkg/errors/v2/code.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Code is a dotted, hierarchical identifier registered at process start. It
|
||||
// encodes domain (subsystem), op (verb), optional sub (qualifier), and a
|
||||
// terminal reason. Codes are values; two Codes with the same string are equal
|
||||
// by value and safe to compare with ==.
|
||||
type Code struct{ s string }
|
||||
|
||||
// String returns the dotted code as it appears on the wire. Empty for the
|
||||
// zero value.
|
||||
func (c Code) String() string { return c.s }
|
||||
|
||||
// codePattern allows 2-4 dotted segments, each starting with a lowercase
|
||||
// letter and continuing with [a-z0-9_]. One segment is too broad (use a
|
||||
// domain prefix); five or more means the domain should be split.
|
||||
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$`)
|
||||
|
||||
// Meta is the per-code default envelope applied by constructors before
|
||||
// per-call options. Every field has a natural per-code default — an auth
|
||||
// code always wants Reauthenticate, every documented code wants its docs
|
||||
// URL — so the registry is the right place to declare them once.
|
||||
type Meta struct {
|
||||
Category Category
|
||||
Fault Fault
|
||||
Retry Retry
|
||||
Remediation Remediation
|
||||
Refs map[RefKind]string
|
||||
}
|
||||
|
||||
// Retry tells the caller how and when to retry. After is meaningful only
|
||||
// when Policy == RetryAfter.
|
||||
type Retry struct {
|
||||
Policy RetryPolicy
|
||||
After time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = map[string]Meta{}
|
||||
)
|
||||
|
||||
// Register installs a code with its default Meta and returns the Code value.
|
||||
// It panics on a malformed code string or a duplicate registration — both
|
||||
// indicate a programming error that must be caught at boot, not at first
|
||||
// failure.
|
||||
//
|
||||
// Call from the owning domain's package init or top-level var block:
|
||||
//
|
||||
// var CodeUnknownFunction = errors.Register("query.parse.unknown_function", errors.Meta{
|
||||
// Category: errors.CategoryInvalidInput,
|
||||
// Fault: errors.FaultCaller,
|
||||
// Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
// })
|
||||
func Register(s string, meta Meta) Code {
|
||||
if !codePattern.MatchString(s) {
|
||||
panic("errors/v2: malformed code: " + s)
|
||||
}
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[s]; ok {
|
||||
panic("errors/v2: duplicate code: " + s)
|
||||
}
|
||||
registry[s] = meta
|
||||
return Code{s: s}
|
||||
}
|
||||
|
||||
// MetaOf returns the Meta a code was registered with. Returns the zero Meta
|
||||
// and false for unregistered or zero codes.
|
||||
func MetaOf(c Code) (Meta, bool) {
|
||||
if c.s == "" {
|
||||
return Meta{}, false
|
||||
}
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
m, ok := registry[c.s]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// registerOrGet is the internal idempotent register used by adapters that
|
||||
// may see the same code (e.g. legacy.<v1>) more than once across the process
|
||||
// lifetime. It panics on malformed codes — duplicate codes silently keep the
|
||||
// existing Meta.
|
||||
func registerOrGet(s string, meta Meta) Code {
|
||||
if !codePattern.MatchString(s) {
|
||||
panic("errors/v2: malformed code: " + s)
|
||||
}
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[s]; !ok {
|
||||
registry[s] = meta
|
||||
}
|
||||
return Code{s: s}
|
||||
}
|
||||
93
pkg/errors/v2/enums.go
Normal file
93
pkg/errors/v2/enums.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package v2
|
||||
|
||||
// The enums in this file are closed sets. Each value is a package-level var of
|
||||
// an unexported-field struct, so external code cannot synthesize new values —
|
||||
// it must reference one of the defined ones. String() returns the stable
|
||||
// snake_case wire name; once shipped, those names are append-only.
|
||||
|
||||
// Category groups errors by what kind of failure occurred. It is the coarsest
|
||||
// branch-worthy axis and is intended to be a superset of gRPC status codes
|
||||
// extended with cases SigNoz cares about (e.g. license issues land under
|
||||
// FailedDependency or ResourceExhausted depending on context).
|
||||
type Category struct{ s string }
|
||||
|
||||
func (c Category) String() string { return c.s }
|
||||
|
||||
var (
|
||||
CategoryInvalidInput = Category{"invalid_input"} // request was malformed or violated a documented constraint.
|
||||
CategoryNotFound = Category{"not_found"} // referenced resource does not exist.
|
||||
CategoryAlreadyExists = Category{"already_exists"} // resource creation conflicts with an existing one.
|
||||
CategoryConflict = Category{"conflict"} // concurrent modification or state mismatch (e.g. stale revision).
|
||||
CategoryPrecondition = Category{"precondition"} // a required precondition (system or caller-asserted) was not met.
|
||||
CategoryUnauthenticated = Category{"unauthenticated"} // credentials are missing or invalid.
|
||||
CategoryForbidden = Category{"forbidden"} // authenticated but not authorized for this action.
|
||||
CategoryResourceExhausted = Category{"resource_exhausted"} // quota, rate limit, or other budget exceeded.
|
||||
CategoryFailedDependency = Category{"failed_dependency"} // an upstream service we depend on failed (db, license, etc.).
|
||||
CategoryUnavailable = Category{"unavailable"} // service is temporarily down; retry with backoff.
|
||||
CategoryTimeout = Category{"timeout"} // deadline exceeded before the operation completed.
|
||||
CategoryCanceled = Category{"canceled"} // caller or context canceled the operation.
|
||||
CategoryUnimplemented = Category{"unimplemented"} // operation is not supported (or not yet) by this server.
|
||||
CategoryDataLoss = Category{"data_loss"} // unrecoverable data corruption or loss detected.
|
||||
CategoryInternal = Category{"internal"} // bug — invariant broken; should not occur in normal operation.
|
||||
)
|
||||
|
||||
// Fault attributes responsibility. An agent uses this to decide whether to
|
||||
// fix the request (Caller), retry/escalate (Server, Upstream), or page a
|
||||
// human (Operator).
|
||||
type Fault struct{ s string }
|
||||
|
||||
func (f Fault) String() string { return f.s }
|
||||
|
||||
var (
|
||||
FaultCaller = Fault{"caller"}
|
||||
FaultServer = Fault{"server"}
|
||||
FaultUpstream = Fault{"upstream"}
|
||||
FaultOperator = Fault{"operator"}
|
||||
)
|
||||
|
||||
// RetryPolicy tells the caller how to behave on retry. Backoff implies the
|
||||
// caller should use its own backoff schedule; After means honor Retry.After
|
||||
// exactly; AfterFix and AfterAuth signal that retry is pointless until the
|
||||
// caller fixes the request or re-authenticates.
|
||||
type RetryPolicy struct{ s string }
|
||||
|
||||
func (r RetryPolicy) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RetryNever = RetryPolicy{"never"}
|
||||
RetryImmediate = RetryPolicy{"immediate"}
|
||||
RetryBackoff = RetryPolicy{"backoff"}
|
||||
RetryAfter = RetryPolicy{"after"}
|
||||
RetryAfterFix = RetryPolicy{"after_fix"}
|
||||
RetryAfterAuth = RetryPolicy{"after_auth"}
|
||||
)
|
||||
|
||||
// Remediation names the single recommended next action. It does not execute.
|
||||
type Remediation struct{ s string }
|
||||
|
||||
func (r Remediation) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RemediationNone = Remediation{"none"}
|
||||
RemediationFixInput = Remediation{"fix_input"}
|
||||
RemediationReauthenticate = Remediation{"reauthenticate"}
|
||||
RemediationWaitAndRetry = Remediation{"wait_and_retry"}
|
||||
RemediationFailover = Remediation{"failover"}
|
||||
RemediationContactOperator = Remediation{"contact_operator"}
|
||||
RemediationFileBug = Remediation{"file_bug"}
|
||||
RemediationUpgradeLicense = Remediation{"upgrade_license"}
|
||||
)
|
||||
|
||||
// RefKind classifies a reference URL attached to the error.
|
||||
type RefKind struct{ s string }
|
||||
|
||||
func (r RefKind) String() string { return r.s }
|
||||
|
||||
var (
|
||||
RefDocs = RefKind{"docs"}
|
||||
RefRunbook = RefKind{"runbook"}
|
||||
RefDashboard = RefKind{"dashboard"}
|
||||
RefTrace = RefKind{"trace"}
|
||||
RefSource = RefKind{"source"}
|
||||
RefIssue = RefKind{"issue"}
|
||||
)
|
||||
248
pkg/errors/v2/error.go
Normal file
248
pkg/errors/v2/error.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Package v2 is the redesigned pkg/errors.
|
||||
//
|
||||
// Every branch-worthy field on the Error struct is a closed enum and every
|
||||
// variable part is a typed key/value. The intent is to make errors first-class
|
||||
// data for programmatic consumers — SDK clients, UI surfaces, alerting, and
|
||||
// LLM agents — without sacrificing human readability.
|
||||
//
|
||||
// Domain and op are encoded into Code (e.g. "query.parse.unknown_function")
|
||||
// rather than carried as separate struct fields. Frames[0] is the
|
||||
// authoritative call-site location, captured at construction time.
|
||||
package v2
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Error is the redesigned error value. *Error is the canonical form passed
|
||||
// around — the zero value is unused, construct via New / Newf / Wrap / Wrapf.
|
||||
//
|
||||
// Frames are intentionally not a struct field: resolving captured PCs into
|
||||
// func/file/line is the dominant construction cost, so we capture PCs eagerly
|
||||
// at construction time (so the snapshot is faithful to the call site) and
|
||||
// resolve them lazily via Frames() only when something actually inspects them.
|
||||
type Error struct {
|
||||
// WHAT
|
||||
Category Category
|
||||
Code Code
|
||||
Title string
|
||||
Detail string
|
||||
|
||||
// WHY / WHO
|
||||
Cause error
|
||||
Fault Fault
|
||||
|
||||
// WHAT NEXT
|
||||
Retry Retry
|
||||
Remediation Remediation
|
||||
Refs map[RefKind]string
|
||||
|
||||
// CONTEXT
|
||||
Attrs map[string]any
|
||||
TraceID string
|
||||
SpanID string
|
||||
|
||||
// stack is the captured PCs plus a memoized []Frame; never read directly,
|
||||
// always go through Frames().
|
||||
stack *frameStack
|
||||
}
|
||||
|
||||
// Frames returns the captured stack, resolved to func/file/line on first
|
||||
// access. Frames[0] is the constructor's caller. Safe for concurrent use.
|
||||
func (e *Error) Frames() []Frame {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.stack.frames()
|
||||
}
|
||||
|
||||
// New creates an Error for a registered Code. Defaults from the registered
|
||||
// Meta are applied first; opts override per call site.
|
||||
func New(code Code, title string, opts ...Option) *Error {
|
||||
e := &Error{Code: code, Title: title, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Newf is New with fmt.Sprintf-style formatting for the title.
|
||||
func Newf(code Code, format string, args ...any) *Error {
|
||||
e := &Error{Code: code, Title: fmt.Sprintf(format, args...), stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// Wrap creates an Error that wraps cause. The new error's Title is the
|
||||
// caller-supplied title (not the cause's message), so Error() reports what
|
||||
// went wrong at this layer — the cause is reachable via Unwrap.
|
||||
func Wrap(cause error, code Code, title string, opts ...Option) *Error {
|
||||
e := &Error{Code: code, Title: title, Cause: cause, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Wrapf is Wrap with fmt.Sprintf-style formatting for the title.
|
||||
func Wrapf(cause error, code Code, format string, args ...any) *Error {
|
||||
e := &Error{Code: code, Title: fmt.Sprintf(format, args...), Cause: cause, stack: captureStack(3)}
|
||||
applyMeta(e)
|
||||
return e
|
||||
}
|
||||
|
||||
// applyMeta copies default values from the registered Meta into a fresh
|
||||
// Error. It runs before per-call options so options win.
|
||||
func applyMeta(e *Error) {
|
||||
meta, ok := MetaOf(e.Code)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if (e.Category == Category{}) {
|
||||
e.Category = meta.Category
|
||||
}
|
||||
if (e.Fault == Fault{}) {
|
||||
e.Fault = meta.Fault
|
||||
}
|
||||
if (e.Retry == Retry{}) {
|
||||
e.Retry = meta.Retry
|
||||
}
|
||||
if (e.Remediation == Remediation{}) {
|
||||
e.Remediation = meta.Remediation
|
||||
}
|
||||
if len(meta.Refs) > 0 {
|
||||
if e.Refs == nil {
|
||||
e.Refs = make(map[RefKind]string, len(meta.Refs))
|
||||
}
|
||||
for k, v := range meta.Refs {
|
||||
if _, exists := e.Refs[k]; !exists {
|
||||
e.Refs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns the Title (the message specifically attached at this wrap
|
||||
// site), not the cause's message. This fixes the v1 surprise where Error()
|
||||
// returned the wrapped cause's text.
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return e.Title
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped cause, enabling errors.Is / errors.As.
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Format implements fmt.Formatter.
|
||||
//
|
||||
// %s, %v → Title only
|
||||
// %+v → full chain: code, title, frames, attrs, recursive cause
|
||||
func (e *Error) Format(f fmt.State, verb rune) {
|
||||
switch verb {
|
||||
case 's':
|
||||
_, _ = io.WriteString(f, e.Title)
|
||||
case 'v':
|
||||
if f.Flag('+') {
|
||||
_, _ = io.WriteString(f, e.fullString())
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(f, e.Title)
|
||||
case 'q':
|
||||
fmt.Fprintf(f, "%q", e.Title)
|
||||
default:
|
||||
fmt.Fprintf(f, "%%!%c(*errors/v2.Error)", verb)
|
||||
}
|
||||
}
|
||||
|
||||
// fullString produces the %+v rendering. Format is intentionally
|
||||
// human-readable rather than machine-parseable; consumers that want structure
|
||||
// should marshal to JSON.
|
||||
func (e *Error) fullString() string {
|
||||
var b strings.Builder
|
||||
e.appendFull(&b, 0)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (e *Error) appendFull(b *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
fmt.Fprintf(b, "%s[%s] %s\n", indent, e.Code.s, e.Title)
|
||||
if e.Detail != "" {
|
||||
fmt.Fprintf(b, "%s detail: %s\n", indent, e.Detail)
|
||||
}
|
||||
if len(e.Attrs) > 0 {
|
||||
// Stable key order for deterministic output.
|
||||
keys := make([]string, 0, len(e.Attrs))
|
||||
for k := range e.Attrs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Fprintf(b, "%s attrs:\n", indent)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(b, "%s %s=%v\n", indent, k, e.Attrs[k])
|
||||
}
|
||||
}
|
||||
if frames := e.Frames(); len(frames) > 0 {
|
||||
fmt.Fprintf(b, "%s frames:\n", indent)
|
||||
for _, fr := range frames {
|
||||
fmt.Fprintf(b, "%s %s\n%s %s:%s\n", indent, fr.Func, indent, fr.File, strconv.Itoa(fr.Line))
|
||||
}
|
||||
}
|
||||
if e.Cause != nil {
|
||||
fmt.Fprintf(b, "%scaused by:\n", indent)
|
||||
var ce *Error
|
||||
if stderrors.As(e.Cause, &ce) && ce != nil {
|
||||
ce.appendFull(b, depth+1)
|
||||
} else {
|
||||
fmt.Fprintf(b, "%s %s\n", indent, e.Cause.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AsError extracts a *Error from anywhere in err's wrap chain. It is the
|
||||
// common shortcut around errors.As for code that always wants this package's
|
||||
// type.
|
||||
func AsError(err error) (*Error, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
var e *Error
|
||||
if stderrors.As(err, &e) {
|
||||
return e, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Is reports whether err or any error in its chain has the given Code.
|
||||
// Convenience wrapper that's friendlier than errors.As at call sites that
|
||||
// only care about code identity.
|
||||
func Is(err error, code Code) bool {
|
||||
e, ok := AsError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for e != nil {
|
||||
if e.Code == code {
|
||||
return true
|
||||
}
|
||||
next, ok := AsError(e.Cause)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = next
|
||||
}
|
||||
return false
|
||||
}
|
||||
90
pkg/errors/v2/example.go
Normal file
90
pkg/errors/v2/example.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package v2
|
||||
|
||||
// This file is a self-contained walkthrough of how a domain integrates with
|
||||
// pkg/errors/v2. It mirrors what a real pkg/<domain>/errors.go looks like in
|
||||
// practice — registering codes, constructing typed errors at failure sites,
|
||||
// and consuming them at API boundaries. The "example.*" namespace is reserved
|
||||
// for these demo codes so they never collide with a real domain's
|
||||
// registrations.
|
||||
|
||||
// 1. Register codes at package init time. Each Register call panics on
|
||||
// malformed code or duplicate registration, so misconfiguration is caught
|
||||
// at process boot, not at first failure.
|
||||
var (
|
||||
// A caller-fault, fix-the-input error: rejected before any work happens.
|
||||
exampleCodeInvalidQuery = Register("example.query.invalid_filter", Meta{
|
||||
Category: CategoryInvalidInput,
|
||||
Fault: FaultCaller,
|
||||
Remediation: RemediationFixInput,
|
||||
Retry: Retry{Policy: RetryAfterFix},
|
||||
Refs: map[RefKind]string{
|
||||
RefDocs: "https://signoz.io/docs/query/filters",
|
||||
},
|
||||
})
|
||||
|
||||
// A quota error: the caller's request was well-formed but their plan
|
||||
// doesn't allow it. The recommended remediation is structural (upgrade),
|
||||
// not "try again later."
|
||||
exampleCodeQuotaExceeded = Register("example.billing.quota_exceeded", Meta{
|
||||
Category: CategoryResourceExhausted,
|
||||
Fault: FaultCaller,
|
||||
Remediation: RemediationUpgradeLicense,
|
||||
Retry: Retry{Policy: RetryNever},
|
||||
})
|
||||
)
|
||||
|
||||
// 2. Construct errors at the failure site. Notice that variable parts of
|
||||
// the message (the offending field, the limits) live in typed Attrs, not in
|
||||
// the title prose — a downstream agent can read them without parsing English.
|
||||
func exampleRejectInvalidFilter(field string) *Error {
|
||||
return New(exampleCodeInvalidQuery, "filter is not supported",
|
||||
WithAttr("field", field),
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Consume errors at the API boundary. Branching on Category gives the
|
||||
// HTTP status; Retry tells an SDK how to behave; Fault drives logging
|
||||
// classification (caller errors are warnings, server/upstream errors page).
|
||||
func exampleClassifyForHTTP(err error) (status int, retry RetryPolicy) {
|
||||
e, ok := AsError(err)
|
||||
if !ok {
|
||||
return 500, RetryNever
|
||||
}
|
||||
switch e.Category {
|
||||
case CategoryInvalidInput, CategoryPrecondition:
|
||||
status = 400
|
||||
case CategoryUnauthenticated:
|
||||
status = 401
|
||||
case CategoryForbidden:
|
||||
status = 403
|
||||
case CategoryNotFound:
|
||||
status = 404
|
||||
case CategoryConflict, CategoryAlreadyExists:
|
||||
status = 409
|
||||
case CategoryResourceExhausted:
|
||||
status = 429
|
||||
case CategoryUnavailable, CategoryTimeout:
|
||||
status = 503
|
||||
case CategoryUnimplemented:
|
||||
status = 501
|
||||
default:
|
||||
status = 500
|
||||
}
|
||||
return status, e.Retry.Policy
|
||||
}
|
||||
|
||||
// 4. Identify a specific failure mode by Code. Is walks the cause chain so
|
||||
// a wrapper at the HTTP layer still matches when the root cause was raised
|
||||
// deep in the call graph.
|
||||
func exampleIsQuotaExceeded(err error) bool {
|
||||
return Is(err, exampleCodeQuotaExceeded)
|
||||
}
|
||||
|
||||
// The example helpers are reference-only: they exist to document call-site
|
||||
// patterns, not to be called from anywhere in the binary. This anchor keeps
|
||||
// them visible to readers (and the linter) without exporting demo code.
|
||||
var _ = []any{
|
||||
exampleRejectInvalidFilter,
|
||||
exampleClassifyForHTTP,
|
||||
exampleIsQuotaExceeded,
|
||||
}
|
||||
65
pkg/errors/v2/frame.go
Normal file
65
pkg/errors/v2/frame.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Frame is a single line in the call stack. Frames[0] is the constructor's
|
||||
// caller — the authoritative "where this error came from" — and downstream
|
||||
// consumers can filter (e.g. "frames inside our code") without regex
|
||||
// reparsing of a pre-formatted stack string.
|
||||
type Frame struct {
|
||||
Func string `json:"func,omitempty"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// frameStack carries the PCs captured at construction plus the resolved
|
||||
// []Frame slice, behind a sync.Once. Resolving frames into func/file/line is
|
||||
// expensive (runtime.CallersFrames walks the symbol table); the vast majority
|
||||
// of errors are constructed and never inspected, so we only pay that cost
|
||||
// when a consumer actually asks for frames (Frames()/MarshalJSON/%+v).
|
||||
//
|
||||
// The PC capture itself is cheap and happens at construction so that
|
||||
// Frames[0] is a faithful "where" record of the original call site.
|
||||
type frameStack struct {
|
||||
pcs []uintptr
|
||||
|
||||
once sync.Once
|
||||
resolved []Frame
|
||||
}
|
||||
|
||||
// captureStack is called by every constructor. skip drops runtime.Callers,
|
||||
// captureStack itself, and the constructor frame so that the first PC is the
|
||||
// user code that invoked the constructor.
|
||||
func captureStack(skip int) *frameStack {
|
||||
const depth = 32
|
||||
pcs := make([]uintptr, depth)
|
||||
n := runtime.Callers(skip, pcs)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return &frameStack{pcs: pcs[:n:n]}
|
||||
}
|
||||
|
||||
// frames resolves the captured PCs into []Frame. The resolution is memoized
|
||||
// — concurrent calls are safe and only one of them does the work.
|
||||
func (s *frameStack) frames() []Frame {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.once.Do(func() {
|
||||
cf := runtime.CallersFrames(s.pcs)
|
||||
out := make([]Frame, 0, len(s.pcs))
|
||||
for {
|
||||
f, more := cf.Next()
|
||||
out = append(out, Frame{Func: f.Function, File: f.File, Line: f.Line})
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.resolved = out
|
||||
})
|
||||
return s.resolved
|
||||
}
|
||||
175
pkg/errors/v2/http.go
Normal file
175
pkg/errors/v2/http.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// CodeUnknown is the sentinel returned when AsJSON / AsURLValues are called
|
||||
// on a non-*Error. A consumer that sees this on the wire should read it as
|
||||
// "the producer did not raise a v2 Error and we projected it through the
|
||||
// fallback path" — i.e. somewhere upstream is still using std errors or v1.
|
||||
var CodeUnknown = Register("unknown.unset", Meta{
|
||||
Category: CategoryInternal,
|
||||
Fault: FaultServer,
|
||||
Retry: Retry{Policy: RetryNever},
|
||||
})
|
||||
|
||||
// JSON is the wire envelope for an Error. It is intentionally a superset of
|
||||
// v1's pkg/errors.JSON: SDK clients that only read v1's {code, message, url,
|
||||
// errors[]} keep working, while v2 consumers can branch on the new typed
|
||||
// fields (category, fault, retry, remediation, attrs, refs, cause).
|
||||
type JSON struct {
|
||||
Code string `json:"code" required:"true"`
|
||||
Title string `json:"title" required:"true"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Fault string `json:"fault,omitempty"`
|
||||
Retry *RetryJSON `json:"retry,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Attrs map[string]any `json:"attrs,omitempty"`
|
||||
Refs map[string]string `json:"refs,omitempty"`
|
||||
Frames []Frame `json:"frames,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
SpanID string `json:"span_id,omitempty"`
|
||||
Cause *CauseJSON `json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
// RetryJSON renders Retry as an object so consumers can branch on policy
|
||||
// before consulting AfterMS. AfterMS is omitted unless policy is "after".
|
||||
type RetryJSON struct {
|
||||
Policy string `json:"policy"`
|
||||
AfterMS int64 `json:"after_ms,omitempty"`
|
||||
}
|
||||
|
||||
// CauseJSON is the thin recursive shape for a cause chain. Only code, title,
|
||||
// and a nested cause are guaranteed — producers may add more, consumers must
|
||||
// not rely on it.
|
||||
type CauseJSON struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Cause *CauseJSON `json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
// AsJSON projects any error onto the v2 wire envelope. If cause is a
|
||||
// *Error (anywhere in its wrap chain) every field is filled from it;
|
||||
// otherwise the result is a CodeUnknown envelope with Title=cause.Error()
|
||||
// so the wire shape is always valid and never panics.
|
||||
func AsJSON(cause error) *JSON {
|
||||
if cause == nil {
|
||||
return nil
|
||||
}
|
||||
e, ok := AsError(cause)
|
||||
if !ok {
|
||||
return &JSON{
|
||||
Code: CodeUnknown.s,
|
||||
Title: cause.Error(),
|
||||
Category: CategoryInternal.s,
|
||||
Fault: FaultServer.s,
|
||||
}
|
||||
}
|
||||
return errorToJSON(e)
|
||||
}
|
||||
|
||||
func errorToJSON(e *Error) *JSON {
|
||||
out := &JSON{
|
||||
Code: e.Code.s,
|
||||
Title: e.Title,
|
||||
Detail: e.Detail,
|
||||
Category: e.Category.s,
|
||||
Fault: e.Fault.s,
|
||||
Remediation: e.Remediation.s,
|
||||
Attrs: e.Attrs,
|
||||
TraceID: e.TraceID,
|
||||
SpanID: e.SpanID,
|
||||
}
|
||||
if (e.Retry.Policy != RetryPolicy{}) {
|
||||
out.Retry = &RetryJSON{Policy: e.Retry.Policy.s}
|
||||
if e.Retry.Policy == RetryAfter && e.Retry.After > 0 {
|
||||
out.Retry.AfterMS = e.Retry.After.Milliseconds()
|
||||
}
|
||||
}
|
||||
if len(e.Refs) > 0 {
|
||||
out.Refs = make(map[string]string, len(e.Refs))
|
||||
for k, v := range e.Refs {
|
||||
out.Refs[k.s] = v
|
||||
}
|
||||
}
|
||||
if frames := e.Frames(); len(frames) > 0 {
|
||||
out.Frames = frames
|
||||
}
|
||||
if e.Cause != nil {
|
||||
out.Cause = causeToJSON(e.Cause)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func causeToJSON(err error) *CauseJSON {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if e, ok := err.(*Error); ok {
|
||||
c := &CauseJSON{Code: e.Code.s, Title: e.Title}
|
||||
if e.Cause != nil {
|
||||
c.Cause = causeToJSON(e.Cause)
|
||||
}
|
||||
return c
|
||||
}
|
||||
// Non-*Error leaf: only Title is set, no Code.
|
||||
return &CauseJSON{Title: err.Error()}
|
||||
}
|
||||
|
||||
// AsURLValues projects an error onto a flat url.Values, matching v1's shape
|
||||
// for callers (e.g. OAuth/SSO redirects) that smuggle errors back through a
|
||||
// query string. Complex fields (attrs, refs, retry, frames, cause) are
|
||||
// JSON-marshaled into a single value rather than spread across multiple
|
||||
// keys, since query strings have no good representation for nested data.
|
||||
func AsURLValues(cause error) url.Values {
|
||||
j := AsJSON(cause)
|
||||
if j == nil {
|
||||
return url.Values{}
|
||||
}
|
||||
v := url.Values{
|
||||
"code": {j.Code},
|
||||
"title": {j.Title},
|
||||
}
|
||||
if j.Detail != "" {
|
||||
v.Set("detail", j.Detail)
|
||||
}
|
||||
if j.Category != "" {
|
||||
v.Set("category", j.Category)
|
||||
}
|
||||
if j.Fault != "" {
|
||||
v.Set("fault", j.Fault)
|
||||
}
|
||||
if j.Remediation != "" {
|
||||
v.Set("remediation", j.Remediation)
|
||||
}
|
||||
if j.TraceID != "" {
|
||||
v.Set("trace_id", j.TraceID)
|
||||
}
|
||||
if j.SpanID != "" {
|
||||
v.Set("span_id", j.SpanID)
|
||||
}
|
||||
if j.Retry != nil {
|
||||
if b, err := json.Marshal(j.Retry); err == nil {
|
||||
v.Set("retry", string(b))
|
||||
}
|
||||
}
|
||||
if len(j.Refs) > 0 {
|
||||
if b, err := json.Marshal(j.Refs); err == nil {
|
||||
v.Set("refs", string(b))
|
||||
}
|
||||
}
|
||||
if len(j.Attrs) > 0 {
|
||||
if b, err := json.Marshal(j.Attrs); err == nil {
|
||||
v.Set("attrs", string(b))
|
||||
}
|
||||
}
|
||||
if j.Cause != nil {
|
||||
if b, err := json.Marshal(j.Cause); err == nil {
|
||||
v.Set("cause", string(b))
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
85
pkg/errors/v2/options.go
Normal file
85
pkg/errors/v2/options.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package v2
|
||||
|
||||
import "time"
|
||||
|
||||
// Option mutates an Error during construction. Options are applied after the
|
||||
// registered Meta defaults so a per-call WithFault wins over the code's
|
||||
// default Fault.
|
||||
type Option func(*Error)
|
||||
|
||||
// WithTitle overrides the title (used when Newf's formatted string is not
|
||||
// what you want, or after a Wrap that took its title from the cause).
|
||||
func WithTitle(s string) Option { return func(e *Error) { e.Title = s } }
|
||||
|
||||
// WithDetail adds a long, user-safe explanation. Detail must never include
|
||||
// raw cause text; the cause is already in the chain.
|
||||
func WithDetail(s string) Option { return func(e *Error) { e.Detail = s } }
|
||||
|
||||
// WithCategory overrides the registered Category.
|
||||
func WithCategory(c Category) Option { return func(e *Error) { e.Category = c } }
|
||||
|
||||
// WithFault overrides the registered Fault.
|
||||
func WithFault(f Fault) Option { return func(e *Error) { e.Fault = f } }
|
||||
|
||||
// WithRetry overrides the registered Retry.
|
||||
func WithRetry(r Retry) Option { return func(e *Error) { e.Retry = r } }
|
||||
|
||||
// WithRetryAfter is a convenience for the common RetryAfter case.
|
||||
func WithRetryAfter(d time.Duration) Option {
|
||||
return func(e *Error) { e.Retry = Retry{Policy: RetryAfter, After: d} }
|
||||
}
|
||||
|
||||
// WithRemediation overrides the registered Remediation.
|
||||
//
|
||||
// Convention for "did you mean" hints: stash a []string under
|
||||
// Attrs["suggestions"], ranked best-first. Each element should be a complete,
|
||||
// copy-pasteable replacement — not an explanation of what went wrong (use
|
||||
// WithDetail for that). Once 3-4 domains adopt the convention identically,
|
||||
// promote to a first-class field.
|
||||
func WithRemediation(r Remediation) Option { return func(e *Error) { e.Remediation = r } }
|
||||
|
||||
// WithRef adds (or replaces) a single reference URL keyed by kind.
|
||||
func WithRef(kind RefKind, url string) Option {
|
||||
return func(e *Error) {
|
||||
if e.Refs == nil {
|
||||
e.Refs = make(map[RefKind]string, 1)
|
||||
}
|
||||
e.Refs[kind] = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttr sets a single typed attribute. Prefer typed per-domain helpers
|
||||
// (e.g. WithQueryAttrs(q Query)) over raw WithAttr at call sites — they keep
|
||||
// the attr keys consistent and let the compiler reject typos.
|
||||
func WithAttr(key string, value any) Option {
|
||||
return func(e *Error) {
|
||||
if e.Attrs == nil {
|
||||
e.Attrs = make(map[string]any, 1)
|
||||
}
|
||||
e.Attrs[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// WithAttrs merges a map of attributes; later keys win.
|
||||
func WithAttrs(attrs map[string]any) Option {
|
||||
return func(e *Error) {
|
||||
if len(attrs) == 0 {
|
||||
return
|
||||
}
|
||||
if e.Attrs == nil {
|
||||
e.Attrs = make(map[string]any, len(attrs))
|
||||
}
|
||||
for k, v := range attrs {
|
||||
e.Attrs[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrace stamps the error with OTel trace and span IDs so the JSON
|
||||
// response can link back to the originating span.
|
||||
func WithTrace(traceID, spanID string) Option {
|
||||
return func(e *Error) {
|
||||
e.TraceID = traceID
|
||||
e.SpanID = spanID
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,40 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
errors "github.com/SigNoz/signoz/pkg/errors/v2"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// HAVING-expression validator codes. All three are caller-fault, fix-the-input
|
||||
// errors — the user wrote an expression we cannot turn into SQL — so retry
|
||||
// is pointless until the expression itself changes.
|
||||
var (
|
||||
codeHavingStringLiteral = errors.Register("querybuilder.having.string_literal", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
codeHavingInvalidReference = errors.Register("querybuilder.having.invalid_reference", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
codeHavingSyntaxError = errors.Register("querybuilder.having.syntax_error", errors.Meta{
|
||||
Category: errors.CategoryInvalidInput,
|
||||
Fault: errors.FaultCaller,
|
||||
Retry: errors.Retry{Policy: errors.RetryAfterFix},
|
||||
Remediation: errors.RemediationFixInput,
|
||||
})
|
||||
)
|
||||
|
||||
// havingExpressionRewriteVisitor walks the parse tree of a HavingExpression in a single
|
||||
// pass, simultaneously rewriting user-facing references to their SQL column names and
|
||||
// collecting any references that could not be resolved.
|
||||
@@ -281,10 +306,10 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// This is checked before invalid references so that "contains string literals" takes
|
||||
// priority when a bare string literal is also an unresolvable operand.
|
||||
if v.hasStringLiteral {
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
return "", errors.New(codeHavingStringLiteral,
|
||||
"`Having` expression contains string literals",
|
||||
).WithAdditional("Aggregator results are numeric")
|
||||
errors.WithDetail("Aggregator results are numeric"),
|
||||
)
|
||||
}
|
||||
|
||||
if len(v.invalid) > 0 {
|
||||
@@ -294,7 +319,10 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
validKeys = append(validKeys, k)
|
||||
}
|
||||
sort.Strings(validKeys)
|
||||
additional := []string{"Valid references are: [" + strings.Join(validKeys, ", ") + "]"}
|
||||
opts := []errors.Option{
|
||||
errors.WithAttr("invalid_refs", v.invalid),
|
||||
errors.WithAttr("valid_refs", validKeys),
|
||||
}
|
||||
if len(v.invalid) == 1 {
|
||||
inv := v.invalid[0]
|
||||
// Only suggest for plain identifier typos, not for unresolved function
|
||||
@@ -303,15 +331,13 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// a simple string substitution produce a corrupt expression.
|
||||
isFuncCall := strings.Contains(original, inv+"(")
|
||||
if match, dist := closestMatch(inv, validKeys); !isFuncCall && !strings.Contains(match, "(") && dist <= 3 {
|
||||
corrected := strings.ReplaceAll(original, inv, match)
|
||||
additional = append(additional, "Suggestion: `"+corrected+"`")
|
||||
opts = append(opts, errors.WithAttr("suggestions", []string{strings.ReplaceAll(original, inv, match)}))
|
||||
}
|
||||
}
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Invalid references in `Having` expression: [%s]",
|
||||
strings.Join(v.invalid, ", "),
|
||||
).WithAdditional(additional...)
|
||||
return "", errors.New(codeHavingInvalidReference,
|
||||
fmt.Sprintf("Invalid references in `Having` expression: [%s]", strings.Join(v.invalid, ", ")),
|
||||
opts...,
|
||||
)
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -328,17 +354,20 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
if detail == "" {
|
||||
detail = "check the expression syntax"
|
||||
}
|
||||
additional := []string{detail}
|
||||
opts := []errors.Option{
|
||||
errors.WithDetail(detail),
|
||||
errors.WithAttr("syntax_errors", msgs),
|
||||
}
|
||||
// For single-error expressions, try to produce an actionable suggestion.
|
||||
if len(allSyntaxErrors) == 1 {
|
||||
if s := havingSuggestion(allSyntaxErrors[0], original); s != "" {
|
||||
additional = append(additional, "Suggestion: `"+s+"`")
|
||||
opts = append(opts, errors.WithAttr("suggestions", []string{s}))
|
||||
}
|
||||
}
|
||||
return "", errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
return "", errors.New(codeHavingSyntaxError,
|
||||
"Syntax error in `Having` expression",
|
||||
).WithAdditional(additional...)
|
||||
opts...,
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
Reference in New Issue
Block a user