Compare commits

..

8 Commits

Author SHA1 Message Date
Tushar Vats
411d3e64a4 fix: draft 2026-05-08 14:13:23 +05:30
Deepak Mardi
078b82e957 fix(frontend): simplify password matching logic (#11174)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(frontend): simplify password matching logic

* fix(frontend): address review comments on validation feedback

* refactor(signup): remove redundant password mismatch check
2026-05-06 21:51:08 +00:00
dasmat
72036b42e3 fix(frontend): open trace details in new tab from funnel results (#10999)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-05-06 15:06:48 +00:00
Tushar Vats
9301b2fb1c fix: dashboard date refresh (#11201)
* fix: dashboard invalid date state upon refresh

* fix: dashboard invalid date state upon refresh

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-05-06 09:25:47 +00:00
primus-bot[bot]
6d0e60822c chore(release): bump to v0.122.0 (#11204)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-05-06 08:01:25 +00:00
Abhi kumar
acdaef6c2e chore: added reference to crosspanel sync docs (#11200)
* chore: added reference to crosspanel sync docs

* chore: minor changes
2026-05-06 05:44:03 +00:00
Abhi kumar
a7690bdaa2 fix: added fix for all series in tooltip sync mode (#11197)
* fix: added fix for all series in tooltip sync mode

* chore: minor cleanup
2026-05-06 04:24:33 +00:00
Nityananda Gohain
a7fde606ca fix: use ms in prepareFillZeroArgsWithStep (#11196)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-05-05 18:27:19 +00:00
23 changed files with 1227 additions and 185 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -24,6 +24,43 @@
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import { Col, Input, Radio, Select, Space, Tooltip, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
@@ -10,7 +10,7 @@ import {
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { Check, ExternalLink, Info, X } from '@signozhq/icons';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
@@ -173,9 +173,36 @@ function GeneralDashboardSettings(): JSX.Element {
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<Info size={14} className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>

View File

@@ -0,0 +1,158 @@
import { act, render } from '@testing-library/react';
import { Modal } from 'antd';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useTabVisibility from 'hooks/useTabFocus';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useDashboardQuery } from './useDashboardQuery';
const mockDispatch = jest.fn();
const mockSetDashboardData = jest.fn();
const mockSetLayouts = jest.fn();
const mockSetPanelMap = jest.fn();
const mockResetDashboardStore = jest.fn();
const mockGetUrlVariables = jest.fn();
const mockUpdateUrlVariable = jest.fn();
const mockRefetch = jest.fn();
let mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
let currentQueryData: unknown;
jest.mock('react-i18next', () => ({
useTranslation: (): { t: (key: string) => string } => ({
t: (key: string): string => key,
}),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(() => mockDispatch),
useSelector: jest.fn(
(
selectorFn: (state: { globalTime: typeof mockGlobalTime }) => unknown,
): unknown => selectorFn({ globalTime: mockGlobalTime }),
),
}));
jest.mock('hooks/useTabFocus', () => jest.fn(() => true));
jest.mock('hooks/dashboard/useDashboardVariablesSync', () => ({
useDashboardVariablesSync: jest.fn(),
}));
jest.mock('./useDashboardQuery', () => ({
useDashboardQuery: jest.fn(),
}));
jest.mock('hooks/dashboard/useTransformDashboardVariables', () => ({
useTransformDashboardVariables: jest.fn(),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: jest.fn(),
}));
jest.mock('providers/Dashboard/initializeDefaultVariables', () => ({
initializeDefaultVariables: jest.fn(),
}));
jest.mock('lib/dashboard/getUpdatedLayout', () => ({
getUpdatedLayout: jest.fn(() => []),
}));
jest.mock('providers/Dashboard/util', () => ({
sortLayout: jest.fn((layout) => layout),
}));
jest.mock('lib/getMinMax', () => ({
getMinMaxForSelectedTime: jest.fn(),
}));
function TestComponent({ confirm }: { confirm: typeof Modal.confirm }): null {
useDashboardBootstrap('dashboard-1', { confirm });
return null;
}
describe('useDashboardBootstrap', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
jest.mocked(useDashboardStore as unknown as jest.Mock).mockReturnValue({
setDashboardData: mockSetDashboardData,
setLayouts: mockSetLayouts,
setPanelMap: mockSetPanelMap,
resetDashboardStore: mockResetDashboardStore,
});
jest
.mocked(useTransformDashboardVariables as unknown as jest.Mock)
.mockReturnValue({
getUrlVariables: mockGetUrlVariables,
updateUrlVariable: mockUpdateUrlVariable,
transformDashboardVariables: <T,>(data: T): T => data,
});
jest.mocked(useTabVisibility as unknown as jest.Mock).mockReturnValue(true);
jest
.mocked(useDashboardQuery as unknown as jest.Mock)
.mockImplementation(() => ({
data: currentQueryData,
isLoading: false,
isError: false,
isFetching: false,
error: null,
refetch: mockRefetch,
}));
});
it('keeps minTime and maxTime unchanged for custom range on refresh confirm', () => {
const initialDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T00:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const updatedDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T01:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const mockConfirm = jest.fn<
ReturnType<typeof Modal.confirm>,
Parameters<typeof Modal.confirm>
>(() => ({ destroy: jest.fn(), update: jest.fn() }));
currentQueryData = { data: initialDashboard };
const { rerender } = render(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).not.toHaveBeenCalled();
currentQueryData = { data: updatedDashboard };
rerender(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).toHaveBeenCalledTimes(1);
const firstCall = mockConfirm.mock.calls[0];
expect(firstCall).toBeDefined();
const [confirmProps] = firstCall as Parameters<typeof Modal.confirm>;
act(() => {
confirmProps.onOk?.();
});
expect(getMinMaxForSelectedTime).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'UPDATE_TIME_INTERVAL',
payload: {
selectedTime: 'custom',
minTime: mockGlobalTime.minTime,
maxTime: mockGlobalTime.maxTime,
},
});
});
});

View File

@@ -102,11 +102,19 @@ export function useDashboardBootstrap(
onOk() {
setDashboardData(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
const { maxTime, minTime } =
globalTime.selectedTime === 'custom'
? {
// For custom ranges, min/max are already stored in nanoseconds.
// Recomputing via getMinMaxForSelectedTime would multiply them again.
maxTime: globalTime.maxTime,
minTime: globalTime.minTime,
}
: getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: { maxTime, minTime, selectedTime: globalTime.selectedTime },

View File

@@ -24,10 +24,15 @@ export default function Tooltip({
);
const showHeader = showTooltipHeader || activeItem != null;
// With a single series the active item is fully represented in the header —
// hide the divider and list to avoid showing a duplicate row.
const showList = tooltipContent.length > 1;
const showDivider = showList && showHeader;
// A single row collapses into the header when it's the active item, but
// must stay in the list when there's no active item (e.g. sync-driven
// tooltips with no focused series) — otherwise the row would vanish.
const showList =
tooltipContent.length > 1 ||
(tooltipContent.length === 1 && activeItem == null);
// The divider separates the active row in the header from the list; with
// no active item it has nothing to separate.
const showDivider = showList && showHeader && activeItem != null;
return (
<div

View File

@@ -4,7 +4,6 @@ import cx from 'classnames';
import uPlot from 'uplot';
import logEvent from 'api/common/logEvent';
import { syncCursorRegistry } from './syncCursorRegistry';
import { createSyncDisplayHook } from './syncDisplayHook';
import {
createInitialControllerState,
@@ -13,7 +12,6 @@ import {
createSetSeriesHandler,
getPlot,
isScrollEventInPlot,
shouldShowTooltipForSync,
updatePlotVisibility,
updateWindowSize,
} from './tooltipController';
@@ -302,17 +300,49 @@ export default function TooltipPlugin({
}
};
// Returns false when the cursor is offscreen so the caller can bail
// without scheduling side effects.
function pinAtCursor(): boolean {
const plot = getPlot(controller);
if (!plot) {
return false;
// Handles all tooltip-pin keyboard interactions:
// Escape — always releases the tooltip when pinned (never steals Escape
// from other handlers since we do not call stopPropagation).
// pinKey — toggles: pins when hovering+unpinned, unpins when pinned.
const handleKeyDown = (event: KeyboardEvent): void => {
// Escape: release-only (never toggles on).
if (event.key === 'Escape') {
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
});
dismissTooltip();
}
return;
}
if (event.key.toLowerCase() !== pinKey.toLowerCase()) {
return;
}
// Toggle off: P pressed while already pinned.
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
});
dismissTooltip();
return;
}
// Toggle on: P pressed while hovering.
const plot = getPlot(controller);
if (
!plot ||
!controller.hoverActive ||
controller.focusedSeriesIndex == null
) {
return;
}
const cursorLeft = plot.cursor.left ?? -1;
const cursorTop = plot.cursor.top ?? -1;
if (cursorLeft < 0 || cursorTop < 0) {
return false;
return;
}
const plotRect = plot.over.getBoundingClientRect();
@@ -330,71 +360,8 @@ export default function TooltipPlugin({
id: config.getId(),
});
scheduleRender(true);
return true;
}
// Escape always releases (never pins); pinKey toggles. Unpin fans out
// naturally — every pinned panel's document listener sees the same key
// event and dismisses itself — so only pinning needs a broadcast.
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
});
dismissTooltip();
}
return;
}
if (event.key.toLowerCase() !== pinKey.toLowerCase()) {
return;
}
if (controller.pinned) {
logEvent(Events.TOOLTIP_UNPINNED, {
id: config.getId(),
});
dismissTooltip();
return;
}
if (!controller.hoverActive || controller.focusedSeriesIndex == null) {
return;
}
if (!pinAtCursor()) {
return;
}
// Deferred to a macrotask so the same P keydown finishes dispatching
// to every panel's listener first. A microtask is not enough — those
// run between listener invocations, and once a receiver's `pinned`
// flips, its own listener takes the unpin branch on the same event.
if (syncTooltipWithDashboard) {
setTimeout(() => {
syncCursorRegistry.broadcastPin(syncKey);
}, 0);
}
};
// Skips the focusedSeriesIndex guard so single-series or no-overlap
// receivers still pin at the synced timestamp.
const handleSyncPinBroadcast = (): void => {
if (controller.pinned) {
return;
}
if (!shouldShowTooltipForSync(controller, syncTooltipWithDashboard)) {
return;
}
pinAtCursor();
};
const unsubscribeSyncPin =
syncTooltipWithDashboard && canPinTooltip
? syncCursorRegistry.subscribePin(syncKey, handleSyncPinBroadcast)
: null;
// Forward overlay clicks to the consumer-provided onClick callback.
const handleOverClick = (event: MouseEvent): void => {
const plot = getPlot(controller);
@@ -486,7 +453,6 @@ export default function TooltipPlugin({
removeSetLegendHook();
removeSetCursorHook();
removeSyncDisplayHook?.();
unsubscribeSyncPin?.();
if (overClickHandler) {
const plot = getPlot(controller);
plot?.over.removeEventListener('click', overClickHandler);

View File

@@ -17,9 +17,6 @@ const activeSeriesMetricBySyncKey = new Map<
Record<string, string> | null
>();
type SyncPinListener = () => void;
const pinListenersBySyncKey = new Map<string, Set<SyncPinListener>>();
export const syncCursorRegistry = {
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
metadataBySyncKey.set(syncKey, metadata);
@@ -39,20 +36,4 @@ export const syncCursorRegistry = {
getActiveSeriesMetric(syncKey: string): Record<string, string> | null {
return activeSeriesMetricBySyncKey.get(syncKey) ?? null;
},
subscribePin(syncKey: string, listener: SyncPinListener): () => void {
let listeners = pinListenersBySyncKey.get(syncKey);
if (!listeners) {
listeners = new Set();
pinListenersBySyncKey.set(syncKey, listeners);
}
listeners.add(listener);
return (): void => {
pinListenersBySyncKey.get(syncKey)?.delete(listener);
};
},
broadcastPin(syncKey: string): void {
pinListenersBySyncKey.get(syncKey)?.forEach((listener) => listener());
},
};

View File

@@ -137,7 +137,7 @@ function applyReceiverSync({
if (commonKeys.length === 0) {
uPlotInstance.setSeries(null, { focus: false });
return [];
return noMatchResult;
}
if ((uPlotInstance.cursor.left ?? -1) < 0) {

View File

@@ -8,7 +8,7 @@ import afterLogin from 'AppRoutes/utils';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import { useNotifications } from 'hooks/useNotifications';
import { ArrowRight, CircleAlert } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import APIError from 'types/api/error';
import tvUrl from '@/assets/svgs/tv.svg';
@@ -28,9 +28,8 @@ type FormValues = {
function SignUp(): JSX.Element {
const [loading, setLoading] = useState(false);
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [formError, setFormError] = useState<APIError | null>();
const { notifications } = useNotifications();
@@ -84,35 +83,10 @@ function SignUp(): JSX.Element {
})();
};
const handleValuesChange: (changedValues: Partial<FormValues>) => void = (
changedValues,
) => {
// Clear error if passwords match while typing (but don't set error until blur)
if ('password' in changedValues || 'confirmPassword' in changedValues) {
const { password, confirmPassword } = form.getFieldsValue();
const isPasswordMismatch =
Boolean(confirmPassword) && password !== confirmPassword;
if (password && confirmPassword && password === confirmPassword) {
setConfirmPasswordError(false);
}
}
};
const handlePasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
// Only validate if confirm password has a value
if (confirmPassword) {
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
};
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (password && confirmPassword) {
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
};
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
const isValidForm = useMemo(
(): boolean =>
@@ -120,8 +94,8 @@ function SignUp(): JSX.Element {
Boolean(email?.trim()) &&
Boolean(password?.trim()) &&
Boolean(confirmPassword?.trim()) &&
!confirmPasswordError,
[loading, email, password, confirmPassword, confirmPasswordError],
password === confirmPassword,
[loading, email, password, confirmPassword],
);
return (
@@ -140,12 +114,7 @@ function SignUp(): JSX.Element {
</Typography.Paragraph>
</div>
<FormContainer
onFinish={handleSubmit}
onValuesChange={handleValuesChange}
form={form}
className="signup-form"
>
<FormContainer onFinish={handleSubmit} form={form} className="signup-form">
<div className="signup-form-container">
<div className="signup-form-fields">
<div className="signup-field-container">
@@ -175,7 +144,6 @@ function SignUp(): JSX.Element {
placeholder="Enter new password"
disabled={loading}
className="signup-antd-input"
onBlur={handlePasswordBlur}
/>
</FormContainer.Item>
</div>
@@ -185,6 +153,12 @@ function SignUp(): JSX.Element {
<FormContainer.Item
name="confirmPassword"
validateTrigger="onBlur"
validateStatus={showPasswordMismatchError ? 'error' : undefined}
help={
showPasswordMismatchError
? "Passwords don't match. Please try again."
: undefined
}
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<AntdInput.Password
@@ -193,7 +167,7 @@ function SignUp(): JSX.Element {
placeholder="Confirm your new password"
disabled={loading}
className="signup-antd-input"
onBlur={handleConfirmPasswordBlur}
onBlur={() => setConfirmPasswordTouched(true)}
/>
</FormContainer.Item>
</div>
@@ -205,19 +179,7 @@ function SignUp(): JSX.Element {
your admin for an invite link
</Callout>
{confirmPasswordError && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="signup-error-callout"
>
Passwords don&apos;t match. Please try again.
</Callout>
)}
{formError && !confirmPasswordError && <AuthError error={formError} />}
{formError && <AuthError error={formError} />}
<div className="signup-form-actions">
<Button

View File

@@ -7,7 +7,12 @@ export const topTracesTableColumns = [
dataIndex: 'trace_id',
key: 'trace_id',
render: (traceId: string): JSX.Element => (
<Link to={`/trace/${traceId}`} className="trace-id-cell">
<Link
to={`/trace/${traceId}`}
className="trace-id-cell"
target="_blank"
rel="noopener noreferrer"
>
{traceId}
</Link>
),

99
pkg/errors/v2/code.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/govaluate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -969,11 +970,19 @@ func (q *querier) prepareFillZeroArgsWithStep(functions []qbtypes.Function, req
updatedFunctions := make([]qbtypes.Function, len(functions))
copy(updatedFunctions, functions)
// funcFillZero expects start/end in milliseconds. req.Start/req.End may
// arrive in s/ms/μs/ns depending on the caller; normalize via ToNanoSecs
// (same pattern used elsewhere in the codebase, e.g. RecommendedStepInterval)
// then convert to ms. Without this, an ns payload makes (end-start)/step
// 10^6× too large and OOMs the process.
startMs := querybuilder.ToNanoSecs(req.Start) / 1_000_000
endMs := querybuilder.ToNanoSecs(req.End) / 1_000_000
for i, fn := range updatedFunctions {
if fn.Name == qbtypes.FunctionNameFillZero && len(fn.Args) == 0 {
fn.Args = []qbtypes.FunctionArg{
{Value: float64(req.Start)},
{Value: float64(req.End)},
{Value: float64(startMs)},
{Value: float64(endMs)},
{Value: float64(step)},
}
updatedFunctions[i] = fn

View File

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