Compare commits

...

5 Commits

Author SHA1 Message Date
Tushar Vats
0d633701c4 refactor(qb): build IN as an OR of equalities
The IN and NOT IN cases route each value back through the condition builder
with `=` / `!=`, instead of assembling the comparisons themselves. Whatever a
builder does for a scalar comparison then applies to the list form without
being restated.

Applied to logs, traces, audit and resourcefilter, which all fanned a list out
into per-value comparisons already. Metrics and rulestatehistory build a real
sb.In, so there is nothing to delegate to. telemetrymetadata is left alone as
well: it applies a key-existence guard at a single exit, so a recursed arm
comes back already wrapped, and either the guard nests or the case has to skip
the shared tail and lose the invariant that every case is guarded.

This fixes `body.<path>[*] IN [...]` with use_json_body off, which returned a
500. The list shape made the path extract as Array(String), and comparing that
to each scalar is something ClickHouse rejects outright (code 130); extracting
per value reads the field instead. Covered end-to-end by the new case in
querierlogs/06_json_body.py, which fails on main and passes here.

resourcefilter changes shape without changing results: each value is paired
with its own index filter — `(e1 AND k AND l1) OR (e2 AND k AND l2)` rather
than `(e1 OR e2) AND k AND (l1 OR l2)` — which selects the same rows because
each equality implies its own filter.
2026-08-11 00:58:04 +05:30
Aditya Singh
1a293652f7 fix(sentry): drop benign aborted/cancelled requests from error reporting (#12495)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This PR filters network request aborts via fetch and axios in beforeSend
so they stop surfacing as Sentry issues.
- axios: ECONNABORTED ("Request aborted"), ERR_CANCELED
- native fetch: AbortError

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-10 16:13:14 +00:00
Vikrant Gupta
0f3fb71067 feat(reset-password): use the v2 endpoint on the reset password page (#12492)
#### Description

- The reset password page still called the deprecated `POST
/api/v1/resetPassword`. It now uses the generated `useResetPassword`
hook, which targets `POST /api/v2/factor_password/reset`.
- Deletes the hand-written v1 client and its types; nothing else
referenced them.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/9804f619-5928-4c72-83a2-2aae16855e7f


#### Additional Information

- Manual `loading` and `errorMessage` state give way to the hook's
`isLoading` and `convertToApiError`, matching how `ForgotPassword`
consumes its generated hook.
- Once this merges, `POST /api/v1/resetPassword` has no callers left in
the product.
2026-08-10 15:17:06 +00:00
Aditya Singh
b4f5b3eddf fix(query-builder): normalise doc length in codemirror fixing Selection points outside of document error (#12496)
Setting the query expression to a value containing CRLF line breaks
crashed the search bar with "RangeError: Selection points outside of
document".

CodeMirror normalises CRLF to LF when building a change, so the
resulting document is shorter than the raw string. The selection anchor
used value.length (pre-normalisation), which pointed past the end of the
document.

Build the ChangeSet first and anchor the selection at changes.newLength,
the actual post-change document length. Adds a regression test.


<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
To fix the above mentioned problem, we now switch the cursor position
from `value.length` (which is not yet normalized by CodeMirror) to
`changes.newLength`, which is the normalized length.
Added test case


<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5869

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before


https://github.com/user-attachments/assets/ec7a3182-177f-4545-9bae-83ee0c3a61db

After


https://github.com/user-attachments/assets/06350698-1960-47c2-b65e-81ea2d10b15d
2026-08-10 14:14:28 +00:00
Srikanth Chekuri
b9f4fcd681 chore(telemetrytypes): introduce LogicalField (#12499)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Possible options

1. The compatibility keys maps (the approach already in the code).

`backward_compat_keys.go` makes an alias key at metadata time. We
rejected this option because of evidence. The alias key resolves, but it
reads the wrong data. It prepares to `attributes_string['<alias>']`, and
that physical key does not hold the data.

2. The flat multi-key.

`GetKeys` returns multiple keys in order, and the downstream code uses
the list. The option fails on semantics. It removes one piece of
information that the downstream must have. The downstream must know the
difference between two cases:

- Two keys are the same field with two spellings so we can merge them
into one expression.
- Two keys are different fields with the same name. The condition
builder must make one condition for each key. The operator connects the
conditions.

Three failures show the problem:

- Negative operators connect with OR across the keys. A row that has
only one spelling then always matches. Example: `env != 'prod'` matches
each row that does not have one of the two keys.
- A row that has both spellings with different values gets no clear
result.
- A value position (group-by, select) needs exactly one expression for
one field. A flat list cannot point to that expression.

The information must live somewhere.

3. Annotations on `TelemetryFieldKey`

Maintain the `SemconvMembers` and `SemconvMaterializedColumns` fields on
the keys. The information is the same as in option 4. But it's awkward
because "these N keys are one family, in this order" lives in N copies,
one copy on each key.

4. introduce `LogicalField`

The information is the same as in option 3, but the structure holds it:

- The slice is the ambiguity.
- The group is the family.
- The member order is the precedence. The code sorts the members one
time, by family rank, at construction.
- The members point to the metadata entries. The code copies nothing and
changes nothing.
- The identity (signal, context, data type) is on the group. A merge
across contexts or data types is not possible. The design does not avoid
that merge; the design cannot express it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:40:37 +00:00
18 changed files with 402 additions and 115 deletions

View File

@@ -376,7 +376,19 @@ function App(): JSX.Element {
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event) {
beforeSend(event, hint) {
const error = hint?.originalException as
| { name?: string; code?: string | number }
| undefined;
// Ignore benign aborted/cancelled requests (axios + fetch).
if (error?.code === 'ERR_CANCELED' || error?.code === 'ECONNABORTED') {
return null;
}
if (error?.name === 'AbortError') {
return null;
}
// Drop the event if its level is 'warning' or 'info'
if (event.level === 'warning' || event.level === 'info') {
return null;

View File

@@ -1,31 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/resetPassword';
/**
* @deprecated Use the generated `useResetPassword` hook (or `resetPassword` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const resetPassword = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>(`/resetPassword`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default resetPassword;

View File

@@ -183,15 +183,14 @@ function QuerySearch({
isProgrammaticChangeRef.current = true;
}
const changes = view.state.changes({
from: 0,
to: currentValue.length,
insert: value,
});
view.dispatch({
changes: {
from: 0,
to: currentValue.length,
insert: value,
},
selection: {
anchor: value.length,
},
changes,
selection: { anchor: changes.newLength },
});
},
[],

View File

@@ -301,6 +301,66 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
dispatchSpy.mockRestore();
});
it('does not crash when the expression contains CRLF line breaks (issue #5869)', async () => {
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const initialExpression = "service.name = 'frontend'";
// Filtering on a multi-line log value (CRLF) used to throw
// "RangeError: Selection points outside of document".
const crlfExpression = "body CONTAINS 'line1\r\nline2\r\nline3'";
const baseQueryData = {
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: initialExpression },
};
const { rerender } = render(
<QuerySearch
onChange={onChange}
queryData={baseQueryData}
dataSource={DataSource.LOGS}
/>,
);
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent.textContent || '').toBe(initialExpression);
},
{ timeout: 3000 },
);
rerender(
<QuerySearch
onChange={onChange}
queryData={{ ...baseQueryData, filter: { expression: crlfExpression } }}
dataSource={DataSource.LOGS}
/>,
);
// The programmatic replace dispatched without throwing, and the selection anchor
// stayed within the CRLF-normalized document (the bug set it past the end).
await waitFor(() => {
const spec = dispatchSpy.mock.calls
.map(
(call) =>
call[0] as {
selection?: { anchor?: number };
changes?: { newLength?: number };
},
)
.find((s) => s?.selection?.anchor != null && s?.changes?.newLength != null);
expect(spec).toBeDefined();
expect(spec?.selection?.anchor).toBeLessThanOrEqual(
spec?.changes?.newLength as number,
);
});
dispatchSpy.mockRestore();
});
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions

View File

@@ -33,7 +33,7 @@ jest.mock('hooks/useNotifications', () => ({
}),
}));
const RESET_PASSWORD_ENDPOINT = '*/resetPassword';
const RESET_PASSWORD_ENDPOINT = '*/api/v2/factor_password/reset';
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push

View File

@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Form, Input as AntdInput } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useResetPassword } from 'api/generated/services/users';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
@@ -14,7 +15,6 @@ import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
import { Label } from 'pages/SignUp/styles';
import APIError from 'types/api/error';
import { FormContainer } from './styles';
@@ -26,40 +26,41 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<APIError | null>();
const [isValidPassword, setIsValidPassword] = useState(false);
const [loading, setLoading] = useState(false);
const { t } = useTranslation(['common']);
const { search } = useLocation();
const params = new URLSearchParams(search);
const token = params.get('token');
const { notifications } = useNotifications();
const {
mutate: resetPassword,
isLoading,
error: mutationError,
} = useResetPassword();
const errorMessage = useMemo(
() => convertToApiError(mutationError),
[mutationError],
);
const [form] = Form.useForm<FormValues>();
const handleFormSubmit: () => Promise<void> = async () => {
try {
setLoading(true);
setErrorMessage(null);
const { password } = form.getFieldsValue();
const handleFormSubmit = (): void => {
const { password } = form.getFieldsValue();
await resetPasswordApi({
password,
token: token || '',
});
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
setLoading(false);
} catch (error) {
setLoading(false);
setErrorMessage(error as APIError);
}
resetPassword(
{ data: { password, token: token || '' } },
{
onSuccess: (): void => {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
},
},
);
};
const validatePassword = (): boolean => {
@@ -222,7 +223,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
color="primary"
type="submit"
data-attr="reset-password"
disabled={!isValidPassword || loading}
disabled={!isValidPassword || isLoading}
className="reset-password-submit-button"
suffix={<ArrowRight size={16} />}
>

View File

@@ -1,9 +0,0 @@
export interface Props {
token: string;
password: string;
}
export interface PayloadProps {
data: string;
status: string;
}

View File

@@ -469,6 +469,8 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
expectedErr: nil,
},
{
// The `[*]` path is extracted per value, not as an Array(String) compared to a
// scalar — ClickHouse rejects that outright (code 130).
name: "IN operator with json search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -479,7 +481,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSON_VALUE(body, '$.\"user_names\"[*]') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("user_names[*]")},
},

View File

@@ -180,20 +180,16 @@ func (b *defaultConditionBuilder) conditionForKey(
if !ok {
return "", qbtypes.ErrInValues
}
// each value carries its own index filter, since `=` derives one from the value
inConditions := make([]string, 0, len(values))
for _, v := range values {
inConditions = append(inConditions, sb.E(fieldName, querybuilder.FormatValueForContains(v)))
}
mainCondition := sb.Or(inConditions...)
valConditions := make([]string, 0, len(values))
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
for _, v := range valuesForIndexFilter {
valConditions = append(valConditions, sb.Like(column.Name, v))
cond, err := b.conditionForKey(ctx, startNs, endNs, key, qbtypes.FilterOperatorEqual, v, sb)
if err != nil {
return "", err
}
inConditions = append(inConditions, cond)
}
mainCondition = sb.And(mainCondition, keyIdxFilter, sb.Or(valConditions...))
return mainCondition, nil
return sb.Or(inConditions...), nil
case qbtypes.FilterOperatorNotIn:
values, ok := value.([]any)
if !ok {
@@ -201,17 +197,13 @@ func (b *defaultConditionBuilder) conditionForKey(
}
notInConditions := make([]string, 0, len(values))
for _, v := range values {
notInConditions = append(notInConditions, sb.NE(fieldName, querybuilder.FormatValueForContains(v)))
}
mainCondition := sb.And(notInConditions...)
valConditions := make([]string, 0, len(values))
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
for _, v := range valuesForIndexFilter {
valConditions = append(valConditions, sb.NotLike(column.Name, v))
cond, err := b.conditionForKey(ctx, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, v, sb)
if err != nil {
return "", err
}
notInConditions = append(notInConditions, cond)
}
mainCondition = sb.And(mainCondition, sb.And(valConditions...))
return mainCondition, nil
return sb.And(notInConditions...), nil
case qbtypes.FilterOperatorExists:
return sb.And(

View File

@@ -109,8 +109,8 @@ func TestConditionBuilder(t *testing.T) {
},
op: qbtypes.FilterOperatorIn,
value: []any{"watch", "redis"},
expected: "(simpleJSONExtractString(labels, 'k8s.namespace.name') = ? OR simpleJSONExtractString(labels, 'k8s.namespace.name') = ?) AND labels LIKE ? AND (labels LIKE ? OR labels LIKE ?)",
expectedArgs: []any{"watch", "redis", "%k8s.namespace.name%", "%k8s.namespace.name\":\"watch%", "%k8s.namespace.name\":\"redis%"},
expected: "((simpleJSONExtractString(labels, 'k8s.namespace.name') = ? AND labels LIKE ? AND labels LIKE ?) OR (simpleJSONExtractString(labels, 'k8s.namespace.name') = ? AND labels LIKE ? AND labels LIKE ?))",
expectedArgs: []any{"watch", "%k8s.namespace.name%", "%k8s.namespace.name\":\"watch%", "redis", "%k8s.namespace.name%", "%k8s.namespace.name\":\"redis%"},
},
{
name: "string_not_in",
@@ -120,8 +120,8 @@ func TestConditionBuilder(t *testing.T) {
},
op: qbtypes.FilterOperatorNotIn,
value: []any{"watch", "redis"},
expected: "(simpleJSONExtractString(labels, 'k8s.namespace.name') <> ? AND simpleJSONExtractString(labels, 'k8s.namespace.name') <> ?) AND (labels NOT LIKE ? AND labels NOT LIKE ?)",
expectedArgs: []any{"watch", "redis", "%k8s.namespace.name\":\"watch%", "%k8s.namespace.name\":\"redis%"},
expected: "((simpleJSONExtractString(labels, 'k8s.namespace.name') <> ? AND labels NOT LIKE ?) AND (simpleJSONExtractString(labels, 'k8s.namespace.name') <> ? AND labels NOT LIKE ?))",
expectedArgs: []any{"watch", "%k8s.namespace.name\":\"watch%", "redis", "%k8s.namespace.name\":\"redis%"},
},
{
name: "string_exists",
@@ -173,8 +173,8 @@ func TestConditionBuilder(t *testing.T) {
},
op: qbtypes.FilterOperatorIn,
value: []any{1, 2},
expected: "(simpleJSONExtractString(labels, 'test_num') = ? OR simpleJSONExtractString(labels, 'test_num') = ?) AND labels LIKE ? AND (labels LIKE ? OR labels LIKE ?)",
expectedArgs: []any{"1", "2", "%test_num%", "%test_num\":\"1%", "%test_num\":\"2%"},
expected: "((simpleJSONExtractString(labels, 'test_num') = ? AND labels LIKE ? AND labels LIKE ?) OR (simpleJSONExtractString(labels, 'test_num') = ? AND labels LIKE ? AND labels LIKE ?))",
expectedArgs: []any{"1", "%test_num%", "%test_num\":\"1%", "2", "%test_num%", "%test_num\":\"2%"},
},
{
name: "number_between",

View File

@@ -229,8 +229,8 @@ func TestResourceFilterStatementBuilder_Traces(t *testing.T) {
start: testStartNs,
end: testEndNs,
expected: &qbtypes.Statement{
Query: "SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? OR simpleJSONExtractString(labels, 'service.name') = ?) AND labels LIKE ? AND (labels LIKE ? OR labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint",
Args: []any{"redis", "postgres", "%service.name%", "%service.name\":\"redis%", "%service.name\":\"postgres%", expectedBucketStart, expectedBucketEnd},
Query: "SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint",
Args: []any{"redis", "%service.name%", "%service.name\":\"redis%", "postgres", "%service.name%", "%service.name\":\"postgres%", expectedBucketStart, expectedBucketEnd},
},
},
{
@@ -244,8 +244,8 @@ func TestResourceFilterStatementBuilder_Traces(t *testing.T) {
start: testStartNs,
end: testEndNs,
expected: &qbtypes.Statement{
Query: "SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE ((simpleJSONExtractString(labels, 'service.name') <> ? AND simpleJSONExtractString(labels, 'service.name') <> ?) AND (labels NOT LIKE ? AND labels NOT LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint",
Args: []any{"redis", "postgres", "%service.name\":\"redis%", "%service.name\":\"postgres%", expectedBucketStart, expectedBucketEnd},
Query: "SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE ((simpleJSONExtractString(labels, 'service.name') <> ? AND labels NOT LIKE ?) AND (simpleJSONExtractString(labels, 'service.name') <> ? AND labels NOT LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint",
Args: []any{"redis", "%service.name\":\"redis%", "postgres", "%service.name\":\"postgres%", expectedBucketStart, expectedBucketEnd},
},
},
{

View File

@@ -94,7 +94,11 @@ func (c *conditionBuilder) conditionFor(
}
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.E(fieldExpression, value))
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -104,7 +108,11 @@ func (c *conditionBuilder) conditionFor(
}
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.NE(fieldExpression, value))
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
return sb.And(conditions...), nil
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:

View File

@@ -410,7 +410,11 @@ func (c *conditionBuilder) conditionForResolvedKey(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.E(fieldExpression, value))
cond, err := c.conditionForResolvedKey(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -421,7 +425,11 @@ func (c *conditionBuilder) conditionForResolvedKey(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.NE(fieldExpression, value))
cond, err := c.conditionForResolvedKey(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
return sb.And(conditions...), nil

View File

@@ -905,3 +905,52 @@ func TestConditionForJSONBodySearch(t *testing.T) {
})
}
}
// IN on the body column routes each value back through the `=` path; the SQL it produces
// must stay what the shared IN handling produced before, including for a mixed-type list.
func TestConditionForBodyIn(t *testing.T) {
testCases := []struct {
name string
values []any
expectedSQL string
expectedArgs []any
}{
{
name: "strings",
values: []any{"alpha", "beta"},
expectedSQL: "(body = ? OR body = ?)",
expectedArgs: []any{"alpha", "beta"},
},
{
name: "mixed types are stringified before they reach the column",
values: []any{"alpha", float64(1), true},
expectedSQL: "(body = ? OR body = ? OR body = ?)",
expectedArgs: []any{"alpha", "1", "true"},
},
}
fl := flaggertest.New(t)
fm := NewFieldMapper(fl)
conditionBuilder := NewConditionBuilder(fm, fl)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("1").From("t")
cond, _, err := conditionBuilder.ConditionFor(context.Background(), valuer.UUID{}, 0, 0, &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{},
qbtypes.FilterOperatorIn, tc.values, sb)
require.NoError(t, err)
sb.Where(cond...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expectedSQL)
assert.Equal(t, tc.expectedArgs, args)
})
}
}

View File

@@ -135,7 +135,11 @@ func (c *conditionBuilder) conditionFor(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.E(fieldExpression, value))
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -146,7 +150,11 @@ func (c *conditionBuilder) conditionFor(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.NE(fieldExpression, value))
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
return sb.And(conditions...), nil

View File

@@ -0,0 +1,78 @@
package telemetrytypes
import "strings"
// LogicalField is one queryable field. Its Name is the spelling that the
// request used. Its Members are the physical keys that store the field.
// LogicalField is the output type of name resolution: resolution changes a
// referenced name into logical fields, and compilers make SQL from them.
//
// A []*LogicalField shows ambiguity. Ambiguity means that possibly different
// fields have the same name. Each logical field in the slice gets its own
// condition. The operator tells the compiler how to connect the conditions.
//
// One LogicalField with more than one member shows a semantic-convention
// family. A family is one field that has more than one spelling. The members
// are in current-first order. The compiler merges the members into one
// expression, and the current name wins.
//
// Members always has one entry or more. A field that is not a family has
// exactly one member. The members point to the metadata map entries. Do not
// change the members.
type LogicalField struct {
// Name is the spelling that the request used. Aliases, series labels,
// and warnings use this spelling. Because of this, the response shows
// the same spelling as the request.
Name string
// Signal, FieldContext, and FieldDataType are the identity that all
// members share. Members with a different signal, field context, or
// data type are parts of different logical fields.
Signal Signal
FieldContext FieldContext
FieldDataType FieldDataType
// Members are the physical keys that store this field, in current-first
// order. Each member has its own physical data (Materialized,
// Evolutions, JSONPlan, ...). A per-member accessor does not need data
// from the other members.
Members []*TelemetryFieldKey
}
// SingleLogicalField makes a logical field that has one physical key.
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
return &LogicalField{
Name: name,
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
Members: []*TelemetryFieldKey{key},
}
}
// Single returns the only member of a single-member field. A decision that
// uses only the shared identity can also use Single on a family. This is
// safe because all members have the same signal, context, and data type.
func (l *LogicalField) Single() *TelemetryFieldKey {
return l.Members[0]
}
// IsFamily returns true when the field has more than one physical member.
func (l *LogicalField) IsFamily() bool {
return len(l.Members) > 1
}
// String implements fmt.Stringer. A single-member field prints as its
// member. Because of this, a message made from the field and a message made
// from the key are the same. A family prints its shared identity and its
// member spellings.
func (l *LogicalField) String() string {
if len(l.Members) == 1 {
return l.Members[0].String()
}
names := make([]string, 0, len(l.Members))
for _, member := range l.Members {
names = append(names, member.Name)
}
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
}

View File

@@ -0,0 +1,50 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSingleLogicalFieldSharesIdentityAndAliasesKey(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
logical := SingleLogicalField("resource.service.name", key)
assert.Equal(t, "resource.service.name", logical.Name, "the identity is the spelling that the request used, not the stored spelling")
assert.Equal(t, key.Signal, logical.Signal)
assert.Equal(t, key.FieldContext, logical.FieldContext)
assert.Equal(t, key.FieldDataType, logical.FieldDataType)
assert.False(t, logical.IsFamily())
assert.Same(t, key, logical.Single(), "the member points to the key; there is no copy")
}
func TestStringDelegatesForSingleMember(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
assert.Equal(t, key.String(), SingleLogicalField(key.Name, key).String(),
"a message made from a single-member field must be the same as a message made from the key")
}
func TestStringListsFamilyMembers(t *testing.T) {
logical := &LogicalField{
Name: "deployment.environment.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
Members: []*TelemetryFieldKey{
{Name: "deployment.environment.name"},
{Name: "deployment.environment"},
},
}
assert.True(t, logical.IsFamily())
assert.Equal(t, "deployment.environment.name(resource, string, members: deployment.environment.name, deployment.environment)", logical.String())
}

View File

@@ -3,11 +3,13 @@ from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import build_order_by, build_raw_query, get_rows, make_query_request
def test_logs_json_body_simple_searches(
@@ -911,3 +913,61 @@ def test_logs_json_body_listing(
assert len(results) == 1
count = results[0]["data"][0][0]
assert count == 4 # 4 logs have status="success"
@pytest.mark.parametrize(
"expression,expected_services",
[
pytest.param("body.service IN ['auth', 'payment']", {"auth", "payment"}, id="in_scalar_path"),
pytest.param("body.status IN [200, 500]", {"auth", "payment"}, id="in_number_path"),
pytest.param("body.service NOT IN ['auth']", {"payment", "search"}, id="not_in_scalar_path"),
# An `[]` path is extracted as an array. Comparing that array to each scalar in the
# list is something ClickHouse rejects outright (code 130), so this shape used to
# fail the whole query; per-value extraction reads the first element instead.
pytest.param("body.user_names[*] IN ['alpha', 'gamma']", {"auth", "payment"}, id="in_array_path"),
],
)
def test_logs_json_body_in_operator(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected_services: set[str],
) -> None:
"""IN over a body JSON path fans out to one comparison per value."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
specs = [("auth", 200, ["alpha", "beta"]), ("payment", 500, ["gamma"]), ("search", 404, ["beta", "alpha"])]
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
body=json.dumps({"service": service, "status": status, "user_names": user_names}),
)
for i, (service, status, user_names) in enumerate(specs)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
# flag off: the body comes back as the raw JSON string
assert {json.loads(row["data"]["body"])["service"] for row in get_rows(response)} == expected_services